diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 8325438..4124b25 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -84,3 +84,5 @@ - 重新打开后没有新增 canvas-save / canvas-dirty 日志,服务端仍为版本 253;原图与结构哈希再次一致。本次只读重开未触发 PATCH。 本次修复消除了内嵌图片对整个快照的阻塞、统一短截止造成的正文中断与视角变化触发全量保存。公网链路仍可能抖动;已确认 TCP 重传及拥塞窗口收缩,但未定位具体物理丢包跳点,也未更改共享反代或内核配置。不能声称网络丢包已被修复。 + +迁移验收后按用户要求删除临时开发功能:页面不再显示“优化图片加载”,前端不再包含其迁移执行入口、状态和 Base64 转附件工具。已上传的原图与画布引用保持不变,后续正常图片上传和预览继续使用附件接口。 diff --git a/README.md b/README.md index 47e598d..21e81a8 100644 --- a/README.md +++ b/README.md @@ -127,4 +127,4 @@ Headless Canvas API 的拆包方案保留在本地忽略的 `docs/HEADLESS_CANVA 真实画布新上传的图片存入 Foundry 附件 API,快照只保存原始附件引用,避免 Base64 随每次打开和保存传输。原图保留;画布按需读取单一最长边 1024px 的预览,放大后可读取原图。预览权限与原图一致,Core 的配套预览功能已随 PR #15 合并并部署(b2ae48e);小图或 GIF 可能直接返回原始文件。 -已有内嵌图片通过画布提示中的“优化图片加载”明确迁移一次:上传原始字节,然后按当前版本保存相同节点、尺寸、位置与连线。打开页面不会自动迁移或 PATCH。只改变视角保存在本机;真实内容修改才自动保存到服务器。 +指定测试画布已完成一次性旧图迁移,原图、节点与连线的哈希核对一致;临时迁移入口及执行逻辑已移除。打开页面不会自动迁移或 PATCH。只改变视角保存在本机;真实内容修改才自动保存到服务器。 diff --git a/VALIDATION.md b/VALIDATION.md index f5e5d5c..a5a761e 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -52,3 +52,7 @@ - 同步 TCP/数据库证据及隐私边界见 PERFORMANCE.md。Core PR #15 已由用户合并为 b2ae48e;分支/PR CI #936/#937、主线检查 #940 和部署 #941 全部成功。用户已明确执行迁移与重开,生产验收完成:父图版本 252→253,两张原图 SHA-256 一致,节点/内容/边哈希一致;数据库 JSON 477,397→3,708 字节。成功重开请求 219.6ms(正文 1.3ms),独立图片 551.2/1,354.9ms,随后无新增保存且版本保持 253。另一次零正文取消请求与网络波动限制见 PERFORMANCE.md。 - 最终前端检查:TypeScript、84 项测试、完整 DOM 回归、隔离 Webpack 构建和 API 冒烟均通过(包括 15.2 秒慢正文、原图/预览参数及响应头)。 - 最后补充:图片按需加载/异步解码;优化提示保持到版本保存确认后才消失,并阻止与新图片上传重叠。延迟保存的 DOM 回归和最终隔离构建通过。 + +## 临时迁移功能清理 + +指定生产画布完成迁移验收后,按用户要求移除“优化图片加载”入口、专用 Hook 状态和 Base64 迁移辅助函数。保留正常附件上传、原图引用及独立预览。类型检查、4 项附件单元测试、完整 DOM 回归通过;DOM 覆盖旧内嵌图片无迁移按钮/无后台上传、只读打开零 PATCH,以及既有 AI/连线/子图功能。此次删除变更未重复执行生产构建;此前隔离构建结果保留为历史记录。 diff --git a/src/components/canvas/canvas.module.css b/src/components/canvas/canvas.module.css index afdf0a1..df41592 100644 --- a/src/components/canvas/canvas.module.css +++ b/src/components/canvas/canvas.module.css @@ -188,6 +188,4 @@ .rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; } .canvasBusy button { margin: 8px 5px; padding: 6px 9px; border: 1px solid #ded7ce; border-radius: 5px; background: #fffefb; cursor: pointer; } -.imageMigration { position: absolute; bottom: 88px; left: 50%; transform: translateX(-50%); z-index: 120; display: flex; align-items: center; gap: 12px; max-width: min(600px,90%); padding: 10px 14px; background: #fffdf9; border: 1px solid #ded5cb; border-radius: 10px; color: #796e63; font-size: 12px; box-shadow: 0 4px 18px #372f2410; } -.imageMigration button { flex-shrink: 0; border: 0; border-radius: 6px; padding: 6px 9px; background: #eee9e2; color: #51483d; cursor: pointer; } -.imageMigration button:disabled { opacity: .5; cursor: wait; } +.imageUploadStatus { position: absolute; bottom: 88px; left: 50%; transform: translateX(-50%); z-index: 120; display: flex; align-items: center; gap: 12px; max-width: min(600px,90%); padding: 10px 14px; background: #fffdf9; border: 1px solid #ded5cb; border-radius: 10px; color: #796e63; font-size: 12px; box-shadow: 0 4px 18px #372f2410; } diff --git a/src/components/canvas/graph-canvas.tsx b/src/components/canvas/graph-canvas.tsx index 47a32e3..43c723f 100644 --- a/src/components/canvas/graph-canvas.tsx +++ b/src/components/canvas/graph-canvas.tsx @@ -1,5 +1,5 @@ "use client"; -import { inlineImageCount, uploadCanvasImage } from "@/lib/canvas-assets"; +import { uploadCanvasImage } from "@/lib/canvas-assets"; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { @@ -478,8 +478,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap

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

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

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

正在上传图片…

} + {imageUploading &&

正在上传图片…

} {iconPicker && setIconPicker(false)} choose={(name, label) => { if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点。"); return; } diff --git a/src/components/canvas/use-canvas-tree.ts b/src/components/canvas/use-canvas-tree.ts index 30254ed..13279b2 100644 --- a/src/components/canvas/use-canvas-tree.ts +++ b/src/components/canvas/use-canvas-tree.ts @@ -1,7 +1,6 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { EdgeChange, NodeChange } from "@xyflow/react"; -import { prepareManagedImages } from "@/lib/canvas-assets"; import { api, ApiError } from "@/lib/api"; import { canvasReducer, decodeCanvas, type CanvasAction, type CanvasDocument, type CanvasHistory, type CanvasNode } from "@/lib/canvas"; import { CanvasSync, canvasFingerprint, canvasPath, decodeSnapshot, readLocalRecovery, type CanvasSnapshot } from "@/lib/canvas-sync"; @@ -25,7 +24,6 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo const retryOperation = useRef<(() => Promise) | null>(null); const locked = useRef(false), mounted = useRef(true), pause = useRef(paused); pause.current = paused; const writers = useRef(new Map()); - const migrationAbort = useRef(null); const loading = useRef(new Set()); const [retryLoad, setRetryLoad] = useState(0); const [recoveries, setRecoveries] = useState(new Map()); @@ -56,7 +54,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo } // Initialization never writes a snapshot to the server. if (snapshot && !writers.current.has(root)) install(root, snapshot); - useEffect(() => { mounted.current = true; return () => { mounted.current = false; migrationAbort.current?.abort(); }; }, []); + useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const update = (boardId: string, action: CanvasAction) => { if (locked.current || retryOperation.current) return; const before = current.current.get(boardId); if (!before) return; @@ -274,21 +272,6 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo } tick((v) => v + 1); } - async function migrateImages() { - return exclusive(async () => { - const controller = new AbortController(); migrationAbort.current = controller; - 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, 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 }; + return { documents, current, views, 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/src/lib/canvas-assets.ts b/src/lib/canvas-assets.ts index a3021e0..57042ac 100644 --- a/src/lib/canvas-assets.ts +++ b/src/lib/canvas-assets.ts @@ -1,11 +1,6 @@ -import type { CanvasDocument } from "./canvas.ts"; - type Request = (path: string, options?: RequestInit) => Promise; export type CanvasAsset = { id: string; url: string; width: number; height: number; bytes: number; mime_type: string }; export const ASSET_LIMIT = 20 * 1024 * 1024; -export function inlineImageCount(document: CanvasDocument) { - return document.nodes.filter((node) => ["image", "plain-image"].includes(node.data.kind) && node.data.src?.startsWith("data:image/")).length; -} export async function uploadCanvasImage(request: Request, workspace: string, file: Blob, name: string, signal?: AbortSignal): Promise { if (!file.size || file.size > ASSET_LIMIT) throw new Error("asset_too_large"); const form = new FormData(); form.append("image", file, name); @@ -22,22 +17,3 @@ export async function uploadCanvasImage(request: Request, workspace: string, fil if (!/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(asset.id) || url.origin !== "https://api.foundry.entroia.com" || url.pathname !== `/projects/${process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"}/workspaces/${workspace}/assets/${asset.id}` || url.search || !Number.isFinite(asset.width) || asset.width < 1 || !Number.isFinite(asset.height) || asset.height < 1) throw new Error("invalid_asset_response"); return asset; } -export function inlineImageBlob(source: string) { - const match = /^data:image\/(png|jpeg|webp|gif);base64,([A-Za-z0-9+/]+={0,2})$/.exec(source); - if (!match || match[2].length > Math.ceil(ASSET_LIMIT / 3) * 4) throw new Error("invalid_asset"); - const bytes = Uint8Array.from(atob(match[2]), (char) => char.charCodeAt(0)); - return { file: new Blob([bytes], { type: "image/" + match[1] }), extension: match[1] }; -} -/** Explicit one-time action: preserve image bytes and all node/edge identities. */ -export async function prepareManagedImages(document: CanvasDocument, request: Request, workspace: string, signal?: AbortSignal) { - const assets = new Map(); - const nodes = []; - for (const node of document.nodes) { - const source = node.data.src; - if (!["image", "plain-image"].includes(node.data.kind) || !source?.startsWith("data:image/")) { nodes.push(node); continue; } - let asset = assets.get(source); - if (!asset) { const { file, extension } = inlineImageBlob(source); asset = await uploadCanvasImage(request, workspace, file, `image-${node.id}.${extension}`, signal); assets.set(source, asset); } - nodes.push({ ...node, data: { ...node.data, src: asset.url, ...(document.schema_version === 2 ? { assetId: asset.id } : {}) } }); - } - return { ...document, nodes }; -} diff --git a/tests/canvas-assets.test.mjs b/tests/canvas-assets.test.mjs index 268733b..f4c9038 100644 --- a/tests/canvas-assets.test.mjs +++ b/tests/canvas-assets.test.mjs @@ -1,37 +1,28 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { prepareManagedImages, uploadCanvasImage, inlineImageCount, inlineImageBlob } from '../src/lib/canvas-assets.ts'; -import { emptyCanvas, makeCanvasNode } from '../src/lib/canvas.ts'; +import { uploadCanvasImage } from '../src/lib/canvas-assets.ts'; import { managedImageSource } from '../src/lib/canvas-v2.ts'; const source='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB'; const id='77777777-7777-4777-8777-777777777777'; const asset={id,url:`https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/${id}`,width:1,height:1,bytes:24,mime_type:'image/png'}; -const fixture=()=>({...emptyCanvas(),schema_version:2,nodes:[makeCanvasNode('image',{x:15,y:20},source),makeCanvasNode('plain-image',{x:800,y:300},source)],edges:[]}); -test('explicit legacy image migration uploads original bytes once and preserves graph geometry and identity',async()=>{ - const document=fixture(),original=structuredClone(document);let calls=0; - const result=await prepareManagedImages(document,async(path,options)=>{ - calls++;assert.equal(path,'workspaces/w/assets');assert.match(options.headers['Idempotency-Key'],/^[a-f0-9-]{36}$/);assert.equal(options.method,'POST');assert.ok(options.body instanceof FormData); - assert.deepEqual(Buffer.from(await options.body.get('image').arrayBuffer()),Buffer.from(source.split(',')[1],'base64'));return asset; - },'w'); - assert.equal(calls,1);assert.equal(inlineImageCount(result),0);assert.deepEqual(document,original); - assert.equal(result.edges,document.edges);for(let i=0;i<2;i++){assert.equal(result.nodes[i].id,document.nodes[i].id);assert.deepEqual(result.nodes[i].position,document.nodes[i].position);assert.equal(result.nodes[i].width,document.nodes[i].width);assert.equal(result.nodes[i].data.src,asset.url);assert.equal(result.nodes[i].data.assetId,id);} - assert.equal(inlineImageCount(document),2); +test('attachment upload sends the original file bytes',async()=>{ + const bytes=Buffer.from(source.split(',')[1],'base64');let calls=0; + const result=await uploadCanvasImage(async(path,options)=>{ + calls++;assert.equal(path,'workspaces/w/assets');assert.match(options.headers['Idempotency-Key'],/^[a-f0-9-]{36}$/);assert.equal(options.method,'POST'); + assert.deepEqual(Buffer.from(await options.body.get('image').arrayBuffer()),bytes);return asset; + },'w',new Blob([bytes],{type:'image/png'}),'image.png'); + assert.equal(calls,1);assert.deepEqual(result,asset); }); -test('failed upload or invalid response leaves the original document untouched',async()=>{ - const document=fixture(),original=structuredClone(document); - await assert.rejects(prepareManagedImages(document,async()=>{throw new Error('forbidden');},'w'),/forbidden/);assert.deepEqual(document,original); - await assert.rejects(prepareManagedImages(document,async()=>({...asset,url:'https://invalid.example/a.png'}),'w'),/invalid_asset_response/);assert.deepEqual(document,original); +test('attachment upload propagates permission failures and rejects unmanaged response URLs',async()=>{ + const file=new Blob(['test'],{type:'image/png'}); + await assert.rejects(uploadCanvasImage(async()=>{throw new Error('forbidden');},'w',file,'test.png'),/forbidden/); + await assert.rejects(uploadCanvasImage(async()=>({...asset,url:'https://invalid.example/a.png'}),'w',file,'test.png'),/invalid_asset_response/); }); test('uncertain upload retries the exact bytes with the same idempotency key',async()=>{ let calls=0,key; await uploadCanvasImage(async(_path,options)=>{calls++;if(!key)key=options.headers['Idempotency-Key'];assert.equal(options.headers['Idempotency-Key'],key);if(calls===1)throw new TypeError('connection lost');return asset;},'w',new Blob(['test'],{type:'image/png'}),'test.png');assert.equal(calls,2); }); -test('V1 uses managed URLs without unsupported V2 fields and clean documents cause no upload',async()=>{ - const document=fixture();document.schema_version=1;let calls=0; - const result=await prepareManagedImages(document,async()=>{calls++;return asset;},'w');assert.equal(result.schema_version,1);assert.equal(result.nodes[0].data.assetId,undefined); - await prepareManagedImages(result,async()=>{throw new Error('must not upload');},'w');assert.equal(calls,1); -}); test('preview URLs are renderer-only and do not replace original managed references',()=>{ assert.equal(managedImageSource(asset.url),`/api/foundry/workspaces/w/assets/${id}`);assert.equal(managedImageSource(asset.url,true),`/api/foundry/workspaces/w/assets/${id}?variant=preview`); - assert.equal(managedImageSource('https://example.com/photo.png',true),'https://example.com/photo.png');assert.throws(()=>inlineImageBlob('data:image/svg+xml;base64,PHN2Zz4='),/invalid_asset/); + assert.equal(managedImageSource('https://example.com/photo.png',true),'https://example.com/photo.png'); }); diff --git a/tests/canvas-v2-scenario.mjs b/tests/canvas-v2-scenario.mjs index b515f60..a05819f 100644 --- a/tests/canvas-v2-scenario.mjs +++ b/tests/canvas-v2-scenario.mjs @@ -25,7 +25,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas if(!suffix&&method==='PATCH'){ if(options.headers['If-Match']!==`"${snapshot.version}"`)return json({error:'version_conflict'},412); 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'); + 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}); } @@ -151,14 +151,10 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas await waitUntil(()=>document.querySelector('[aria-label="元素 AI 工具"]')?.textContent.includes('已取消生成'),'cancelled image run'); assert.equal(records.get(parent).document.nodes.find(node=>node.id==='image-target').data.src,generated.data.src); 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); - assert.equal([...document.querySelectorAll('button')].some(button=>button.textContent==='优化图片加载'),false); - assert.ok(document.querySelector('img[src$="?variant=preview"]'),'Canvas renders a preview URL while the snapshot preserves the original'); - console.log('PASS images: explicit original-preserving migration, one upload, graph preservation and preview-only render URL'); + assert.equal([...document.querySelectorAll('button')].some(button=>button.textContent==='优化图片加载'),false,'Temporary migration controls are absent even with an inline image'); + assert.equal(assetUploads,0,'Existing inline images do not trigger background uploads'); + assert.ok(records.get(parent).document.nodes.find(node=>node.id==='legacy-image').data.src.startsWith('data:image/'),'Existing image references are not rewritten'); + assert.ok(document.querySelector('img[src$="?variant=preview"]'),'Managed images still render previews'); + console.log('PASS images: temporary migration removed, no background uploads and managed previews retained'); console.log('PASS V2: child ownership and saves, live model catalog, AI propagation/polling, geometry preservation, collapse/expand and transactional child creation'); }