Remove temporary legacy image migration controls
This commit is contained in:
@@ -84,3 +84,5 @@
|
||||
- 重新打开后没有新增 canvas-save / canvas-dirty 日志,服务端仍为版本 253;原图与结构哈希再次一致。本次只读重开未触发 PATCH。
|
||||
|
||||
本次修复消除了内嵌图片对整个快照的阻塞、统一短截止造成的正文中断与视角变化触发全量保存。公网链路仍可能抖动;已确认 TCP 重传及拥塞窗口收缩,但未定位具体物理丢包跳点,也未更改共享反代或内核配置。不能声称网络丢包已被修复。
|
||||
|
||||
迁移验收后按用户要求删除临时开发功能:页面不再显示“优化图片加载”,前端不再包含其迁移执行入口、状态和 Base64 转附件工具。已上传的原图与画布引用保持不变,后续正常图片上传和预览继续使用附件接口。
|
||||
|
||||
@@ -127,4 +127,4 @@ Headless Canvas API 的拆包方案保留在本地忽略的 `docs/HEADLESS_CANVA
|
||||
|
||||
真实画布新上传的图片存入 Foundry 附件 API,快照只保存原始附件引用,避免 Base64 随每次打开和保存传输。原图保留;画布按需读取单一最长边 1024px 的预览,放大后可读取原图。预览权限与原图一致,Core 的配套预览功能已随 PR #15 合并并部署(b2ae48e);小图或 GIF 可能直接返回原始文件。
|
||||
|
||||
已有内嵌图片通过画布提示中的“优化图片加载”明确迁移一次:上传原始字节,然后按当前版本保存相同节点、尺寸、位置与连线。打开页面不会自动迁移或 PATCH。只改变视角保存在本机;真实内容修改才自动保存到服务器。
|
||||
指定测试画布已完成一次性旧图迁移,原图、节点与连线的哈希核对一致;临时迁移入口及执行逻辑已移除。打开页面不会自动迁移或 PATCH。只改变视角保存在本机;真实内容修改才自动保存到服务器。
|
||||
|
||||
@@ -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/连线/子图功能。此次删除变更未重复执行生产构建;此前隔离构建结果保留为历史记录。
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
<p>⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。</p>
|
||||
<p>可通过右上角导出 JSON 备份。</p>
|
||||
</div>
|
||||
{!!snapshot && (inlineImageCount(tree.rootDocument) > 0 || tree.migratingImages) && <div className={styles.imageMigration} role="status"><span>此画布的旧图片可优化加载,原图和内容都会保留。</span><button type="button" disabled={tree.busy || imageUploading || !!recovery} onClick={() => void tree.migrateImages().catch((error) => setError(errorText(error)))}>{tree.busy ? "正在处理…" : "优化图片加载"}</button></div>}
|
||||
{imageUploading && <p className={styles.imageMigration} role="status">正在上传图片…</p>}
|
||||
{imageUploading && <p className={styles.imageUploadStatus} role="status">正在上传图片…</p>}
|
||||
<BranchMenu request={branchRequest} close={closeBranch} choose={chooseBranch} />
|
||||
{iconPicker && <IconPicker close={() => setIconPicker(false)} choose={(name, label) => {
|
||||
if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点。"); return; }
|
||||
|
||||
@@ -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<CanvasCommandResult>) | null>(null);
|
||||
const locked = useRef(false), mounted = useRef(true), pause = useRef(paused); pause.current = paused;
|
||||
const writers = useRef(new Map<string, CanvasSync>());
|
||||
const migrationAbort = useRef<AbortController | null>(null);
|
||||
const loading = useRef(new Set<string>());
|
||||
const [retryLoad, setRetryLoad] = useState(0);
|
||||
const [recoveries, setRecoveries] = useState(new Map<string, CanvasDocument>());
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { CanvasDocument } from "./canvas.ts";
|
||||
|
||||
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
|
||||
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<CanvasAsset> {
|
||||
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<string, CanvasAsset>();
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user