feat: multiple scenes
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[])
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
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 = {
|
||||
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()}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user