feat: multiple scenes

This commit is contained in:
2026-03-29 01:53:15 +01:00
parent 9607ff3478
commit 7ced2172af
15 changed files with 503 additions and 48 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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)
}

View File

@@ -16,7 +16,7 @@ import { executeRun } from '../services/graphRunnerService.js'
*/
export async function handleCreateRun(req: Request, res: Response): Promise<void> {
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<void
const run: Run = {
id: crypto.randomUUID(),
recollectionId,
sceneId: typeof sceneId === 'string' ? sceneId : null,
status: 'pending',
graphSnapshot: JSON.stringify(graph),
createdAt: new Date().toISOString(),

View File

@@ -202,11 +202,13 @@ function FullscreenNodeContent({ node }: { node: AppNode }) {
export type CanvasPageProps = {
/** Optional recollection id for per-recollection graph loading */
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). */
focusNodeId?: string
}
export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
export function CanvasPage({ recollectionId, sceneId, focusNodeId }: CanvasPageProps) {
const { theme } = useTheme()
const { showMinimap } = usePlatform()
const {
@@ -225,7 +227,7 @@ export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
setStateImmediate,
save,
saveStatus,
} = useCanvasGraph(recollectionId)
} = useCanvasGraph(recollectionId, sceneId)
const [rfInstance, setRfInstance] = React.useState<unknown>(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
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

View File

@@ -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[])

View File

@@ -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<typeof useGraphStateWithHistory> &
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.

View File

@@ -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 (
<RecollectionActionsProvider>
<RecollectionSidebarProvider>
<div className="flex min-h-0 flex-1 flex-col">
<RecollectionMenubar />
<div className="flex min-h-0 flex-1 flex-row overflow-hidden">
<RecollectionSidebar />
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<Outlet />
<FluxSceneProvider>
<div className="flex min-h-0 flex-1 flex-col">
<RecollectionMenubar />
<div className="flex min-h-0 flex-1 flex-row overflow-hidden">
<RecollectionSidebar />
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<Outlet />
</div>
</div>
</div>
</div>
</FluxSceneProvider>
</RecollectionSidebarProvider>
</RecollectionActionsProvider>
)

View File

@@ -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 (
<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>
)
}

View 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>
}

View File

@@ -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() {
<div className="flex items-center gap-2">
{statusIcon[run.status] ?? statusIcon.pending}
<span className="font-medium capitalize">{run.status}</span>
{run.sceneId && (
<span className="text-xs text-muted-foreground">({run.sceneId})</span>
)}
</div>
<span className="text-xs text-muted-foreground">
{new Date(run.createdAt).toLocaleString()}

View File

@@ -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)

View File

@@ -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() {
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Flux</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<button
type="button"
data-active={isFluxView}
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
onClick={() => navigate(`${base}/flux`)}
>
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
<span className="truncate">Canvas</span>
</button>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</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>
<SidebarGroupLabel>Flux</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<button
type="button"
data-active={isFluxView}
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
onClick={() => navigate(`${base}/flux`)}
>
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
<span className="truncate">Canvas</span>
</button>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}
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>
)
}

View File

@@ -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))
}

View File

@@ -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<void> {
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) {