From 62436024024b3ceaaa2f13f69c94930a6fb101a2 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 01:03:05 +0800 Subject: [PATCH] Add durable AI cancellation and undoable output application --- src/components/canvas/headless-ai-panel.tsx | 15 +++-- src/lib/canvas-core/editor.ts | 6 +- src/lib/canvas-core/types.ts | 4 +- src/lib/canvas-runtime/engine.ts | 74 ++++++++++++++++++--- src/lib/canvas-runtime/ledger.ts | 19 ++++-- src/lib/canvas-runtime/reconcile.ts | 4 +- src/lib/canvas-runtime/recovery.ts | 29 +++++++- src/lib/canvas-runtime/runs.ts | 5 +- src/lib/canvas-runtime/scheduler.ts | 4 +- src/lib/canvas-service/runs.ts | 49 ++++++++++++-- tests/headless/core.test.mjs | 6 ++ tests/headless/renderer.integration.mjs | 23 ++++++- tests/headless/runs.test.mjs | 64 ++++++++++++++++-- 13 files changed, 255 insertions(+), 47 deletions(-) diff --git a/src/components/canvas/headless-ai-panel.tsx b/src/components/canvas/headless-ai-panel.tsx index 9824094..790f9ee 100644 --- a/src/components/canvas/headless-ai-panel.tsx +++ b/src/components/canvas/headless-ai-panel.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { ArrowUp, Sparkles, X } from "lucide-react"; +import { ArrowUp, Sparkles, Square, 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"; @@ -20,8 +20,11 @@ export function HeadlessAIPanel({ engine, element, close }: { engine: ProjectEng 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 starts = [...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); + const pending=starts.filter(record=>record.phase!=='resolved'), rejectedStart=starts.at(-1)?.resolution==='not-applied'?starts.at(-1):undefined; + const actions=[...root.runtime.ledger.operations.values()].filter(record=>(record.intent.operationType==='run.cancel'||record.intent.operationType==='run.apply')&&record.intent.action.runId===run?.id&&record.phase!=='resolved'); + const cancelling=actions.some(record=>record.intent.operationType==='run.cancel'),applying=actions.some(record=>record.intent.operationType==='run.apply'); + const busy = pending.length > 0 || actions.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(''); @@ -38,16 +41,18 @@ export function HeadlessAIPanel({ engine, element, close }: { engine: ProjectEng 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]) : '结果将显示在当前元素'; + const appliedOutput=typeof output.output_id==='string'&&run?.appliedOutputIds?.includes(output.output_id); + const message = cancelling ? '取消请求已保存,等待确认…' : applying ? '正在应用生成结果…' : rejectedStart ? `生成未受理:${rejectedStart.blockers.join('、')}` : appliedOutput ? '此生成结果已应用' : 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}