From 6576a45c0a914ed36e5e351cb27f0ef35023720d Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 02:04:00 +0800 Subject: [PATCH] docs(canvas): explain task panels and restore PR delivery workflow --- AGENTS.md | 2 +- src/components/canvas/ai-panel.tsx | 14 +++++++++ src/components/canvas/draft-recovery.tsx | 7 +++++ src/components/canvas/headless-ai-panel.tsx | 20 ++++++++++++ src/components/canvas/headless-imports.tsx | 14 +++++++++ .../canvas/headless-recovery-dialog.tsx | 22 +++++++++++++ .../canvas/headless-resource-picker.tsx | 20 ++++++++++++ .../canvas/legacy-canvas-migration.tsx | 31 +++++++++++++++++++ 8 files changed, 129 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index f80030d..486a5f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,6 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex - 按逻辑完整、可验证的工作单元提交;同一目标可以包含多个 commit。目标或可交付阶段完成并通过适当检查后,主动推送,不必每次提交后立即推送。 - 连续收到需求时,结合当前目标判断是继续同一工作单元,还是拆成独立提交;不要把无关需求混进同一个 commit。尚未验证或未完成的工作如实报告,不把推送成功当作功能验证通过。 - 每次提交前审查差异和暂存区,推送前检查远程状态和待推送 commit;只纳入本次负责的改动。保留用户或其他任务的未提交、未跟踪文件,不擅自暂存、丢弃、stash 或提交。 -- 分支和 PR 方式遵循用户当前明确授权。本项目当前阶段已获授权直接推送 main、暂不创建 PR;该阶段结束或用户调整约定后,恢复全局工作分支和 PR 流程。不得把主动管理 Git 理解为强推、重写共享历史、自行合并 PR 或绕过权限的授权。 +- 分支和 PR 方式遵循用户当前明确授权。本项目已恢复工作分支和 PR 流程:完成并验证后推送工作分支、创建或更新面向 main 的 PR,等待用户手动合并;不直接推送 main,也不自行合并 PR。不得把主动管理 Git 理解为强推、重写共享历史、自行合并 PR 或绕过权限的授权。 - 涉及 gitea.entroia.com 时始终使用专用 codex-bot 身份和隔离 SSH 配置,遵守全局身份约定。 - 每次交付用中文简要说明涉及的文件与修改目的、检查结果、分支、commit 标识及 push 状态;有阻碍及时说明,已获授权的常规操作无需反复询问。 diff --git a/src/components/canvas/ai-panel.tsx b/src/components/canvas/ai-panel.tsx index 3ae6347..0243071 100644 --- a/src/components/canvas/ai-panel.tsx +++ b/src/components/canvas/ai-panel.tsx @@ -1,3 +1,7 @@ +/** + * 旧版画布的节点AI浮层。通过useCanvasAI返回的控制接口管理生成,模型目录由旧画布AI接口读取。 + * 配置草稿保存在本地state,save回调将配置写回旧节点;本组件不承担新版ledger调度与恢复。 + */ "use client"; import { useEffect, useState } from "react"; import { NodeToolbar, Position } from "@xyflow/react"; @@ -8,6 +12,7 @@ import { CANVAS_ACCEPT, managedImageSource } from "@/lib/canvas-v2"; import type { CanvasNode } from "@/lib/canvas"; import type { CanvasModel, useCanvasAI } from "./use-canvas-ai"; import styles from "./canvas.module.css"; +/** 以boardId+node.id定位run步骤,列表最后一项作为当前任务;NodeToolbar将面板附着在显示节点下方。 */ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string; node: CanvasNode; workspace: string; boardId: string; ai: ReturnType; save: (patch: NonNullable) => void }) { const [models, setModels] = useState([]); const [model, setModel] = useState(node.data.ai?.modelId || ""); @@ -19,7 +24,10 @@ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string const image = ["image", "plain-image"].includes(node.data.kind); const relevant = [...ai.runs.values()].filter((run) => run.steps?.some((step) => step.board_id === boardId && step.node_id === node.id)); const run = relevant.at(-1), step = run?.steps?.find((step) => step.board_id === boardId && step.node_id === node.id); + // starting防止本地重复启动,之后以步骤queued/running判断生成忙碌;这不是整份画布的同步锁。 const busy = starting || step?.status === "queued" || step?.status === "running"; + // 演示/非live路径不请求模型;真实路径按输出模态筛选,并保留仍兼容选择或回退到首个可用模型。 + // 清理时abort,失败和finally检查signal再更新;成功分支直接采用api返回的结果。 useEffect(() => { if (!ai.live) { setLoading(false); return; } const controller = new AbortController(); @@ -30,10 +38,16 @@ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string }).catch((error) => { if (!controller.signal.aborted) setError(errorText(error)); }).finally(() => { if (!controller.signal.aborted) setLoading(false); }); return () => controller.abort(); }, [workspace, boardId, image, ai.live]); + /** 构造当前输入配置,参数固定为空对象;状态切换事件需显式替换刚改变的值,避免读取旧React state。 */ const config = () => ({ enabled: true, modelId: model, prompt, propagate, parameters: {} }); + /** 输入blur时,有模型且live才调用save;仅持久化配置,不启动服务端任务。 */ const persist = () => { if (model && ai.live) save(config()); }; + // output取当前步骤资源,文本内容则从最后一个step.output事件取;两者按同一board/node限定。 const output = run?.outputs?.find((item) => item.step_id === step?.step_id)?.output; const textOutput = run?.events?.filter((event) => event.type === "step.output" && event.node_id === node.id && event.board_id === boardId).at(-1)?.output?.text; + // 模型和传播开关变化即时save,正文blur保存;执行由ai.run负责,starting在finally复位。 + // 上传中、演示或没有可用模型禁止启动;与新版不同,此处未要求prompt非空,也未在busy时禁用textarea。 + // 冲突时保留结果并给显式apply入口,错误优先显示本面板错误;关闭/取消任务的行为交由父层与ai接口决定。 return
event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
{image ? "图像创作" : "文本创作"}{node.data.title}
diff --git a/src/components/canvas/draft-recovery.tsx b/src/components/canvas/draft-recovery.tsx index ca753e5..df2df4d 100644 --- a/src/components/canvas/draft-recovery.tsx +++ b/src/components/canvas/draft-recovery.tsx @@ -1,10 +1,17 @@ +/** + * 旧版画布未同步草稿的选择对话框。通过原生dialog提供模态展示,把恢复、导出或使用服务端版本交给调用方。 + * 本组件不读取草稿、不写服务端,也不判断本地内容是否能安全覆盖;canRestore由上层计算。 + */ "use client"; import { useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import styles from "./canvas.module.css"; +/** 可恢复时显示恢复按钮,否则提示子图关系变化;导出按钮仅在提供exportDraft回调时出现。 */ export function DraftRecovery({ restore, discard, canRestore = true, exportDraft }: { restore: () => void; discard: () => void; canRestore?: boolean; exportDraft?: () => void }) { const dialog = useRef(null); + // 挂载时showModal启用浏览器模态行为,卸载时close;不额外保存一份open状态。 useEffect(() => { const element = dialog.current; element?.showModal(); return () => element?.close(); }, []); + // 无document不创建Portal;取消事件阻止默认Escape关闭,要求用户通过明确的版本选择按钮处理。 if (typeof document === "undefined") return null; return createPortal( event.preventDefault()}>

发现未同步的本地草稿

{canRestore ? "服务端画布已读取。恢复草稿会以本地内容替换当前服务端版本,请选择要继续编辑的版本。" : "画布的子图关系已更新,这份旧草稿不能直接覆盖。可以先导出草稿,再使用服务端版本继续编辑。"}

diff --git a/src/components/canvas/headless-ai-panel.tsx b/src/components/canvas/headless-ai-panel.tsx index fcf5b78..15e3095 100644 --- a/src/components/canvas/headless-ai-panel.tsx +++ b/src/components/canvas/headless-ai-panel.tsx @@ -1,3 +1,7 @@ +/** + * 新版元素AI工具面板。配置、生成开始、取消和结果采用均通过engine命令进入既有状态与同步链路。 + * 任务事实读取runtime.runs/ledger,局部state只保存输入、模型目录和界面错误;关闭面板不等于取消生成。 + */ "use client"; import { useEffect, useState } from "react"; @@ -10,7 +14,10 @@ import { errorText } from "@/lib/api"; import { managedImageSource } from "@/lib/canvas-v2"; import styles from "./canvas.module.css"; +/** 只接受非数组JSON对象作为配置/输出包络,其它值退为空对象,避免直接按对象读取标量。 */ const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +/** 配置输入从元素当前ai字段初始化;模型输出模态按image/plain-image与其它组件区分。 + * 元素数据来自父层投影,本组件直接读取engine根用于显示任务,不再创建额外轮询器。 */ export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: ProjectEngine; element: ElementRecord; close(): void; viewId:string }) { const saved = object(element.data.ai), root = engine.getSnapshot(); const [models, setModels] = useState([]), [loading, setLoading] = useState(true), [error, setError] = useState(''); @@ -18,14 +25,20 @@ export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: Pr 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); + // 用runContainsTarget核对目标身份,选择相关任务列表的最后一项;不是按时间戳重新排序。 const relevant = [...root.runtime.runs.values()].filter(run => runContainsTarget(run,element)); const run = relevant.at(-1); + // ledger里的run.start可尚未得到服务端run,必须单独显示等待/阻断,不能仅看runs来判定空闲。 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; + // 同一run未解决的cancel/apply也占忙碌状态;hasMore/pendingPage表示任务事件尚未完整读取。 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)); + // 展示当前元素最后一个输出事件;是否可采用还要检查output_id、任务冲突状态及已应用集合。 const outputEvent = run?.outputEvents.filter(event => event.node_id === element.id).at(-1), output = object(outputEvent?.output); + // 按当前ownerCanvasId读取可用模型,保留仍兼容的选择,否则选首个available;目录不可用原因单独提示。 + // 切作用域/卸载时abort;所有异步结果先检查取消状态,不把旧目录写回新面板。 useEffect(() => { const controller = new AbortController();setLoading(true);setError(''); engine.models(element.ownerCanvasId,controller.signal).then(catalog => { @@ -36,13 +49,20 @@ export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: Pr }).catch(error => { if(!controller.signal.aborted)setError(errorText(error)); }).finally(() => { if(!controller.signal.aborted)setLoading(false); }); return () => controller.abort(); },[engine,element.ownerCanvasId,image]); + /** 将同步命令的rejected结果显示为错误;返回布尔值供“先保存配置再启动”短路。 */ function check(result: Result) { if(result.status === 'rejected') { setError(errorText(new Error(result.code)));return false; }return true; } + /** 有模型且不忙时提交ai配置;否则不发修改并返回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 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]) : '结果将显示在当前元素'; + // 输入blur保存配置,执行按钮再保存一次且成功后startRun;忙碌时锁定配置和启动,关闭按钮仍可用。 + // queued/running提供取消请求入口;冲突输出保留展示,只有事件读完且未采用时才允许显式替换。 + // 跟踪重试调用engine.retry,不新建run.start;pointer/keydown止于面板,避免触发画布手势或快捷键。 return
event.stopPropagation()} onKeyDown={event=>event.stopPropagation()}>
{image ? '图像创作' : '文本创作'}{element.data.title as string}