feat: multiple scenes
This commit is contained in:
@@ -24,6 +24,7 @@ export function getDb(): Database.Database {
|
|||||||
db.pragma('foreign_keys = ON')
|
db.pragma('foreign_keys = ON')
|
||||||
|
|
||||||
initSchema(db)
|
initSchema(db)
|
||||||
|
migrateSchema(db)
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ function initSchema(db: Database.Database): void {
|
|||||||
CREATE TABLE IF NOT EXISTS runs (
|
CREATE TABLE IF NOT EXISTS runs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
recollectionId TEXT NOT NULL,
|
recollectionId TEXT NOT NULL,
|
||||||
|
sceneId TEXT,
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
graphSnapshot TEXT NOT NULL,
|
graphSnapshot TEXT NOT NULL,
|
||||||
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
|
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
@@ -61,6 +63,14 @@ function initSchema(db: Database.Database): void {
|
|||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrateSchema(db: Database.Database): void {
|
||||||
|
// Add sceneId column if it doesn't exist (added in Phase 2)
|
||||||
|
const cols = db.prepare("PRAGMA table_info(runs)").all() as Array<{ name: string }>
|
||||||
|
if (!cols.some((c) => c.name === 'sceneId')) {
|
||||||
|
db.exec('ALTER TABLE runs ADD COLUMN sceneId TEXT')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function closeDb(): void {
|
export function closeDb(): void {
|
||||||
if (db) {
|
if (db) {
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type RunStatus = 'pending' | 'running' | 'completed' | 'failed'
|
|||||||
export type Run = {
|
export type Run = {
|
||||||
id: string
|
id: string
|
||||||
recollectionId: string
|
recollectionId: string
|
||||||
|
sceneId?: string | null
|
||||||
status: RunStatus
|
status: RunStatus
|
||||||
graphSnapshot: string
|
graphSnapshot: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import type { Run, RunStep, RunStatus } from '../models/run.js'
|
|||||||
export function createRun(run: Run): void {
|
export function createRun(run: Run): void {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO runs (id, recollectionId, status, graphSnapshot, createdAt, updatedAt, error)
|
INSERT INTO runs (id, recollectionId, sceneId, status, graphSnapshot, createdAt, updatedAt, error)
|
||||||
VALUES (@id, @recollectionId, @status, @graphSnapshot, @createdAt, @updatedAt, @error)
|
VALUES (@id, @recollectionId, @sceneId, @status, @graphSnapshot, @createdAt, @updatedAt, @error)
|
||||||
`).run(run)
|
`).run(run)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { executeRun } from '../services/graphRunnerService.js'
|
|||||||
*/
|
*/
|
||||||
export async function handleCreateRun(req: Request, res: Response): Promise<void> {
|
export async function handleCreateRun(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { recollectionId, graph } = req.body
|
const { recollectionId, sceneId, graph } = req.body
|
||||||
|
|
||||||
if (!recollectionId || typeof recollectionId !== 'string') {
|
if (!recollectionId || typeof recollectionId !== 'string') {
|
||||||
res.status(400).json({ error: 'recollectionId is required' })
|
res.status(400).json({ error: 'recollectionId is required' })
|
||||||
@@ -30,6 +30,7 @@ export async function handleCreateRun(req: Request, res: Response): Promise<void
|
|||||||
const run: Run = {
|
const run: Run = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
recollectionId,
|
recollectionId,
|
||||||
|
sceneId: typeof sceneId === 'string' ? sceneId : null,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
graphSnapshot: JSON.stringify(graph),
|
graphSnapshot: JSON.stringify(graph),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
|||||||
@@ -202,11 +202,13 @@ function FullscreenNodeContent({ node }: { node: AppNode }) {
|
|||||||
export type CanvasPageProps = {
|
export type CanvasPageProps = {
|
||||||
/** Optional recollection id for per-recollection graph loading */
|
/** Optional recollection id for per-recollection graph loading */
|
||||||
recollectionId?: string
|
recollectionId?: string
|
||||||
|
/** Optional scene id for per-scene graph loading */
|
||||||
|
sceneId?: string
|
||||||
/** Optional node id to focus when the canvas loads (e.g. from Katalogos artifacts). */
|
/** Optional node id to focus when the canvas loads (e.g. from Katalogos artifacts). */
|
||||||
focusNodeId?: string
|
focusNodeId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
|
export function CanvasPage({ recollectionId, sceneId, focusNodeId }: CanvasPageProps) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const { showMinimap } = usePlatform()
|
const { showMinimap } = usePlatform()
|
||||||
const {
|
const {
|
||||||
@@ -225,7 +227,7 @@ export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
|
|||||||
setStateImmediate,
|
setStateImmediate,
|
||||||
save,
|
save,
|
||||||
saveStatus,
|
saveStatus,
|
||||||
} = useCanvasGraph(recollectionId)
|
} = useCanvasGraph(recollectionId, sceneId)
|
||||||
|
|
||||||
const [rfInstance, setRfInstance] = React.useState<unknown>(null)
|
const [rfInstance, setRfInstance] = React.useState<unknown>(null)
|
||||||
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
|
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
|
||||||
@@ -281,10 +283,10 @@ export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
|
|||||||
// Reset previous run state, save, then run
|
// Reset previous run state, save, then run
|
||||||
resetRun()
|
resetRun()
|
||||||
save()
|
save()
|
||||||
createAndStreamRun(recollectionId, { nodes, edges }, connectToRun).catch((err) => {
|
createAndStreamRun(recollectionId, { nodes, edges }, connectToRun, sceneId).catch((err) => {
|
||||||
toast.error(`Run failed: ${err.message}`)
|
toast.error(`Run failed: ${err.message}`)
|
||||||
})
|
})
|
||||||
}, [recollectionId, nodes, edges, connectToRun, save, runStatus, resetRun])
|
}, [recollectionId, sceneId, nodes, edges, connectToRun, save, runStatus, resetRun])
|
||||||
|
|
||||||
const nodesRef = useRef(nodes)
|
const nodesRef = useRef(nodes)
|
||||||
nodesRef.current = nodes
|
nodesRef.current = nodes
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
|
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
|
||||||
import { loadGraphFromStorage } from '@/app/recollections/state/recollectionGraphStorage'
|
import { loadGraphFromStorage } from '@/app/recollections/state/recollectionGraphStorage'
|
||||||
|
import { getSceneGraph } from '@/app/recollections/state/recollectionStore'
|
||||||
|
|
||||||
/** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */
|
/** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */
|
||||||
export function backfillEdgeTargetTypes(
|
export function backfillEdgeTargetTypes(
|
||||||
@@ -70,9 +71,12 @@ export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
|
|||||||
return { nodes, edges }
|
return { nodes, edges }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInitialGraph(recollectionId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
|
export function getInitialGraph(recollectionId: string | undefined, sceneId?: string): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||||
if (recollectionId) {
|
if (recollectionId) {
|
||||||
const stored = loadGraphFromStorage(recollectionId)
|
// Scene-aware: load from scene key; only fall back to legacy when no sceneId
|
||||||
|
const stored = sceneId
|
||||||
|
? getSceneGraph(recollectionId, sceneId)
|
||||||
|
: loadGraphFromStorage(recollectionId)
|
||||||
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
|
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
|
||||||
const nodes = stored.nodes as AppNode[]
|
const nodes = stored.nodes as AppNode[]
|
||||||
const edges = backfillEdgeTargetTypes(nodes, stored.edges as AppEdge[])
|
const edges = backfillEdgeTargetTypes(nodes, stored.edges as AppEdge[])
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
||||||
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
||||||
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
||||||
|
import { setSceneGraph } from '@/app/recollections/state/recollectionStore'
|
||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
|
|
||||||
export type SaveStatus = 'saved' | 'unsaved' | 'saving'
|
export type SaveStatus = 'saved' | 'unsaved' | 'saving'
|
||||||
@@ -18,8 +19,8 @@ export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory> &
|
|||||||
saveStatus: SaveStatus
|
saveStatus: SaveStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGraphResult {
|
export function useCanvasGraph(recollectionId: string | undefined, sceneId?: string): UseCanvasGraphResult {
|
||||||
const initialGraph = useMemo(() => getInitialGraph(recollectionId), [recollectionId])
|
const initialGraph = useMemo(() => getInitialGraph(recollectionId, sceneId), [recollectionId, sceneId])
|
||||||
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
|
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
|
||||||
const { nodes, edges } = result
|
const { nodes, edges } = result
|
||||||
|
|
||||||
@@ -47,17 +48,23 @@ export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGra
|
|||||||
edges: edgesRef.current,
|
edges: edgesRef.current,
|
||||||
})
|
})
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
saveGraphToStorage(recollectionId, {
|
const graphState = {
|
||||||
version: RECOLLECTION_VERSION,
|
version: RECOLLECTION_VERSION,
|
||||||
nodes: nodesRef.current,
|
nodes: nodesRef.current,
|
||||||
edges: edgesRef.current,
|
edges: edgesRef.current,
|
||||||
})
|
}
|
||||||
|
// Save to scene-specific key if sceneId is provided, otherwise legacy key
|
||||||
|
if (sceneId) {
|
||||||
|
setSceneGraph(recollectionId, sceneId, graphState)
|
||||||
|
} else {
|
||||||
|
saveGraphToStorage(recollectionId, graphState)
|
||||||
|
}
|
||||||
const SAVING_DISPLAY_MS = 360
|
const SAVING_DISPLAY_MS = 360
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setLastSavedSerialized(snapshot)
|
setLastSavedSerialized(snapshot)
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
}, SAVING_DISPLAY_MS)
|
}, SAVING_DISPLAY_MS)
|
||||||
}, [recollectionId])
|
}, [recollectionId, sceneId])
|
||||||
|
|
||||||
// Auto-save after 5 seconds of inactivity when there are unsaved changes.
|
// Auto-save after 5 seconds of inactivity when there are unsaved changes.
|
||||||
// Prevents data loss if the user closes the tab without pressing Ctrl+S.
|
// Prevents data loss if the user closes the tab without pressing Ctrl+S.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { toast } from 'sonner'
|
|||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { RecollectionActionsProvider } from './layout/RecollectionActionsContext'
|
import { RecollectionActionsProvider } from './layout/RecollectionActionsContext'
|
||||||
import { RecollectionSidebarProvider } from './layout/RecollectionSidebarContext'
|
import { RecollectionSidebarProvider } from './layout/RecollectionSidebarContext'
|
||||||
|
import { FluxSceneProvider } from './flux/FluxSceneContext'
|
||||||
import { RecollectionMenubar } from './layout/RecollectionMenubar'
|
import { RecollectionMenubar } from './layout/RecollectionMenubar'
|
||||||
import { RecollectionSidebar } from './layout/RecollectionSidebar'
|
import { RecollectionSidebar } from './layout/RecollectionSidebar'
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ export function RecollectionLayout() {
|
|||||||
return (
|
return (
|
||||||
<RecollectionActionsProvider>
|
<RecollectionActionsProvider>
|
||||||
<RecollectionSidebarProvider>
|
<RecollectionSidebarProvider>
|
||||||
|
<FluxSceneProvider>
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<RecollectionMenubar />
|
<RecollectionMenubar />
|
||||||
<div className="flex min-h-0 flex-1 flex-row overflow-hidden">
|
<div className="flex min-h-0 flex-1 flex-row overflow-hidden">
|
||||||
@@ -59,6 +61,7 @@ export function RecollectionLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</FluxSceneProvider>
|
||||||
</RecollectionSidebarProvider>
|
</RecollectionSidebarProvider>
|
||||||
</RecollectionActionsProvider>
|
</RecollectionActionsProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Flux route: renders the graph canvas (CanvasPage) for the current recollection.
|
* Flux route: renders the graph canvas (CanvasPage) for the current recollection + scene.
|
||||||
* Supports optional focusNode query param to center on a specific node.
|
* Supports optional focusNode query param to center on a specific node.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import React, { useEffect, useRef } from 'react'
|
|||||||
import { useParams, useSearchParams } from 'react-router-dom'
|
import { useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { CanvasPage } from '@/app/canvas/CanvasPage'
|
import { CanvasPage } from '@/app/canvas/CanvasPage'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
|
import { useFluxScenes } from './FluxSceneContext'
|
||||||
|
|
||||||
export function FluxRoute() {
|
export function FluxRoute() {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
@@ -14,6 +15,7 @@ export function FluxRoute() {
|
|||||||
const { updateLastEdited } = usePlatform()
|
const { updateLastEdited } = usePlatform()
|
||||||
const updateLastEditedRef = useRef(updateLastEdited)
|
const updateLastEditedRef = useRef(updateLastEdited)
|
||||||
updateLastEditedRef.current = updateLastEdited
|
updateLastEditedRef.current = updateLastEdited
|
||||||
|
const { activeSceneId } = useFluxScenes()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (recollectionId) updateLastEditedRef.current(recollectionId)
|
if (recollectionId) updateLastEditedRef.current(recollectionId)
|
||||||
@@ -25,7 +27,12 @@ export function FluxRoute() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<CanvasPage key={recollectionId} recollectionId={recollectionId} focusNodeId={focusNodeId} />
|
<CanvasPage
|
||||||
|
key={`${recollectionId}_${activeSceneId}`}
|
||||||
|
recollectionId={recollectionId}
|
||||||
|
sceneId={activeSceneId}
|
||||||
|
focusNodeId={focusNodeId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
146
frontend/src/app/recollections/flux/FluxSceneContext.tsx
Normal file
146
frontend/src/app/recollections/flux/FluxSceneContext.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Context for Flux scenes: scene tree, active scene, URL sync.
|
||||||
|
* Mirrors RecollectionSidebarContext for Logos pages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
||||||
|
import { useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
ensureFluxSceneTree,
|
||||||
|
getFluxSceneTree,
|
||||||
|
setFluxSceneTree,
|
||||||
|
removeSceneGraph,
|
||||||
|
DEFAULT_SCENE_ID,
|
||||||
|
type FluxSceneMeta,
|
||||||
|
type FluxSceneId,
|
||||||
|
} from '../state/recollectionStore'
|
||||||
|
|
||||||
|
export type FluxSceneContextValue = {
|
||||||
|
scenes: FluxSceneMeta[]
|
||||||
|
activeSceneId: FluxSceneId
|
||||||
|
setScenes: React.Dispatch<React.SetStateAction<FluxSceneMeta[]>>
|
||||||
|
handleSelectScene: (id: FluxSceneId) => void
|
||||||
|
handleAddScene: (title?: string) => FluxSceneId
|
||||||
|
handleRenameScene: (id: FluxSceneId, title: string) => void
|
||||||
|
handleDeleteScene: (id: FluxSceneId) => void
|
||||||
|
handleReorderScenes: (scenes: FluxSceneMeta[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const FluxSceneContext = createContext<FluxSceneContextValue | null>(null)
|
||||||
|
|
||||||
|
export function useFluxScenes(): FluxSceneContextValue {
|
||||||
|
const ctx = useContext(FluxSceneContext)
|
||||||
|
if (!ctx) throw new Error('useFluxScenes must be used within FluxSceneProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOptionalFluxScenes(): FluxSceneContextValue | null {
|
||||||
|
return useContext(FluxSceneContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
let sceneCounter = 0
|
||||||
|
|
||||||
|
export function FluxSceneProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const [scenes, setScenes] = useState<FluxSceneMeta[]>([])
|
||||||
|
const [activeSceneId, setActiveSceneId] = useState<FluxSceneId>(DEFAULT_SCENE_ID)
|
||||||
|
|
||||||
|
// Load scene tree when recollection changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recollectionId) return
|
||||||
|
const tree = ensureFluxSceneTree(recollectionId)
|
||||||
|
setScenes(tree)
|
||||||
|
}, [recollectionId])
|
||||||
|
|
||||||
|
// Sync active scene from URL
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recollectionId || scenes.length === 0) return
|
||||||
|
const sceneFromUrl = searchParams.get('scene')
|
||||||
|
setActiveSceneId((prev) => {
|
||||||
|
if (sceneFromUrl && scenes.some((s) => s.id === sceneFromUrl)) return sceneFromUrl
|
||||||
|
if (scenes.some((s) => s.id === prev)) return prev
|
||||||
|
return scenes[0].id
|
||||||
|
})
|
||||||
|
}, [recollectionId, searchParams, scenes])
|
||||||
|
|
||||||
|
// Persist scene tree on changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recollectionId || scenes.length === 0) return
|
||||||
|
setFluxSceneTree(recollectionId, scenes)
|
||||||
|
}, [recollectionId, scenes])
|
||||||
|
|
||||||
|
const handleSelectScene = useCallback(
|
||||||
|
(id: FluxSceneId) => {
|
||||||
|
setActiveSceneId(id)
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev)
|
||||||
|
next.set('scene', id)
|
||||||
|
return next
|
||||||
|
}, { replace: true })
|
||||||
|
},
|
||||||
|
[setSearchParams]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAddScene = useCallback(
|
||||||
|
(title?: string) => {
|
||||||
|
sceneCounter += 1
|
||||||
|
const id = `scene_${Date.now()}_${sceneCounter}`
|
||||||
|
const maxPos = scenes.reduce((max, s) => Math.max(max, s.position), -1)
|
||||||
|
const newScene: FluxSceneMeta = {
|
||||||
|
id,
|
||||||
|
title: title ?? `Scene ${scenes.length + 1}`,
|
||||||
|
position: maxPos + 1,
|
||||||
|
}
|
||||||
|
setScenes((prev) => [...prev, newScene])
|
||||||
|
handleSelectScene(id)
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
[scenes, handleSelectScene]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRenameScene = useCallback(
|
||||||
|
(id: FluxSceneId, title: string) => {
|
||||||
|
setScenes((prev) => prev.map((s) => (s.id === id ? { ...s, title } : s)))
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDeleteScene = useCallback(
|
||||||
|
(id: FluxSceneId) => {
|
||||||
|
if (!recollectionId) return
|
||||||
|
// Prevent deleting the last scene
|
||||||
|
if (scenes.length <= 1) return
|
||||||
|
removeSceneGraph(recollectionId, id)
|
||||||
|
setScenes((prev) => {
|
||||||
|
const next = prev.filter((s) => s.id !== id)
|
||||||
|
// If we deleted the active scene, switch to the first remaining
|
||||||
|
if (activeSceneId === id && next.length > 0) {
|
||||||
|
handleSelectScene(next[0].id)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[recollectionId, scenes.length, activeSceneId, handleSelectScene]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleReorderScenes = useCallback(
|
||||||
|
(newScenes: FluxSceneMeta[]) => {
|
||||||
|
setScenes(newScenes)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const value: FluxSceneContextValue = {
|
||||||
|
scenes,
|
||||||
|
activeSceneId,
|
||||||
|
setScenes,
|
||||||
|
handleSelectScene,
|
||||||
|
handleAddScene,
|
||||||
|
handleRenameScene,
|
||||||
|
handleDeleteScene,
|
||||||
|
handleReorderScenes,
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FluxSceneContext.Provider value={value}>{children}</FluxSceneContext.Provider>
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { CheckCircle2, XCircle, Clock, Loader2 } from 'lucide-react'
|
|||||||
|
|
||||||
type RunSummary = {
|
type RunSummary = {
|
||||||
id: string
|
id: string
|
||||||
|
sceneId?: string | null
|
||||||
status: string
|
status: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
@@ -105,6 +106,9 @@ export function RunsTab() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{statusIcon[run.status] ?? statusIcon.pending}
|
{statusIcon[run.status] ?? statusIcon.pending}
|
||||||
<span className="font-medium capitalize">{run.status}</span>
|
<span className="font-medium capitalize">{run.status}</span>
|
||||||
|
{run.sceneId && (
|
||||||
|
<span className="text-xs text-muted-foreground">({run.sceneId})</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{new Date(run.createdAt).toLocaleString()}
|
{new Date(run.createdAt).toLocaleString()}
|
||||||
|
|||||||
@@ -12,11 +12,16 @@ import {
|
|||||||
getLogosContent,
|
getLogosContent,
|
||||||
setLogosContent,
|
setLogosContent,
|
||||||
upsertRenderOutputEntry,
|
upsertRenderOutputEntry,
|
||||||
|
getFluxSceneTree,
|
||||||
|
setFluxSceneTree,
|
||||||
|
getSceneGraph,
|
||||||
|
setSceneGraph,
|
||||||
RECOLLECTION_FILE_EXT,
|
RECOLLECTION_FILE_EXT,
|
||||||
RECOLLECTION_VERSION,
|
RECOLLECTION_VERSION,
|
||||||
type StoredGraphState,
|
type StoredGraphState,
|
||||||
type StoredLogosContent,
|
type StoredLogosContent,
|
||||||
type RenderOutputCacheEntry,
|
type RenderOutputCacheEntry,
|
||||||
|
type FluxSceneMeta,
|
||||||
} from '../state/recollectionStore'
|
} from '../state/recollectionStore'
|
||||||
|
|
||||||
export type { RenderOutputCacheEntry }
|
export type { RenderOutputCacheEntry }
|
||||||
@@ -31,6 +36,8 @@ export type RecollectionFilePayload = {
|
|||||||
version?: number
|
version?: number
|
||||||
graph?: { nodes: unknown[]; edges: unknown[] }
|
graph?: { nodes: unknown[]; edges: unknown[] }
|
||||||
logos?: StoredLogosContent
|
logos?: StoredLogosContent
|
||||||
|
/** Flux scenes (Phase 2+). Each entry has scene metadata + graph. */
|
||||||
|
scenes?: Array<{ id: string; title: string; position: number; graph: { nodes: unknown[]; edges: unknown[] } }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FluxSlot = {
|
export type FluxSlot = {
|
||||||
@@ -104,6 +111,26 @@ function validateAndWritePayload(
|
|||||||
result.logos = payload.logos as StoredLogosContent
|
result.logos = payload.logos as StoredLogosContent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Import scenes
|
||||||
|
if (payload.scenes && Array.isArray(payload.scenes)) {
|
||||||
|
const sceneMetas: FluxSceneMeta[] = []
|
||||||
|
for (const s of payload.scenes) {
|
||||||
|
if (!s || typeof s.id !== 'string' || typeof s.title !== 'string') continue
|
||||||
|
sceneMetas.push({ id: s.id, title: s.title, position: s.position ?? 0 })
|
||||||
|
if (s.graph && Array.isArray(s.graph.nodes) && Array.isArray(s.graph.edges)) {
|
||||||
|
const nodes = s.graph.nodes as AppNode[]
|
||||||
|
const edges = backfillEdges(nodes, s.graph.edges as AppEdge[])
|
||||||
|
setSceneGraph(recollectionId, s.id, {
|
||||||
|
version: payload.version ?? RECOLLECTION_VERSION,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sceneMetas.length > 0) {
|
||||||
|
setFluxSceneTree(recollectionId, sceneMetas)
|
||||||
|
}
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,10 +178,17 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac
|
|||||||
if (!recollectionId) return
|
if (!recollectionId) return
|
||||||
const graph = getGraph(recollectionId)
|
const graph = getGraph(recollectionId)
|
||||||
const logosContent = getLogosContent(recollectionId)
|
const logosContent = getLogosContent(recollectionId)
|
||||||
|
// Export scenes
|
||||||
|
const sceneTree = getFluxSceneTree(recollectionId)
|
||||||
|
const scenes = sceneTree.map((s) => {
|
||||||
|
const sg = getSceneGraph(recollectionId, s.id)
|
||||||
|
return { ...s, graph: sg ? { nodes: sg.nodes, edges: sg.edges } : { nodes: [], edges: [] } }
|
||||||
|
})
|
||||||
const payload: RecollectionFilePayload = {
|
const payload: RecollectionFilePayload = {
|
||||||
version: RECOLLECTION_VERSION,
|
version: RECOLLECTION_VERSION,
|
||||||
...(graph && { graph: { nodes: graph.nodes, edges: graph.edges } }),
|
...(graph && { graph: { nodes: graph.nodes, edges: graph.edges } }),
|
||||||
...(logosContent && { logos: logosContent }),
|
...(logosContent && { logos: logosContent }),
|
||||||
|
...(scenes.length > 0 && { scenes }),
|
||||||
}
|
}
|
||||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
* Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes.
|
* Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback } from 'react'
|
import React, { useCallback, useState } from 'react'
|
||||||
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Plus, FileText } from 'lucide-react'
|
import { Plus, FileText, Pencil, Trash2, Check, X } from 'lucide-react'
|
||||||
import { FluxIcon } from '@/lib/icons'
|
import { FluxIcon } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
||||||
|
import { useOptionalFluxScenes } from '../flux/FluxSceneContext'
|
||||||
import { TreeBrowser } from './TreeBrowser'
|
import { TreeBrowser } from './TreeBrowser'
|
||||||
import {
|
import {
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -87,6 +88,25 @@ export function RecollectionSidebar() {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
<FluxScenesSection base={base} isFluxView={isFluxView} />
|
||||||
|
</SidebarContent>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Flux Scenes Section
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function FluxScenesSection({ base, isFluxView }: { base: string; isFluxView: boolean }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const fluxScenes = useOptionalFluxScenes()
|
||||||
|
const [renamingId, setRenamingId] = useState<string | null>(null)
|
||||||
|
const [renameValue, setRenameValue] = useState('')
|
||||||
|
|
||||||
|
// Fallback: single "Canvas" link when provider isn't mounted
|
||||||
|
if (!fluxScenes) {
|
||||||
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>Flux</SidebarGroupLabel>
|
<SidebarGroupLabel>Flux</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
@@ -105,7 +125,113 @@ export function RecollectionSidebar() {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
)
|
||||||
</div>
|
}
|
||||||
|
|
||||||
|
const { scenes, activeSceneId, handleSelectScene, handleAddScene, handleRenameScene, handleDeleteScene } = fluxScenes
|
||||||
|
|
||||||
|
const startRename = (id: string, currentTitle: string) => {
|
||||||
|
setRenamingId(id)
|
||||||
|
setRenameValue(currentTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitRename = () => {
|
||||||
|
if (renamingId && renameValue.trim()) {
|
||||||
|
handleRenameScene(renamingId, renameValue.trim())
|
||||||
|
}
|
||||||
|
setRenamingId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelRename = () => {
|
||||||
|
setRenamingId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarGroup>
|
||||||
|
<div className="flex items-center justify-between gap-2 py-1.5">
|
||||||
|
<SidebarGroupLabel className="py-0">Flux</SidebarGroupLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 gap-1 px-1.5 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
const id = handleAddScene()
|
||||||
|
navigate(`${base}/flux?scene=${encodeURIComponent(id)}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
New scene
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{scenes.map((scene) => {
|
||||||
|
const isActive = isFluxView && scene.id === activeSceneId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem key={scene.id}>
|
||||||
|
{renamingId === scene.id ? (
|
||||||
|
<div className="flex w-full items-center gap-1 px-2 py-1">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="h-6 flex-1 min-w-0 rounded border border-sidebar-border bg-sidebar px-1.5 text-xs outline-none focus:ring-1 focus:ring-sidebar-ring"
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') commitRename()
|
||||||
|
if (e.key === 'Escape') cancelRename()
|
||||||
|
}}
|
||||||
|
onBlur={commitRename}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={commitRename} className="shrink-0 text-sidebar-foreground/70 hover:text-sidebar-foreground">
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={cancelRename} className="shrink-0 text-sidebar-foreground/70 hover:text-sidebar-foreground">
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="group flex w-full items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'flex-1 min-w-0')}
|
||||||
|
onClick={() => {
|
||||||
|
handleSelectScene(scene.id)
|
||||||
|
navigate(`${base}/flux?scene=${encodeURIComponent(scene.id)}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||||
|
<span className="truncate">{scene.title}</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pr-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); startRename(scene.id, scene.title) }}
|
||||||
|
className="rounded p-0.5 text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent"
|
||||||
|
title="Rename scene"
|
||||||
|
>
|
||||||
|
<Pencil className="size-3" />
|
||||||
|
</button>
|
||||||
|
{scenes.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDeleteScene(scene.id) }}
|
||||||
|
className="rounded p-0.5 text-sidebar-foreground/50 hover:text-destructive hover:bg-sidebar-accent"
|
||||||
|
title="Delete scene"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,16 +47,40 @@ export function formatTimeSinceLastUpdate(updatedAt: number | undefined): string
|
|||||||
export const RECOLLECTION_FILE_EXT = '.zui.json'
|
export const RECOLLECTION_FILE_EXT = '.zui.json'
|
||||||
export const RECOLLECTION_VERSION = 1
|
export const RECOLLECTION_VERSION = 1
|
||||||
|
|
||||||
|
/** Id for a Flux scene. */
|
||||||
|
export type FluxSceneId = string
|
||||||
|
|
||||||
|
/** Metadata for one scene in the Flux scene tree. */
|
||||||
|
export type FluxSceneMeta = {
|
||||||
|
id: FluxSceneId
|
||||||
|
title: string
|
||||||
|
/** Order among scenes. */
|
||||||
|
position: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SCENE_ID: FluxSceneId = '_default'
|
||||||
|
|
||||||
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
||||||
|
const FLUX_SCENE_TREE_PREFIX = 'zui_flux_scenes_'
|
||||||
|
const FLUX_SCENE_GRAPH_PREFIX = 'zui_flux_scene_'
|
||||||
const LOGOS_KEY_PREFIX = 'zui_logos_'
|
const LOGOS_KEY_PREFIX = 'zui_logos_'
|
||||||
const LOGOS_PAGE_TREE_PREFIX = 'zui_logos_pagetree_'
|
const LOGOS_PAGE_TREE_PREFIX = 'zui_logos_pagetree_'
|
||||||
const LOGOS_PAGE_CONTENT_PREFIX = 'zui_logos_page_'
|
const LOGOS_PAGE_CONTENT_PREFIX = 'zui_logos_page_'
|
||||||
const RENDER_CACHE_KEY_PREFIX = 'zui_render_cache_'
|
const RENDER_CACHE_KEY_PREFIX = 'zui_render_cache_'
|
||||||
|
|
||||||
|
/** Legacy single-graph key (pre-scenes). */
|
||||||
function getGraphKey(recollectionId: string): string {
|
function getGraphKey(recollectionId: string): string {
|
||||||
return `${GRAPH_KEY_PREFIX}${recollectionId}`
|
return `${GRAPH_KEY_PREFIX}${recollectionId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFluxSceneTreeKey(recollectionId: string): string {
|
||||||
|
return `${FLUX_SCENE_TREE_PREFIX}${recollectionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFluxSceneGraphKey(recollectionId: string, sceneId: FluxSceneId): string {
|
||||||
|
return `${FLUX_SCENE_GRAPH_PREFIX}${recollectionId}_${sceneId}`
|
||||||
|
}
|
||||||
|
|
||||||
function getLogosKey(recollectionId: string): string {
|
function getLogosKey(recollectionId: string): string {
|
||||||
return `${LOGOS_KEY_PREFIX}${recollectionId}`
|
return `${LOGOS_KEY_PREFIX}${recollectionId}`
|
||||||
}
|
}
|
||||||
@@ -155,6 +179,82 @@ export function setGraph(recollectionId: string, state: StoredGraphState): void
|
|||||||
localStorage.setItem(getGraphKey(recollectionId), JSON.stringify(state))
|
localStorage.setItem(getGraphKey(recollectionId), JSON.stringify(state))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Flux Scene Storage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Scene tree: ordered list of scene metas. */
|
||||||
|
export function getFluxSceneTree(recollectionId: string): FluxSceneMeta[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(getFluxSceneTreeKey(recollectionId))
|
||||||
|
if (!raw) return []
|
||||||
|
const data = JSON.parse(raw) as unknown
|
||||||
|
if (!Array.isArray(data)) return []
|
||||||
|
return data.filter(
|
||||||
|
(item): item is FluxSceneMeta =>
|
||||||
|
item != null &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
typeof (item as FluxSceneMeta).id === 'string' &&
|
||||||
|
typeof (item as FluxSceneMeta).title === 'string' &&
|
||||||
|
typeof (item as FluxSceneMeta).position === 'number'
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFluxSceneTree(recollectionId: string, tree: FluxSceneMeta[]): void {
|
||||||
|
localStorage.setItem(getFluxSceneTreeKey(recollectionId), JSON.stringify(tree))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-scene graph data. */
|
||||||
|
export function getSceneGraph(recollectionId: string, sceneId: FluxSceneId): StoredGraphState | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(getFluxSceneGraphKey(recollectionId, sceneId))
|
||||||
|
if (!raw) return null
|
||||||
|
const data = JSON.parse(raw) as unknown
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
typeof data !== 'object' ||
|
||||||
|
!Array.isArray((data as StoredGraphState).nodes) ||
|
||||||
|
!Array.isArray((data as StoredGraphState).edges)
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
return data as StoredGraphState
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSceneGraph(recollectionId: string, sceneId: FluxSceneId, state: StoredGraphState): void {
|
||||||
|
localStorage.setItem(getFluxSceneGraphKey(recollectionId, sceneId), JSON.stringify(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSceneGraph(recollectionId: string, sceneId: FluxSceneId): void {
|
||||||
|
localStorage.removeItem(getFluxSceneGraphKey(recollectionId, sceneId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize scene tree for a recollection. If no scenes exist, migrate
|
||||||
|
* the legacy single graph into a '_default' scene.
|
||||||
|
*/
|
||||||
|
export function ensureFluxSceneTree(recollectionId: string): FluxSceneMeta[] {
|
||||||
|
let tree = getFluxSceneTree(recollectionId)
|
||||||
|
if (tree.length > 0) return tree
|
||||||
|
|
||||||
|
// Migrate legacy single graph to default scene
|
||||||
|
const legacyGraph = getGraph(recollectionId)
|
||||||
|
const defaultScene: FluxSceneMeta = { id: DEFAULT_SCENE_ID, title: 'Main', position: 0 }
|
||||||
|
tree = [defaultScene]
|
||||||
|
setFluxSceneTree(recollectionId, tree)
|
||||||
|
|
||||||
|
if (legacyGraph) {
|
||||||
|
setSceneGraph(recollectionId, DEFAULT_SCENE_ID, legacyGraph)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Logos Storage
|
// Logos Storage
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -270,14 +370,23 @@ export function upsertRenderOutputEntry(recollectionId: string, entry: RenderOut
|
|||||||
// Remove Recollection Data
|
// Remove Recollection Data
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Removes both graph, logos (legacy + page tree + all per-page content), and render cache for the recollection. */
|
/** Removes all data for the recollection: graph, scenes, logos, and render cache. */
|
||||||
export function removeRecollectionData(recollectionId: string): void {
|
export function removeRecollectionData(recollectionId: string): void {
|
||||||
|
// Legacy graph
|
||||||
localStorage.removeItem(getGraphKey(recollectionId))
|
localStorage.removeItem(getGraphKey(recollectionId))
|
||||||
|
// Flux scenes
|
||||||
|
const scenes = getFluxSceneTree(recollectionId)
|
||||||
|
for (const scene of scenes) {
|
||||||
|
removeSceneGraph(recollectionId, scene.id)
|
||||||
|
}
|
||||||
|
localStorage.removeItem(getFluxSceneTreeKey(recollectionId))
|
||||||
|
// Logos
|
||||||
localStorage.removeItem(getLogosKey(recollectionId))
|
localStorage.removeItem(getLogosKey(recollectionId))
|
||||||
const tree = getLogosPageTree(recollectionId)
|
const logosTree = getLogosPageTree(recollectionId)
|
||||||
for (const page of tree) {
|
for (const page of logosTree) {
|
||||||
removeLogosPageContent(recollectionId, page.id)
|
removeLogosPageContent(recollectionId, page.id)
|
||||||
}
|
}
|
||||||
localStorage.removeItem(getLogosPageTreeKey(recollectionId))
|
localStorage.removeItem(getLogosPageTreeKey(recollectionId))
|
||||||
|
// Render cache
|
||||||
localStorage.removeItem(getRenderCacheKey(recollectionId))
|
localStorage.removeItem(getRenderCacheKey(recollectionId))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,12 +91,13 @@ export function useRunStream() {
|
|||||||
export async function createAndStreamRun(
|
export async function createAndStreamRun(
|
||||||
recollectionId: string,
|
recollectionId: string,
|
||||||
graph: { nodes: unknown[]; edges: unknown[] },
|
graph: { nodes: unknown[]; edges: unknown[] },
|
||||||
connectToRun: (runId: string) => void
|
connectToRun: (runId: string) => void,
|
||||||
|
sceneId?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch('/api/runs', {
|
const res = await fetch('/api/runs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ recollectionId, graph }),
|
body: JSON.stringify({ recollectionId, sceneId, graph }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user