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');
}