From d07bc2ef91421992601d420dbc91634476808523 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 00:48:36 +0800 Subject: [PATCH] Persist AI admissions and reconcile canvas runs across navigation --- src/components/canvas/headless-ai-panel.tsx | 54 ++++++ .../canvas/headless-graph-canvas.tsx | 8 +- .../workspace/canvas-runtime-host.tsx | 4 +- src/lib/canvas-core/types.ts | 3 +- src/lib/canvas-runtime/contracts.ts | 2 + src/lib/canvas-runtime/engine.ts | 112 ++++++++++-- src/lib/canvas-runtime/ledger.ts | 8 +- src/lib/canvas-runtime/network.ts | 26 +++ src/lib/canvas-runtime/recovery.ts | 32 +++- src/lib/canvas-runtime/runs.ts | 104 +++++++++++ src/lib/canvas-runtime/scheduler.ts | 2 +- src/lib/canvas-runtime/workspace.ts | 2 +- src/lib/canvas-service/foundry.ts | 27 +-- src/lib/canvas-service/index.ts | 2 + src/lib/canvas-service/runs.ts | 96 ++++++++++ tests/headless/browser-storage-cases.mjs | 32 ++++ tests/headless/renderer.integration.mjs | 30 +++- tests/headless/runs.test.mjs | 169 ++++++++++++++++++ 18 files changed, 671 insertions(+), 42 deletions(-) create mode 100644 src/components/canvas/headless-ai-panel.tsx create mode 100644 src/lib/canvas-runtime/network.ts create mode 100644 src/lib/canvas-runtime/runs.ts create mode 100644 src/lib/canvas-service/runs.ts create mode 100644 tests/headless/runs.test.mjs diff --git a/src/components/canvas/headless-ai-panel.tsx b/src/components/canvas/headless-ai-panel.tsx new file mode 100644 index 0000000..9824094 --- /dev/null +++ b/src/components/canvas/headless-ai-panel.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ArrowUp, Sparkles, X } from "lucide-react"; +import type { ElementRecord, Json, Result } from "@/lib/canvas-core/types"; +import { runContainsTarget } from "@/lib/canvas-runtime/runs"; +import type { RunModel } from "@/lib/canvas-runtime/runs"; +import type { ProjectEngine } from "@/lib/canvas-runtime/workspace"; +import { errorText } from "@/lib/api"; +import { managedImageSource } from "@/lib/canvas-v2"; +import styles from "./canvas.module.css"; + +const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +export function HeadlessAIPanel({ engine, element, close }: { engine: ProjectEngine; element: ElementRecord; close(): void }) { + const saved = object(element.data.ai), root = engine.getSnapshot(); + const [models, setModels] = useState([]), [loading, setLoading] = useState(true), [error, setError] = useState(''); + const [model, setModel] = useState(typeof saved.modelId === 'string' ? saved.modelId : ''); + const [prompt, setPrompt] = useState(typeof saved.prompt === 'string' ? saved.prompt : ''); + const [propagate, setPropagate] = useState(saved.propagate === true); + const image = ['image','plain-image'].includes(element.componentId); + const relevant = [...root.runtime.runs.values()].filter(run => runContainsTarget(run,element)); + const run = relevant.at(-1); + const pending = [...root.runtime.ledger.operations.values()].filter(record => record.intent.operationType === 'run.start' && record.intent.run.target.id === element.id && record.intent.run.target.incarnation === element.incarnation && record.phase !== 'resolved'); + const busy = pending.length > 0 || !!run && (run.hasMore || !!run.pendingPage || ['queued','running'].includes(run.status)); + const outputEvent = run?.outputEvents.filter(event => event.node_id === element.id).at(-1), output = object(outputEvent?.output); + useEffect(() => { + const controller = new AbortController();setLoading(true);setError(''); + engine.models(element.ownerCanvasId,controller.signal).then(catalog => { + if(controller.signal.aborted)return; + const compatible = catalog.models.filter(model => model.output_modalities.includes(image ? 'image' : 'text')); + setModels(compatible);setModel(current => compatible.some(model => model.id === current) ? current : compatible.find(model => model.available)?.id ?? ''); + if(catalog.unavailableReason)setError(errorText(new Error(catalog.unavailableReason))); + }).catch(error => { if(!controller.signal.aborted)setError(errorText(error)); }).finally(() => { if(!controller.signal.aborted)setLoading(false); }); + return () => controller.abort(); + },[engine,element.ownerCanvasId,image]); + function check(result: Result) { if(result.status === 'rejected') { setError(errorText(new Error(result.code)));return false; }return true; } + function save() { + if(!model || busy)return true; + return check(engine.editor.dispatch({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{ai:{enabled:true,modelId:model,prompt,propagate,parameters:{}}}},{label:'Configure generation'})); + } + const message = pending.length ? pending.some(record => record.phase === 'blocked') ? '生成受理需要处理' : '正在等待同步并受理生成…' : run?.blocked ? '生成结果已保留,当前编辑需要协调' : run?.error ? '任务跟踪需要处理,可重试' : run?.hasMore ? '正在接收生成结果…' : run ? ({queued:'已受理,等待生成',running:'正在生成…',succeeded:'已更新当前元素',failed:'生成未完成,原内容已保留',conflict:'内容已改变,生成结果已保留',cancelled:'已取消生成',partially_succeeded:'部分步骤已完成'}[run.status]) : '结果将显示在当前元素'; + return
event.stopPropagation()} onKeyDown={event=>event.stopPropagation()}> +
{image ? '图像创作' : '文本创作'}{element.data.title as string}
+