docs(canvas): 补充任务面板注释并恢复 PR 交付 #2

Merged
Zayden-Jung merged 1 commits from codex/canvas-panel-comments into main 2026-11-11 18:07:39 +00:00
8 changed files with 129 additions and 1 deletions
+1 -1
View File
@@ -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 状态;有阻碍及时说明,已获授权的常规操作无需反复询问。
+14
View File
@@ -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<typeof useCanvasAI>; save: (patch: NonNullable<CanvasNode["data"]["ai"]>) => void }) {
const [models, setModels] = useState<CanvasModel[]>([]);
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 <NodeToolbar nodeId={id} isVisible position={Position.Bottom} offset={26} style={{ zIndex: 110 }}><section className={`${styles.aiPanel} nodrag nopan nowheel`} aria-label="元素 AI 工具"
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
<header><Sparkles size={14} /><strong>{image ? "图像创作" : "文本创作"}</strong><span>{node.data.title}</span></header>
+7
View File
@@ -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<HTMLDialogElement>(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(<dialog ref={dialog} className={styles.recoveryDialog} aria-labelledby="draft-recovery-title" onCancel={(event) => event.preventDefault()}>
<h2 id="draft-recovery-title">发现未同步的本地草稿</h2><p>{canRestore ? "服务端画布已读取。恢复草稿会以本地内容替换当前服务端版本,请选择要继续编辑的版本。" : "画布的子图关系已更新,这份旧草稿不能直接覆盖。可以先导出草稿,再使用服务端版本继续编辑。"}</p>
@@ -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<readonly RunModel[]>([]), [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 <section data-canvas-ui-view={viewId} className={`${styles.aiPanel} nodrag nopan nowheel`} aria-label="元素 AI 工具" style={{position:'absolute',right:24,bottom:90,zIndex:110,maxWidth:'calc(100% - 48px)',maxHeight:'70%',overflow:'auto'}} onPointerDown={event=>event.stopPropagation()} onKeyDown={event=>event.stopPropagation()}>
<header><Sparkles size={14}/><strong>{image ? '图像创作' : '文本创作'}</strong><span>{element.data.title as string}</span><button aria-label="关闭 AI 工具" onClick={close}><X size={14}/></button></header>
<textarea aria-label="AI 指令" placeholder="希望如何处理当前内容?" maxLength={16000} disabled={busy} value={prompt} onChange={event=>setPrompt(event.target.value)} onBlur={save}/>
@@ -1,3 +1,7 @@
/**
* 新版画布的导入/复制任务显示入口。订阅engine已发布状态,选择文件后启动runtime任务,并展示已加入前缀、等待项与失败原因。
* 面板不保存独立任务事实;暂停/继续/停止由imports实现,停止后已加入内容仍保留,不能把它当成整批回滚。
*/
"use client";
import { useCallback, useRef, useState, useSyncExternalStore } from "react";
@@ -7,15 +11,25 @@ import { IconButton } from "@/components/ui/icon-button";
import { errorText } from "@/lib/api";
import styles from "./canvas.module.css";
/** 将记录级错误码转成可读说明,未识别的码使用通用提示;原始错误仍保存在任务记录中。 */
const reason = (code: string) => ({ import_asset_authorization_failed: "资源访问授权失败,此记录未加入", import_asset_capacity: "本组资源过大,无法一次准备", asset_depot_unavailable: "资源仓库暂不可用", import_resource_authorization_required: "资源需要重新授权,此记录尚未加入", invalid_document_import: "导出文档结构不合法", invalid_json_array: "JSON 记录数组格式错误", incomplete_json_array: "JSON 数组尾部不完整", json_nesting_limit: "JSON 嵌套层数过多", invalid_json: "记录格式错误", invalid_json_value: "记录包含不支持的数据", invalid_utf8: "文件编码不是 UTF-8", record_too_large: "单条记录过大", unresolved_dependency: "引用的记录未加入", invalid_native_import_record: "不是画布记录", import_component_unavailable: "元素类型暂不支持", invalid_geometry: "元素尺寸不合法", invalid_endpoint: "连线端点已不存在", duplicate_import_source_key: "记录标识重复", import_source_read_failed: "暂时无法读取源文件,可重试;已加入内容保留", import_source_verification_failed: "源文件校验失败,可重试;已加入内容保留", invalid_import_read_proof: "源文件读取证据不一致,任务已暂停", import_source_unavailable: "导入源文件不可用", import_source_changed: "源文件内容与保存时不一致,任务已暂停", import_source_integrity_unavailable: "旧任务缺少源内容校验,无法安全继续;已加入内容保留", import_source_integrity_missing: "源校验记录缺失,任务已暂停", import_source_integrity_interpreter_unavailable: "源校验版本暂不支持", import_target_missing: "目标分区已不存在", import_target_changed: "目标分区已改变", import_dependency_changed: "引用对象已删除或恢复为新版本", dependency_capacity: "等待引用过多,请调整文件顺序", copy_external_changed: "原图引用已删除、移动或恢复为新版本", invalid_duplicate_source: "复制源数据不可用", invalid_copy_snapshot: "复制快照不可用", invalid_copy_preparation: "复制准备记录不可用", copy_interpreter_changed: "复制规则已变化,需要重新复制", invalid_import_source: "文件过大或格式不支持" } as Record<string,string>)[code] ?? "这条记录暂时无法加入";
/** 只显示目标canvas最近三个任务,倒序展示;任务数量与汇总仅覆盖这个显示窗口,不代表项目全部导入。 */
export function HeadlessImports({ engine, canvasId, viewId }: { engine: ProjectEngine; canvasId: string; viewId:string }) {
// React订阅唯一发布根,不在组件里复制任务状态;同一个getSnapshot也作为服务端快照入口。
const subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]);
const snapshot = useSyncExternalStore(subscribe, engine.getSnapshot, engine.getSnapshot);
const input = useRef<HTMLInputElement>(null), [open, setOpen] = useState(false), [error, setError] = useState("");
// 每次渲染更新engine/canvas身份,用于异步文件探测和控制回调完成后避免更新另一画布的错误显示。
const scope = useRef({ engine, canvasId }); scope.current = { engine, canvasId };
const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse();
/** 统一承接runtime控制Promise的成功/失败;这里只更新面板错误,不自行重排或重启任务。 */
const control = (work: Promise<void>) => { void work.then(() => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(""); }).catch(error => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(errorText(error)); }); };
// canStart决定文件入口可用性;input每次先清value,允许再次选择同一个文件。
// .json读取前64KiB探测首个非空白字符:对象走文档导入,数组走记录数组;其它后缀走NDJSON。
// 探测前捕获canvas父关系及入口生命周期,await后重新核对,防止向已被替换/移走的目标启动任务。
// integrityVersion=1源校验未完成显示校验中;=2允许内容继续编辑,但提示校验完成后同步。
// 任务按钮按runtime状态显示;失败详情最多列前10项,停止提示明确已加入内容继续同步。
return <aside data-canvas-ui-view={viewId} className={styles.importPanel} aria-label="画布导入">
<div className={styles.importActions}><IconButton icon={FolderInput} label="导入画布记录" disabled={!engine.imports.canStart(canvasId).enabled} onClick={() => input.current?.click()} /><span>NDJSON / JSON 记录数组</span></div>
<input data-canvas-import ref={input} type="file" accept=".ndjson,.jsonl,.json,application/json,application/x-ndjson" hidden onChange={async event => {
@@ -1,3 +1,7 @@
/**
* 新版画布的同步冲突处理界面。取得engine提供的恢复会话,按操作逐项选择保留/放弃,先预览再应用。
* 真正的冻结、回执核对、重放及备份由runtime完成;面板只保存选择与方案展示,不自行覆盖当前文档。
*/
"use client";
import { useEffect, useRef, useState } from 'react';
@@ -7,6 +11,7 @@ import type { RecoverySession } from '@/lib/canvas-runtime/engine';
import type { ConflictPlan, RecoveryChoice } from '@/lib/canvas-runtime/conflicts';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogTitle } from '@/components/ui/dialog';
/** 按恢复错误分类说明需要等待、重新选择或更新解释器;未知错误保留通用重试提示。 */
function message(error:unknown){
const code=(error as {code?:string;message?:string})?.code??(error as Error)?.message;
if(code==='recovery_waiting_for_receipts')return '还有请求正在确认。请关闭此窗口,重试同步后再处理。';
@@ -30,19 +35,24 @@ function message(error:unknown){
if(code==='forbidden'||code==='unauthorized'||code==='identity_changed')return '当前账号无法读取或修改此画布,请确认权限后重试。';
return '处理未完成,当前修改仍已保留。请重试,或恢复编辑后稍后处理。';
}
/** undefined明确显示未设置,字符串直接显示,其它JSON序列化;超过240字符可展开全文。
* React文本节点负责转义内容,不将导入内容或服务端值作为HTML执行。 */
function PreviewValue({value}:{value:Json|undefined}){
if(value===undefined)return <span>未设置</span>;
const text=typeof value==='string'?value:JSON.stringify(value);
return text.length>240?<details><summary>{text.slice(0,240)}…(展开全文)</summary><pre style={{whiteSpace:'pre-wrap',overflowWrap:'anywhere'}}>{text}</pre></details>:<span>{text}</span>;
}
/** 仅对常见字段提供具体中文名,其它data字段与结构关系使用类别名,不改变字段寻址规则。 */
const fieldLabel=(field:string)=>({'data.title':'标题','data.text':'正文',geometry:'位置与尺寸',path:'连线路径'}[field]??(field.startsWith('data.')?'内容字段':'元素关系'));
/** 创建/删除/替换类变更并排显示服务端当前记录与本地after;字段变更由主列表另行渲染。 */
function StructureReview({change,document}:{change:Exclude<Change,{kind:'field'}>;document:Document}){
const current=change.kind==='element'?document.elements.get(change.id):document.canvases.get(change.id),kind=change.kind==='element'?'元素':'分区';
const title=change.kind==='element'?(change.after?.data.title??change.before?.data.title??change.id):change.id;
return <div style={{fontSize:13,overflowWrap:'anywhere',marginTop:8}}><strong>{change.after===null?'删除':change.before===null?'创建':'调整'}{kind} · {String(title)}</strong>
<div>服务端:{current?<PreviewValue value={current as unknown as Json}/>:<span>不存在</span>}</div><div>本地请求:{change.after?<PreviewValue value={change.after as unknown as Json}/>:<span>删除此{kind}</span>}</div></div>;
}
/** 展示方案内结构影响:记录创建删除,以及owner/端点/路径字段;统计删除数量便于核对关联范围。 */
function PlanStructureReview({plan}:{plan:ConflictPlan}){
const changes=plan.operationIds.flatMap(id=>plan.operations.get(id)!.changes),structural=changes.filter(change=>change.kind!=='field'||['ownerCanvasId','source','target','path'].includes(change.field));
if(!structural.length)return null;
@@ -52,17 +62,29 @@ function PlanStructureReview({plan}:{plan:ConflictPlan}){
</details>;
}
/** owner保存清理时要取消的会话,session用于显示;records按操作序列展示并排除上传任务。
* choices每个operationId一个决定;plan是会话生成的预览,任何选择修改都使旧plan失效。 */
export function HeadlessRecoveryDialog({engine,close,viewId}:{engine:ProjectEngine;close():void;viewId:string}){
const owner=useRef<RecoverySession|undefined>(undefined),[session,setSession]=useState<RecoverySession>(),[records,setRecords]=useState<readonly OperationIntent[]>([]);
// busy防重复动作;frozen在开始应用后锁住选择,保存异常时允许原方案重试,明确拒绝才解除冻结。
const [choices,setChoices]=useState(new Map<Id,RecoveryChoice>()),[plan,setPlan]=useState<ConflictPlan>(),[error,setError]=useState(''),[busy,setBusy]=useState(true),[frozen,setFrozen]=useState(false);
// 微任务中准备会话,组件已清理时跳过;会话晚到则立即cancel,不再认领为当前面板。
useEffect(()=>{
let live=true;
Promise.resolve().then(()=>live?engine.prepareRecovery():undefined).then(async session=>{if(!session)return;if(!live){await session.cancel();return;}owner.current=session;setSession(session);setRecords([...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>session.pendingOperationIds.includes(record.intent.id)&&record.intent.operationType!=='asset.upload').sort((a,b)=>a.intent.sequence-b.intent.sequence).map(record=>record.intent));}).catch(error=>{if(live)setError(message(error));}).finally(()=>{if(live)setBusy(false);});
// 卸载尝试取消自己取得的会话;失败时engine仍保留持久屏障,后续retry/cancelRecovery继续收尾。
return()=>{live=false;void owner.current?.cancel().catch(()=>{/* Engine retains the failed durable barrier; retry/cancelRecovery can finish it. */});};
},[engine]);
/** 已有session且正在操作时不允许取消;否则等待engine解除恢复屏障,成功才关闭,失败留窗提示。 */
async function cancel(){if(busy&&session)return;setBusy(true);try{await engine.cancelRecovery();close();}catch(error){setError(message(error));}finally{setBusy(false);}}
/** 有plan则resolve逐项方案,无待处理操作则apply采用最新根;拒绝结果展示原因,异常保留冻结方案供重试。 */
async function apply(){if(!session||busy)return;setBusy(true);setError('');setFrozen(true);try{const result=plan?await session.resolve(plan):await session.apply();if(result.status==='rejected'){setFrozen(false);setError(message({code:result.code}));return;}close();}catch(error){setError(message(error));}finally{setBusy(false);}}
// canonical是恢复会话捕获的服务端基准,不直接读working当成服务端值。
const canonical=session?.canonical.document;
// Dialog关闭统一走cancel,session忙碌时抑制Escape;初次准备期间仍可请求恢复编辑。
// 逐项显示服务端、本地与方案值:afterPresent=false明确表示移除字段,不等同undefined值。
// 仅document.change/run.apply提供保留选项,生成开始/取消等动作可放弃但不能在此自动重新执行。
// 选择齐全才允许plan,存在本地记录则必须先有plan才允许应用;方案汇总同时显示undo/redo数量变化。
return <Dialog open onOpenChange={open=>{if(!open)void cancel();}}><DialogContent data-canvas-ui-view={viewId} aria-label="处理同步修改" style={{zIndex:300,maxHeight:'80vh',overflowY:'auto',background:'var(--background, #fffdf9)',color:'var(--foreground, #302d29)'}} onEscapeKeyDown={event=>{if(busy&&session)event.preventDefault();}}>
<DialogTitle>处理同步修改</DialogTitle>
<DialogDescription>检查期间暂停编辑。逐项选择要保留的修改,预览后再应用;原内容和撤销记录会保留备份。</DialogDescription>
@@ -1,3 +1,7 @@
/**
* 从同工作区已打开的其它项目中发现可复用图片,授权接纳资源后在目标画布创建新图片元素。
* 面板不重新上传原文件;资源接纳与元素创建的事务、持久化和授权由engine.adoptResource负责。
*/
"use client";
import { useEffect, useId, useRef, useState } from "react";
@@ -9,27 +13,37 @@ import type { Id, Point } from "@/lib/canvas-core/types";
import { fault } from "@/lib/canvas-core/values";
import { errorText } from "@/lib/api";
/** 懒加载候选缩略图,失败后以通用图标代替;failed是该预览组件的局部显示状态,不修改资源记录。 */
function Preview({src}:{src:string}){const [failed,setFailed]=useState(false);return failed?<ImageIcon aria-hidden className="h-8 w-8 opacity-50"/>:<img src={src} alt="" loading="lazy" className="h-full w-full object-contain" onError={()=>setFailed(true)}/>;}
/** 列表只保存资源ID和展示标题;插入时重新由engine取得资源元数据,不能信任缩略图即代表可访问。 */
type Candidate = { id: Id; title: string };
/** position是打开面板时捕获的画布坐标,插入图片以此为中心;onAdded让调用方选择新元素。 */
export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,close,onAdded,viewId}:{engine:ProjectEngine;workspaceId:Id;canvasId:Id;position:Point;close():void;onAdded(id:Id):void;viewId:string}) {
const host=useCanvasRuntimeHost(),[items,setItems]=useState<Candidate[]>([]),[loading,setLoading]=useState(true),[busy,setBusy]=useState<Id>(),[error,setError]=useState("");
// controller取消本次面板操作,requesting同步防重复点击,busy用于禁用按钮和展示当前候选进度。
const lifecycle=useRef<AbortController|null>(null),requesting=useRef(false),descriptionId=useId();
/** 关闭前发取消信号;已经提交的事务不会由这个UI回调自动撤销。 */
const dismiss=()=>{lifecycle.current?.abort();close();};
// 初次挂载捕获目标canvas及父入口记录,异步接纳资源后仍须核对入口身份与生命周期。
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(()=>{
const controller=new AbortController();lifecycle.current=controller;
const identity=host.identity(),workspace=host.getWorkspaces().get(workspaceId);
void (async()=>{
if(!workspace?.isCurrentIdentity()||workspace.accountId!==identity.accountId)throw fault('identity_changed');
// 单个项目打开失败不阻断其它候选,allSettled保留每项结果;排除本项目避免重复从自身取资源。
const results=await Promise.allSettled([...workspace.getProjects()].filter(([id])=>id!==engine.getSnapshot().document.projectId).map(([,project])=>project));
if(controller.signal.aborted)return;
if(!workspace.isCurrentIdentity()||host.identity().authEpoch!==identity.authEpoch)throw fault('identity_changed');
// 按资源ID去重,排除本项目已有资源;其它项目若有本地未stored资源也跳过,避免复用未可靠保存的文件。
const candidates=new Map<Id,Candidate>(),owned=engine.getSnapshot().runtime.resources;
for(const result of results)if(result.status==='fulfilled'){
const snapshot=result.value.getSnapshot();
for(const element of snapshot.document.elements.values()){
const id=element.data.assetId;
if(element.structure!=='node'||typeof id!=='string'||!id||owned.has(id)||candidates.has(id)||snapshot.runtime.resources.has(id)&&!snapshot.runtime.resources.get(id)!.stored)continue;
// 只从明确声明assetId资源字段的组件提取,不能把任意同名业务字段当图片资源。
if(!result.value.editor.registry.components.get(element.componentId)?.resourceFields?.includes('assetId'))continue;
candidates.set(id,{id,title:typeof element.data.title==='string'&&element.data.title.trim()?element.data.title:'未命名图片'});
}
@@ -38,6 +52,8 @@ export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,clo
})().catch(error=>{if(!controller.signal.aborted){setLoading(false);setError(errorText(error));}});
return()=>{controller.abort();if(lifecycle.current===controller)lifecycle.current=null;};
},[engine,host,workspaceId]);
/** 一次只接纳一个候选。生成新元素ID,在adoptResource提供的事务内校验目标并创建图片。
* 失败保持面板以便重试;关闭或卸载后不再更新错误/选中状态,取消的实际边界由engine处理。 */
async function insert(item:Candidate){
const signal=lifecycle.current?.signal;if(!signal||signal.aborted||requesting.current)return;
requesting.current=true;
@@ -45,15 +61,19 @@ export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,clo
try{
const id=crypto.randomUUID(),result=await engine.adoptResource(item.id,(resource,tx)=>{
const document=tx.getDocument(),canvas=document.canvases.get(canvasId),entry=canvas?.parent?document.elements.get(canvas.parent.entryElementId):undefined;
// 父入口ID变化或原入口incarnation变化都拒绝,避免资源授权等待期间目标已经重建。
if(!canvas||canvas.parent?.entryElementId!==target.canvas?.parent?.entryElementId||target.entry&&(entry?.id!==target.entry.id||entry.incarnation!==target.entry.incarnation))throw fault('asset_target_changed');
// 按原始宽高等比装入260×200边界,再以position为中心计算左上角;小图也可能被放大。
const scale=Math.min(260/resource.asset.width,200/resource.asset.height),width=resource.asset.width*scale,height=resource.asset.height*scale;
tx.dispatch({type:'element.create',element:{id,incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:canvasId,geometry:{x:position.x-width/2,y:position.y-height/2,width,height,rotation:0},data:{title:item.title,text:'',assetId:resource.id,src:resource.src}}});
},{signal});
// engine拒绝与异常统一进入错误展示;仅在面板仍活跃时通知onAdded并关闭。
if(result.status==='rejected')throw fault(result.code,result.targets);
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);}
}
// Dialog关闭入口都走dismiss;缩略图使用工作区资源预览接口,标题截断仅影响显示。
return <Dialog open onOpenChange={open=>{if(!open)dismiss();}}><DialogContent data-canvas-ui-view={viewId} aria-describedby={descriptionId}>
<DialogHeader><DialogTitle>从其它画布添加图片</DialogTitle><DialogDescription id={descriptionId}>选择同一工作区已打开画布中的图片,无需再次上传。</DialogDescription></DialogHeader>
{loading?<p role="status">正在查找图片…</p>:items.length===0?<p>暂无可复用的图片。先打开有已上传图片的画布,再回到这里。</p>:<div className="grid max-h-80 grid-cols-2 gap-3 overflow-y-auto">{items.map(item=><button key={item.id} type="button" aria-label={`添加图片:${item.title}`} disabled={!!busy} onClick={()=>void insert(item)} className="flex min-w-0 flex-col items-center gap-2 rounded-lg border border-border p-3 text-sm hover:bg-muted disabled:opacity-50">
@@ -1,3 +1,7 @@
/**
* 旧画布迁往canonical模型的界面协调层。持有本页旧编辑租约,迁移前停止新编辑并排空旧writer,结果确认后重开新版。
* “升级”指数据与协议迁移;本地记录和浏览器锁用于跨页协调,服务端版本校验与幂等仍由迁移服务负责。
*/
"use client";
import {useEffect,useRef,useState} from 'react';
import type {Registry} from '@/lib/canvas-core/types';
@@ -6,21 +10,33 @@ import {canvasMigrationStorageKey,migrationBlocksCanvas,readCanvasMigration,type
import {errorText} from '@/lib/api';
import styles from './canvas.module.css';
/** 将迁移错误码翻成可操作提示,尤其区分确定拒绝与结果未知;未知结果不能当成没写入而直接开放旧编辑。 */
function message(error:unknown){
const code=error instanceof Error?error.message:String(error);
return ({migration_busy:'其他旧画布页面仍在编辑或升级,请先完成那里的保存和上传,再关闭页面重试。',migration_lock_unavailable:'此浏览器不支持安全升级,请使用支持 Web Locks 的浏览器。',migration_response_unknown:'升级结果尚未确认,编辑已暂停。可继续查询原请求。',invalid_migration_receipt:'升级回执无法确认,已保留原请求并暂停编辑。请继续查询或联系支持。',migration_pending_uploads:'请先完成或取消图片上传,再升级。',draft_recovery_required:'请先处理本地草稿恢复,再升级。',canvas_busy:'请先完成当前操作与同步,再升级。',migration_waiting_for_runs:'请等待生成任务结束,再重新准备升级。',migration_version_conflict:'画布已发生变化,请重新准备升级。',workspace_migration_pending:'本工作区还有另一画布的升级待确认,请先处理原画布。',invalid_migration_record:'无法读取本地升级进度,已保留记录并暂停编辑。',migration_interpreter_unavailable:'本地升级进度需要其他版本解释,已保留记录并暂停编辑。',migration_not_durable:'无法可靠保存升级进度,请保留页面并重试。'} as Record<string,string>)[code]??errorText(error instanceof Error?error:new Error(code));
}
/** hold控制旧编辑入口;prepare排空指定根的保存工作,check检查当前是否允许迁移。
* canLeave保护重开页面前的未同步内容,exportDraft提供本页备份,context提供账户与迁移根身份。 */
type Props={context:LegacyMigrationContext;workspaceId:string;canvasId:string;registry:Registry;held:boolean;hold(value:boolean):void;prepare(rootId:string):Promise<void>;check():void;canLeave():boolean;exportDraft():void};
/** 只保存迁移展示状态,实际请求/本地记录由createBrowserCanvasMigration管理。
* attempt是同步防重入标记,running只用于渲染,finished表示本页已进入成功或外部变更后的交接阶段。 */
export function LegacyCanvasMigration(props:Props){
// 回调经ref读取最新props,避免每次父组件渲染都重新申请共享旧编辑租约。
const port=useRef(props);port.current=props;
const [job,setJob]=useState<CanvasMigrationJob>(),[error,setError]=useState(''),[checking,setChecking]=useState(true),[running,setRunning]=useState(false),[changedElsewhere,setChangedElsewhere]=useState(false);
// lease是本页共享旧编辑锁;life取消本次挂载工作;previous用来识别其它页面删除待确认记录的变化。
const lease=useRef<{release():Promise<void>}|undefined>(undefined),life=useRef<AbortController|undefined>(undefined),attempt=useRef(false),finished=useRef(false),previous=useRef<CanvasMigrationJob|undefined>(undefined);
// localStorage迁移记录按账户+工作区隔离,canvasId用于判断该任务是否阻断当前画布。
const scope={accountId:props.context.accountId,workspaceId:props.workspaceId},key=canvasMigrationStorageKey(scope);
const refresh=useRef<()=>Promise<void>>(async()=>{});
// 挂载或作用域变化时检查记录并申请旧编辑租约;身份失效或卸载后不再采用异步结果。
useEffect(()=>{
const controller=new AbortController();life.current=controller;
/** 同时检查本页取消与账户身份,不能只因旧请求返回就更新当前页面或解锁编辑。 */
const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
/** 读取并校验本作用域持久迁移记录;格式/解释版本错误也要走保留记录和暂停路径。 */
const read=()=>readCanvasMigration(localStorage,scope);
/** 迁移尝试进行中不抢租约;已有pending或finished就保持hold,正常情形取得共享锁后开放旧编辑。 */
async function inspect(){
if(attempt.current||!current())return;
setChecking(true);
@@ -29,9 +45,11 @@ export function LegacyCanvasMigration(props:Props){
if(migrationBlocksCanvas(next,props.canvasId)||finished.current){
port.current.hold(true);const held=lease.current;lease.current=undefined;await held?.release();setError('');return;
}
// 已持有共享锁时复用;申请新锁前先暂停编辑,避免没有锁的空档接收修改。
if(lease.current){port.current.hold(false);setError('');return;}
port.current.hold(true);
const acquired=await claimLegacyCanvasEditing({scope,canvasId:props.canvasId,signal:controller.signal,isCurrentIdentity:current});
// 等锁期间页面可能卸载或换账户,拿到过期锁立即释放,不再发布可编辑状态。
if(!current()){await acquired.release();return;}
lease.current=acquired;port.current.hold(false);setError('');
}catch(error){
@@ -39,6 +57,7 @@ export function LegacyCanvasMigration(props:Props){
port.current.hold(true);const held=lease.current;lease.current=undefined;await held?.release();
// Without Web Locks this browser cannot upgrade, but existing legacy
// editing remains supported unless there is an unresolved identity job.
// 不支持Web Locks仅禁止安全迁移;没有阻断记录时仍允许旧编辑,不能把这类环境错误当数据已迁移。
if(error instanceof Error&&error.message==='migration_lock_unavailable'){
try{if(!migrationBlocksCanvas(read(),props.canvasId))port.current.hold(false);}catch{}
}
@@ -46,6 +65,8 @@ export function LegacyCanvasMigration(props:Props){
}finally{if(current())setChecking(false);}
}
refresh.current=inspect;void inspect();
/** 其它页改变同一记录时重新检查;本页迁移中忽略事件,由当前尝试负责收尾。
* 待确认记录消失只说明外部变化,本页不能丢掉内存内容,先冻结并提示导出/重新打开。 */
const storage=(event:StorageEvent)=>{
if(event.key!==key&&event.key!==null||!current()||attempt.current)return;
// Never discard another view's in-memory content because a record vanished.
@@ -56,10 +77,13 @@ export function LegacyCanvasMigration(props:Props){
void inspect();
};
window.addEventListener('storage',storage);
// 卸载取消检查、拆storage监听并释放本页共享锁,不删除尚待确认的迁移记录。
return()=>{controller.abort();window.removeEventListener('storage',storage);const held=lease.current;lease.current=undefined;void held?.release();};
// The port ref follows renders without re-acquiring a shared editing lease.
// eslint-disable-next-line react-hooks/exhaustive-deps
},[key,props.canvasId]);
/** 新尝试先check并hold,在共享锁仍持有时prepare;释放共享锁后由迁移器申请独占锁。
* pending复用原记录resume,其它情况按rootId重新准备,不能把每次点击都变成新的迁移请求。 */
async function upgrade(){
const controller=life.current;if(!controller||controller.signal.aborted||attempt.current)return;
attempt.current=true;setRunning(true);setError('');
@@ -67,6 +91,7 @@ export function LegacyCanvasMigration(props:Props){
let failure='';
try{
const pending=readCanvasMigration(localStorage,scope);
// 工作区已有另一根pending时禁止挤占;同一根结果未知则继续原请求查询,不重复做初始准备。
if(pending?.phase==='pending'&&!migrationBlocksCanvas(pending,props.canvasId))throw new Error('workspace_migration_pending');
if(pending?.phase!=='pending')port.current.check();
port.current.hold(true);
@@ -75,14 +100,17 @@ export function LegacyCanvasMigration(props:Props){
if(pending?.phase!=='pending'&&props.context.rootId){await port.current.prepare(props.context.rootId);if(!current())return;}
const held=lease.current;lease.current=undefined;await held?.release();if(!current())return;
const migration=createBrowserCanvasMigration({scope,registry:props.registry,signal:controller.signal,isCurrentIdentity:current});
// start独占阶段再次check/prepare/check,覆盖共享锁释放到独占锁取得之间可能出现的旧写入。
const result=pending?.phase==='pending'?await migration.resume():props.context.rootId?await migration.start(props.context.rootId,async()=>{port.current.check();await port.current.prepare(props.context.rootId!);port.current.check();}):undefined;
if(!current())return;
// 只有确认applied或已迁移才通知外层重开;拒绝/未知保留记录,交finally重新决定是否能恢复旧编辑。
if(result?.status==='applied'||result?.status==='already-migrated'){
finished.current=true;port.current.context.onApplied();return;
}
if(result&&'error'in result){failure=message(result.error);setError(failure);}
const next=migration.read();previous.current=next;setJob(next);
}catch(error){if(current()){failure=message(error);setError(failure);}}
// 不论尝试结果都清重入标记;仅当前身份有效才更新UI,并从持久记录重新判断是否仍需hold。
finally{
attempt.current=false;
if(current()){
@@ -92,8 +120,11 @@ export function LegacyCanvasMigration(props:Props){
}
}
}
// pending表示本页在迁移范围内;other表示工作区另一画布待确认,按钮禁用但不篡改其任务。
const pending=migrationBlocksCanvas(job,props.canvasId),other=job?.phase==='pending'&&!pending;
if(!props.context.rootId&&!pending&&!error&&!props.held)return null;
// 入口依次呈现检查中/执行中/外部变更重开/开始或继续迁移;旧撤销记录不迁移,提示写在按钮title。
// 本页被hold时提供备份;外部变化后的重开仍须check及canLeave,不能直接丢弃尚未同步的内存内容。
return <div className={styles.migrationPanel} aria-label="画布升级">
{checking?<span role="status">正在检查画布编辑权限…</span>:<>
{running?<span role="status">正在保存并升级画布…</span>:changedElsewhere?<button onClick={()=>{try{port.current.check();if(!port.current.canLeave())throw new Error('本页有未同步内容,请先导出备份并手动重新打开画布。');port.current.context.onApplied();}catch(error){setError(message(error));}}}>重新打开画布</button>:props.context.rootId||pending?<button disabled={other} title="保存内容并使用新版编辑器;旧撤销记录不会带入" onClick={()=>void upgrade()}>{pending?'继续查询升级':job?.phase==='rejected'?'重新准备升级':'升级画布'}</button>:props.held?<button onClick={()=>void refresh.current()}>重新检查</button>:null}