diff --git a/backend/src/db/connection.ts b/backend/src/db/connection.ts index 92d7490..cdc2806 100644 --- a/backend/src/db/connection.ts +++ b/backend/src/db/connection.ts @@ -24,6 +24,7 @@ export function getDb(): Database.Database { db.pragma('foreign_keys = ON') initSchema(db) + migrateSchema(db) return db } @@ -32,6 +33,7 @@ function initSchema(db: Database.Database): void { CREATE TABLE IF NOT EXISTS runs ( id TEXT PRIMARY KEY, recollectionId TEXT NOT NULL, + sceneId TEXT, status TEXT NOT NULL DEFAULT 'pending', graphSnapshot TEXT NOT NULL, 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 { if (db) { db.close() diff --git a/backend/src/models/run.ts b/backend/src/models/run.ts index 08e9145..56f6e41 100644 --- a/backend/src/models/run.ts +++ b/backend/src/models/run.ts @@ -7,6 +7,7 @@ export type RunStatus = 'pending' | 'running' | 'completed' | 'failed' export type Run = { id: string recollectionId: string + sceneId?: string | null status: RunStatus graphSnapshot: string createdAt: string diff --git a/backend/src/repositories/runRepository.ts b/backend/src/repositories/runRepository.ts index fb313dc..42f04d8 100644 --- a/backend/src/repositories/runRepository.ts +++ b/backend/src/repositories/runRepository.ts @@ -8,8 +8,8 @@ import type { Run, RunStep, RunStatus } from '../models/run.js' export function createRun(run: Run): void { const db = getDb() db.prepare(` - INSERT INTO runs (id, recollectionId, status, graphSnapshot, createdAt, updatedAt, error) - VALUES (@id, @recollectionId, @status, @graphSnapshot, @createdAt, @updatedAt, @error) + INSERT INTO runs (id, recollectionId, sceneId, status, graphSnapshot, createdAt, updatedAt, error) + VALUES (@id, @recollectionId, @sceneId, @status, @graphSnapshot, @createdAt, @updatedAt, @error) `).run(run) } diff --git a/backend/src/routes/runRoutes.ts b/backend/src/routes/runRoutes.ts index 8d230e9..fbd4c55 100644 --- a/backend/src/routes/runRoutes.ts +++ b/backend/src/routes/runRoutes.ts @@ -16,7 +16,7 @@ import { executeRun } from '../services/graphRunnerService.js' */ export async function handleCreateRun(req: Request, res: Response): Promise { try { - const { recollectionId, graph } = req.body + const { recollectionId, sceneId, graph } = req.body if (!recollectionId || typeof recollectionId !== 'string') { res.status(400).json({ error: 'recollectionId is required' }) @@ -30,6 +30,7 @@ export async function handleCreateRun(req: Request, res: Response): Promise(null) const [renamingNodeId, setRenamingNodeId] = React.useState(null) @@ -281,10 +283,10 @@ export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) { // Reset previous run state, save, then run resetRun() save() - createAndStreamRun(recollectionId, { nodes, edges }, connectToRun).catch((err) => { + createAndStreamRun(recollectionId, { nodes, edges }, connectToRun, sceneId).catch((err) => { 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) nodesRef.current = nodes diff --git a/frontend/src/app/canvas/canvasGraphUtils.ts b/frontend/src/app/canvas/canvasGraphUtils.ts index 9550fc6..b003d67 100644 --- a/frontend/src/app/canvas/canvasGraphUtils.ts +++ b/frontend/src/app/canvas/canvasGraphUtils.ts @@ -6,6 +6,7 @@ import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes' import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils' 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). */ export function backfillEdgeTargetTypes( @@ -70,9 +71,12 @@ export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } { 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) { - 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)) { const nodes = stored.nodes as AppNode[] const edges = backfillEdgeTargetTypes(nodes, stored.edges as AppEdge[]) diff --git a/frontend/src/app/canvas/useCanvasGraph.ts b/frontend/src/app/canvas/useCanvasGraph.ts index a60fcea..909825f 100644 --- a/frontend/src/app/canvas/useCanvasGraph.ts +++ b/frontend/src/app/canvas/useCanvasGraph.ts @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory' import { getInitialGraph } from '@/app/canvas/canvasGraphUtils' import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage' +import { setSceneGraph } from '@/app/recollections/state/recollectionStore' import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes' export type SaveStatus = 'saved' | 'unsaved' | 'saving' @@ -18,8 +19,8 @@ export type UseCanvasGraphResult = ReturnType & saveStatus: SaveStatus } -export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGraphResult { - const initialGraph = useMemo(() => getInitialGraph(recollectionId), [recollectionId]) +export function useCanvasGraph(recollectionId: string | undefined, sceneId?: string): UseCanvasGraphResult { + const initialGraph = useMemo(() => getInitialGraph(recollectionId, sceneId), [recollectionId, sceneId]) const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges) const { nodes, edges } = result @@ -47,17 +48,23 @@ export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGra edges: edgesRef.current, }) setIsSaving(true) - saveGraphToStorage(recollectionId, { + const graphState = { version: RECOLLECTION_VERSION, nodes: nodesRef.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 setTimeout(() => { setLastSavedSerialized(snapshot) setIsSaving(false) }, SAVING_DISPLAY_MS) - }, [recollectionId]) + }, [recollectionId, sceneId]) // 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. diff --git a/frontend/src/app/recollections/RecollectionLayout.tsx b/frontend/src/app/recollections/RecollectionLayout.tsx index cc46d05..eb17de3 100644 --- a/frontend/src/app/recollections/RecollectionLayout.tsx +++ b/frontend/src/app/recollections/RecollectionLayout.tsx @@ -8,6 +8,7 @@ import { toast } from 'sonner' import { usePlatform } from '@/app/kosmos/KosmosContext' import { RecollectionActionsProvider } from './layout/RecollectionActionsContext' import { RecollectionSidebarProvider } from './layout/RecollectionSidebarContext' +import { FluxSceneProvider } from './flux/FluxSceneContext' import { RecollectionMenubar } from './layout/RecollectionMenubar' import { RecollectionSidebar } from './layout/RecollectionSidebar' @@ -50,15 +51,17 @@ export function RecollectionLayout() { return ( -
- -
- -
- + +
+ +
+ +
+ +
-
+ ) diff --git a/frontend/src/app/recollections/flux/FluxRoute.tsx b/frontend/src/app/recollections/flux/FluxRoute.tsx index a846fe0..ce65580 100644 --- a/frontend/src/app/recollections/flux/FluxRoute.tsx +++ b/frontend/src/app/recollections/flux/FluxRoute.tsx @@ -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. */ @@ -7,6 +7,7 @@ import React, { useEffect, useRef } from 'react' import { useParams, useSearchParams } from 'react-router-dom' import { CanvasPage } from '@/app/canvas/CanvasPage' import { usePlatform } from '@/app/kosmos/KosmosContext' +import { useFluxScenes } from './FluxSceneContext' export function FluxRoute() { const { recollectionId } = useParams<{ recollectionId: string }>() @@ -14,6 +15,7 @@ export function FluxRoute() { const { updateLastEdited } = usePlatform() const updateLastEditedRef = useRef(updateLastEdited) updateLastEditedRef.current = updateLastEdited + const { activeSceneId } = useFluxScenes() useEffect(() => { if (recollectionId) updateLastEditedRef.current(recollectionId) @@ -25,7 +27,12 @@ export function FluxRoute() { return (
- +
) } diff --git a/frontend/src/app/recollections/flux/FluxSceneContext.tsx b/frontend/src/app/recollections/flux/FluxSceneContext.tsx new file mode 100644 index 0000000..40a2767 --- /dev/null +++ b/frontend/src/app/recollections/flux/FluxSceneContext.tsx @@ -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> + 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(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([]) + const [activeSceneId, setActiveSceneId] = useState(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 {children} +} diff --git a/frontend/src/app/recollections/katalogos/RunsTab.tsx b/frontend/src/app/recollections/katalogos/RunsTab.tsx index 4b735c8..2c7cbe5 100644 --- a/frontend/src/app/recollections/katalogos/RunsTab.tsx +++ b/frontend/src/app/recollections/katalogos/RunsTab.tsx @@ -8,6 +8,7 @@ import { CheckCircle2, XCircle, Clock, Loader2 } from 'lucide-react' type RunSummary = { id: string + sceneId?: string | null status: string createdAt: string updatedAt: string @@ -105,6 +106,9 @@ export function RunsTab() {
{statusIcon[run.status] ?? statusIcon.pending} {run.status} + {run.sceneId && ( + ({run.sceneId}) + )}
{new Date(run.createdAt).toLocaleString()} diff --git a/frontend/src/app/recollections/layout/RecollectionActionsContext.tsx b/frontend/src/app/recollections/layout/RecollectionActionsContext.tsx index 7e3d320..f4ff76e 100644 --- a/frontend/src/app/recollections/layout/RecollectionActionsContext.tsx +++ b/frontend/src/app/recollections/layout/RecollectionActionsContext.tsx @@ -12,11 +12,16 @@ import { getLogosContent, setLogosContent, upsertRenderOutputEntry, + getFluxSceneTree, + setFluxSceneTree, + getSceneGraph, + setSceneGraph, RECOLLECTION_FILE_EXT, RECOLLECTION_VERSION, type StoredGraphState, type StoredLogosContent, type RenderOutputCacheEntry, + type FluxSceneMeta, } from '../state/recollectionStore' export type { RenderOutputCacheEntry } @@ -31,6 +36,8 @@ export type RecollectionFilePayload = { version?: number graph?: { nodes: unknown[]; edges: unknown[] } 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 = { @@ -104,6 +111,26 @@ function validateAndWritePayload( 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 } @@ -151,10 +178,17 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac if (!recollectionId) return const graph = getGraph(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 = { version: RECOLLECTION_VERSION, ...(graph && { graph: { nodes: graph.nodes, edges: graph.edges } }), ...(logosContent && { logos: logosContent }), + ...(scenes.length > 0 && { scenes }), } const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) diff --git a/frontend/src/app/recollections/layout/RecollectionSidebar.tsx b/frontend/src/app/recollections/layout/RecollectionSidebar.tsx index 3104785..10e29f8 100644 --- a/frontend/src/app/recollections/layout/RecollectionSidebar.tsx +++ b/frontend/src/app/recollections/layout/RecollectionSidebar.tsx @@ -2,13 +2,14 @@ * 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 { 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 { cn } from '@/lib/utils' import { useRecollectionSidebar } from './RecollectionSidebarContext' +import { useOptionalFluxScenes } from '../flux/FluxSceneContext' import { TreeBrowser } from './TreeBrowser' import { SidebarContent, @@ -87,25 +88,150 @@ export function RecollectionSidebar() { - - Flux - - - - - - - - +
) } + +// --------------------------------------------------------------------------- +// Flux Scenes Section +// --------------------------------------------------------------------------- + +function FluxScenesSection({ base, isFluxView }: { base: string; isFluxView: boolean }) { + const navigate = useNavigate() + const fluxScenes = useOptionalFluxScenes() + const [renamingId, setRenamingId] = useState(null) + const [renameValue, setRenameValue] = useState('') + + // Fallback: single "Canvas" link when provider isn't mounted + if (!fluxScenes) { + return ( + + Flux + + + + + + + + + ) + } + + 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 ( + +
+ Flux + +
+ + + {scenes.map((scene) => { + const isActive = isFluxView && scene.id === activeSceneId + + return ( + + {renamingId === scene.id ? ( +
+ setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename() + if (e.key === 'Escape') cancelRename() + }} + onBlur={commitRename} + /> + + +
+ ) : ( +
+ +
+ + {scenes.length > 1 && ( + + )} +
+
+ )} +
+ ) + })} +
+
+
+ ) +} diff --git a/frontend/src/app/recollections/state/recollectionStore.ts b/frontend/src/app/recollections/state/recollectionStore.ts index cbf6d75..5e1bfdc 100644 --- a/frontend/src/app/recollections/state/recollectionStore.ts +++ b/frontend/src/app/recollections/state/recollectionStore.ts @@ -47,16 +47,40 @@ export function formatTimeSinceLastUpdate(updatedAt: number | undefined): string export const RECOLLECTION_FILE_EXT = '.zui.json' 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 FLUX_SCENE_TREE_PREFIX = 'zui_flux_scenes_' +const FLUX_SCENE_GRAPH_PREFIX = 'zui_flux_scene_' const LOGOS_KEY_PREFIX = 'zui_logos_' const LOGOS_PAGE_TREE_PREFIX = 'zui_logos_pagetree_' const LOGOS_PAGE_CONTENT_PREFIX = 'zui_logos_page_' const RENDER_CACHE_KEY_PREFIX = 'zui_render_cache_' +/** Legacy single-graph key (pre-scenes). */ function getGraphKey(recollectionId: string): string { 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 { return `${LOGOS_KEY_PREFIX}${recollectionId}` } @@ -155,6 +179,82 @@ export function setGraph(recollectionId: string, state: StoredGraphState): void 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 // --------------------------------------------------------------------------- @@ -270,14 +370,23 @@ export function upsertRenderOutputEntry(recollectionId: string, entry: RenderOut // 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 { + // Legacy graph 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)) - const tree = getLogosPageTree(recollectionId) - for (const page of tree) { + const logosTree = getLogosPageTree(recollectionId) + for (const page of logosTree) { removeLogosPageContent(recollectionId, page.id) } localStorage.removeItem(getLogosPageTreeKey(recollectionId)) + // Render cache localStorage.removeItem(getRenderCacheKey(recollectionId)) } diff --git a/frontend/src/hooks/useRunStream.ts b/frontend/src/hooks/useRunStream.ts index b8d2d8b..85adc62 100644 --- a/frontend/src/hooks/useRunStream.ts +++ b/frontend/src/hooks/useRunStream.ts @@ -91,12 +91,13 @@ export function useRunStream() { export async function createAndStreamRun( recollectionId: string, graph: { nodes: unknown[]; edges: unknown[] }, - connectToRun: (runId: string) => void + connectToRun: (runId: string) => void, + sceneId?: string ): Promise { const res = await fetch('/api/runs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ recollectionId, graph }), + body: JSON.stringify({ recollectionId, sceneId, graph }), }) if (!res.ok) {