From af844131ed74cdd79d815b89a454129a93e6cd16 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 6 Nov 2026 20:48:08 +0800 Subject: [PATCH] Show nested canvas directory and reduce canvas loading overhead --- PERFORMANCE.md | 72 +++++-------------- README.md | 2 +- VALIDATION.md | 9 +++ src/app/api/foundry/[...path]/route.ts | 7 ++ src/app/dashboard/dashboard.tsx | 28 ++++---- src/components/canvas/use-canvas-ai.ts | 31 +++++--- src/components/canvas/use-canvas-tree.ts | 1 + src/components/workspace/board-directory.tsx | 26 +++++++ src/components/workspace/graph-page.tsx | 5 +- src/components/workspace/workspace.module.css | 9 +++ src/lib/board-directory.ts | 50 +++++++++++++ tests/api-smoke.mjs | 24 +++++++ tests/board-directory.test.mjs | 19 +++++ tests/canvas-sync.test.mjs | 7 ++ tests/canvas-ui-check.mjs | 46 +++++++++++- 15 files changed, 254 insertions(+), 82 deletions(-) create mode 100644 src/components/workspace/board-directory.tsx create mode 100644 src/lib/board-directory.ts create mode 100644 tests/board-directory.test.mjs diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 80026be..cb4084d 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -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 值、响应体下载时间和体积进一步追踪公网链路/后端;前端减少重复工作不等于消除了上游延迟。 diff --git a/README.md b/README.md index 63cb7cb..305ba45 100644 --- a/README.md +++ b/README.md @@ -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。没有空间时先创建“个人空间”,成功后跳转到图谱的独立地址。 diff --git a/VALIDATION.md b/VALIDATION.md index f7a4043..75527b8 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -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 冒烟(含上游响应体暂停时首段透传、目录分页参数)。 diff --git a/src/app/api/foundry/[...path]/route.ts b/src/app/api/foundry/[...path]/route.ts index ae3f570..4812e96 100644 --- a/src/app/api/foundry/[...path]/route.ts +++ b/src/app/api/foundry/[...path]/route.ts @@ -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", diff --git a/src/app/dashboard/dashboard.tsx b/src/app/dashboard/dashboard.tsx index abb5bb4..bc6edb4 100644 --- a/src/app/dashboard/dashboard.tsx +++ b/src/app/dashboard/dashboard.tsx @@ -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(null); const [workspaces, setWorkspaces] = useState([]); const [selectedWorkspace, setSelectedWorkspace] = useState(""); + const [directoryRevision, setDirectoryRevision] = useState(0); const [boards, setBoards] = useState([]); 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; + 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() {
{currentWorkspace?.name || "工作空间"}{filtered.length}
{status === "loading" &&

正在读取工作空间…

} {status === "ready" && !workspace &&

新建页面时会自动建立个人空间。

} - {filtered.map((board) => ( - -