Show nested canvas directory and reduce canvas loading overhead

This commit is contained in:
2026-11-06 20:48:08 +08:00
parent 05d96abd0e
commit af844131ed
15 changed files with 254 additions and 82 deletions
+17 -55
View File
@@ -1,63 +1,25 @@
# 卡顿与首次创建失败检查
# 画布打开与空闲性能检查(2026-11-06)
范围:本地 Next.js 前端、Foundry 代理、线上只读健康检查与现有 Go 代码。没有读取浏览器会话令牌,也没有向用户的线上账号写测试数据。
## 本次证据
## 结论与证据
用户提供的开发日志中,画布页面路由多数约 20ms;物料元数据读取约 200–1,646ms,完整画布 GET 出现 190ms、2.4s、5.1s、11.4s 等样本,PATCH 约 1.4–4.1s。路由渲染与远程数据读取是两个阶段,不能以路由 200 很快判断画布已经可用。
主要等待位于本地前端到公网 API 的请求链路;原来的前端串行加载进一步累加了这部分延迟。尚无证据表明页面绘制或某条业务 SQL 是这次秒级等待的主要来源。下面的采样数量有限,不是负载测试,也不能代替真实授权请求的逐段追踪。
日志只有 Next application-code 总时间,包含代理请求上游、响应体传输及 JSON 处理,不能据此断言是数据库 SQL 或本机渲染卡顿。既有鉴权令牌没有读取,本轮没有对用户线上画布做测试写入或生成。
| 测量位置 | 本次样本 |
| --- | --- |
| 本地 /login HTTP 完整响应,3 次 | 1.6、1.8、7.0 ms |
| 本地代理调用未登录 /profile,修改前,3 次 | 473、686、1262 ms |
| 公网 /healthz,直连、新连接,3 次 | 1.38、2.71、4.09 s |
| 公网 /healthz,经当前 Clash 代理,3 次 | 1.19、2.65、3.28 s |
| 服务器容器内 /readyz,5 次 | 10–81 ms |
| 服务器访问 Google token URL(无有效 code),3 次 | 52、74、309 ms |
| 服务器访问 Google userinfo(无会话),3 次 | 55、60、112 ms |
## 本次修改
本地代理新增 Server-Timing 后,再测同一条未登录 /profile:
- 画布编辑器改为独立动态模块,路由客户端先启动元数据/快照读取,同时加载编辑器代码。元数据和快照继续并行读取,不引入第二段等待。
- 成功的 Canvas GET 在同源代理直接流式转发,不在 Next 中先缓冲完整 JSON,再解析并重新编码。ETag、schema、HttpOnly 会话续期和 no-store 保留;登录兑换响应仍按原逻辑移除凭证。
- Canvas 流式响应的 Server-Timing 分别报告上游响应头等待和代理准备时间。它们不包含后续完整响应体传输时间,不把 proxy_headers 当作整个请求完成时间。
- 空闲 AI 任务发现改为每 30 秒;有任务或尚待合并结果时每 1.2 秒轮询。发起生成、取消、应用结果会立即唤醒;隐藏标签页暂停网络查询,切回立即检查。
- 没有新事件/状态时不再替换 runs/activeEdges 状态,避免空闲轮询反复触发画布渲染。
- 侧栏请求 include_subgraphs=true 并完成分页,以 parent_board_id 建立可展开目录。它读取物料摘要,不下载每个子画布快照。创建、解散和撤销关系变更后刷新目录。
| 样本 | 等待上游响应头 | 代理总耗时 | 总耗时减去上游等待 |
| --- | --- | --- | --- |
| 1 | 5196.4 ms | 5198.3 ms | 1.9 ms |
| 2 | 614.6 ms | 615.0 ms | 0.4 ms |
| 3 | 1080.6 ms | 1081.5 ms | 0.9 ms |
## 验证方法与边界
这条未登录请求在后端会话格式校验时就返回 401,不需要业务 SQL。上游耗时包含 DNS、连接/TLS、公网传输、边缘入口与后端响应;这次未在边缘入口加分段追踪,不能把它进一步精确归因给某一个网络跳点。直连与代理样本都有波动,目前不支持简单添加代理规则即可解决的结论。
- 单元测试覆盖子图分页、层级、缺失父项、循环防护、搜索路径、当前子图祖先,以及元数据/快照同时开始读取。
- DOM 回归使用实际目录组件和 AI hook,验证展开/收起、当前路径、空闲 30 秒调度、空响应无重渲染、隐藏暂停及可见恢复;保留原有生成、取消、跨图连线和事务回归。
- API 冒烟让模拟上游发送快照开头后暂停,在收到代理首段后才释放剩余响应,以证明代理没有等待完整 JSON;同时检查版本、schema 和会话续期。
- 生产构建在临时副本进行,不改动当前开发服务器产物。最终执行结果记录于 VALIDATION.md。
## 代码中的等待与失败点
1. 登录回调先兑换会话,再导航到 dashboard;dashboard 原来依次获取 profile、workspaces,最后再请求首个空间的画布。资料与空间没有数据依赖,原来的串行等待是多余的。
2. 初次进入的账号可能没有 workspace。原来的创建画布分支直接抛出 workspace_required,错误映射又没有这个编码,所以截图只显示“操作未完成,请稍后重试”。没有发出创建画布 HTTP 请求。
3. 切换空间时没有取消旧读取;旧列表还可能在创建完成后返回,从而覆盖刚添加的画布。
4. 画布读取期间显示空状态,Google 跳转时缺乏忙碌提示,会把真实等待和“操作没有生效”混在一起。
5. 全局 h2 样式覆盖 DialogTitle 的字体工具类,导致截图中的弹窗标题异常偏大。
## 本地已修改
- src/lib/workspace.ts:并行加载资料和空间;创建首张画布前建立默认个人空间,空间创建成功后立即保留其 ID,避免画布失败重试时重复创建空间。
- src/pages/dashboard.tsx:接入上述流程;取消过期读取,防止旧列表覆盖新画布,增加画布加载状态;读取空间失败时禁止误判为“尚无空间”;限定弹窗标题样式。
- src/lib/api.ts:补充空间、物料与超时的明确错误提示。
- src/pages/api/foundry/[...path].ts:增加仅含耗时数字的 Server-Timing;取消已断开的上游读取;明确区分超时,保持 HttpOnly 会话处理。
- src/pages/login.tsx:Google / Slack 跳转提供忙碌状态,避免重复点击。
- src/styles/globals.css:明确限定弹窗标题、描述字体大小。
- tests/workspace.test.mjs:覆盖读取并行、首张画布、创建失败后的重试、已有空间直接创建。
- tests/api-smoke.mjs:保留会话安全检查并验证耗时标记。
没有修改后端业务源码、路由安全策略或线上数据。公网链路延迟仍待进一步追踪;本轮没有把它宣称为已解决。
## 验证
- 最终 npm run build 成功,包含 TypeScript 检查。
- 4 项 workspace 流程回归全部成功。
- API 会话、跨站拒绝、白名单与耗时标记 smoke 通过。
- 隔离浏览器验收:空账号直接点击新建,创建“个人空间”和“周末的灵感”成功。模拟旧画布列表延迟 650 ms,新建响应 100 ms,画布仍保持可见,没有被旧列表覆盖。
- 隔离后端记录 profile 与 workspaces 在相隔 2 ms 时发起,确认已并行。
- 本地生产预览已重启到 http://localhost:17321。原有登录 Cookie 保留;刷新页面即可载入新代码。
- 首次画布的完整浏览器回归使用本机模拟后端;没有替用户创建线上测试画布。
![首次创建回归(本机隔离数据)](/Users/zzyang/Documents/Codex/2026-11-05/foundry-core-codex-integration/outputs/foundry-first-board-check.jpg)
后续真实账号验收已完成:创建「个人空间」和「周末的灵感」后,完整刷新页面仍能读取画布。此前的隔离测试限制已由此项验收补齐,详情见 VALIDATION.md。
本轮没有真实登录会话的公网前后对照,也没有浏览器视觉/手感验收,因此不能给出“线上打开从 11.4 秒降至某个时间”的结论。若更新后仍等待数秒,应结合 Network 中 Server-Timing 的 upstream 值、响应体下载时间和体积进一步追踪公网链路/后端;前端减少重复工作不等于消除了上游延迟。
+1 -1
View File
@@ -47,7 +47,7 @@ Google / Slack 的提供方回调仍是 Foundry 后端地址;后端的项目
- 响应式首页、锚点导航与 FAQ。
- 邮箱注册 / 登录 / 邮件验证兑换,Google 和 Slack 的 PKCE 登录入口及回调处理。
- 真实用户资料、空间列表与创建、画布列表与创建。资料和空间并行读取;首次创建画布时自动建立个人空间。
- 单侧栏第一行提供完全收起/恢复与新建页面,第二行为当前空间页面列表搜索。侧栏使用 Motion for React 14.9.1 实现 240ms 开关动画,尊重系统减少动态效果偏好,收起后保留左上角快捷浮框。
- 单侧栏第一行提供完全收起/恢复与新建页面,第二行为当前空间页面列表搜索。画布列表包含可展开的父子画布目录,搜索保留祖先路径,直接打开子图时自动展开所在目录。侧栏使用 Motion for React 14.9.1 实现 240ms 开关动画,尊重系统减少动态效果偏好,收起后保留左上角快捷浮框。
- 账户菜单分为工作空间概览、账户设置、用户资料与空间切换、新建工作空间、退出登录;账户设置和新建空间均接入 Foundry。菜单文字和行距参考 Notion 收紧,工作空间分组标题保留浅灰色。
- 底部按钮、账户菜单和设置面板使用 Foundry `profile.avatar_url` 提供的 Google 头像;无头像或图片加载失败时显示姓名首字。
- 新建页面使用 Foundry Core 的 `POST /workspaces/{workspace}/materials`,提交 `kind: board`、默认名称“未命名图谱”和内容版本;由后端生成 ID。没有空间时先创建“个人空间”,成功后跳转到图谱的独立地址。
+9
View File
@@ -25,3 +25,12 @@
- 网络未知结果保留请求并暂停编辑;普通版本冲突保留本地草稿,不强推覆盖。
- Headless 库尚未创建,提取方案已改写在本地忽略的 docs/HEADLESS_CANVAS_API_DRAFT.md;已替代的分区/元素/后端需求文档已清理。会话策略文档仍保留。
- 原有 next-env.d.ts、pnpm-lock.yaml 和 pnpm-workspace.yaml 不属于本次交付,保留未提交。
## 侧栏目录与加载性能修正
- 侧栏通过 include_subgraphs=true 和完整分页展示嵌套子画布;支持展开/收起、活动路径、搜索保留祖先,关系事务完成后刷新。
- 空闲 AI 查询降至 30 秒、任务期间 1.2 秒;隐藏标签页暂停,返回或本地命令后唤醒;空响应不重渲染。
- 编辑器拆成动态模块,与元数据/快照并行加载;成功 Canvas GET 代理采用流式转发。
- 实际证据、性能边界及未执行的公网/浏览器验收见 PERFORMANCE.md;不将本地模拟测试当作公网提速实测。
- 本次最终检查通过:TypeScript、73 项单元测试、React Flow DOM 全量回归、隔离 Webpack 生产构建及 API 冒烟(含上游响应体暂停时首段透传、目录分页参数)。
+7
View File
@@ -108,6 +108,13 @@ async function handler(
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
}
const headersReceived = performance.now();
// Snapshots contain no login exchange payload. Forward them without buffering
// and parsing/re-encoding multi-megabyte inline image JSON in the proxy.
if (canvas && path.endsWith("/canvas") && req.method === "GET" && upstream.ok) {
responseHeaders.set("Content-Type", "application/json; charset=utf-8");
responseHeaders.set("Server-Timing", `upstream;dur=${(headersReceived - started).toFixed(1)}, proxy_headers;dur=${(performance.now() - headersReceived).toFixed(1)}`);
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
}
const body = upstream.status === 204 ? null : await upstream.json();
responseHeaders.set(
"Server-Timing",
+16 -12
View File
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronsUpDown, FilePlus2, Home, Network, PanelLeftClose, PanelLeftOpen, Plus, Search, Settings } from "lucide-react";
import { Check, ChevronsUpDown, FilePlus2, Home, PanelLeftClose, PanelLeftOpen, Plus, Search, Settings } from "lucide-react";
import { IconButton } from "@/components/ui/icon-button";
import { api, errorText, type Profile } from "@/lib/api";
import { boardHref, createBoard, renameBoard, matchesRef, DEFAULT_BOARD_TITLE, loadWorkspaceHome, type Board, type Workspace } from "@/lib/workspace";
@@ -14,6 +14,8 @@ import { EditableTitle } from "@/components/workspace/editable-title";
import { UserAvatar } from "@/components/workspace/user-avatar";
import { AccountPanel } from "@/components/workspace/account-panel";
import { readDemoBoards } from "@/lib/demo-workspace";
import { loadBoardDirectory } from "@/lib/board-directory";
import { BoardDirectory } from "@/components/workspace/board-directory";
import styles from "@/components/workspace/workspace.module.css";
export default function DashboardSidebar() {
@@ -26,6 +28,7 @@ export default function DashboardSidebar() {
const [profile, setProfile] = useState<Profile | null>(null);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [selectedWorkspace, setSelectedWorkspace] = useState("");
const [directoryRevision, setDirectoryRevision] = useState(0);
const [boards, setBoards] = useState<Board[]>([]);
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
const [error, setError] = useState("");
@@ -75,17 +78,24 @@ export default function DashboardSidebar() {
if (!workspace || status !== "ready") return;
const version = ++listVersion.current;
let active = true;
setBoards([]);
setBoards((list) => list.filter((board) => board.workspace_id === workspace));
if (demo) {
setBoards(readDemoBoards(workspace));
return;
}
const controller = new AbortController();
api<{ items: Board[] }>(`workspaces/${encodeURIComponent(workspace)}/materials?kind=board&sort=modified&limit=100`, { signal: controller.signal })
.then(({ items }) => { if (active && version === listVersion.current) setBoards(items); })
loadBoardDirectory(api, workspace, controller.signal)
.then((items) => { if (active && version === listVersion.current) setBoards(items); })
.catch((e) => { if (active) setError(errorText(e)); });
return () => { active = false; controller.abort(); };
}, [workspace, demo, status]);
}, [workspace, demo, status, directoryRevision]);
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const refresh = (event: Event) => { if ((event as CustomEvent).detail?.workspace !== workspace) return; clearTimeout(timer); timer = setTimeout(() => setDirectoryRevision((value) => value + 1), 100); };
window.addEventListener("endoleap:board-directory-changed", refresh);
return () => { clearTimeout(timer); window.removeEventListener("endoleap:board-directory-changed", refresh); };
}, [workspace]);
async function newPage() {
if (createLock.current || status !== "ready") return;
@@ -206,13 +216,7 @@ export default function DashboardSidebar() {
<div className={styles.sectionLabel}>{currentWorkspace?.name || "工作空间"}<span>{filtered.length}</span></div>
{status === "loading" && <p className={styles.hint} role="status">正在读取工作空间…</p>}
{status === "ready" && !workspace && <p className={styles.hint}>新建页面时会自动建立个人空间。</p>}
{filtered.map((board) => (
<Link key={board.id} className={styles.pageLink} aria-current={matchesRef(board, params.board) ? "page" : undefined} href={boardHref(currentWorkspace?.slug || workspace, board.slug || board.id, demo)}>
<Network size={16} strokeWidth={1.6} aria-hidden="true" />
<span>{board.title}</span>
</Link>
))}
{query && !filtered.length && <p className={styles.hint}>没有找到相关页面</p>}
<BoardDirectory key={workspace} boards={boards} workspace={currentWorkspace?.slug || workspace} active={params.board} query={query} demo={demo} />
</nav>
{error && <p className={styles.error} role="alert">{error}</p>}
<div className={styles.accountArea}>
+21 -10
View File
@@ -20,6 +20,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
const [conflicts, setConflicts] = useState(new Set<string>());
const [activeEdges, setActiveEdges] = useState(new Set<string>());
const reference = useRef(tree); reference.current = tree;
const wake = useRef<() => void>(() => {});
const cache = useRef(new Map<string, AIRun>());
const edgeStates = useRef(new Map<string, Set<string>>());
const pendingRefresh = useRef(new Map<string, number>());
@@ -27,20 +28,24 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
useEffect(() => {
if (!live) return;
let alive = true, timer: ReturnType<typeof setTimeout> | undefined;
let polling = false, dirty = false, nextDiscovery = 0;
const controller = new AbortController();
const request = <T,>(path: string) => api<T>(path, { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } });
async function discover() {
let cursor = ""; const seen = new Set<string>();
do {
const page = await request<{ runs: { run_id: string; board_id: string; status: string }[]; next_cursor?: string }>(canvasPath(workspace, root) + "/ai/runs?status=active&include_descendants=true&limit=50" + (cursor ? "&cursor=" + encodeURIComponent(cursor) : ""));
for (const run of page.runs || []) if (!cache.current.has(run.run_id)) cache.current.set(run.run_id, { ...run, next_seq: 0 });
for (const run of page.runs || []) if (!cache.current.has(run.run_id)) { cache.current.set(run.run_id, { ...run, next_seq: 0 }); dirty = true; }
cursor = page.next_cursor || ""; if (seen.has(cursor)) throw new Error("invalid_run_cursor"); seen.add(cursor);
} while (cursor && alive);
}
let polls = 0;
const hasActive = () => [...cache.current.values()].some((run) => active(run.status) || run.has_more);
async function poll() {
if (!alive || polling) return;
if (document.visibilityState === "hidden") { timer = setTimeout(poll, 30000); return; }
polling = true;
try {
if (++polls % 4 === 0) await discover();
if (Date.now() >= nextDiscovery) { await discover(); nextDiscovery = Date.now() + (hasActive() ? 6000 : 30000); }
for (const [id, previous] of cache.current) {
if (!active(previous.status) && !previous.has_more) continue;
let current = previous;
@@ -54,6 +59,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
}
if (!active(page.status)) edges.clear(); edgeStates.current.set(id, edges);
current = { ...page, board_id: previous.board_id, events: [...(current.events || []), ...(page.events || [])].slice(-1000) };
if (current.next_seq !== previous.next_seq || current.status !== previous.status) dirty = true;
cache.current.set(id, current);
} while (current.has_more && alive);
}
@@ -68,27 +74,32 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
}
pendingRefresh.current.delete(board);
}
if (alive) { changed(); setError(""); }
if (alive) { if (dirty) { changed(); dirty = false; } setError(""); }
} catch (error) { if (alive && !controller.signal.aborted) { changed(); setError(error instanceof Error ? error.message : "request_failed"); } }
if (alive) timer = setTimeout(poll, 1200);
polling = false;
if (alive) timer = setTimeout(poll, hasActive() || pendingRefresh.current.size ? 1200 : 30000);
}
void discover().catch((error) => { if (alive) setError(error.message); }).finally(() => { if (alive) void poll(); });
return () => { alive = false; controller.abort(); if (timer) clearTimeout(timer); };
const resume = () => { if (timer) clearTimeout(timer); nextDiscovery = 0; void poll(); };
wake.current = resume;
const visible = () => { if (document.visibilityState === "visible") resume(); };
document.addEventListener("visibilitychange", visible);
void poll();
return () => { alive = false; wake.current = () => {}; controller.abort(); if (timer) clearTimeout(timer); document.removeEventListener("visibilitychange", visible); };
}, [workspace, root, live]);
async function run(boardId: string, node: string, model: string, prompt: string, propagate: boolean) {
if (!live) throw new Error("demo_ai_unavailable");
const result = await reference.current.command(boardId, "ai/runs", { node_id: node, model_id: model, prompt, parameters: {}, propagate }, undefined, false);
const id = result.run_id as string; cache.current.set(id, { run_id: id, board_id: boardId, status: "queued", next_seq: 0, steps: [{ step_id: "pending", board_id: boardId, node_id: node, status: "queued" }] }); changed();
const id = result.run_id as string; cache.current.set(id, { run_id: id, board_id: boardId, status: "queued", next_seq: 0, steps: [{ step_id: "pending", board_id: boardId, node_id: node, status: "queued" }] }); changed(); wake.current();
}
async function cancel(run: AIRun) {
await api(canvasPath(workspace, run.board_id) + `/ai/runs/${run.run_id}/cancel`, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": crypto.randomUUID() }, body: "{}" });
const next = cache.current.get(run.run_id); if (next) { next.status = "queued"; } // Poll canonical terminal state, including already applied steps.
const next = cache.current.get(run.run_id); if (next) { next.status = "queued"; } wake.current(); // Poll canonical terminal state, including already applied steps.
}
async function apply(run: AIRun, node: string, output: string, targetBoard: string) {
await reference.current.prepareApply(targetBoard, node);
await reference.current.command(run.board_id, `ai/runs/${run.run_id}/apply`, { node_id: node, output_id: output }, undefined, false, targetBoard);
setConflicts((current) => { const next = new Set(current); next.delete(targetBoard); return next; });
const previous = cache.current.get(run.run_id); if (previous) { previous.status = "queued"; changed(); }
const previous = cache.current.get(run.run_id); if (previous) { previous.status = "queued"; changed(); } wake.current();
}
return { enabled, setEnabled, runs, conflicts, activeEdges, error, run, cancel, apply, live };
}
+1
View File
@@ -205,6 +205,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
try {
const result = await api<CanvasCommandResult>(canvasPath(workspace, boardId) + "/" + path, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${version}"`, "Idempotency-Key": key }, body: JSON.stringify(body) });
const changed = accept(result);
if (path.startsWith("subgraphs") || path.startsWith("transactions/")) window.dispatchEvent(new window.CustomEvent("endoleap:board-directory-changed", { detail: { workspace } }));
if (!changed.length && result.canvas_version) { const target = typeof result.board_id === "string" ? result.board_id : boardId; const remote = await read(target); install(target, remote); publish(new Map(current.current).set(target, keepSelection(remote.document, current.current.get(target)))); }
if (record && result.transaction_id) { undo.current.push({ boardId, path, payload, id: result.transaction_id, participants: changed }); redo.current = []; tick((v) => v + 1); }
retryOperation.current = null; setPendingCommand(false); setError("");
@@ -0,0 +1,26 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { ChevronRight, Network } from "lucide-react";
import { activeBoardAncestors, boardDirectory, filterBoardDirectory, type BoardBranch } from "@/lib/board-directory";
import { boardHref, matchesRef, type Board } from "@/lib/workspace";
import styles from "./workspace.module.css";
export function BoardDirectory({ boards, workspace, active, query, demo }: { boards: Board[]; workspace: string; active?: string; query: string; demo: boolean }) {
const [closed, setClosed] = useState(new Set<string>());
const branches = useMemo(() => filterBoardDirectory(boardDirectory(boards), query), [boards, query]);
useEffect(() => {
const path = activeBoardAncestors(boards, active);
setClosed((previous) => { const next = new Set(previous); for (const id of path) next.delete(id); return next; });
}, [active, boards]);
function render(items: BoardBranch[], depth = 0) {
return <ul className={styles.directoryList}>{items.map(({ board, children }) => {
const expanded = !!query.trim() || !closed.has(board.id);
return <li key={board.id}><div className={styles.directoryRow} style={{ paddingLeft: Math.min(depth, 8) * 16 }}>
{children.length ? <button type="button" className={styles.directoryToggle} aria-label={`${expanded ? "收起" : "展开"}${board.title}的子画布`} aria-expanded={expanded} onClick={() => setClosed((previous) => { const next = new Set(previous); if (expanded) next.add(board.id); else next.delete(board.id); return next; })}><ChevronRight size={13} style={{ transform: expanded ? "rotate(90deg)" : undefined }} /></button> : <span className={styles.directorySpacer} />}
<Link className={styles.pageLink} aria-current={matchesRef(board, active) ? "page" : undefined} href={boardHref(workspace, board.slug || board.id, demo)}><Network size={15} strokeWidth={1.6} aria-hidden="true" /><span>{board.title}</span></Link>
</div>{expanded && children.length > 0 && render(children, depth + 1)}</li>;
})}</ul>;
}
return branches.length ? render(branches) : query ? <p className={styles.hint}>没有找到相关页面</p> : null;
}
+4 -1
View File
@@ -9,7 +9,8 @@ import { loadBoardCanvas } from "@/lib/canvas-loader";
import type { CanvasSnapshot } from "@/lib/canvas-sync";
import styles from "./workspace.module.css";
import { useActiveBoard } from "./active-board";
import GraphCanvas from "@/components/canvas/graph-canvas";
import dynamic from "next/dynamic";
const GraphCanvas = dynamic(() => import("@/components/canvas/graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string };
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
@@ -22,6 +23,8 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
const [retry, setRetry] = useState(0);
useEffect(() => {
setLoaded(null); setError("");
// Fetch data while the much larger editor chunk loads.
void import("@/components/canvas/graph-canvas").catch(() => {});
if (demo) {
const item = readDemoBoards(workspaceId).find((board) => matchesRef(board, boardId));
if (item) setLoaded({ board: item, route });
@@ -124,3 +124,12 @@
.floatingAvatarButton .avatar { flex-basis: var(--toolbar-icon-size, 20px); width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); font-size: 14px; }
.floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0; color: #37352f; font-size: 14px; line-height: var(--toolbar-control-height, 32px); font-weight: 500; overflow: visible; }
@media (max-width: 700px) { .floatingTitle { max-width: 180px; } }
.directoryList { list-style: none; padding: 0; margin: 0; }
.directoryRow { display: flex; align-items: center; min-width: 0; }
.directoryRow .pageLink { flex: 1; min-width: 0; padding-left: 3px; }
.directoryToggle, .directorySpacer { flex: 0 0 20px; width: 20px; height: 26px; }
.directoryToggle { display: grid; place-items: center; border: 0; border-radius: 4px; background: transparent; color: #87837c; cursor: pointer; }
.directoryToggle:hover { background: #eae7e2; }
.directoryToggle:focus-visible { outline: 2px solid #9b877a; outline-offset: -2px; }
.directoryToggle svg { transition: transform 120ms ease; }
+50
View File
@@ -0,0 +1,50 @@
import { matchesRef, type Board } from "./workspace.ts";
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
/** Include children and finish pagination before building ownership paths. */
export async function loadBoardDirectory(request: Request, workspace: string, signal?: AbortSignal) {
const boards = new Map<string, Board>();
let offset = 0;
const seen = new Set<number>();
while (true) {
if (seen.has(offset)) throw new Error("invalid_pagination");
seen.add(offset);
const page = await request<{ items: Board[]; next_offset?: number | null }>(`workspaces/${encodeURIComponent(workspace)}/materials?kind=board&sort=modified&include_subgraphs=true&limit=100&offset=${offset}`, { signal });
for (const board of page.items) boards.set(board.id, board);
if (page.next_offset == null) return [...boards.values()];
if (!Number.isSafeInteger(page.next_offset) || page.next_offset <= offset) throw new Error("invalid_pagination");
offset = page.next_offset;
}
}
export type BoardBranch = { board: Board; children: BoardBranch[] };
export function boardDirectory(boards: Board[]): BoardBranch[] {
const entries = new Map(boards.map((board) => [board.id, { board, children: [] as BoardBranch[] }]));
const roots: BoardBranch[] = [];
for (const entry of entries.values()) {
const seen = new Set([entry.board.id]);
let parent = entry.board.parent_board_id;
let cyclic = false;
while (parent && entries.has(parent)) {
if (seen.has(parent)) { cyclic = true; break; }
seen.add(parent); parent = entries.get(parent)!.board.parent_board_id;
}
const owner = entry.board.parent_board_id && entries.get(entry.board.parent_board_id);
if (owner && !cyclic) owner.children.push(entry); else roots.push(entry);
}
return roots;
}
export function activeBoardAncestors(boards: Board[], ref?: string) {
const result = new Set<string>();
let item = boards.find((board) => matchesRef(board, ref));
while (item && !result.has(item.id)) { result.add(item.id); item = boards.find((board) => board.id === item!.parent_board_id); }
return result;
}
export function filterBoardDirectory(branches: BoardBranch[], query: string): BoardBranch[] {
const term = query.trim().toLocaleLowerCase();
if (!term) return branches;
return branches.flatMap((branch) => {
if (branch.board.title.toLocaleLowerCase().includes(term)) return [branch];
const children = filterBoardDirectory(branch.children, term);
return children.length ? [{ ...branch, children }] : [];
});
}
+24
View File
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
import { readdir, readFile } from "node:fs/promises";
const host = "http://127.0.0.1:3005";
const graphs = new Map();
let releaseStream = () => {};
let canvasSnapshot = { version: 1, document: { schema_version: 1, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } };
let mockSessionSeconds = "2592000";
let mockProfile = {
@@ -23,6 +24,13 @@ const upstream = createServer(async (req, res) => {
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/stream-board/canvas")) {
assert.equal(req.headers.authorization, "Bearer test-only-session");
res.setHeader("ETag", '\"1\"'); res.setHeader("X-Canvas-Schema-Version", "2");
res.write('{"version":1,"document":');
await new Promise((resolve) => { releaseStream = resolve; });
res.end('{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}}'); return;
}
if (canonical.includes("/canvas/")) {
res.end(JSON.stringify({url:req.url,method:req.method,accept:req.headers.accept,key:req.headers["idempotency-key"],version:req.headers["if-match"]})); return;
}
@@ -163,12 +171,28 @@ try {
assert(cookie.includes("SameSite=Lax"));
assert(cookie.includes("Max-Age=2592000"));
const session = cookie.split(";")[0];
// Hold the upstream tail: snapshot headers and its first chunk must arrive
// before the proxy has the full JSON body (the old buffering path deadlocks).
let streamTimer;
try {
const streamed = await Promise.race([fetch(host + "/api/foundry/workspaces/space/materials/stream-board/canvas", { headers: { Cookie: session } }), new Promise((_, reject) => { streamTimer = setTimeout(() => reject(new Error("Snapshot proxy buffered the body")), 2500); })]);
assert.equal(streamed.headers.get("etag"), '\"1\"'); assert.equal(streamed.headers.get("x-canvas-schema-version"), "2");
assert.match(streamed.headers.get("server-timing"), /upstream;dur=.*proxy_headers;dur=/);
assert.ok(streamed.headers.get("set-cookie")?.includes("HttpOnly"));
const reader = streamed.body.getReader(); const first = await reader.read(); assert.ok(first.value.length > 0);
releaseStream(); let raw = new TextDecoder().decode(first.value);
while (true) { const next = await reader.read(); if (next.done) break; raw += new TextDecoder().decode(next.value); }
assert.equal(JSON.parse(raw).document.schema_version, 2);
} finally { clearTimeout(streamTimer); releaseStream(); }
const v2Base = host + "/api/foundry/workspaces/space/materials/foundry-generated-id/canvas";
const idem = "11111111-1111-4111-8111-111111111111";
for (const path of ["connections", "subgraphs", "subgraphs/migrate", "subgraphs/frame-1/members", "subgraphs/frame-1/dissolve", "transactions/tx-1/undo", "ai/runs", "ai/runs/run-1/cancel", "ai/runs/run-1/apply"]) {
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 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 asset = await fetch(host + "/api/foundry/workspaces/space/assets/image-1", { headers: { Cookie: session } });
+19
View File
@@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { loadBoardDirectory, boardDirectory, activeBoardAncestors, filterBoardDirectory } from '../src/lib/board-directory.ts';
const board=(id,parent,title=id)=>({id,parent_board_id:parent,title,workspace_id:'w',kind:'board',version:1,slug:id+'-slug'});
test('directory includes subgraphs, traverses all pages and preserves nested ownership',async()=>{
const calls=[];const request=async(path)=>{calls.push(path);const offset=new URLSearchParams(path.split('?')[1]).get('offset');return offset==='0'?{items:[board('child','parent'),board('parent')],next_offset:100}:{items:[board('grandchild','child')],next_offset:null};};
const boards=await loadBoardDirectory(request,'space slug');assert.equal(calls.length,2);assert.ok(calls.every(path=>path.includes('include_subgraphs=true')));assert.ok(calls[1].endsWith('offset=100'));assert.ok(calls[0].includes('space%20slug'));
const roots=boardDirectory(boards);assert.equal(roots.length,1);assert.equal(roots[0].board.id,'parent');assert.equal(roots[0].children[0].children[0].board.id,'grandchild');
assert.deepEqual([...activeBoardAncestors(boards,'grandchild-slug')],['grandchild','child','parent']);
assert.deepEqual(filterBoardDirectory(roots,'grandchild')[0].children[0].children.map(item=>item.board.id),['grandchild']);
assert.equal(filterBoardDirectory(roots,'parent')[0].children.length,1);
});
test('missing parents and corrupt cycles remain accessible without recursive loops',()=>{
const roots=boardDirectory([board('orphan','missing'),board('a','b'),board('b','a')]);assert.deepEqual(roots.map(item=>item.board.id),['orphan','a','b']);assert.ok(roots.every(item=>item.children.length===0));
});
test('directory rejects non-advancing pagination and propagates aborts',async()=>{
await assert.rejects(loadBoardDirectory(async()=>({items:[],next_offset:0}),'w'),/invalid_pagination/);
const controller=new AbortController();controller.abort();await assert.rejects(loadBoardDirectory(async(_path,options)=>{options.signal.throwIfAborted();},'w',controller.signal),{name:'AbortError'});
});
+7
View File
@@ -83,3 +83,10 @@ test("canonical fingerprints ignore server JSON key ordering", async () => {
const storage = { getItem: () => JSON.stringify({ document: local, dirty: true }) };
assert.equal(readLocalRecovery(storage, "key", { version: 1, document: remote }), null);
});
test("board metadata and snapshot begin concurrently without a request waterfall", async () => {
const started = []; const releases = [];
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;
});
+43 -3
View File
@@ -51,19 +51,59 @@ const folder = await mkdtemp(resolve(tmpdir(), "endoleap-canvas-ui-"));
try {
const outfile = resolve(folder, "fixture.cjs");
await build({
stdin: { contents: 'export { default as GraphCanvas } from "./src/components/canvas/graph-canvas"; export { IconPicker } from "./src/components/canvas/icon-picker"; export { makeCanvasNode, emptyCanvas } from "./src/lib/canvas"; export { createRoot } from "react-dom/client"; export { act, createElement } from "react";', resolveDir: process.cwd(), loader: "tsx" },
stdin: { contents: 'export { default as GraphCanvas } from "./src/components/canvas/graph-canvas"; export { BoardDirectory } from "./src/components/workspace/board-directory"; export { useCanvasAI } from "./src/components/canvas/use-canvas-ai"; export { IconPicker } from "./src/components/canvas/icon-picker"; export { makeCanvasNode, emptyCanvas } from "./src/lib/canvas"; export { createRoot } from "react-dom/client"; export { act, createElement } from "react";', resolveDir: process.cwd(), loader: "tsx" },
outfile, bundle: true, platform: "node", format: "cjs", jsx: "automatic", logLevel: "silent", nodePaths: [resolve("node_modules")],
loader: { ".css": "empty" },
plugins: [{ name: "dynamic-component", setup(build) {
build.onResolve({ filter: /^next\/link$/ }, () => ({ path: "link", namespace: "fixture" }));
build.onResolve({ filter: /^next\/dynamic$/ }, () => ({ path: "dynamic", namespace: "fixture" }));
build.onLoad({ filter: /.*/, namespace: "fixture" }, () => ({ contents: 'import React from "react"; export default function dynamic(loader) { const Component = React.lazy(loader); return function Dynamic(props) { return React.createElement(React.Suspense, {fallback:null}, React.createElement(Component,props)); }; }', resolveDir: process.cwd() }));
build.onLoad({ filter: /.*/, namespace: "fixture" }, ({ path }) => ({ contents: path === "link" ? 'import React from "react"; export default function Link(props) { return React.createElement("a",props); }' : 'import React from "react"; export default function dynamic(loader) { const Component = React.lazy(loader); return function Dynamic(props) { return React.createElement(React.Suspense, {fallback:null}, React.createElement(Component,props)); }; }', resolveDir: process.cwd() }));
if (process.env.ROTATION_BASELINE === "1") build.onLoad({ filter: /node-style-toolbar\.tsx$/ }, async ({ path }) => ({ contents: (await readFile(path, "utf8")).replace('onClick={(event) => event.stopPropagation()}', ''), loader: "tsx" }));
} }],
});
const { GraphCanvas, IconPicker, makeCanvasNode, emptyCanvas, act, createElement: h, createRoot } = createRequire(import.meta.url)(outfile);
const { GraphCanvas, BoardDirectory, useCanvasAI, IconPicker, makeCanvasNode, emptyCanvas, act, createElement: h, createRoot } = createRequire(import.meta.url)(outfile);
const root = createRoot(document.querySelector("#app"));
const tick = () => new Promise((resolve) => setTimeout(resolve, 30));
const event = async (target, type, init = {}) => { assert.ok(target, `Missing target for ${type}`); await act(async () => { const Type = type.startsWith("pointer") ? PointerEvent : type.startsWith("key") ? window.KeyboardEvent : window.MouseEvent; target.dispatchEvent(new Type(type, { bubbles: true, cancelable: true, view: window, ...init })); await tick(); }); };
{
const boards = [{ id: "root", title: "主画布", workspace_id: "w", slug: "root-slug" }, { id: "child", title: "未命名分区", parent_board_id: "root", workspace_id: "w", slug: "child-slug" }, { id: "nested", title: "孙画布", parent_board_id: "child", workspace_id: "w" }];
const renderDirectory = (active, query = "") => h(BoardDirectory, { boards, workspace: "space", active, query, demo: false });
await act(async () => { root.render(renderDirectory("root-slug")); await tick(); });
assert.equal(document.querySelectorAll("ul ul").length, 2);
assert.ok(document.querySelector('a[href="/dashboard/space/child-slug"]'));
await event(document.querySelector('[aria-label="收起主画布的子画布"]'), "click");
assert.equal(document.querySelector('a[href="/dashboard/space/child-slug"]'), null);
await act(async () => { root.render(renderDirectory("nested")); await tick(); });
assert.ok(document.querySelector('a[aria-current="page"][href="/dashboard/space/nested"]'));
await act(async () => { root.render(renderDirectory("nested", "孙画布")); await tick(); });
assert.equal(document.querySelectorAll("a").length, 3, "Search retains parent paths");
console.log("PASS directory: nested children, collapse, active ancestor expansion and search paths");
}
{
const originalSetTimeout = globalThis.setTimeout, originalClearTimeout = globalThis.clearTimeout;
const scheduled = [];
globalThis.setTimeout = (callback, delay, ...args) => { const handle = originalSetTimeout(callback, delay, ...args); if (delay >= 1000) scheduled.push({ callback, delay, handle }); return handle; };
let calls = 0, controls, renders = 0;
apiMock = async () => { calls++; return Response.json({ runs: [] }); };
const tree = { busy: false, writers: { current: new Map() } };
function Probe() { renders++; controls = useCanvasAI("w", "root", true, tree); return null; }
try {
await act(async () => { root.render(h(Probe)); await tick(); });
assert.equal(calls, 1, "One initial discovery");
assert.equal(scheduled.at(-1).delay, 30000, "Idle discovery backs off to 30 seconds");
const before = renders; const idle = scheduled.at(-1); originalClearTimeout(idle.handle);
await act(async () => { await idle.callback(); await tick(); });
assert.equal(calls, 1, "No repeated discovery before its deadline");
assert.equal(renders, before, "An empty poll does not rerender the canvas");
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
const pending = scheduled.at(-1); originalClearTimeout(pending.handle);
await act(async () => { await pending.callback(); await tick(); }); assert.equal(calls, 1, "Hidden tabs do not query runs");
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
await act(async () => { document.dispatchEvent(new window.Event("visibilitychange")); await tick(); }); assert.equal(calls, 2, "Returning to the tab checks immediately");
await act(async () => { root.render(null); await tick(); });
console.log("PASS polling: single discovery, idle backoff, no empty rerenders, hidden pause and visible resume");
} finally { globalThis.setTimeout = originalSetTimeout; for (const item of scheduled) originalClearTimeout(item.handle); apiMock = null; }
}
const node = makeCanvasNode("text", { x: 100, y: 100 }); node.id = "text-one";
const frameNode = makeCanvasNode("frame", { x: 500, y: 100 }); frameNode.id = "frame-one"; frameNode.data.title = "分区 A";
localStorage.setItem("endoleap_canvas_v1:w:b", JSON.stringify({ ...emptyCanvas(), nodes: [node, frameNode, { ...makeCanvasNode("text", { x: 900, y: 100 }), id: "text-two" }], edges: [{ id: "edge-test", source: "text-one", target: "text-two", sourceHandle: "right", targetHandle: "left" }] }));