diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 9a624ac..b87c052 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -69,3 +69,6 @@ - 数据库聚合确认父图含 9 个节点、2 条边、2 张内嵌图片。JSON 文本约 477,646 字节,图片 src 合计 474,073 字符,约占 99.3%。原先设计让两张图片阻塞整张画布的数据读取。 修复采用普通 personal SaaS 附件模式:一份原始附件、一份按需显示预览,快照仅引用附件。前端添加真实附件上传及显式旧图迁移;Core PR #15 提供单一 1024px 预览、权限检查和有界缓存。原图不替换,AI 继续取原始内容。图片独立加载后,结构读取不再受两张完整图片的传输时间阻塞。生产部署和指定画布迁移后的实际字节数/时间须重测后记录,不能把预计缩减当作已完成实测。 + + +后端 PR #15 已由用户合并为 b2ae48e,分支/PR #936/#937、主线 #940、部署 #941 均成功。Endoleap main 570cd53 已接入。业务数据尚需用户明确点击“优化图片加载”;目前未把上传接口、预览上线或模拟测试当成该画布已经完成迁移/提速。迁移前聚合与哈希基线保存在服务器 `/tmp/endoleap-canvas-diag-20261106T131903Z/migration-baseline.json`,当前版本 252、8 个父图节点、2 张内嵌图片。预期在下一次明确操作后核对哈希和真实下载字节/时间。 diff --git a/README.md b/README.md index 2c43a6a..47e598d 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,6 @@ Headless Canvas API 的拆包方案保留在本地忽略的 `docs/HEADLESS_CANVA ## 图片附件与加载 -真实画布新上传的图片存入 Foundry 附件 API,快照只保存原始附件引用,避免 Base64 随每次打开和保存传输。原图保留;画布按需读取单一最长边 1024px 的预览,放大后可读取原图。预览权限与原图一致,Core 的配套预览功能通过 PR #15 交付,部署前服务端仍可能返回原图。 +真实画布新上传的图片存入 Foundry 附件 API,快照只保存原始附件引用,避免 Base64 随每次打开和保存传输。原图保留;画布按需读取单一最长边 1024px 的预览,放大后可读取原图。预览权限与原图一致,Core 的配套预览功能已随 PR #15 合并并部署(b2ae48e);小图或 GIF 可能直接返回原始文件。 已有内嵌图片通过画布提示中的“优化图片加载”明确迁移一次:上传原始字节,然后按当前版本保存相同节点、尺寸、位置与连线。打开页面不会自动迁移或 PATCH。只改变视角保存在本机;真实内容修改才自动保存到服务器。 diff --git a/VALIDATION.md b/VALIDATION.md index b92158b..7671967 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -49,5 +49,6 @@ - 新图片使用受管附件;旧图仅在用户明确点击后迁移,字节保持原样,快照不保存预览地址。 - 单元测试增加字节一致性、重复图片去重、幂等重试、失败不替换、V1 兼容和渲染 URL 分离;共 84 项通过。 - DOM 回归确认只读打开/缩放零 PATCH、显式迁移一次上传、位置和边不变、画布使用预览而快照保存原图引用;原有 AI/子图/连线回归通过。 -- 同步 TCP/数据库证据及隐私边界见 PERFORMANCE.md。Core PR #15 的最新远程检查、部署及用户画布迁移后实测仍在进行。 +- 同步 TCP/数据库证据及隐私边界见 PERFORMANCE.md。Core PR #15 已由用户合并为 b2ae48e;分支/PR CI #936/#937、主线检查 #940 和部署 #941 全部成功。当前仍等待用户明确执行旧图片迁移与重开日志,尚未完成实际画布的提速验收。 - 最终前端检查:TypeScript、84 项测试、完整 DOM 回归、隔离 Webpack 构建和 API 冒烟均通过(包括 15.2 秒慢正文、原图/预览参数及响应头)。 +- 最后补充:图片按需加载/异步解码;优化提示保持到版本保存确认后才消失,并阻止与新图片上传重叠。延迟保存的 DOM 回归和最终隔离构建通过。 diff --git a/src/components/canvas/graph-canvas.tsx b/src/components/canvas/graph-canvas.tsx index 171e5ac..47a32e3 100644 --- a/src/components/canvas/graph-canvas.tsx +++ b/src/components/canvas/graph-canvas.tsx @@ -478,7 +478,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap

⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。

可通过右上角导出 JSON 备份。

- {!!snapshot && inlineImageCount(tree.rootDocument) > 0 &&
此画布的旧图片可优化加载,原图和内容都会保留。
} + {!!snapshot && (inlineImageCount(tree.rootDocument) > 0 || tree.migratingImages) &&
此画布的旧图片可优化加载,原图和内容都会保留。
} {imageUploading &&

正在上传图片…

} {iconPicker && setIconPicker(false)} choose={(name, label) => { diff --git a/src/components/canvas/material-node.tsx b/src/components/canvas/material-node.tsx index a18e333..12fb013 100644 --- a/src/components/canvas/material-node.tsx +++ b/src/components/canvas/material-node.tsx @@ -26,7 +26,7 @@ export function MaterialNode(props: NodeProps) {
- {data.kind === "image" ? {data.title} : ( + {data.kind === "image" ? {data.title} : ( )} diff --git a/src/components/canvas/plain-image-node.tsx b/src/components/canvas/plain-image-node.tsx index 3a774bb..9474a6f 100644 --- a/src/components/canvas/plain-image-node.tsx +++ b/src/components/canvas/plain-image-node.tsx @@ -7,5 +7,5 @@ import styles from "./canvas.module.css"; export function PlainImageNode(props: NodeProps) { const { zoom } = useViewport(); const preview = (props.width || 320) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024; - return {props.data.title}; + return {props.data.title}; } diff --git a/src/components/canvas/use-canvas-tree.ts b/src/components/canvas/use-canvas-tree.ts index 059ffe4..30254ed 100644 --- a/src/components/canvas/use-canvas-tree.ts +++ b/src/components/canvas/use-canvas-tree.ts @@ -276,19 +276,19 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo } async function migrateImages() { return exclusive(async () => { - await flush(); - const before = current.current.get(root)!; const controller = new AbortController(); migrationAbort.current = controller; - let document: CanvasDocument; - try { document = await prepareManagedImages(before, api, workspace, controller.signal); } - finally { migrationAbort.current = null; } - if (!mounted.current || controller.signal.aborted) throw new DOMException("Aborted", "AbortError"); - undo.current.push({ boardId: root, before }); redo.current = []; - publish(new Map(current.current).set(root, document)); - const writer = writers.current.get(root)!; - await writer.enqueue(document); await writer.settled(); if (writer.error) throw new Error(writer.error); + try { + await flush(); + const before = current.current.get(root)!; + const document = await prepareManagedImages(before, api, workspace, controller.signal); + if (!mounted.current || controller.signal.aborted) throw new DOMException("Aborted", "AbortError"); + undo.current.push({ boardId: root, before }); redo.current = []; + publish(new Map(current.current).set(root, document)); + const writer = writers.current.get(root)!; + await writer.enqueue(document); await writer.settled(); if (writer.error) throw new Error(writer.error); + } finally { migrationAbort.current = null; } }); } const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); }; - return { documents, current, views, migrateImages, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !!undo.current.length, canRedo: !!redo.current.length, writers, rootDocument: documents.get(root)!, setError }; + return { documents, current, views, migrateImages, migratingImages: !!migrationAbort.current, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !!undo.current.length, canRedo: !!redo.current.length, writers, rootDocument: documents.get(root)!, setError }; } diff --git a/tests/canvas-v2-scenario.mjs b/tests/canvas-v2-scenario.mjs index 107f690..b515f60 100644 --- a/tests/canvas-v2-scenario.mjs +++ b/tests/canvas-v2-scenario.mjs @@ -24,7 +24,8 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas if(!suffix&&method==='GET')return json(structuredClone(snapshot)); if(!suffix&&method==='PATCH'){ if(options.headers['If-Match']!==`"${snapshot.version}"`)return json({error:'version_conflict'},412); - const payload=JSON.parse(options.body).document;assert.ok(payload.nodes.every(node=>!node.id.includes('::')),'No projected IDs may be persisted'); + const payload=JSON.parse(options.body).document; + if(payload.nodes.some(node=>node.id==='legacy-image' && node.data.assetId))await new Promise(resolve=>setTimeout(resolve,200));assert.ok(payload.nodes.every(node=>!node.id.includes('::')),'No projected IDs may be persisted'); if(board===parent)assert.ok(!payload.nodes.some(node=>node.id==='child-text'),'Child content cannot be duplicated into parent'); saves.push({board,document:payload});snapshot.document=structuredClone(payload);snapshot.version++;return json({version:snapshot.version}); } @@ -152,6 +153,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas console.log('PASS AI images: sticky mode, modality filtering, original-node asset output and cancellation'); const beforeMigration=structuredClone(records.get(parent).document); await event([...document.querySelectorAll('button')].find(button=>button.textContent==='优化图片加载'),'click'); + assert.ok([...document.querySelectorAll('button')].some(button=>button.textContent==='正在处理…'),'Migration stays visible until the versioned save is acknowledged'); await waitUntil(()=>records.get(parent).document.nodes.find(node=>node.id==='legacy-image')?.data.src.startsWith('https://api.foundry.entroia.com/'),'explicit image migration'); assert.equal(assetUploads,1);const migrated=records.get(parent).document.nodes.find(node=>node.id==='legacy-image');assert.deepEqual(migrated.position,beforeMigration.nodes.find(node=>node.id==='legacy-image').position); assert.deepEqual(records.get(parent).document.edges,beforeMigration.edges);