Fix canvas transfer timeouts and move images out of snapshots

This commit is contained in:
2026-11-06 21:51:13 +08:00
parent af844131ed
commit 570cd53fc3
21 changed files with 460 additions and 48 deletions
+46
View File
@@ -23,3 +23,49 @@
- 生产构建在临时副本进行,不改动当前开发服务器产物。最终执行结果记录于 VALIDATION.md。
本轮没有真实登录会话的公网前后对照,也没有浏览器视觉/手感验收,因此不能给出“线上打开从 11.4 秒降至某个时间”的结论。若更新后仍等待数秒,应结合 Network 中 Server-Timing 的 upstream 值、响应体下载时间和体积进一步追踪公网链路/后端;前端减少重复工作不等于消除了上游延迟。
## 2026-11-06:传输中断与误保存实测
旧 15 秒 deadline 同时约束响应头和整个响应体。流式响应先发出 200 后,正文仍可能被该 deadline 中断;此前客户端把 JSON 读取失败吞成 null,随后误报 invalid_canvas_snapshot。已改为响应头 15 秒、正文总计 90 秒、单次无数据等待 30 秒,并保留客户端取消。读取中断和 JSON 格式错误分别报告,不制造空快照。
`[canvas-transfer]` 只记录 trace_id、资源路径、毫秒、字节数、chunk 数、结果/错误类型;不记录令牌、Cookie、节点内容或图片数据。first_chunk_ms 为收到响应头后到首段正文的时间;transfer_ms 为代理消费正文的时间,包含链路和下游背压,不能独自区分网络节点。
用户真实父画布样本(约 477,400 字节,466 KiB):
| 样本 | 响应头前 | 正文 | 合计 | 结果 |
| --- | ---: | ---: | ---: | --- |
| e342dd5b | 707ms | 25.925s | 26.632s | 读到 364,538 字节后 ResponseAborted |
| 000949da | 632ms | 32.119s | 32.751s | 完整 477,441 字节 |
| e33e4a53 | 713ms | 1.951s | 2.663s | 完整 477,385 字节 |
| 7b5e0787 | 956ms | 15.407s | 16.363s | 读到 282,618 字节后 ResponseAborted |
| 4f77aafc | 657ms | 22.591s | 23.248s | 完整 477,385 字节 |
同一画布大小基本不变,正文平均吞吐从约 14.5 KiB/s 到约 239 KiB/s 波动。慢样本大部分时间在正文传输,而非页面路由(17–39ms)或首响应前。ResponseAborted 表示连接/消费端被取消,不能等同于我们设置的 TimeoutError,也不能仅凭它判定用户主动点击取消。开发热更新可取消旧请求,但具体请求的取消来源尚未证实。
子画布只返回 727 字节,正文约 1ms;首响应等待约 0.27–1.46s。未携带凭据的健康检查三个新连接样本:DNS 1.6–1.9ms、TCP 累计约 190–197ms、TLS 累计约 465–1,454ms、首字节累计约 723–1,842ms。健康检查只能作小响应链路基线,不能代替登录后的快照或 SQL 测量。
### 为什么只看页面也会 PATCH
实际浏览器 `[canvas-dirty]` 抓到多次仅 viewport=true,nodes=[]、edges=false 的保存。原保存器把视角变化与内容变化一同比较,因此平移、缩放也上传整个约 466 KiB 快照。已改为内容指纹决定是否 PATCH;只改变视角时保存在独立本机键中,不上传,也不产生内容恢复冲突。节点/连线发生真实修改时仍按版本保存完整快照。初始化的无来源视口回调、微小浮点差异和非用户尺寸测量均不作为服务器内容修改。
新的 `[canvas-save]` 样本中,客户端请求体读取 1.5–7.5ms、JSON 编码 1.6–2.1ms、上传到上游并等待响应头 334–1,440ms、响应体读取解析 0.6–0.8ms;请求约 477,370 字节。中间阶段包含上传、后端排队/SQL/提交和返回链路,前端不能进一步给出各自占比。
### 尚需服务端对照
- Core canvas GET 调用 lockMaterial,源码先对 workspace 行 FOR UPDATE,且事务延续到 reply 返回。这是可能的同空间排队点;当前慢请求首响应前不足 1 秒,不能将其当作 22–32 秒正文慢的已证实主因。
- 需 ECS 本机与公网读取同一快照的分段对照,并记录数据库取数、JSON 编码、写响应耗时及出口/网关限速。Gitea 与 API 解析到不同 IP,不能把 Gitea 大文件测试冒充同服务器对照。
- 上传源码仍支持将图片内嵌为 data URL,可能放大快照;该父画布的实际图片占比尚需只统计字节、不输出内容的检查。迁到 managed assets 可让结构与图片独立读取,但不得为此在打开页面时暗中发起修改。
- 此次修复没有宣称公网传输速度已经稳定,也未修改 Core 或用户线上图片存储。
## 同步服务端对照:2026-11-06 13:23 UTC
父画布 trace `1462ea3c`:13:23:04.973 → 13:23:21.652 UTC,首响应 219.8ms,正文 16,458.6ms,共 477,388 字节。服务端记录位于 `/tmp/endoleap-canvas-diag-20261106T131903Z`(只含计数、TCP 状态与聚合等待,不含 HTTP 正文/令牌)。
- 公网 TCP RTT 约 172ms,累计重传从 1 增至 31,拥塞窗口最低 2 个报文段,发送队列最高 138,240 字节;包丢失/重传导致 CUBIC 反复收缩窗口,解释了长正文传输和吞吐波动。
- 内部 Core→Traefik 链路出现接收窗口背压,但最后完成约 481KB 的应用响应;不是数据库在逐字节生成 16 秒的数据。
- 对齐窗口的 PostgreSQL 聚合记录没有持续 Lock 等待;只捕获一次约 154ms 的活动。只读 EXPLAIN ANALYZE 的本机快照体积计算执行 6.23ms、规划 11.4ms,不能将几十秒归因于该 SQL。
- 同期 eth0 出口每秒约 14–62KB,无宿主队列丢弃增量;这不足以判定具体哪一段运营商或云端限速产生丢包,未擅自更改共享反代/内核网络配置。
- 数据库聚合确认父图含 9 个节点、2 条边、2 张内嵌图片。JSON 文本约 477,646 字节,图片 src 合计 474,073 字符,约占 99.3%。原先设计让两张图片阻塞整张画布的数据读取。
修复采用普通 personal SaaS 附件模式:一份原始附件、一份按需显示预览,快照仅引用附件。前端添加真实附件上传及显式旧图迁移;Core PR #15 提供单一 1024px 预览、权限检查和有界缓存。原图不替换,AI 继续取原始内容。图片独立加载后,结构读取不再受两张完整图片的传输时间阻塞。生产部署和指定画布迁移后的实际字节数/时间须重测后记录,不能把预计缩减当作已完成实测。
+7
View File
@@ -121,3 +121,10 @@ Headless Canvas API 的拆包方案保留在本地忽略的 `docs/HEADLESS_CANVA
## 会话期限
会话为可撤销的随机令牌。30 天闲置续期、90 天绝对期限由 Core 控制,代理按剩余期限更新 HttpOnly Cookie;既有过期会话不恢复。详细本地说明为 `docs/SESSION_LIFETIME.md`。
## 图片附件与加载
真实画布新上传的图片存入 Foundry 附件 API,快照只保存原始附件引用,避免 Base64 随每次打开和保存传输。原图保留;画布按需读取单一最长边 1024px 的预览,放大后可读取原图。预览权限与原图一致,Core 的配套预览功能通过 PR #15 交付,部署前服务端仍可能返回原图。
已有内嵌图片通过画布提示中的“优化图片加载”明确迁移一次:上传原始字节,然后按当前版本保存相同节点、尺寸、位置与连线。打开页面不会自动迁移或 PATCH。只改变视角保存在本机;真实内容修改才自动保存到服务器。
+17
View File
@@ -34,3 +34,20 @@
- 编辑器拆成动态模块,与元数据/快照并行加载;成功 Canvas GET 代理采用流式转发。
- 实际证据、性能边界及未执行的公网/浏览器验收见 PERFORMANCE.md;不将本地模拟测试当作公网提速实测。
- 本次最终检查通过:TypeScript、73 项单元测试、React Flow DOM 全量回归、隔离 Webpack 生产构建及 API 冒烟(含上游响应体暂停时首段透传、目录分页参数)。
## 传输中断与只读打开修正
- 大快照分离响应头、正文总时限和正文空闲时限;加入仅含时间/计数的传输、保存和 dirty 字段诊断。
- JSON 读取失败不再吞成 null;区分传输中断、不可解析响应和真实快照校验失败。
- 内容指纹忽略单独的 viewport 变化;视角保存在本机,初始化与缩放不会上传快照。浏览器日志已确认原误保存字段仅为 viewport。
- 新增慢正文、空闲超时、请求取消、失败 200、204、视口浮点与真实内容保存的测试;DOM 覆盖父/子图打开与缩放零 PATCH。
- 用户完整读取已成功(父图 477,385 字节),但链路样本仍有 2.66–32.75 秒波动;ECS/公网对照尚待补充,详见 PERFORMANCE.md。
## 附件与预览修正
- 新图片使用受管附件;旧图仅在用户明确点击后迁移,字节保持原样,快照不保存预览地址。
- 单元测试增加字节一致性、重复图片去重、幂等重试、失败不替换、V1 兼容和渲染 URL 分离;共 84 项通过。
- DOM 回归确认只读打开/缩放零 PATCH、显式迁移一次上传、位置和边不变、画布使用预览而快照保存原图引用;原有 AI/子图/连线回归通过。
- 同步 TCP/数据库证据及隐私边界见 PERFORMANCE.md。Core PR #15 的最新远程检查、部署及用户画布迁移后实测仍在进行。
- 最终前端检查:TypeScript、84 项测试、完整 DOM 回归、隔离 Webpack 构建和 API 冒烟均通过(包括 15.2 秒慢正文、原图/预览参数及响应头)。
+32 -5
View File
@@ -1,3 +1,4 @@
import { fetchCanvasResponse } from "@/lib/canvas-response";
import { sessionCookie, sessionMaxAge, SESSION_EXPIRY_HEADER } from "@/lib/session-cookie";
import { NextRequest, NextResponse } from "next/server";
@@ -36,6 +37,8 @@ async function handler(
req: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
const requestStarted = performance.now();
const requestStartedAt = new Date().toISOString();
const responseHeaders = new Headers({ "Cache-Control": "no-store" });
const json = (body: unknown, status: number) =>
NextResponse.json(body, { status, headers: responseHeaders });
@@ -61,6 +64,7 @@ async function handler(
return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400);
}
}
const bodyReceived = performance.now();
const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
const headers: Record<string, string> = { "Content-Type": asset ? (req.headers.get("content-type") || "application/octet-stream") : "application/json" };
@@ -72,20 +76,31 @@ async function handler(
if (value !== null) headers[h] = value;
}
const query = new URLSearchParams();
for (const key of ["kind", "sort", "q", "limit", "offset", "after_seq", "status", "include_descendants", "include_subgraphs", "cursor"]) {
for (const key of ["kind", "sort", "q", "limit", "offset", "after_seq", "status", "include_descendants", "include_subgraphs", "cursor", "variant", "width"]) {
const values = req.nextUrl.searchParams.getAll(key);
if (values.length === 1 && values[0].length < 300) query.set(key, values[0]);
}
const encodingStarted = performance.now();
const outboundBody = req.method === "GET" ? undefined : asset ? requestBody as BodyInit : JSON.stringify(requestBody || {});
const encodeMs = performance.now() - encodingStarted;
const started = performance.now();
try {
const upstream = await fetch(
const snapshotRead = canvas && path.endsWith("/canvas") && req.method === "GET";
const streamedRead = snapshotRead || asset && req.method === "GET";
const snapshotWrite = canvas && path.endsWith("/canvas") && req.method === "PATCH";
const traceId = streamedRead || snapshotWrite ? crypto.randomUUID() : "";
const requestUpstream = streamedRead ? (url: string, options: RequestInit) => fetchCanvasResponse(url, options, undefined, fetch, (trace) => {
console.info(snapshotRead ? "[canvas-transfer]" : "[asset-transfer]", JSON.stringify({ trace_id: traceId, resource: path, ...trace }));
}) : fetch;
if (streamedRead || snapshotWrite) responseHeaders.set("X-Canvas-Trace-Id", traceId);
const upstream = await requestUpstream(
origin + "/projects/" + encodeURIComponent(project) + "/" + path +
(query.size ? "?" + query.toString() : ""),
{
method: req.method,
headers,
body: req.method === "GET" ? undefined : asset ? requestBody as BodyInit : JSON.stringify(requestBody || {}),
signal: AbortSignal.any([AbortSignal.timeout(15000), req.signal]),
body: outboundBody,
signal: streamedRead ? req.signal : AbortSignal.any([AbortSignal.timeout(15000), req.signal]),
redirect: "manual",
cache: "no-store",
},
@@ -103,8 +118,11 @@ async function handler(
}
if (asset && upstream.ok && req.method === "GET") {
const type = upstream.headers.get("content-type") || "";
if (!/^image\/(png|jpeg|webp|gif)(?:;|$)/i.test(type)) return json({ error: "invalid_asset" }, 502);
if (!/^image\/(png|jpeg|webp|gif)(?:;|$)/i.test(type)) { await upstream.body?.cancel(); return json({ error: "invalid_asset" }, 502); }
responseHeaders.set("Content-Type", type); responseHeaders.set("X-Content-Type-Options", "nosniff");
const variant = upstream.headers.get("x-asset-variant");
if (variant === "original" || variant === "preview") responseHeaders.set("X-Asset-Variant", variant);
responseHeaders.set("Server-Timing", `upstream;dur=${(performance.now() - started).toFixed(1)}`);
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
}
const headersReceived = performance.now();
@@ -116,6 +134,15 @@ async function handler(
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
}
const body = upstream.status === 204 ? null : await upstream.json();
if (snapshotWrite) console.info("[canvas-save]", JSON.stringify({
trace_id: traceId, resource: path, status: upstream.status, started_at: requestStartedAt, finished_at: new Date().toISOString(),
client_body_ms: Math.round((bodyReceived - requestStarted) * 10) / 10,
encode_ms: Math.round(encodeMs * 10) / 10,
request_bytes: typeof outboundBody === "string" ? new TextEncoder().encode(outboundBody).length : 0,
upload_and_server_ms: Math.round((headersReceived - started) * 10) / 10,
response_body_ms: Math.round((performance.now() - headersReceived) * 10) / 10,
total_ms: Math.round((performance.now() - requestStarted) * 10) / 10,
}));
responseHeaders.set(
"Server-Timing",
`upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`,
+4
View File
@@ -187,3 +187,7 @@
.framePort:global(.connectionindicator) { pointer-events: auto; }
.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; }
+36 -15
View File
@@ -1,4 +1,5 @@
"use client";
import { inlineImageCount, uploadCanvasImage } from "@/lib/canvas-assets";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import {
@@ -46,13 +47,19 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
const [initial] = useState(() => {
try {
const local = readLocalRecovery(localStorage, storageKey, snapshot);
return { document: snapshot?.document || local || emptyCanvas(), recovery: snapshot ? local : null, error: "" };
let document = snapshot?.document || local || emptyCanvas();
try {
const view = JSON.parse(localStorage.getItem(storageKey + ":viewport") || "null");
if (view && [view.x, view.y, view.zoom].every(Number.isFinite) && Math.abs(view.x) <= 1e6 && Math.abs(view.y) <= 1e6 && view.zoom >= .1 && view.zoom <= 4) document = { ...document, viewport: view };
} catch { /* A broken local view never blocks the server document. */ }
return { document, recovery: snapshot ? local : null, error: "" };
} catch { return { document: snapshot?.document || emptyCanvas(), recovery: null, error: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; }
});
const [tool, setTool] = useState<"select" | "hand">("select");
const [error, setError] = useState(initial.error);
const [branchRequest, setBranchRequest] = useState<BranchRequest | null>(null);
const [imageUploading, setImageUploading] = useState(false);
const [iconPicker, setIconPicker] = useState(false);
const [resizing, setResizing] = useState<string | null>(null);
const [rotating, setRotating] = useState<string | null>(null);
@@ -98,6 +105,13 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
return () => clearTimeout(timer);
}, [membershipNotice]);
const duration = reduceMotion ? 0 : 180;
const rememberViewport = (viewport: import("@xyflow/react").Viewport) => {
treeRef.current.dispatch({ type: "viewport", viewport });
try { localStorage.setItem(storageKey + ":viewport", JSON.stringify(viewport)); } catch { /* View changes do not require server writes. */ }
};
const changeView = (operation: () => Promise<boolean>) => {
void operation().then(() => rememberViewport(flow.getViewport())).catch((error) => setError(error.message));
};
const latest = useRef(state.present);
latest.current = state.present;
@@ -272,13 +286,15 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
const file = event.target.files?.[0];
event.target.value = "";
if (!file) { pendingImage.current = null; return; }
if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > 2_000_000) {
pendingImage.current = null; setError("请选择小于 2 MB 的 PNG、JPEG、WebP 或 GIF 图片。"); return;
if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > (snapshot ? 20 * 1024 * 1024 : 2_000_000)) {
pendingImage.current = null; setError(snapshot ? "请选择小于 20 MiB 的 PNG、JPEG、WebP 或 GIF 图片。" : "演示模式请选择小于 2 MB 的图片。"); return;
}
const request = pendingImage.current; pendingImage.current = null;
const kind = imageKind.current;
setImageUploading(true);
try {
const src = await new Promise<string>((resolve, reject) => {
const uploaded = snapshot ? await uploadCanvasImage(api, board.workspace_id, file, file.name) : null;
const src = uploaded?.url || await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("无法读取图片。"));
@@ -288,13 +304,16 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
if (kind === "plain-image" && !request) {
size = await new Promise((resolve, reject) => {
const image = new window.Image();
image.onload = () => { const scale = Math.min(1, 480 / image.naturalWidth, 480 / image.naturalHeight); resolve({ width: Math.max(24, Math.round(image.naturalWidth * scale)), height: Math.max(24, Math.round(image.naturalHeight * scale)) }); };
image.onerror = () => reject(new Error("无法读取图片尺寸。"));
image.src = src;
const localSource = uploaded ? URL.createObjectURL(file) : src;
const clean = () => { if (uploaded) URL.revokeObjectURL(localSource); };
image.onload = () => { const scale = Math.min(1, 480 / image.naturalWidth, 480 / image.naturalHeight); clean(); resolve({ width: Math.max(24, Math.round(image.naturalWidth * scale)), height: Math.max(24, Math.round(image.naturalHeight * scale)) }); };
image.onerror = () => { clean(); reject(new Error("无法读取图片尺寸。")); };
image.src = localSource;
});
}
if (request) createBranch(request, "image", src); else addNode(kind, src, size, kind === "plain-image" ? file.name : undefined);
} catch { pendingImage.current = null; setError("无法读取图片,请重新选择。"); }
} catch (error) { pendingImage.current = null; setError(errorText(error)); }
finally { setImageUploading(false); }
}
async function importProject(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
@@ -396,7 +415,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
onConnectEnd={connectionEnd}
onPaneClick={() => { closeBranch(); setRotating(null); setResizing(null); }} onNodeClick={(_, node) => { if (node.id !== rotating) setRotating(null); if (node.id !== resizing) setResizing(null); }}
onMoveStart={closeBranch}
onMoveEnd={(_, viewport) => dispatch({ type: "viewport", viewport })}
onMoveEnd={(event, viewport) => { if (event) rememberViewport(viewport); }}
defaultViewport={initial.document.viewport} minZoom={0.1} maxZoom={4}
panOnDrag panOnScroll zoomOnScroll={false} zoomOnPinch zoomActivationKeyCode={["Meta", "Control"]}
nodesDraggable={tool === "select" && !tree.busy} nodesConnectable={tool === "select" && !tree.busy}
@@ -420,10 +439,10 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
<IconButton icon={Undo2} label="撤销" disabled={!tree.canUndo || tree.busy} onClick={() => dispatch({ type: "undo" })} />
<IconButton icon={Redo2} label="重做" disabled={!tree.canRedo || tree.busy} onClick={() => dispatch({ type: "redo" })} />
<span className={toolbar.divider} aria-hidden="true" />
<IconButton icon={Minus} label="缩小" onClick={() => void flow.zoomOut({ duration })} />
<button className={styles.zoomValue} onClick={() => void flow.zoomTo(1, { duration })} title="恢复 100% 缩放">{Math.round(zoom * 100)}%</button>
<IconButton icon={Plus} label="放大" onClick={() => void flow.zoomIn({ duration })} />
<IconButton icon={Maximize} label="适应画布" onClick={() => nodes.length ? void flow.fitView({ padding: 0.2, duration }) : void flow.setViewport({ x: 0, y: 0, zoom: 1 }, { duration })} />
<IconButton icon={Minus} label="缩小" onClick={() => changeView(() => flow.zoomOut({ duration }))} />
<button className={styles.zoomValue} onClick={() => changeView(() => flow.zoomTo(1, { duration }))} title="恢复 100% 缩放">{Math.round(zoom * 100)}%</button>
<IconButton icon={Plus} label="放大" onClick={() => changeView(() => flow.zoomIn({ duration }))} />
<IconButton icon={Maximize} label="适应画布" onClick={() => changeView(() => nodes.length ? flow.fitView({ padding: 0.2, duration }) : flow.setViewport({ x: 0, y: 0, zoom: 1 }, { duration }))} />
<span className={toolbar.divider} aria-hidden="true" />
<IconButton icon={Download} label="导出图谱" onClick={exportProject} />
</div>
@@ -434,8 +453,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
<span className={toolbar.divider} aria-hidden="true" />
<IconButton icon={Type} label="添加纯文本" onClick={() => addNode("plain-text")} />
<IconButton icon={StickyNote} label="添加文本卡片" onClick={() => addNode("text")} />
<IconButton icon={ImagePlus} label="添加图片" onClick={() => { pendingImage.current = null; imageKind.current = "image"; imageInput.current?.click(); }} />
<IconButton icon={ImageIcon} label="添加纯图片" onClick={() => { pendingImage.current = null; imageKind.current = "plain-image"; imageInput.current?.click(); }} />
<IconButton disabled={imageUploading || tree.busy} icon={ImagePlus} label="添加图片" onClick={() => { pendingImage.current = null; imageKind.current = "image"; imageInput.current?.click(); }} />
<IconButton disabled={imageUploading || tree.busy} icon={ImageIcon} label="添加纯图片" onClick={() => { pendingImage.current = null; imageKind.current = "plain-image"; imageInput.current?.click(); }} />
<IconButton icon={FileText} label="添加脚本" onClick={() => addNode("script")} />
<IconButton icon={Frame} label="添加框选区" onClick={() => addNode("frame")} />
<IconButton icon={Shapes} label="添加图标" aria-expanded={iconPicker} onClick={() => setIconPicker(!iconPicker)} />
@@ -459,6 +478,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
<p>⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。</p>
<p>可通过右上角导出 JSON 备份。</p>
</div>
{!!snapshot && inlineImageCount(tree.rootDocument) > 0 && <div className={styles.imageMigration} role="status"><span>此画布的旧图片可优化加载,原图和内容都会保留。</span><button type="button" disabled={tree.busy || !!recovery} onClick={() => void tree.migrateImages().catch((error) => setError(errorText(error)))}>{tree.busy ? "正在处理…" : "优化图片加载"}</button></div>}
{imageUploading && <p className={styles.imageMigration} 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; }
+4 -2
View File
@@ -2,7 +2,7 @@
import { managedImageSource } from "@/lib/canvas-v2";
import { useContext, useRef } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { Handle, Position, useViewport, type NodeProps } from "@xyflow/react";
import { FileText, Image as ImageIcon, Type } from "lucide-react";
import { elementCapabilities } from "@/lib/canvas-elements";
import { nodeDirections, type CanvasNode } from "@/lib/canvas";
@@ -18,13 +18,15 @@ const labels = { top: "上方", right: "右侧", bottom: "下方", left: "左侧
export function MaterialNode(props: NodeProps<CanvasNode>) {
const { id, data, selected } = props;
const editing = useContext(NodeEditing);
const { zoom } = useViewport();
const preview = (props.width || 260) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
const pointerStart = useRef({ x: 0, y: 0 });
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
return (
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
<header className={styles.nodeHeading}><Icon size={13} /><NodeTitle id={id} title={data.title} selected={selected} /></header>
<Handle type="target" position={Position.Left} className={styles.legacyHandle} isConnectable={false} />
{data.kind === "image" ? <img className={styles.image} src={managedImageSource(data.src)} alt={data.title} draggable={false} referrerPolicy="no-referrer" /> : (
{data.kind === "image" ? <img className={styles.image} src={managedImageSource(data.src, preview)} alt={data.title} draggable={false} referrerPolicy="no-referrer" /> : (
<CanvasTextEditor id={id} data={data} selected={selected} />
)}
<Handle type="source" position={Position.Right} className={styles.legacyHandle} isConnectable={false} />
+4 -2
View File
@@ -1,9 +1,11 @@
"use client";
import { managedImageSource } from "@/lib/canvas-v2";
import type { NodeProps } from "@xyflow/react";
import { useViewport, type NodeProps } from "@xyflow/react";
import type { CanvasNode } from "@/lib/canvas";
import { TransformableNode } from "./transformable-node";
import styles from "./canvas.module.css";
export function PlainImageNode(props: NodeProps<CanvasNode>) {
return <TransformableNode {...props} className={styles.plainImageFrame}><img className={styles.plainImage} src={managedImageSource(props.data.src)} alt={props.data.title} draggable={false} referrerPolicy="no-referrer" /></TransformableNode>;
const { zoom } = useViewport();
const preview = (props.width || 320) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
return <TransformableNode {...props} className={styles.plainImageFrame}><img className={styles.plainImage} src={managedImageSource(props.data.src, preview)} alt={props.data.title} draggable={false} referrerPolicy="no-referrer" /></TransformableNode>;
}
+27 -5
View File
@@ -1,6 +1,7 @@
"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";
@@ -24,6 +25,7 @@ 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>());
@@ -54,7 +56,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; }; }, []);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; migrationAbort.current?.abort(); }; }, []);
const update = (boardId: string, action: CanvasAction) => {
if (locked.current || retryOperation.current) return;
const before = current.current.get(boardId); if (!before) return;
@@ -74,7 +76,12 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const adjusted = referenceAfterMoves(root, current.current, view.current.boards, ref.boardId, moves);
const next = { ...change, id: ref.id };
if (next.type === "position" && next.position) next.position = { x: (next.position.x - adjusted.origin.x) / ref.scale, y: (next.position.y - adjusted.origin.y) / ref.scale };
if (next.type === "dimensions" && next.dimensions) next.dimensions = { width: next.dimensions.width / ref.scale, height: next.dimensions.height / ref.scale };
if (next.type === "dimensions") {
// ResizeObserver measurements are editor state. Only an explicit resize
// gesture may write node dimensions back to the owning snapshot.
if (!next.resizing) next.setAttributes = false;
if (next.dimensions) next.dimensions = { width: next.dimensions.width / ref.scale, height: next.dimensions.height / ref.scale };
}
groups.set(ref.boardId, [...(groups.get(ref.boardId) || []), next]);
}
for (const [boardId, changes] of groups) update(boardId, { type: "nodes", changes });
@@ -148,7 +155,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
for (const [boardId, document] of current.current) try {
if (pendingRecovery.current.has(boardId)) continue;
const writer = writers.current.get(boardId);
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: decodeCanvas(JSON.stringify(document)), baseVersion: writer?.version || 1, dirty: writer ? writer.saved !== canvasFingerprint(document) : false }));
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: decodeCanvas(JSON.stringify(document)), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false }));
} catch { setError("local_backup_failed"); }
}, 250);
const save = setTimeout(() => {
@@ -162,7 +169,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
if (pause.current) return;
for (const [boardId, document] of current.current) {
if (pendingRecovery.current.has(boardId)) continue;
try { const writer = writers.current.get(boardId); localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: decodeCanvas(JSON.stringify(document)), baseVersion: writer?.version || 1, dirty: writer ? writer.saved !== canvasFingerprint(document) : false })); } catch { /* Existing draft is retained if storage is full. */ }
try { const writer = writers.current.get(boardId); localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: decodeCanvas(JSON.stringify(document)), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false })); } catch { /* Existing draft is retained if storage is full. */ }
}
};
window.addEventListener("pagehide", backup);
@@ -267,6 +274,21 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
tick((v) => v + 1);
}
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);
});
}
const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); };
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 };
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 };
}
+24 -4
View File
@@ -6,7 +6,12 @@ export type Profile = {
avatar_url?: string | null;
};
export class ApiError extends Error {
constructor(code: string, readonly status: number, readonly details?: unknown, readonly retryAfter?: string | null) { super(code); this.name = "ApiError"; }
readonly status: number;
readonly details?: unknown;
readonly retryAfter?: string | null;
constructor(code: string, status: number, details?: unknown, retryAfter?: string | null) {
super(code); this.name = "ApiError"; this.status = status; this.details = details; this.retryAfter = retryAfter;
}
}
export async function api<T>(
path: string,
@@ -16,10 +21,20 @@ export async function api<T>(
...options,
headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers },
});
const body = await res.json().catch(() => null);
let body: unknown;
if (res.status === 204) body = null;
else {
try { body = await res.json(); }
catch (error) {
if (options.signal?.aborted) throw options.signal.reason;
const malformed = error instanceof SyntaxError;
throw new ApiError(malformed ? "invalid_api_response" : "response_interrupted", res.ok ? 502 : res.status);
}
}
const payload = body as { error?: string | { code?: string }; details?: unknown } | null;
if (!res.ok) {
const code = body?.error?.code || body?.error;
throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, body?.details, res.headers.get("retry-after"));
const code = typeof payload?.error === "object" ? payload.error?.code : payload?.error;
throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, payload?.details, res.headers.get("retry-after"));
}
return body as T;
}
@@ -55,6 +70,11 @@ export const errorText = (error: unknown) => {
invalid_profile: "请检查显示名称,以及用户名是否为 3–40 位小写字母、数字、下划线或连字符。",
forbidden: "你没有在此工作空间创建或编辑图谱的权限。",
invalid_canvas: "画布数据未通过服务端校验,请导出备份后检查节点内容。",
response_interrupted: "画布数据传输中断或超时,请重新加载;当前内容未被覆盖。",
invalid_api_response: "接口返回了不完整或无法解析的数据,请重新加载。",
invalid_asset: "图片格式无法识别,请选择有效的 PNG、JPEG、WebP 或 GIF。",
asset_too_large: "图片超出上传限制,请选择小于 20 MiB 的文件。",
invalid_asset_response: "图片上传结果无效,画布内容未替换,请重试。",
invalid_canvas_snapshot: "服务端画布数据格式无效,请重新读取或联系维护者。",
canvas_schema_upgrade_required: "这张画布已升级,请刷新以加载新版客户端。",
undo_receipt_expired: "撤销记录已过期,当前内容已保留。",
+43
View File
@@ -0,0 +1,43 @@
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);
const key = crypto.randomUUID();
const send = () => request<CanvasAsset>(`workspaces/${encodeURIComponent(workspace)}/assets`, { method: "POST", headers: { "Idempotency-Key": key }, body: form, signal });
let asset: CanvasAsset;
try { asset = await send(); } catch (error) {
if (signal?.aborted || !(error instanceof TypeError || typeof error === "object" && error && "status" in error && Number(error.status) >= 500)) throw error;
asset = await send(); // The same bytes/key retry a possibly committed upload.
}
if (!asset || typeof asset.url !== "string") throw new Error("invalid_asset_response");
let url: URL;
try { url = new URL(asset.url); } catch { throw new Error("invalid_asset_response"); }
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 };
}
+52
View File
@@ -0,0 +1,52 @@
/** Separate the response-header deadline from a large snapshot's transfer.
* A client disconnect still aborts the upstream; bodies are never cached. */
export type CanvasTransferTrace = { started_at: string; finished_at: string; headers_ms: number; first_chunk_ms?: number; transfer_ms: number; total_ms: number; bytes: number; chunks: number; outcome: "complete" | "cancelled" | "error"; error?: string };
export async function fetchCanvasResponse(url: string, options: RequestInit, limits = { headersMs: 15000, bodyMs: 90000, idleMs: 30000 }, request = fetch, report?: (trace: CanvasTransferTrace) => void) {
const started = performance.now();
const startedAt = new Date().toISOString();
const abort = new AbortController();
const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal;
const timeout = () => abort.abort(new DOMException("Canvas transfer timed out", "TimeoutError"));
let timer = setTimeout(timeout, limits.headersMs);
let upstream: Response;
try { upstream = await request(url, { ...options, signal }); }
catch (error) {
clearTimeout(timer);
const elapsed = Math.round((performance.now() - started) * 10) / 10;
report?.({ started_at: startedAt, finished_at: new Date().toISOString(), headers_ms: elapsed, transfer_ms: 0, total_ms: elapsed, bytes: 0, chunks: 0, outcome: "error", ...(error instanceof Error ? { error: error.name } : {}) });
throw error;
}
clearTimeout(timer);
if (!upstream.body) return upstream;
const headersAt = performance.now();
const reader = upstream.body.getReader();
let bytes = 0, chunks = 0, firstChunk: number | undefined;
const record = (outcome: CanvasTransferTrace["outcome"], error?: unknown) => {
const now = performance.now(), rounded = (value: number) => Math.round(value * 10) / 10;
report?.({ started_at: startedAt, finished_at: new Date().toISOString(), headers_ms: rounded(headersAt - started), ...(firstChunk === undefined ? {} : { first_chunk_ms: rounded(firstChunk - headersAt) }), transfer_ms: rounded(now - headersAt), total_ms: rounded(now - started), bytes, chunks, outcome, ...(error instanceof Error ? { error: error.name } : {}) });
};
let idle: ReturnType<typeof setTimeout> | undefined;
let finished = false;
const cleanup = () => { finished = true; clearTimeout(timer); clearTimeout(idle); signal.removeEventListener("abort", interrupted); };
let interrupt: (reason: unknown) => void;
const interrupted = () => interrupt(signal.reason);
const body = new ReadableStream<Uint8Array>({
start(controller) {
interrupt = (reason) => { if (finished) return; cleanup(); record("error", reason); controller.error(reason); void reader.cancel(reason).catch(() => {}); };
signal.addEventListener("abort", interrupted, { once: true });
timer = setTimeout(timeout, limits.bodyMs);
if (signal.aborted) interrupted();
},
async pull(controller) {
if (finished) return;
idle = setTimeout(timeout, limits.idleMs);
try {
const next = await reader.read(); clearTimeout(idle);
if (finished) return;
if (next.done) { cleanup(); record("complete"); controller.close(); } else { firstChunk ??= performance.now(); bytes += next.value.byteLength; chunks++; controller.enqueue(next.value); }
} catch (error) { if (!finished) { cleanup(); record("error", error); controller.error(error); } }
},
async cancel(reason) { if (!finished) { cleanup(); record("cancelled", reason); } abort.abort(reason); await reader.cancel(reason).catch(() => {}); },
});
return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers: upstream.headers });
}
+28 -8
View File
@@ -11,8 +11,25 @@ export function canvasPayload(document: CanvasDocument) {
const clean = decodeCanvas(JSON.stringify(document));
return { ...clean, nodes: clean.nodes.map(({ id, type, position, width, height, data }) => ({ id, type, position, ...(width !== undefined ? { width, height } : {}), data })) };
}
export function canvasFingerprint(document: CanvasDocument) {
return JSON.stringify(canvasPayload(document), (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value);
export function canvasFingerprint(document: CanvasDocument, includeViewport = true) {
const { viewport, ...content } = canvasPayload(document);
return JSON.stringify(includeViewport ? { ...content, viewport } : content, (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value);
}
export function canvasChanges(before: CanvasDocument, after: CanvasDocument) {
const a = canvasPayload(before), b = canvasPayload(after);
const changed = (left: unknown, right: unknown) => JSON.stringify(left) !== JSON.stringify(right);
return {
viewport: changed(a.viewport, b.viewport),
nodes: b.nodes.flatMap((node) => {
const prior = a.nodes.find((item) => item.id === node.id);
if (!prior) return [{ id: node.id, fields: ["added"] }];
const fields = ["position", "width", "height"].filter((key) => changed(prior[key as keyof typeof prior], node[key as keyof typeof node]));
for (const key of new Set([...Object.keys(prior.data), ...Object.keys(node.data)])) if (changed((prior.data as Record<string, unknown>)[key], (node.data as Record<string, unknown>)[key])) fields.push("data." + key);
return fields.length ? [{ id: node.id, fields }] : [];
}),
removed_nodes: a.nodes.filter((node) => !b.nodes.some((item) => item.id === node.id)).map((node) => node.id),
edges: changed(a.edges, b.edges),
};
}
export function decodeSnapshot(value: CanvasSnapshot): CanvasSnapshot {
if (!Number.isSafeInteger(value?.version) || value.version < 1) throw new Error("invalid_canvas_snapshot");
@@ -23,6 +40,7 @@ export function decodeSnapshot(value: CanvasSnapshot): CanvasSnapshot {
export class CanvasSync {
version: number;
saved: string;
private savedContent: string;
error = "";
document: CanvasDocument;
private pending: CanvasDocument | null = null;
@@ -32,9 +50,10 @@ export class CanvasSync {
private notify: (state: SyncState) => void;
constructor(request: CanvasRequest, path: string, initial: CanvasSnapshot, notify: (state: SyncState) => void) {
this.request = request; this.path = path; this.version = initial.version;
this.saved = canvasFingerprint(initial.document); this.notify = notify;
this.saved = canvasFingerprint(initial.document); this.savedContent = canvasFingerprint(initial.document, false); this.notify = notify;
this.document = initial.document;
}
needsSave(document: CanvasDocument) { return canvasFingerprint(document, false) !== this.savedContent; }
enqueue(document: CanvasDocument): Promise<void> {
this.pending = document;
if (this.error) return Promise.resolve();
@@ -46,7 +65,7 @@ export class CanvasSync {
adopt(snapshot: CanvasSnapshot) {
if (this.running) throw new Error("canvas_busy");
this.pending = null; this.error = ""; this.version = snapshot.version; this.document = snapshot.document;
this.saved = canvasFingerprint(snapshot.document); this.notify({ status: "saved", version: this.version });
this.saved = canvasFingerprint(snapshot.document); this.savedContent = canvasFingerprint(snapshot.document, false); this.notify({ status: "saved", version: this.version });
}
private async drain() {
while (this.pending) {
@@ -54,12 +73,13 @@ export class CanvasSync {
this.notify({ status: "saving", version: this.version });
try {
const fingerprint = canvasFingerprint(document);
if (fingerprint === this.saved) { this.notify({ status: "saved", version: this.version }); continue; }
if (!this.needsSave(document)) { this.notify({ status: "saved", version: this.version }); continue; }
if (process.env.NODE_ENV === "development") console.info("[canvas-dirty]", JSON.stringify({ resource: this.path, ...canvasChanges(this.document, document) }));
const body = JSON.stringify({ document: canvasPayload(document) });
if (new TextEncoder().encode(body).length > 5 * 1024 * 1024) throw new Error("canvas_too_large");
const result = await this.request<{ version: number }>(this.path, { method: "PATCH", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${this.version}"` }, body });
if (!Number.isSafeInteger(result?.version) || result.version !== this.version + 1) throw new Error("invalid_canvas_snapshot");
this.version = result.version; this.saved = fingerprint;
this.version = result.version; this.saved = fingerprint; this.savedContent = canvasFingerprint(document, false);
this.document = document;
this.notify({ status: "saved", version: this.version });
} catch (error) {
@@ -77,13 +97,13 @@ export function readLocalRecovery(storage: Pick<Storage, "getItem">, key: string
if (record) {
const value = JSON.parse(record);
const doc = decodeCanvas(JSON.stringify(value.document));
if (!snapshot || (value.dirty && canvasFingerprint(doc) !== canvasFingerprint(snapshot.document))) return doc;
if (!snapshot || (value.dirty && canvasFingerprint(doc, false) !== canvasFingerprint(snapshot.document, false))) return doc;
return null;
}
const legacy = storage.getItem(key);
if (!legacy) return null;
const doc = decodeCanvas(legacy);
return !snapshot || canvasFingerprint(doc) !== canvasFingerprint(snapshot.document) ? doc : null;
return !snapshot || canvasFingerprint(doc, false) !== canvasFingerprint(snapshot.document, false) ? doc : null;
}
export function canRestoreCanvasDraft(draft: CanvasDocument, remote: CanvasDocument) {
if (draft.schema_version < remote.schema_version) return false;
+2 -2
View File
@@ -82,10 +82,10 @@ export function decodeNodeExtensions(data: Record<string, unknown>, schema: numb
}
return extensions;
}
export function managedImageSource(src?: string) {
export function managedImageSource(src?: string, preview = false) {
if (!src) return src;
try { const url = new URL(src); const match = url.pathname.match(/^\/projects\/endoleap\/(workspaces\/[A-Za-z0-9_-]+\/assets\/[A-Za-z0-9_-]+)$/);
if (url.origin === "https://api.foundry.entroia.com" && match) return "/api/foundry/" + match[1];
if (url.origin === "https://api.foundry.entroia.com" && match) return "/api/foundry/" + match[1] + (preview ? "?variant=preview" : "");
} catch { /* inline and demo images are already browser-readable */ }
return src;
}
+5 -1
View File
@@ -77,7 +77,11 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva
data: { ...node.data, ...(node.data.kind === "frame" && node.data.subgraph && patch.width !== undefined && patch.height !== undefined ? { presentation: { ...node.data.presentation, [node.data.presentation?.collapsed ? "collapsedSize" : "expandedSize"]: { width: patch.width, height: patch.height } } } : {}), ...(patch.rotation !== undefined ? { rotation: patch.rotation } : {}), ...(patch.fontSize !== undefined ? { fontSize: patch.fontSize } : {}) },
}) } };
}
case "viewport": return { ...state, present: { ...state.present, viewport: action.viewport } };
case "viewport": {
const before = state.present.viewport;
if (Math.abs(before.x - action.viewport.x) < 1e-6 && Math.abs(before.y - action.viewport.y) < 1e-6 && Math.abs(before.zoom - action.viewport.zoom) < 1e-9) return state;
return { ...state, present: { ...state.present, viewport: action.viewport } };
}
case "undo": {
const last = state.past.at(-1);
return last ? { past: state.past.slice(0, -1), present: { ...last, viewport: state.present.viewport }, future: [state.present, ...state.future] } : state;
+10 -1
View File
@@ -20,10 +20,16 @@ const upstream = createServer(async (req, res) => {
if (req.url.includes("/canvas")) assert.equal(req.headers.accept, "application/vnd.foundry.canvas+json;version=2");
if (req.url.startsWith("/projects/endoleap/workspaces/space/assets")) {
if (req.headers.authorization !== "Bearer test-only-session") { res.statusCode = 401; res.end(JSON.stringify({error:"invalid_session"})); return; }
if (req.method === "GET") { res.setHeader("Content-Type", req.url.endsWith("/bad") ? "image/svg+xml" : "image/png"); res.end(Buffer.from([137,80,78,71,13,10,26,10])); return; }
if (req.method === "GET") { res.setHeader("X-Asset-Variant", new URL(req.url,"http://mock").searchParams.get("variant") === "preview" ? "preview" : "original"); res.setHeader("Content-Type", req.url.endsWith("/bad") ? "image/svg+xml" : "image/png"); res.end(Buffer.from([137,80,78,71,13,10,26,10])); return; }
let bytes = Buffer.alloc(0); for await (const chunk of req) bytes = Buffer.concat([bytes, chunk]);
res.end(JSON.stringify({type:req.headers["content-type"],bytes:bytes.length,key:req.headers["idempotency-key"],multipart:bytes.includes(Buffer.from('name="image"'))})); return;
}
if (canonical.endsWith("/materials/slow-body-board/canvas")) {
res.setHeader("ETag", '\"210\"');
res.write('{"version":210,"document":');
await new Promise((resolve) => setTimeout(resolve, 15200));
res.end('{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}}'); return;
}
if (canonical.endsWith("/materials/stream-board/canvas")) {
assert.equal(req.headers.authorization, "Bearer test-only-session");
res.setHeader("ETag", '\"1\"'); res.setHeader("X-Canvas-Schema-Version", "2");
@@ -190,11 +196,14 @@ try {
const response = await fetch(v2Base + "/" + path, { method: "POST", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "Idempotency-Key": idem, "If-Match": '\"7\"' }, body: "{}" });
assert.equal(response.status, 200, path); const echo = await response.json(); assert.equal(echo.key, idem); assert.equal(echo.version, '\"7\"'); assert.equal(echo.accept, "application/vnd.foundry.canvas+json;version=2");
}
const preview = await fetch(host + "/api/foundry/workspaces/space/assets/image?variant=preview", { headers: { Cookie: session } });assert.equal(preview.status,200);assert.equal(preview.headers.get("x-asset-variant"),"preview");await preview.arrayBuffer();
const directoryResponse = await (await fetch(host + "/api/foundry/workspaces/space/materials?kind=board&sort=modified&include_subgraphs=true&limit=100&offset=100", { headers: { Cookie: session } })).json();
const directoryQuery = new URL(directoryResponse.url, "http://mock").searchParams;
assert.equal(directoryQuery.get("include_subgraphs"), "true"); assert.equal(directoryQuery.get("offset"), "100");
const events = await (await fetch(v2Base + "/ai/runs/run-1?after_seq=12&limit=100&status=active&include_descendants=true&cursor=cursor&secret=drop", { headers: { Cookie: session } })).json();
const query = new URL(events.url, "http://mock").searchParams; assert.equal(query.get("after_seq"), "12"); assert.equal(query.get("include_descendants"), "true"); assert.equal(query.get("cursor"), "cursor"); assert.equal(query.get("secret"), null);
const slowSnapshot = await fetch(host + "/api/foundry/workspaces/space/materials/slow-body-board/canvas", { headers: { Cookie: session } });
assert.ok(slowSnapshot.headers.get("x-canvas-trace-id")); assert.equal((await slowSnapshot.json()).version, 210, "A valid body can finish after the old 15-second total deadline");
const asset = await fetch(host + "/api/foundry/workspaces/space/assets/image-1", { headers: { Cookie: session } });
assert.equal(asset.status, 200); assert.equal(asset.headers.get("content-type"), "image/png"); assert.equal(asset.headers.get("x-content-type-options"), "nosniff"); assert.ok(asset.headers.get("set-cookie")?.includes("HttpOnly")); assert.equal((await asset.arrayBuffer()).byteLength, 8);
assert.equal((await fetch(host + "/api/foundry/workspaces/space/assets/image-1")).status, 401);
+37
View File
@@ -0,0 +1,37 @@
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 { 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('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('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/);
});
+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fetchCanvasResponse } from '../src/lib/canvas-response.ts';
import { api, ApiError } from '../src/lib/api.ts';
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
test('snapshot body outlives the header deadline and reports transfer timing and bytes',async()=>{
const reports=[]; const bytes=new TextEncoder().encode('{"version":210,"document":{"title":"测试"}}');
const upstream=new Response(new ReadableStream({async start(c){ c.enqueue(bytes.slice(0,10));await wait(50);c.enqueue(bytes.slice(10));c.close();}}),{headers:{ETag:'"210"'}});
const response=await fetchCanvasResponse('https://example.test',{}, {headersMs:10,bodyMs:500,idleMs:300},async()=>upstream, report=>reports.push(report));
assert.equal(response.headers.get('etag'),'"210"');assert.equal((await response.json()).version,210);
assert.equal(reports.length,1);assert.equal(reports[0].outcome,'complete');assert.equal(reports[0].bytes,bytes.length);assert.ok(reports[0].transfer_ms>=30);assert.equal(reports[0].chunks,2);
});
test('snapshot idle timeout aborts unfinished bodies instead of returning a valid empty snapshot',async()=>{
const reports=[];let cancelled=false;
const upstream=new Response(new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('{"version":'));},cancel(){cancelled=true;}}));
const response=await fetchCanvasResponse('https://example.test',{}, {headersMs:100,bodyMs:500,idleMs:15},async()=>upstream,r=>reports.push(r));
await assert.rejects(response.json(),{name:'TimeoutError'});assert.ok(cancelled);assert.equal(reports[0].outcome,'error');assert.equal(reports[0].error,'TimeoutError');
});
test('header deadline rejects a server that has not responded',async()=>{
await assert.rejects(fetchCanvasResponse('https://example.test',{}, {headersMs:10,bodyMs:500,idleMs:100},(_url,options)=>new Promise((_resolve,reject)=>options.signal.addEventListener('abort',()=>reject(options.signal.reason)))),{name:'TimeoutError'});
});
test('disconnect and consumer cancellation release the upstream read',async()=>{
const abort=new AbortController();let cancelled=0;
const make=()=>new Response(new ReadableStream({cancel(){cancelled++;}}));
const response=await fetchCanvasResponse('https://example.test',{signal:abort.signal},{headersMs:100,bodyMs:500,idleMs:100},async()=>make());
abort.abort();await assert.rejects(response.text(),{name:'AbortError'});
const second=await fetchCanvasResponse('https://example.test',{}, {headersMs:100,bodyMs:500,idleMs:100},async()=>make());await second.body.cancel();assert.equal(cancelled,2);
});
test('client distinguishes a failed 200 body transfer, malformed JSON, server errors and 204',async()=>{
const original=globalThis.fetch;
try {
globalThis.fetch=async()=>new Response(new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('{"version":'));c.error(new TypeError('terminated'));}}));
await assert.rejects(api('canvas'),error=>error instanceof ApiError && error.message==='response_interrupted');
globalThis.fetch=async()=>new Response('{"version":');await assert.rejects(api('canvas'),/invalid_api_response/);
globalThis.fetch=async()=>Response.json({error:'upstream_timeout'},{status:504});await assert.rejects(api('canvas'),/upstream_timeout/);
globalThis.fetch=async()=>new Response(null,{status:204});assert.equal(await api('canvas'),null);
} finally {globalThis.fetch=original;}
});
+13 -1
View File
@@ -1,7 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { CanvasSync, canvasPath, canvasPayload, readLocalRecovery } from "../src/lib/canvas-sync.ts";
import { emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
import { emptyCanvas, makeCanvasNode, canvasReducer } from "../src/lib/canvas.ts";
import { loadBoardCanvas } from "../src/lib/canvas-loader.ts";
function document(text = "") { const doc = emptyCanvas(); const node = makeCanvasNode("text", { x: 0, y: 0 }); node.id = "text"; node.data.text = text; doc.nodes.push(node); return doc; }
function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }
@@ -90,3 +90,15 @@ test("board metadata and snapshot begin concurrently without a request waterfall
const loading = loadBoardCanvas((path) => { started.push(path); return new Promise((resolve) => releases.push(() => resolve(path.endsWith("/canvas") ? { version: 1, document: emptyCanvas() } : { id: "board", workspace_id: "space", kind: "board" }))); }, "space", "board");
assert.equal(started.length, 2); releases.forEach((release) => release()); await loading;
});
test("viewport restoration roundoff does not create an autosave", async () => {
const doc=document("keep");doc.viewport={x:132.834729193,y:-73.098456241,zoom:0.637185392};
const state={past:[],present:doc,future:[]};
const next=canvasReducer(state,{type:"viewport",viewport:{x:doc.viewport.x+1e-10,y:doc.viewport.y,zoom:doc.viewport.zoom+1e-12}});
assert.equal(next,state);let writes=0;const writer=new CanvasSync(async()=>{writes++;return {version:2};},"canvas",{version:1,document:doc},()=>{});
await writer.enqueue(next.present);assert.equal(writes,0);
const edited=canvasReducer(state,{type:"viewport",viewport:{...doc.viewport,x:doc.viewport.x+10}});await writer.enqueue(edited.present);assert.equal(writes,0);
edited.present.nodes[0]={...edited.present.nodes[0],data:{...edited.present.nodes[0].data,text:"changed"}};
await writer.enqueue(edited.present);assert.equal(writes,1);
});
+1
View File
@@ -162,6 +162,7 @@ try {
assert.equal(group.querySelector('[aria-checked="true"]').getAttribute("aria-label"), "隐藏名称");
await event(document.querySelector('[aria-label="重做"]'), "click");
assert.equal(group.querySelector('[aria-checked="true"]').getAttribute("aria-label"), "随画布缩放");
await act(async () => { await new Promise(resolve => setTimeout(resolve, 180)); });
await event(document.querySelector('[aria-label="八向拉伸模式"]'), "click");
assert.equal(document.querySelectorAll('[data-resize-handle]').length, 8);
await event(window, "keydown", { key: "Escape" });
+29 -2
View File
@@ -5,13 +5,19 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
const inside=makeCanvasNode('text',{x:10,y:20});inside.id='child-text';inside.data.title='子节点';inside.data.text='原始输入';
const target=makeCanvasNode('text',{x:950,y:100});target.id='outside';target.data.ai={enabled:true,modelId:'test-text',prompt:'继续整理',parameters:{}};
const image=makeCanvasNode('image',{x:1100,y:450},'https://example.com/reference.png');image.id='image-target';image.data.title='图片目标';
const records=new Map([[parent,{version:10,document:{...emptyCanvas(),schema_version:2,nodes:[frame,target,image],edges:[{id:'boundary',source:frame.id,sourceHandle:'port:out',target:target.id,targetHandle:'left',data:{animation:{mode:'executing',effect:'flow'}}}]}}],[child,{version:3,document:{...emptyCanvas(),schema_version:2,nodes:[inside]}}]]);
const legacy=makeCanvasNode('plain-image',{x:1600,y:100},'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB');legacy.id='legacy-image';
const records=new Map([[parent,{version:10,document:{...emptyCanvas(),schema_version:2,nodes:[frame,target,image,legacy],edges:[{id:'boundary',source:frame.id,sourceHandle:'port:out',target:target.id,targetHandle:'left',data:{animation:{mode:'executing',effect:'flow'}}}]}}],[child,{version:3,document:{...emptyCanvas(),schema_version:2,nodes:[inside]}}]]);
let connectionReceipt=null,connectionKey=null,connectionBody=null,connectionRequests=0;
const receipts=new Map();let createCount=0;
let assetUploads=0;
const saves=[];let job=null,poll=0,seq=0,imageRuns=0;const events=[];
const json=(data,status=200)=>new Response(JSON.stringify(data),{status,headers:{'content-type':'application/json'}});
const emit=(type,data)=>events.push({seq:++seq,type,...data});
mock(async(url,options={})=>{
if(new URL(url,'http://localhost').pathname.endsWith('/assets') && options.method==='POST') {
assetUploads++;assert.ok(options.headers['Idempotency-Key']);assert.ok(options.body.get('image').size>0);
return json({id:'77777777-7777-4777-8777-777777777777',url:'https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/77777777-7777-4777-8777-777777777777',width:1,height:1,bytes:24,mime_type:'image/png'},201);
}
assert.match(options.headers?.Accept||'',/version=2/);
const parsed=new URL(url,'http://localhost'), parts=parsed.pathname.split('/');const board=parts[6], suffix=parts.slice(8).join('/'), method=options.method||'GET';
const snapshot=records.get(board);assert.ok(snapshot,`Unknown canvas ${board} ${url}`);
@@ -74,9 +80,22 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
}
throw new Error(`Unhandled V2 API ${method} ${url}`);
});
const readOnly=structuredClone(records.get(parent));readOnly.document.viewport={x:132.834729193,y:-73.098456241,zoom:0.637185392};
await act(async()=>{root.render(h(GraphCanvas,{key:'read-only',board:{id:parent,workspace_id:'w',kind:'board',title:'V2 read only',version:1},snapshot:readOnly}));await tick();});
await act(async()=>{await new Promise(resolve=>setTimeout(resolve,1100));});
assert.equal(saves.length,0,'Non-default viewport restoration, measurement and child loading must not PATCH');
await event(document.querySelector('[aria-label="缩小"]'),'click');
await act(async()=>{await new Promise(resolve=>setTimeout(resolve,1100));});
assert.equal(saves.length,0,'Changing the view without content edits must not PATCH');
const localView=JSON.parse(localStorage.getItem('endoleap_canvas_v1:w:'+parent+':viewport'));assert.ok(localView&&Number.isFinite(localView.zoom));
localStorage.removeItem('endoleap_canvas_v1:w:'+parent+':viewport');
console.log('PASS read-only: root/child loading, viewport restoration and zoom cause zero PATCH requests');
await act(async()=>{root.render(null);await tick();});
await act(async()=>{root.render(h(GraphCanvas,{key:'v2',board:{id:parent,workspace_id:'w',kind:'board',title:'V2 test',version:1},snapshot:structuredClone(records.get(parent))}));await tick();});
await act(async()=>{await tick();});
const node=()=>document.querySelector(`.react-flow__node[data-id="${child}::child-text"]`);
await act(async()=>{await new Promise(resolve=>setTimeout(resolve,1100));});
assert.equal(saves.length,0,'Opening a root and its child without edits must not PATCH');
assert.ok(node(),'Child is expanded from its own snapshot');assert.equal(document.querySelectorAll(`.react-flow__node[data-id="${child}::child-text"]`).length,1);
await event(node(),'click');await event(document.querySelector('[aria-label="AI 模式"]'),'click');await act(async()=>{await tick();});
assert.ok(document.querySelector('[aria-label="元素 AI 工具"]'));assert.ok(document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试文本模型'));
@@ -123,7 +142,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
assert.ok(document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试图片模型'),'Sticky AI mode selects models for the newly selected image');
assert.ok(!document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试文本模型'));
await event(document.querySelector('[aria-label="执行 AI"]'),'click');
await waitUntil(()=>document.querySelector('.react-flow__node[data-id="image-target"] img')?.getAttribute('src')==='/api/foundry/workspaces/w/assets/generated-image','managed AI image result');
await waitUntil(()=>document.querySelector('.react-flow__node[data-id="image-target"] img')?.getAttribute('src')==='/api/foundry/workspaces/w/assets/generated-image?variant=preview','managed AI image result');
const generated=records.get(parent).document.nodes.find(node=>node.id==='image-target');assert.equal(generated.width,image.width);assert.equal(generated.height,image.height);assert.equal(generated.data.title,'图片目标');
await waitUntil(()=>document.querySelector('[aria-label="执行 AI"]'),'image run terminal UI');
await event(document.querySelector('[aria-label="执行 AI"]'),'click');
@@ -131,5 +150,13 @@ 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');
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');
console.log('PASS V2: child ownership and saves, live model catalog, AI propagation/polling, geometry preservation, collapse/expand and transactional child creation');
}