| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import React, { useCallback, useMemo, useState } from "react"; |
| import { |
| ArrowRight, Check, GitBranch, Image as ImageIcon, Loader2, |
| Pencil, Play, RotateCw, X, |
| } from "lucide-react"; |
| import type { InteractiveApi } from "./api"; |
| import type { EdgeItem, NodeItem } from "./types"; |
| import { InteractiveApiError } from "./types"; |
| import { GeneratingPanel } from "./GeneratingPanel"; |
| import { |
| EmptyState, |
| ErrorBanner, |
| Panel, |
| PrimaryButton, |
| SecondaryButton, |
| SkeletonRow, |
| useAsyncResource, |
| useToast, |
| } from "./ui"; |
|
|
| export interface GraphPanelProps { |
| api: InteractiveApi; |
| projectId: string; |
| } |
|
|
| const KIND_ORDER: NodeItem["kind"][] = [ |
| "scene", "decision", "merge", "assessment", "remediation", "ending", |
| ]; |
|
|
| const KIND_BADGE: Record<NodeItem["kind"], string> = { |
| scene: "bg-[#3ea6ff]/10 text-[#3ea6ff] border-[#3ea6ff]/40", |
| decision: "bg-amber-500/10 text-amber-300 border-amber-500/40", |
| merge: "bg-purple-500/10 text-purple-300 border-purple-500/40", |
| assessment: "bg-emerald-500/10 text-emerald-300 border-emerald-500/40", |
| remediation: "bg-orange-500/10 text-orange-300 border-orange-500/40", |
| ending: "bg-[#aaa]/10 text-[#aaa] border-white/10", |
| }; |
|
|
| |
| |
| |
| const RENDERABLE_KINDS = new Set<NodeItem["kind"]>([ |
| "scene", "decision", "assessment", "remediation", |
| ]); |
|
|
| export function GraphPanel({ api, projectId }: GraphPanelProps) { |
| const toast = useToast(); |
| const resource = useAsyncResource<{ nodes: NodeItem[]; edges: EdgeItem[] }>( |
| async (signal) => { |
| const [nodes, edges] = await Promise.all([ |
| api.listNodes(projectId, signal), |
| api.listEdges(projectId, signal), |
| ]); |
| return { nodes, edges }; |
| }, |
| [api, projectId], |
| ); |
|
|
| const { grouped, titleById, outboundByFrom } = useMemo(() => { |
| const nodes = resource.data?.nodes || []; |
| const edges = resource.data?.edges || []; |
| const titleById = new Map<string, string>(); |
| nodes.forEach((n) => titleById.set(n.id, n.title || "(untitled)")); |
|
|
| const outboundByFrom = new Map<string, EdgeItem[]>(); |
| edges.forEach((e) => { |
| const list = outboundByFrom.get(e.from_node_id) || []; |
| list.push(e); |
| outboundByFrom.set(e.from_node_id, list); |
| }); |
|
|
| const grouped: Record<string, NodeItem[]> = {}; |
| for (const n of nodes) { |
| const bucket = grouped[n.kind] || (grouped[n.kind] = []); |
| bucket.push(n); |
| } |
| return { grouped, titleById, outboundByFrom }; |
| }, [resource.data]); |
|
|
| const missingAssetCount = useMemo(() => { |
| const nodes = resource.data?.nodes || []; |
| return nodes.filter( |
| (n) => RENDERABLE_KINDS.has(n.kind) && (n.asset_ids || []).length === 0, |
| ).length; |
| }, [resource.data]); |
|
|
| |
|
|
| const [bulkOpen, setBulkOpen] = useState(false); |
| const [bulkTotal, setBulkTotal] = useState(0); |
| const [bulkDone, setBulkDone] = useState(0); |
| const [bulkTitle, setBulkTitle] = useState(""); |
|
|
| const regenerateAllMissing = useCallback(async () => { |
| setBulkOpen(true); |
| setBulkTotal(0); |
| setBulkDone(0); |
| setBulkTitle(""); |
| try { |
| await api.generateAllStream(projectId, { |
| onEvent: (ev) => { |
| if (ev.type === "rendering_started") { |
| const total = Number((ev.payload as any)?.total || 0); |
| setBulkTotal(total); |
| } |
| if (ev.type === "rendering_scene") { |
| const title = String((ev.payload as any)?.title || ""); |
| if (title) setBulkTitle(title); |
| } |
| if ( |
| ev.type === "scene_rendered" |
| || ev.type === "scene_skipped" |
| || ev.type === "scene_render_failed" |
| ) { |
| setBulkDone((prev) => prev + 1); |
| } |
| }, |
| }); |
| toast.toast({ |
| variant: "success", |
| title: "Regeneration complete", |
| message: "Your scenes are up to date.", |
| }); |
| resource.reload(); |
| } catch (err) { |
| const e = err as InteractiveApiError; |
| toast.toast({ |
| variant: "error", |
| title: "Regeneration failed", |
| message: e.message || "Try again, or regenerate scenes one by one.", |
| }); |
| } finally { |
| setBulkOpen(false); |
| } |
| }, [api, projectId, resource, toast]); |
|
|
| |
|
|
| const handleSavePatch = useCallback( |
| async (nodeId: string, patch: { title?: string; narration?: string; image_prompt?: string }) => { |
| try { |
| await api.patchNode(nodeId, patch); |
| toast.toast({ variant: "success", title: "Scene saved" }); |
| resource.reload(); |
| } catch (err) { |
| const e = err as InteractiveApiError; |
| toast.toast({ |
| variant: "error", |
| title: "Save failed", |
| message: e.message || "Check your input and retry.", |
| }); |
| throw err; |
| } |
| }, |
| [api, resource, toast], |
| ); |
|
|
| const handleRegenerate = useCallback( |
| async (nodeId: string) => { |
| try { |
| const res = await api.renderSingleScene(projectId, nodeId); |
| if (res.status === "rendered") { |
| toast.toast({ |
| variant: "success", |
| title: "Scene regenerated", |
| message: "Refreshing the graphβ¦", |
| }); |
| resource.reload(); |
| } else if (res.status === "skipped") { |
| toast.toast({ |
| variant: "warning", |
| title: "Rendering is disabled", |
| message: "Enable INTERACTIVE_PLAYBACK_RENDER or pick a different model in Settings.", |
| }); |
| } else if (res.status === "failed") { |
| toast.toast({ |
| variant: "error", |
| title: "Render failed", |
| message: res.reason || "Try again in a moment.", |
| }); |
| } else { |
| toast.toast({ |
| variant: "info", |
| title: "Rendered but not attached", |
| message: "The asset exists; refresh to see it.", |
| }); |
| resource.reload(); |
| } |
| } catch (err) { |
| const e = err as InteractiveApiError; |
| toast.toast({ |
| variant: "error", |
| title: "Couldn't regenerate this scene", |
| message: e.message || "Try again.", |
| }); |
| } |
| }, |
| [api, projectId, resource, toast], |
| ); |
|
|
| |
|
|
| const [previewNode, setPreviewNode] = useState<NodeItem | null>(null); |
|
|
| return ( |
| <Panel |
| title="Scene graph" |
| subtitle="Edit, regenerate, or preview any scene. Changes save as you go." |
| actions={ |
| missingAssetCount > 0 ? ( |
| <PrimaryButton |
| onClick={regenerateAllMissing} |
| icon={<RotateCw className="w-3.5 h-3.5" aria-hidden />} |
| size="sm" |
| > |
| Regenerate all ({missingAssetCount} missing) |
| </PrimaryButton> |
| ) : undefined |
| } |
| > |
| {resource.error ? ( |
| <ErrorBanner |
| title="Couldn't load the graph" |
| message={resource.error} |
| onRetry={resource.reload} |
| /> |
| ) : resource.loading && !resource.data ? ( |
| <div className="flex flex-col gap-2" aria-busy="true"> |
| {Array.from({ length: 4 }).map((_, i) => <SkeletonRow key={i} />)} |
| </div> |
| ) : (resource.data?.nodes || []).length === 0 ? ( |
| <EmptyState |
| icon={<GitBranch className="w-12 h-12" aria-hidden />} |
| title="No nodes yet" |
| description="The planner didn't seed a graph. Re-run the wizard, or open the Catalog tab to add scenes manually." |
| /> |
| ) : ( |
| <div className="flex flex-col gap-5"> |
| {KIND_ORDER.map((kind) => { |
| const bucket = grouped[kind]; |
| if (!bucket || bucket.length === 0) return null; |
| return ( |
| <section key={kind}> |
| <header className="flex items-center gap-2 mb-2"> |
| <span |
| className={[ |
| "text-[10px] uppercase tracking-wide px-2 py-0.5 rounded border", |
| KIND_BADGE[kind], |
| ].join(" ")} |
| > |
| {kind} |
| </span> |
| <span className="text-xs text-[#777]">{bucket.length} {bucket.length === 1 ? "node" : "nodes"}</span> |
| </header> |
| <ul className="flex flex-col gap-2"> |
| {bucket.map((node) => ( |
| <NodeRow |
| key={node.id} |
| node={node} |
| outbound={outboundByFrom.get(node.id) || []} |
| titleById={titleById} |
| onSave={handleSavePatch} |
| onRegenerate={handleRegenerate} |
| onPreview={() => setPreviewNode(node)} |
| /> |
| ))} |
| </ul> |
| </section> |
| ); |
| })} |
| </div> |
| )} |
| |
| {bulkOpen && ( |
| <GeneratingPanel |
| title={bulkTotal > 0 |
| ? `Regenerating scenes Β· ${bulkDone} of ${bulkTotal}` |
| : "Regenerating scenes"} |
| description={bulkTitle ? `Now rendering: ${bulkTitle}` : "Starting the render pipelineβ¦"} |
| spinnerSize="large" |
| accentClassName="text-[#c4b5fd]" |
| progress={bulkTotal > 0 ? Math.round((bulkDone / bulkTotal) * 100) : undefined} |
| progressLabel={bulkTotal > 0 ? `${bulkDone} / ${bulkTotal} scenes` : undefined} |
| /> |
| )} |
| |
| {previewNode && ( |
| <PreviewModal |
| api={api} |
| node={previewNode} |
| onClose={() => setPreviewNode(null)} |
| onRegenerate={() => { |
| setPreviewNode(null); |
| handleRegenerate(previewNode.id); |
| }} |
| /> |
| )} |
| </Panel> |
| ); |
| } |
|
|
|
|
| |
|
|
| function NodeRow({ |
| node, outbound, titleById, onSave, onRegenerate, onPreview, |
| }: { |
| node: NodeItem; |
| outbound: EdgeItem[]; |
| titleById: Map<string, string>; |
| onSave: (nodeId: string, patch: { title?: string; narration?: string; image_prompt?: string }) => Promise<void>; |
| onRegenerate: (nodeId: string) => void; |
| onPreview: () => void; |
| }) { |
| const [editing, setEditing] = useState(false); |
| const [saving, setSaving] = useState(false); |
| const [regenerating, setRegenerating] = useState(false); |
| const [draftTitle, setDraftTitle] = useState(node.title || ""); |
| const [draftNarration, setDraftNarration] = useState(node.narration || ""); |
| const [draftImagePrompt, setDraftImagePrompt] = useState(node.image_prompt || ""); |
|
|
| const hasAsset = (node.asset_ids || []).length > 0; |
| const canRender = RENDERABLE_KINDS.has(node.kind); |
|
|
| const openEdit = () => { |
| setDraftTitle(node.title || ""); |
| setDraftNarration(node.narration || ""); |
| setDraftImagePrompt(node.image_prompt || ""); |
| setEditing(true); |
| }; |
|
|
| const saveEdit = async () => { |
| const patch: { title?: string; narration?: string; image_prompt?: string } = {}; |
| if (draftTitle !== (node.title || "")) patch.title = draftTitle; |
| if (draftNarration !== (node.narration || "")) patch.narration = draftNarration; |
| if (draftImagePrompt !== (node.image_prompt || "")) patch.image_prompt = draftImagePrompt; |
| if (Object.keys(patch).length === 0) { |
| setEditing(false); |
| return; |
| } |
| setSaving(true); |
| try { |
| await onSave(node.id, patch); |
| setEditing(false); |
| } catch { |
| |
| } finally { |
| setSaving(false); |
| } |
| }; |
|
|
| const kickRegenerate = async () => { |
| setRegenerating(true); |
| try { |
| await onRegenerate(node.id); |
| } finally { |
| setRegenerating(false); |
| } |
| }; |
|
|
| return ( |
| <li className="bg-[#121212] border border-[#3f3f3f] rounded-md p-3 flex flex-col gap-2"> |
| {editing ? ( |
| <div className="flex flex-col gap-2"> |
| <FieldRow label="Title"> |
| <input |
| type="text" |
| value={draftTitle} |
| onChange={(e) => setDraftTitle(e.target.value)} |
| className="w-full bg-[#0f0f0f] border border-[#3f3f3f] rounded-md px-2.5 py-1.5 text-sm outline-none focus:border-[#3ea6ff]" |
| autoFocus |
| /> |
| </FieldRow> |
| <FieldRow label="Narration"> |
| <textarea |
| value={draftNarration} |
| onChange={(e) => setDraftNarration(e.target.value)} |
| rows={3} |
| className="w-full bg-[#0f0f0f] border border-[#3f3f3f] rounded-md px-2.5 py-1.5 text-sm outline-none focus:border-[#3ea6ff] resize-y" |
| /> |
| </FieldRow> |
| <FieldRow label="Image prompt"> |
| <textarea |
| value={draftImagePrompt} |
| onChange={(e) => setDraftImagePrompt(e.target.value)} |
| rows={2} |
| placeholder="Optional visual direction for the renderer" |
| className="w-full bg-[#0f0f0f] border border-[#3f3f3f] rounded-md px-2.5 py-1.5 text-sm outline-none focus:border-[#3ea6ff] resize-y" |
| /> |
| </FieldRow> |
| <div className="flex items-center justify-end gap-2 mt-1"> |
| <SecondaryButton |
| onClick={() => setEditing(false)} |
| size="sm" |
| disabled={saving} |
| > |
| Cancel |
| </SecondaryButton> |
| <PrimaryButton |
| onClick={saveEdit} |
| size="sm" |
| icon={saving |
| ? <Loader2 className="w-3.5 h-3.5 animate-spin" aria-hidden /> |
| : <Check className="w-3.5 h-3.5" aria-hidden />} |
| disabled={saving} |
| > |
| Save |
| </PrimaryButton> |
| </div> |
| </div> |
| ) : ( |
| <div className="flex items-start justify-between gap-3"> |
| <div className="min-w-0 flex-1"> |
| <div className="text-sm font-medium text-[#f1f1f1] truncate"> |
| {node.title || "(untitled)"} |
| </div> |
| {node.narration && ( |
| <p className="text-xs text-[#aaa] mt-0.5 line-clamp-2">{node.narration}</p> |
| )} |
| {!hasAsset && canRender && ( |
| <div className="text-[11px] text-amber-300/90 mt-1 inline-flex items-center gap-1"> |
| <ImageIcon className="w-3 h-3" aria-hidden /> |
| No asset rendered yet |
| </div> |
| )} |
| </div> |
| <div className="flex items-center gap-1 shrink-0"> |
| {canRender && ( |
| <> |
| <IconAction label="Preview" onClick={onPreview}> |
| <Play className="w-3.5 h-3.5 fill-current" aria-hidden /> |
| </IconAction> |
| <IconAction |
| label={regenerating ? "Regeneratingβ¦" : "Regenerate"} |
| onClick={kickRegenerate} |
| disabled={regenerating} |
| > |
| {regenerating |
| ? <Loader2 className="w-3.5 h-3.5 animate-spin" aria-hidden /> |
| : <RotateCw className="w-3.5 h-3.5" aria-hidden />} |
| </IconAction> |
| </> |
| )} |
| <IconAction label="Edit" onClick={openEdit}> |
| <Pencil className="w-3.5 h-3.5" aria-hidden /> |
| </IconAction> |
| </div> |
| <code className="text-[10px] text-[#555] shrink-0 self-start mt-0.5">{node.id.slice(-6)}</code> |
| </div> |
| )} |
|
|
| {outbound.length > 0 && !editing && ( |
| <div className="flex items-center gap-2 flex-wrap"> |
| <ArrowRight className="w-3.5 h-3.5 text-[#777]" aria-hidden /> |
| {outbound |
| .slice() |
| .sort((a, b) => (a.ordinal || 0) - (b.ordinal || 0)) |
| .map((edge) => ( |
| <span |
| key={edge.id} |
| className="text-[11px] bg-[#1f1f1f] border border-[#3f3f3f] rounded px-2 py-0.5 text-[#cfd8dc]" |
| title={`edge ${edge.id} Β· ${edge.trigger_kind}`} |
| > |
| <span className="text-[#777] mr-1">{edge.trigger_kind}</span> |
| {titleById.get(edge.to_node_id) || edge.to_node_id} |
| </span> |
| ))} |
| </div> |
| )} |
| </li> |
| ); |
| } |
|
|
|
|
| |
|
|
| function FieldRow({ label, children }: { label: string; children: React.ReactNode }) { |
| return ( |
| <label className="flex flex-col gap-1"> |
| <span className="text-[10px] uppercase tracking-wide text-[#777]">{label}</span> |
| {children} |
| </label> |
| ); |
| } |
|
|
|
|
| function IconAction({ |
| children, label, onClick, disabled, |
| }: { |
| children: React.ReactNode; |
| label: string; |
| onClick: () => void; |
| disabled?: boolean; |
| }) { |
| return ( |
| <button |
| type="button" |
| onClick={onClick} |
| aria-label={label} |
| title={label} |
| disabled={disabled} |
| className="w-7 h-7 rounded-md hover:bg-white/10 text-[#aaa] hover:text-[#f1f1f1] flex items-center justify-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] disabled:opacity-50 disabled:cursor-not-allowed" |
| > |
| {children} |
| </button> |
| ); |
| } |
|
|
|
|
| |
|
|
| function PreviewModal({ |
| api, node, onClose, onRegenerate, |
| }: { |
| api: InteractiveApi; |
| node: NodeItem; |
| onClose: () => void; |
| onRegenerate: () => void; |
| }) { |
| const assetId = (node.asset_ids || [])[0] || ""; |
| |
| |
| |
| |
| |
| const urlRes = useAsyncResource<string | null>( |
| (signal) => assetId ? api.resolveAssetUrl(assetId, signal) : Promise.resolve(null), |
| [api, assetId], |
| ); |
| const src = urlRes.data || ""; |
| const looksLikeVideo = /\.(mp4|webm|mov|mkv|m4v)(\?|$)/i.test(src); |
|
|
| return ( |
| <div |
| role="dialog" |
| aria-modal="true" |
| aria-label={`Preview: ${node.title}`} |
| className="fixed inset-0 z-50 flex items-center justify-center p-6 bg-black/75 backdrop-blur-sm" |
| onClick={onClose} |
| > |
| <div |
| className="w-full max-w-2xl rounded-xl bg-[#0f0f0f] border border-[#3f3f3f] overflow-hidden" |
| onClick={(e) => e.stopPropagation()} |
| > |
| <header className="flex items-center justify-between gap-4 px-4 py-3 border-b border-[#2a2a2a]"> |
| <div className="min-w-0"> |
| <div className="text-sm font-semibold truncate">{node.title || "(untitled)"}</div> |
| <div className="text-[11px] text-[#777]">{node.kind} Β· preview</div> |
| </div> |
| <button |
| type="button" |
| onClick={onClose} |
| aria-label="Close preview" |
| className="w-8 h-8 rounded-md hover:bg-white/10 flex items-center justify-center" |
| > |
| <X className="w-4 h-4" aria-hidden /> |
| </button> |
| </header> |
| <div className="aspect-video bg-black flex items-center justify-center"> |
| {urlRes.loading ? ( |
| <Loader2 className="w-6 h-6 text-[#555] animate-spin" aria-hidden /> |
| ) : !src ? ( |
| <div className="text-center p-8"> |
| <ImageIcon className="w-12 h-12 text-[#555] mx-auto mb-3" aria-hidden /> |
| <div className="text-sm text-[#aaa]"> |
| {assetId ? "Asset not resolvable yet." : "No asset rendered yet."} |
| </div> |
| <div className="text-[11px] text-[#777] mt-1"> |
| Click Regenerate below to produce one. |
| </div> |
| </div> |
| ) : looksLikeVideo ? ( |
| <video |
| src={src} |
| controls |
| autoPlay |
| className="max-w-full max-h-full" |
| /> |
| ) : ( |
| <img src={src} alt={node.title} className="max-w-full max-h-full object-contain" /> |
| )} |
| </div> |
| {node.narration && ( |
| <div className="px-4 py-3 border-t border-[#2a2a2a] text-sm text-[#cfd8dc]"> |
| {node.narration} |
| </div> |
| )} |
| <footer className="flex items-center justify-end gap-2 px-4 py-3 border-t border-[#2a2a2a]"> |
| <SecondaryButton onClick={onClose} size="sm"> |
| Close |
| </SecondaryButton> |
| <PrimaryButton |
| onClick={onRegenerate} |
| size="sm" |
| icon={<RotateCw className="w-3.5 h-3.5" aria-hidden />} |
| > |
| Regenerate |
| </PrimaryButton> |
| </footer> |
| </div> |
| </div> |
| ); |
| } |
|
|