From 3ef07cd00c28d3a7b15d7d6ba496df39f3ddbd02 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:29:49 +0800 Subject: [PATCH] docs(canvas): explain AI and resource picker state ownership --- src/components/canvas/ai-panel.tsx | 3 +++ src/components/canvas/headless-ai-panel.tsx | 4 ++++ src/components/canvas/headless-resource-picker.tsx | 4 ++++ src/components/canvas/use-canvas-ai.ts | 5 +++++ 4 files changed, 16 insertions(+) diff --git a/src/components/canvas/ai-panel.tsx b/src/components/canvas/ai-panel.tsx index 0243071..450bbb2 100644 --- a/src/components/canvas/ai-panel.tsx +++ b/src/components/canvas/ai-panel.tsx @@ -14,6 +14,7 @@ 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 }) { + // 配置只用初次挂载的 node 初始化,不随外部 data.ai 自动重置;切换目标时应由宿主重建/隔离面板。 const [models, setModels] = useState([]); const [model, setModel] = useState(node.data.ai?.modelId || ""); const [prompt, setPrompt] = useState(node.data.ai?.prompt || ""); @@ -24,6 +25,7 @@ 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 是 React 状态,按钮禁用在重渲染后生效,不是函数入口的同步互斥标记。 // starting防止本地重复启动,之后以步骤queued/running判断生成忙碌;这不是整份画布的同步锁。 const busy = starting || step?.status === "queued" || step?.status === "running"; // 演示/非live路径不请求模型;真实路径按输出模态筛选,并保留仍兼容选择或回退到首个可用模型。 @@ -39,6 +41,7 @@ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string return () => controller.abort(); }, [workspace, boardId, image, ai.live]); /** 构造当前输入配置,参数固定为空对象;状态切换事件需显式替换刚改变的值,避免读取旧React state。 */ + // 每次保存都显式 parameters:{},不会保留节点先前的自定义参数。 const config = () => ({ enabled: true, modelId: model, prompt, propagate, parameters: {} }); /** 输入blur时,有模型且live才调用save;仅持久化配置,不启动服务端任务。 */ const persist = () => { if (model && ai.live) save(config()); }; diff --git a/src/components/canvas/headless-ai-panel.tsx b/src/components/canvas/headless-ai-panel.tsx index 15e3095..64c4402 100644 --- a/src/components/canvas/headless-ai-panel.tsx +++ b/src/components/canvas/headless-ai-panel.tsx @@ -19,11 +19,13 @@ const object = (value: Json | undefined) => value && typeof value === 'object' & /** 配置输入从元素当前ai字段初始化;模型输出模态按image/plain-image与其它组件区分。 * 元素数据来自父层投影,本组件直接读取engine根用于显示任务,不再创建额外轮询器。 */ export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: ProjectEngine; element: ElementRecord; close(): void; viewId:string }) { + // getSnapshot 是本次 render 的读取,不建立订阅;任务状态更新是否触发重渲染由父层负责。 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); + // 输入 state 仅从初次 saved 初始化;同一挂载内外部 ai 配置变化不会自动覆盖本地草稿。 const image = ['image','plain-image'].includes(element.componentId); // 用runContainsTarget核对目标身份,选择相关任务列表的最后一项;不是按时间戳重新排序。 const relevant = [...root.runtime.runs.values()].filter(run => runContainsTarget(run,element)); @@ -60,6 +62,8 @@ export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: Pr 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]) : '结果将显示在当前元素'; + // 保存配置与 startRun 是两个同步命令,没有合成一笔事务;配置成功而启动拒绝时,配置仍已修改。 + // 命令接受也不等于服务端已受理或已持久确认,后续状态以 ledger/run 为准。 // 输入blur保存配置,执行按钮再保存一次且成功后startRun;忙碌时锁定配置和启动,关闭按钮仍可用。 // queued/running提供取消请求入口;冲突输出保留展示,只有事件读完且未采用时才允许显式替换。 // 跟踪重试调用engine.retry,不新建run.start;pointer/keydown止于面板,避免触发画布手势或快捷键。 diff --git a/src/components/canvas/headless-resource-picker.tsx b/src/components/canvas/headless-resource-picker.tsx index 01388d3..4340ece 100644 --- a/src/components/canvas/headless-resource-picker.tsx +++ b/src/components/canvas/headless-resource-picker.tsx @@ -14,6 +14,7 @@ import { fault } from "@/lib/canvas-core/values"; import { errorText } from "@/lib/api"; /** 懒加载候选缩略图,失败后以通用图标代替;failed是该预览组件的局部显示状态,不修改资源记录。 */ +// failed 不随 src 变化自动重置;宿主以资源 ID 作 key 隔离不同候选的预览实例。 function Preview({src}:{src:string}){const [failed,setFailed]=useState(false);return failed?:setFailed(true)}/>;} /** 列表只保存资源ID和展示标题;插入时重新由engine取得资源元数据,不能信任缩略图即代表可访问。 */ type Candidate = { id: Id; title: string }; @@ -25,6 +26,7 @@ export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,clo /** 关闭前发取消信号;已经提交的事务不会由这个UI回调自动撤销。 */ const dismiss=()=>{lifecycle.current?.abort();close();}; // 初次挂载捕获目标canvas及父入口记录,异步接纳资源后仍须核对入口身份与生命周期。 + // target 只在首次挂载捕获;canvasId/engine 更换时不能指望它自动变成新目标,宿主应重建面板。 const [target]=useState(()=>{const document=engine.getSnapshot().document,canvas=document.canvases.get(canvasId),entry=canvas?.parent?document.elements.get(canvas.parent.entryElementId):undefined;return {canvas,entry};}); // 记录当前账户代次与工作区,收集已打开/正在打开的其它项目;不扫描服务端全部画布。 useEffect(()=>{ @@ -48,6 +50,7 @@ export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,clo candidates.set(id,{id,title:typeof element.data.title==='string'&&element.data.title.trim()?element.data.title:'未命名图片'}); } } + // 候选只在此 effect 扫描一次,不订阅其它项目后续新增/移除的资源;真正插入仍需重新授权校验。 setItems([...candidates.values()]);setLoading(false); })().catch(error=>{if(!controller.signal.aborted){setLoading(false);setError(errorText(error));}}); return()=>{controller.abort();if(lifecycle.current===controller)lifecycle.current=null;}; @@ -69,6 +72,7 @@ export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,clo },{signal}); // engine拒绝与异常统一进入错误展示;仅在面板仍活跃时通知onAdded并关闭。 if(result.status==='rejected')throw fault(result.code,result.targets); + // 这两个宿主回调仍在 try 内;它们抛错会显示失败,但已经提交的创建事务不会回滚。 if(!signal.aborted){onAdded(id);close();} }catch(error){if(!signal.aborted)setError(error instanceof Error&&error.message==='not_found'?'无法读取这张图片,请确认仍有原画布的访问权限。':errorText(error));} finally{requesting.current=false;if(!signal.aborted)setBusy(undefined);} diff --git a/src/components/canvas/use-canvas-ai.ts b/src/components/canvas/use-canvas-ai.ts index 6b57fc2..44350f3 100644 --- a/src/components/canvas/use-canvas-ai.ts +++ b/src/components/canvas/use-canvas-ai.ts @@ -30,6 +30,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree const reference = useRef(tree); reference.current = tree; const wake = useRef<() => void>(() => {}); // cache是跟踪结果缓存,runs是发布给React的副本;每个run有自己的活动边集合,合并后供连线动画使用。 + // 这些 ref/state 不因 workspace/root/live 改变而清空;宿主应按作用域重建 Hook 所在视图,避免沿用旧跟踪表。 const cache = useRef(new Map()); const edgeStates = useRef(new Map>()); // 每个board只保留最高待刷版本,避免同一批step.applied触发多次重复整画布读取。 @@ -45,6 +46,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree /** 所有发现/分页GET共享取消信号并协商旧v2响应格式。 */ const request = (path: string) => api(path, { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } }); /** 分页发现根和后代活动run,每页50;已跟踪run不覆盖,重复cursor判为协议异常避免无限循环。 */ + // alive 只控制继续分页/发布等交接点;await 返回后仍可能先写共享 ref,不是每次赋值都有代次护栏。 async function discover() { let cursor = ""; const seen = new Set(); do { @@ -77,6 +79,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree } if (!active(page.status)) edges.clear(); edgeStates.current.set(id, edges); current = { ...page, board_id: previous.board_id, events: [...(current.events || []), ...(page.events || [])].slice(-1000) }; + // dirty 只比较该 run 原游标和状态;同游标/同状态下其它响应字段变化不一定立即发布给 React。 if (current.next_seq !== previous.next_seq || current.status !== previous.status) dirty = true; cache.current.set(id, current); } while (current.has_more && alive); @@ -115,11 +118,13 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree const id = result.run_id as string; cache.current.set(id, { run_id: id, board_id: boardId, status: "queued", next_seq: 0, steps: [{ step_id: "pending", board_id: boardId, node_id: node, status: "queued" }] }); changed(); wake.current(); } /** 以新幂等键发送取消命令后继续查询终态;暂记queued用于保持轮询,不表示服务端重新执行任务。 */ + // 此写请求没有轮询 effect 的 signal,也未检查 live;按键是否可用由调用方和服务端权限共同约束。 async function cancel(run: AIRun) { await api(canvasPath(workspace, run.board_id) + `/ai/runs/${run.run_id}/cancel`, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": crypto.randomUUID() }, body: "{}" }); const next = cache.current.get(run.run_id); if (next) { next.status = "queued"; } wake.current(); // Poll canonical terminal state, including already applied steps. } /** 先prepareApply协调目标本地状态,再发显式采用命令;成功后移除冲突提示并继续跟踪服务端最终状态。 */ + // run/apply 同样不由 effect 清理自动取消;卸载只停跟踪,不撤销已经发起的命令副作用。 async function apply(run: AIRun, node: string, output: string, targetBoard: string) { await reference.current.prepareApply(targetBoard, node); await reference.current.command(run.board_id, `ai/runs/${run.run_id}/apply`, { node_id: node, output_id: output }, undefined, false, targetBoard);