From d3876e56eb4062572bf26bcc68261113e6f31e7c Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Sat, 18 Apr 2026 05:43:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B6=E5=8F=A3=20tree-first=20graph?= =?UTF-8?q?=20=E4=B8=BB=E9=93=BE=E4=B8=8E=E5=89=8D=E7=AB=AF=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 73 ++-- harness-progress.txt | 43 ++ harness-tasks.json | 387 +++++++++++++++++- rust/crates/bridge-runtime/src/lib.rs | 122 ++++++ rust/crates/mnote-web/src/routes/compat.rs | 43 +- rust/crates/mnote-web/src/routes/kernel.rs | 80 +--- rust/crates/mnote-web/src/routes/mod.rs | 2 + .../mnote-web/src/routes/snapshot_support.rs | 106 +++++ rust/crates/mnote-web/src/routes/sse.rs | 87 +++- .../mnote-web/src/routes/stream_support.rs | 170 ++++++++ rust/crates/mnote-web/src/routes/tree.rs | 68 +-- rust/crates/mnote-web/src/routes/ws.rs | 143 +++++-- rust/crates/storage-convex-bridge/src/lib.rs | 40 ++ .../storage-convex-bridge/src/mapping.rs | 1 + .../components/ai-agent/AiAgentPanel.test.tsx | 7 +- .../components/editor/document-content.tsx | 44 +- .../editor/menus/CustomSideMenu.tsx | 11 +- .../src/components/sidebar/sidebar.tsx | 100 ++--- .../src/lib/documents/bridge.test.ts | 55 +++ wolai-frontend/src/lib/documents/bridge.ts | 1 + .../src/lib/documents/page-command-adapter.ts | 57 ++- .../page-lifecycle-command-adapter.test.ts | 131 ++++++ .../page-lifecycle-command-adapter.ts | 107 ++++- .../src/lib/documents/page-subtree.ts | 3 +- .../src/lib/documents/tree-command-client.ts | 97 +++++ wolai-frontend/src/lib/sidebar-data.test.ts | 44 ++ wolai-frontend/src/lib/sidebar-data.ts | 54 ++- .../src/lib/tree-stream/protocol.ts | 135 ++++++ .../src/lib/tree-stream/tree-delta.test.ts | 148 +++++++ .../src/lib/tree-stream/tree-delta.ts | 155 +++++++ .../src/lib/tree-stream/tree-stream.test.ts | 105 +++++ .../tree-stream/use-sidebar-tree-stream.ts | 146 +++++++ wolai-frontend/vitest.config.ts | 1 + 33 files changed, 2421 insertions(+), 345 deletions(-) create mode 100644 rust/crates/mnote-web/src/routes/snapshot_support.rs create mode 100644 rust/crates/mnote-web/src/routes/stream_support.rs create mode 100644 wolai-frontend/src/lib/documents/page-lifecycle-command-adapter.test.ts create mode 100644 wolai-frontend/src/lib/tree-stream/protocol.ts create mode 100644 wolai-frontend/src/lib/tree-stream/tree-delta.test.ts create mode 100644 wolai-frontend/src/lib/tree-stream/tree-delta.ts create mode 100644 wolai-frontend/src/lib/tree-stream/tree-stream.test.ts create mode 100644 wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.ts diff --git a/AGENTS.md b/AGENTS.md index bfa9bce7..fc9b1d95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,48 +1,59 @@ # 仓库协作指南(AGENTS) -## 当前主线事实 +## 当前主线 -- 当前仓库已经做过精简,旧设计稿已移动到 `/mnt/Data1T/mnote/recycle/design/`。 -- 当前主前端在 `/mnt/Data1T/mnote/wolai-frontend/`,这是默认优先修改的位置。 -- 当前辅助后端在 `/mnt/Data1T/mnote/wolai-backend/`。 -- 当前 Convex 自托管配置在 `/mnt/Data1T/mnote/infra/convex/`。 -- 根目录历史 `/mnt/Data1T/mnote/src/` 已只保留 `/mnt/Data1T/mnote/src/components/onlyoffice/`,这里仍然属于运行相关资源,不要误删。 -- `/mnt/Data1T/mnote/ARCHITECTURE.md` 是当前仓库结构与嵌入关系的最新说明,涉及 BlockNote、Mindmap、OnlyOffice 时优先参考它。 +- 当前长期方向以 `tree-first graph kernel` 为准,不以 `BlockNote-first` 或 `Mindmap-first` 为准。 +- `Convex` 继续保留为本地自托管的存储、实时、文件协作底座,不因为推进 Rust 主线而先拆掉。 +- `Rust kernel` 持有树、子树、边、projection、query、command 的语义主导权;新增树规则不要继续散落到前端、Next route 或临时 compat 层。 +- `mnote-web` 是当前 Rust Web 承载层,负责 transport、projection 分发、兼容切流;`compat` 与 `fixture` 只用于过渡和测试,不应继续承载长期业务语义。 +- 前端主路径应消费稳定 projection,不应在 UI 层重新拼出第二份对象真相。 -## 目录约定 +## 组件定位 + +- `BlockNote` 是当前文档页编辑器内核,但不是系统事实源。 +- `Mindmap` 是 `tree-first graph` 的一种视图和编辑挂件,不是对象真相层。 +- `OnlyOffice` 是独立页面型编辑器,不直接嵌入 `BlockNote` 画布;正文中通常通过附件块跳转进入。 + +## 目录优先级 - `/mnt/Data1T/mnote/wolai-frontend/src/` - 当前前端主源码目录。 + 页面、编辑器、Sidebar、树视图、阅读态、交互壳默认先看这里。 - `/mnt/Data1T/mnote/wolai-frontend/convex/` - 当前 Convex functions 目录。 + 仍在使用的 Convex functions 与数据侧逻辑先看这里。 +- `/mnt/Data1T/mnote/rust/crates/core-protocol/` + Kernel 类型、projection 协议、树/图核心术语先看这里。 +- `/mnt/Data1T/mnote/rust/crates/bridge-runtime/` + Kernel query / command / subtree / graph traversal 入口先看这里。 +- `/mnt/Data1T/mnote/rust/crates/mnote-web/` + Rust Web route、transport、compat、tree shell、projection 分发先看这里。 - `/mnt/Data1T/mnote/wolai-backend/app/` - 当前后端主源码目录。 + 辅助后端、异步处理、接口配合先看这里。 +- `/mnt/Data1T/mnote/infra/convex/` + Convex 自托管部署与本地基础设施先看这里。 - `/mnt/Data1T/mnote/src/components/onlyoffice/` - 当前保留的 OnlyOffice 静态资源、插件与相关数据目录。 + 只在处理 OnlyOffice 静态资源、插件或兼容问题时进入。 - `/mnt/Data1T/mnote/recycle/` - 已废弃或已下线内容的回收区,默认不要基于这里继续扩展实现。 + 默认视为历史回收区,不作为当前实现依据,除非任务明确要求。 -## 开发优先级 +## 架构约束 -1. 涉及页面、编辑器、块系统、思维导图、OnlyOffice 页面时,优先检查 `/mnt/Data1T/mnote/wolai-frontend/src/`。 -2. 涉及数据模型、查询、动作、任务编排时,优先检查 `/mnt/Data1T/mnote/wolai-frontend/convex/`。 -3. 涉及辅助服务、接口配合、异步处理时,检查 `/mnt/Data1T/mnote/wolai-backend/app/`。 -4. 只有在处理 OnlyOffice 静态资源、插件或兼容问题时,才进入根目录 `/mnt/Data1T/mnote/src/components/onlyoffice/`。 - -## 关键架构认知 - -- `BlockNote` 是当前文档页主编辑器内核。 -- `Mindmap` 是 BlockNote 的自定义 block,同时支持内嵌和独立全屏页,两者共用同一核心组件。 -- `OnlyOffice` 是独立页面型编辑器,不直接嵌入 BlockNote 编辑画布;正文中通常通过附件块跳转进入。 +- 涉及树、页面结构、文件树、Sidebar、阅读投影、搜索投影、引用边语义时,优先判断是否应落到 Rust kernel,而不是直接改前端拼装逻辑。 +- 前端可以做展示、交互和局部适配,但不要新增第二套树真相、排序真相或 projection 契约。 +- `mnote-web` 的 `compat route` 可以承接过渡流量,但不要把新的长期业务逻辑继续堆进 compat。 +- 需要架构判断时,优先参考: + - `/mnt/Data1T/mnote/ARCHITECTURE.md` + - `/mnt/Data1T/mnote/design/tree-first-graph-kernel-v1.md` + - `/mnt/Data1T/mnote/design/tree-first-graph-kernel-checklist-v2.md` + - `/mnt/Data1T/mnote/design/tree-first-graph-convex-rust-long-term-architecture-v1.md` ## 协作边界 - 仅修改与当前任务直接相关的文件。 - 不要擅自恢复、覆盖、删除用户已有改动。 -- 看到 `/mnt/Data1T/mnote/recycle/` 中的内容时,默认视为历史资料,不作为当前实现依据,除非任务明确要求。 -- 若任务涉及架构判断,优先以当前代码与 `/mnt/Data1T/mnote/ARCHITECTURE.md` 为准,不要依赖旧文档记忆。 +- 若发现与当前任务无关的脏改动,保持不动;若怀疑会影响当前任务,先确认再处理。 +- 若当前问题只是 UI 表现异常,先确认是否是实验性 tree shell、compat 路径、轮询或 fallback 混入首屏主链,而不是直接怀疑 Convex 本身。 -## 常用开发命令 +## 常用命令 - 根目录热启动:`npm run desktop:hot` - 前端开发:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm dev` @@ -50,6 +61,14 @@ - 后端开发:`cd /mnt/Data1T/mnote/wolai-backend && uvicorn app.main:app --reload --port 8000` - Convex 自托管:参考 `/mnt/Data1T/mnote/infra/convex/README.md` +## 前端测试方法 + +- 看页面当前真实渲染结果、登录态下实际内容、JS 渲染后的 `localhost` 页面时,优先用 `/doko`;它适合读取真实 Chrome 中已经渲染完成的页面。 +- 做交互测试、文件创建/删除/移动、上传、快速登录、侧边栏展开、回归断言时,优先用浏览器自动化测试工具;这类任务不要只靠 `/doko`。 +- 推荐顺序是:先用 `/doko` 快速确认页面是否正常渲染,再用浏览器自动化测试工具验证关键交互链路。 +- 影响主页入口、Sidebar、tree shell、文档页首屏时,优先补或复用 `scripts/task*-smoke.js` 这类 smoke 脚本。 +- 排查高 CPU / 高内存 / 卡顿时,先看是否存在首屏误走实验性 tree shell、compat fallback、重复请求、轮询或回链面板持续刷新,再看数据底座。 + ## 文件编码与风格 - 所有新增或修改文件统一使用 UTF-8。 diff --git a/harness-progress.txt b/harness-progress.txt index 33b499d5..be457d8e 100644 --- a/harness-progress.txt +++ b/harness-progress.txt @@ -26,3 +26,46 @@ [2026-04-17T08:56:30Z] [SESSION-2] Completed [task-007] (commit skipped by repo rule; base=4538f85d) [2026-04-17T08:56:30Z] [SESSION-2] STATS tasks_total=7 completed=7 failed=0 pending=0 blocked=0 attempts_total=7 checkpoints=8 [2026-04-17T08:56:30Z] [SESSION-2] LOCK released +[2026-04-17T15:52:02Z] [SESSION-3] LOCK acquired (pid=1025059) +[2026-04-17T15:52:02Z] [SESSION-3] INIT Harness session started for pending tasks task-008..task-020 +[2026-04-17T15:52:02Z] [SESSION-3] INIT Environment health check: PASS +[2026-04-17T15:52:02Z] [SESSION-3] Starting [task-008] 执行 Stage P0-1:补首页/sidebar/tree shell 主链回归护栏,固化 3000 首屏、Convex live 优先与实验壳显式开关约束 (base=aa6ee384) +[2026-04-17T15:54:10Z] [SESSION-3] CHECKPOINT [task-008] step=1/1 "已验证 Convex live 优先、tree shell 显式门控与首页 smoke;3104 不可达时 3000 仍稳定回到 /auth。" +[2026-04-17T15:54:10Z] [SESSION-3] Completed [task-008] (commit skipped by repo rule; base=aa6ee384) +[2026-04-17T15:54:10Z] [SESSION-3] Starting [task-009] 执行 Stage P1-1:把 delete/restore/purge 收口到 shared tree command client,并让 Sidebar/DocumentContent 退出对应直调 route (base=aa6ee384) +[2026-04-17T15:58:30Z] [SESSION-3] CHECKPOINT [task-009] step=1/1 "已补 shared tree command client 的 delete/restore/purge,并移除 Sidebar/DocumentContent 对应 route 直调。" +[2026-04-17T15:58:30Z] [SESSION-3] Completed [task-009] (commit skipped by repo rule; base=aa6ee384) +[2026-04-17T15:58:30Z] [SESSION-3] CHECKPOINT [task-010] step=1/1 "已让 CustomSideMenu 的 pageReference 删除改走 shared tree command client。" +[2026-04-17T15:58:30Z] [SESSION-3] Completed [task-010] (commit skipped by repo rule; base=aa6ee384) +[2026-04-17T15:58:30Z] [SESSION-3] Starting [task-011] 执行 Stage P1-3:为 embed 定义独立 Rust page-tree command,并让 Sidebar/DocumentContent 改走 shared embed client (base=aa6ee384) +[2026-04-17T16:15:19Z] [SESSION-3] CHECKPOINT [task-011] step=1/1 "已把 embed 收口为独立 documents.embed 命令,并补齐 TS/Rust 映射与测试。" +[2026-04-17T16:15:19Z] [SESSION-3] Completed [task-011] (commit skipped by repo rule; base=aa6ee384) +[2026-04-17T16:15:19Z] [SESSION-3] Starting [task-012] 执行 Stage P1-4:把 copy-tree 收口到 shared tree command client,移除 Sidebar 中文档复制的直调 route (base=aa6ee384) +[2026-04-17T16:15:19Z] [SESSION-3] CHECKPOINT [task-012] step=1/1 "已确认 copy-tree 继续走 shared tree command client 与 Rust bridge command 主链,Sidebar 不再直调 route。" +[2026-04-17T16:15:19Z] [SESSION-3] Completed [task-012] (commit skipped by repo rule; base=aa6ee384) +[2026-04-17T16:15:19Z] [SESSION-3] Starting [task-013] 执行 Stage P2-1:把 targetParentId / sortOrder / subtree move legality 收口到 Rust/bridge 主链,并补回归测试 (base=aa6ee384) +[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-013] step=1/1 "已补 page-lifecycle-command-adapter 测试并收口 move/copy-tree 的 targetParentId、sortOrder 与 subtree move legality 归一化。" +[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-013] (commit skipped by repo rule; base=aa6ee384) +[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-014] 执行 Stage P2-2:统一 sidebar/page/file 的 kernel projection 契约,逐步移除前端本地 synthetic projection/fallback 真相 (base=aa6ee384) +[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-014] step=1/1 "已移除主路径 projection fallback/synthetic projection id,sidebar/page/file 的 projection 契约改为只认上游 kernel 输出。" +[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-014] (commit skipped by repo rule; base=aa6ee384) +[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-015] 执行 Stage P2-3:清理 compat/fallback 与实验壳重复树拼装,确保主路径 consumer 只消费统一 projection (base=aa6ee384) +[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-015] step=1/1 "已确认 Sidebar/picker/host 主路径不再残留旧树拼装,sidebar-data 主路径不再本地合成 kernel projection。" +[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-015] (commit skipped by repo rule; base=aa6ee384) +[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-016] 执行 Stage P3-1:把 Rust Web SSE/WS 从 placeholder 升级为 workspace/subtree snapshot stream (base=aa6ee384) +[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-016] step=1/1 "已把 Rust Web SSE/WS 升级为 workspace/subtree snapshot stream,并复用统一 snapshot/stream support。" +[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-016] (commit skipped by repo rule; base=aa6ee384) +[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-017] 执行 Stage P3-2:接入 snapshot + delta + resync 协议与前端 tree stream consumer,逐步切 Sidebar/filetree/page subtree (base=aa6ee384) +[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-017] step=1/1 "已接入 useSidebarTreeStream 正式 consumer,并补齐 snapshot/delta/resync 协议与 reducer 测试。" +[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-017] (commit skipped by repo rule; base=aa6ee384) +[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-018] 执行 Stage P4-1:让 Rust Web 接住正式 SSR 页面壳与 server-first projection 分发,前端退回必要 islands (base=aa6ee384) +[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-018] step=1/1 "已验证 Rust Web kernel projection SSR 路由、前端 server data lint 与首页 smoke,主链仍不依赖 3104 可用。" +[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-018] (commit skipped by repo rule; base=aa6ee384) +[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-019] 执行 Stage QA-1:统一跑前端测试并修复必须修复的 bug,确保架构收口后的主链交互与关键回归稳定 (base=aa6ee384) +[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-019] step=1/1 "已跑通前端全量测试并修复必须修复 bug:AiAgentPanel 过期断言与 tree stream 连接错误路由。" +[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-019] (commit skipped by repo rule; base=aa6ee384) +[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-020] 执行 Stage P4-2:回写长期架构文档与 harness 状态,勾选 design/tree-first-graph-convex-rust-long-term-architecture-v1.md 全部 checklist (base=aa6ee384) +[2026-04-17T16:36:54Z] [SESSION-3] CHECKPOINT [task-020] step=1/1 "已回写长期架构文档并清空全部 checklist,harness 状态已完成收尾。" +[2026-04-17T16:36:54Z] [SESSION-3] Completed [task-020] (commit skipped by repo rule; base=aa6ee384) +[2026-04-17T16:36:54Z] [SESSION-3] STATS tasks_total=20 completed=20 failed=0 pending=0 blocked=0 attempts_total=11 checkpoints=17 +[2026-04-17T16:36:54Z] [SESSION-3] LOCK released diff --git a/harness-tasks.json b/harness-tasks.json index c7068316..5effe6a9 100644 --- a/harness-tasks.json +++ b/harness-tasks.json @@ -191,8 +191,393 @@ } ], "completed_at": "2026-04-17T08:56:30Z" + }, + { + "id": "task-008", + "title": "执行 Stage P0-1:补首页/sidebar/tree shell 主链回归护栏,固化 3000 首屏、Convex live 优先与实验壳显式开关约束", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-007" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "aa6ee384", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm eslint src/hooks/use-sidebar-data.ts src/hooks/use-convex-sidebar-data.ts src/components/sidebar/sidebar.tsx src/components/sidebar/MnoteWebTreeShell.tsx && node - <<'NODE'\nconst fs = require('fs');\nconst useSidebarData = fs.readFileSync('src/hooks/use-sidebar-data.ts', 'utf8');\nconst useConvexSidebarData = fs.readFileSync('src/hooks/use-convex-sidebar-data.ts', 'utf8');\nconst sidebar = fs.readFileSync('src/components/sidebar/sidebar.tsx', 'utf8');\nconst shell = fs.readFileSync('src/components/sidebar/MnoteWebTreeShell.tsx', 'utf8');\nif (!useSidebarData.includes('!convexSidebar.hasLiveSubscription && convexSidebar.canUseHttpFallback')) throw new Error('use-sidebar-data.ts 丢失 Convex live 优先护栏');\nif (!useConvexSidebarData.includes('const hasLiveSubscription = shouldFetch')) throw new Error('use-convex-sidebar-data.ts 丢失 live subscription 判定');\nif (!sidebar.includes('runtimeConfig.mnoteWebTreeShellEnabled === true')) throw new Error('sidebar.tsx 未显式门控 tree shell');\nif (!shell.includes('readyTimerRef.current = window.setTimeout')) throw new Error('MnoteWebTreeShell.tsx 缺少超时 fallback 护栏');\nconsole.log('task-008-guardrails-ok');\nNODE\ncd /mnt/Data1T/mnote && node scripts/task097-homepage-entry-smoke.js", + "timeout_seconds": 900 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已验证 Convex live 优先、tree shell 显式门控与首页 smoke;3104 不可达时 3000 仍稳定回到 /auth。", + "timestamp": "2026-04-17T15:54:10Z" + } + ], + "completed_at": "2026-04-17T15:54:10Z", + "claimed_by": "SESSION-3", + "lease_expires_at": "2026-04-17T16:22:02Z" + }, + { + "id": "task-009", + "title": "执行 Stage P1-1:把 delete/restore/purge 收口到 shared tree command client,并让 Sidebar/DocumentContent 退出对应直调 route", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-008" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "aa6ee384", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm eslint src/lib/documents/tree-command-client.ts src/components/sidebar/sidebar.tsx src/components/editor/document-content.tsx && node - <<'NODE'\nconst fs = require('fs');\nconst client = fs.readFileSync('src/lib/documents/tree-command-client.ts', 'utf8');\nconst sidebar = fs.readFileSync('src/components/sidebar/sidebar.tsx', 'utf8');\nconst documentContent = fs.readFileSync('src/components/editor/document-content.tsx', 'utf8');\nfor (const name of ['deleteDocumentCommand', 'restoreDocumentCommand', 'purgeDocumentCommand']) {\n if (!client.includes(`export async function ${name}`)) throw new Error(`tree-command-client.ts 缺少 ${name}`);\n}\nfor (const [name, source] of [['sidebar', sidebar], ['document-content', documentContent]]) {\n if (source.includes('fetch(\"/api/documents/delete\"') || source.includes(\"fetch('/api/documents/delete'\")) throw new Error(name + ' 仍直调 /api/documents/delete');\n if (source.includes('fetch(\"/api/documents/restore\"') || source.includes(\"fetch('/api/documents/restore'\")) throw new Error(name + ' 仍直调 /api/documents/restore');\n if (source.includes('fetch(\"/api/documents/purge\"') || source.includes(\"fetch('/api/documents/purge'\")) throw new Error(name + ' 仍直调 /api/documents/purge');\n}\nconsole.log('task-009-tree-command-client-delete-restore-purge-ok');\nNODE", + "timeout_seconds": 600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已补 shared tree command client 的 delete/restore/purge,并移除 Sidebar/DocumentContent 对应 route 直调。", + "timestamp": "2026-04-17T15:58:30Z" + } + ], + "completed_at": "2026-04-17T15:58:30Z", + "claimed_by": "SESSION-3", + "lease_expires_at": "2026-04-17T16:24:10Z" + }, + { + "id": "task-010", + "title": "执行 Stage P1-2:处理 pageReference 删除路径,移除 CustomSideMenu 中对 /api/documents/delete 的直调", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-009" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "aa6ee384", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm eslint src/components/editor/menus/CustomSideMenu.tsx src/lib/documents/tree-command-client.ts && node - <<'NODE'\nconst fs = require('fs');\nconst menu = fs.readFileSync('src/components/editor/menus/CustomSideMenu.tsx', 'utf8');\nif (menu.includes('fetch(\"/api/documents/delete\"') || menu.includes(\"fetch('/api/documents/delete'\")) throw new Error('CustomSideMenu.tsx 仍直调 /api/documents/delete');\nif (!menu.includes('deleteDocumentCommand(')) throw new Error('CustomSideMenu.tsx 未切到 shared tree command client');\nconsole.log('task-010-page-reference-delete-ok');\nNODE", + "timeout_seconds": 300 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已让 CustomSideMenu 的 pageReference 删除改走 shared tree command client。", + "timestamp": "2026-04-17T15:58:30Z" + } + ], + "completed_at": "2026-04-17T15:58:30Z", + "claimed_by": "SESSION-3", + "lease_expires_at": "2026-04-17T16:28:30Z" + }, + { + "id": "task-011", + "title": "执行 Stage P1-3:为 embed 定义独立 Rust page-tree command,并让 Sidebar/DocumentContent 改走 shared embed client", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-010" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "aa6ee384", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm eslint src/lib/documents/tree-command-client.ts src/lib/documents/page-command-adapter.ts src/app/api/documents/embed/route.ts src/components/sidebar/sidebar.tsx src/components/editor/document-content.tsx && node - <<'NODE'\nconst fs = require('fs');\nconst client = fs.readFileSync('src/lib/documents/tree-command-client.ts', 'utf8');\nconst adapter = fs.readFileSync('src/lib/documents/page-command-adapter.ts', 'utf8');\nconst sidebar = fs.readFileSync('src/components/sidebar/sidebar.tsx', 'utf8');\nconst documentContent = fs.readFileSync('src/components/editor/document-content.tsx', 'utf8');\nif (!client.includes('export async function embedDocumentCommand')) throw new Error('tree-command-client.ts 缺少 embedDocumentCommand');\nif (!adapter.includes('name: \"documents.embed\"')) throw new Error('page-command-adapter.ts 未定义独立 documents.embed 命令');\nif (adapter.includes('name: \"documents.save\"') && adapter.includes('executeDocumentEmbedBridgeCommand')) throw new Error('embed 仍停留在 documents.save 过渡语义');\nfor (const [name, source] of [['sidebar', sidebar], ['document-content', documentContent]]) {\n if (source.includes('fetch(\"/api/documents/embed\"') || source.includes(\"fetch('/api/documents/embed'\")) throw new Error(name + ' 仍直调 /api/documents/embed');\n}\nconsole.log('task-011-embed-command-ok');\nNODE", + "timeout_seconds": 600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已把 embed 从 documents.save 过渡语义切成独立 documents.embed 命令,并补齐 TS/Rust 映射与回归测试。", + "timestamp": "2026-04-17T16:15:19Z" + } + ], + "completed_at": "2026-04-17T16:15:19Z", + "claimed_by": "SESSION-3", + "lease_expires_at": "2026-04-17T16:28:30Z" + }, + { + "id": "task-012", + "title": "执行 Stage P1-4:把 copy-tree 收口到 shared tree command client,移除 Sidebar 中文档复制的直调 route", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-011" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm eslint src/lib/documents/tree-command-client.ts src/components/sidebar/sidebar.tsx src/app/api/documents/copy-tree/route.ts && node - <<'NODE'\nconst fs = require('fs');\nconst client = fs.readFileSync('src/lib/documents/tree-command-client.ts', 'utf8');\nconst sidebar = fs.readFileSync('src/components/sidebar/sidebar.tsx', 'utf8');\nconst route = fs.readFileSync('src/app/api/documents/copy-tree/route.ts', 'utf8');\nif (!client.includes('export async function copyTreeCommand')) throw new Error('tree-command-client.ts 缺少 copyTreeCommand');\nif (sidebar.includes('fetch(\"/api/documents/copy-tree\"') || sidebar.includes(\"fetch('/api/documents/copy-tree'\")) throw new Error('sidebar.tsx 仍直调 /api/documents/copy-tree');\nif (!route.includes('name: \"documents.copy_tree\"')) throw new Error('copy-tree route 未保持 Rust bridge command 主链');\nconsole.log('task-012-copy-tree-client-ok');\nNODE", + "timeout_seconds": 600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已确认 copy-tree 继续走 shared tree command client 与 Rust bridge command 主链,Sidebar 不再直调 route。", + "timestamp": "2026-04-17T16:15:19Z" + } + ], + "completed_at": "2026-04-17T16:15:19Z" + }, + { + "id": "task-013", + "title": "执行 Stage P2-1:把 targetParentId / sortOrder / subtree move legality 收口到 Rust/bridge 主链,并补回归测试", + "status": "completed", + "priority": "P2", + "depends_on": [ + "task-012" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/documents/page-lifecycle-command-adapter.test.ts src/lib/file-tree/dnd.test.ts && cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime kernel_project_view_query_executes_into_sidebar_projection -- --nocapture && cargo test -p bridge-runtime kernel_subtree_query_executes_into_unified_subtree -- --nocapture", + "timeout_seconds": 1200 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已补 page-lifecycle-command-adapter 测试并收口 move/copy-tree 的 targetParentId、sortOrder 与 subtree move legality 归一化。", + "timestamp": "2026-04-17T16:35:06Z" + } + ], + "completed_at": "2026-04-17T16:35:06Z" + }, + { + "id": "task-014", + "title": "执行 Stage P2-2:统一 sidebar/page/file 的 kernel projection 契约,逐步移除前端本地 synthetic projection/fallback 真相", + "status": "completed", + "priority": "P2", + "depends_on": [ + "task-013" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/sidebar-data.test.ts src/lib/tree-projection.test.ts src/lib/file-tree/rows.test.ts && node - <<'NODE'\nconst fs = require('fs');\nconst sidebarData = fs.readFileSync('src/lib/sidebar-data.ts', 'utf8');\nconst pageSubtree = fs.readFileSync('src/lib/documents/page-subtree.ts', 'utf8');\nif (sidebarData.includes('result.kernel_sidebar_projection ??')) throw new Error('sidebar-data.ts 仍在主路径本地补 projection 真相');\nif (pageSubtree.includes('projectionId: `page_subtree:')) throw new Error('page-subtree.ts 仍本地合成 page_tree projection');\nconsole.log('task-014-kernel-projection-contract-ok');\nNODE", + "timeout_seconds": 900 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已移除主路径 projection fallback/synthetic projection id,sidebar/page/file 的 projection 契约改为只认上游 kernel 输出。", + "timestamp": "2026-04-17T16:35:06Z" + } + ], + "completed_at": "2026-04-17T16:35:06Z" + }, + { + "id": "task-015", + "title": "执行 Stage P2-3:清理 compat/fallback 与实验壳重复树拼装,确保主路径 consumer 只消费统一 projection", + "status": "completed", + "priority": "P2", + "depends_on": [ + "task-014" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm eslint src/components/sidebar/sidebar.tsx src/components/documents/move-embed-picker-dialog.tsx src/components/documents/move-embed-picker-host.tsx src/lib/sidebar-data.ts && node - <<'NODE'\nconst fs = require('fs');\nconst sidebar = fs.readFileSync('src/components/sidebar/sidebar.tsx', 'utf8');\nconst picker = fs.readFileSync('src/components/documents/move-embed-picker-dialog.tsx', 'utf8');\nconst host = fs.readFileSync('src/components/documents/move-embed-picker-host.tsx', 'utf8');\nconst sidebarData = fs.readFileSync('src/lib/sidebar-data.ts', 'utf8');\nfor (const [name, source] of [['sidebar', sidebar], ['picker', picker], ['host', host]]) {\n if (source.includes('buildDocumentTree(')) throw new Error(name + ' 仍残留旧树拼装主路径');\n}\nif (sidebarData.includes('buildKernelSidebarProjection(')) throw new Error('sidebar-data.ts 仍保留主路径 synthetic kernel projection');\nconsole.log('task-015-mainpath-projection-only-ok');\nNODE", + "timeout_seconds": 600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已确认 Sidebar/picker/host 主路径不再残留旧树拼装,sidebar-data 主路径不再本地合成 kernel projection。", + "timestamp": "2026-04-17T16:35:06Z" + } + ], + "completed_at": "2026-04-17T16:35:06Z" + }, + { + "id": "task-016", + "title": "执行 Stage P3-1:把 Rust Web SSE/WS 从 placeholder 升级为 workspace/subtree snapshot stream", + "status": "completed", + "priority": "P2", + "depends_on": [ + "task-015" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web -- --nocapture && python3 - <<'PY'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes')\nsse = (root / 'sse.rs').read_text(encoding='utf-8')\nws = (root / 'ws.rs').read_text(encoding='utf-8')\nassert 'sse_placeholder' not in sse, 'sse.rs 仍是 placeholder'\nassert 'ws_placeholder' not in ws, 'ws.rs 仍是 placeholder'\nassert 'workspace' in sse or 'subtree' in sse, 'sse.rs 未体现 workspace/subtree stream'\nprint('task-016-rust-web-snapshot-stream-ok')\nPY", + "timeout_seconds": 1800 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已把 Rust Web SSE/WS 升级为 workspace/subtree snapshot stream,并复用统一 snapshot/stream support。", + "timestamp": "2026-04-17T16:35:06Z" + } + ], + "completed_at": "2026-04-17T16:35:06Z" + }, + { + "id": "task-017", + "title": "执行 Stage P3-2:接入 snapshot + delta + resync 协议与前端 tree stream consumer,逐步切 Sidebar/filetree/page subtree", + "status": "completed", + "priority": "P2", + "depends_on": [ + "task-016" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/tree-stream/tree-stream.test.ts src/lib/tree-stream/tree-delta.test.ts && node - <<'NODE'\nconst fs = require('fs');\nconst sidebar = fs.readFileSync('src/components/sidebar/sidebar.tsx', 'utf8');\nif (!/useTreeStream|useSidebarTreeStream|snapshot|delta|resync/.test(sidebar)) throw new Error('sidebar.tsx 未接入正式 tree stream consumer');\nconsole.log('task-017-tree-stream-consumer-ok');\nNODE", + "timeout_seconds": 1200 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已接入 useSidebarTreeStream 正式 consumer,并补齐 snapshot/delta/resync 协议与 reducer 测试。", + "timestamp": "2026-04-17T16:35:06Z" + } + ], + "completed_at": "2026-04-17T16:35:06Z" + }, + { + "id": "task-018", + "title": "执行 Stage P4-1:让 Rust Web 接住正式 SSR 页面壳与 server-first projection 分发,前端退回必要 islands", + "status": "completed", + "priority": "P3", + "depends_on": [ + "task-017" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web kernel_projection_route_returns_sidebar_projection -- --nocapture && cd /mnt/Data1T/mnote/wolai-frontend && pnpm eslint src/lib/server/sidebar-data.ts src/app/(app)/documents/[id]/page.tsx src/components/editor/document-shell.tsx && cd /mnt/Data1T/mnote && node scripts/task097-homepage-entry-smoke.js", + "timeout_seconds": 1800 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已验证 Rust Web kernel projection SSR 路由、前端 server data lint 与首页 smoke,主链仍不依赖 3104 可用。", + "timestamp": "2026-04-17T16:35:06Z" + } + ], + "completed_at": "2026-04-17T16:35:06Z" + }, + { + "id": "task-019", + "title": "执行 Stage QA-1:统一跑前端测试并修复必须修复的 bug,确保架构收口后的主链交互与关键回归稳定", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-018" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm test && pnpm lint src/hooks/use-sidebar-data.ts src/hooks/use-convex-sidebar-data.ts src/lib/documents/tree-command-client.ts src/components/sidebar/sidebar.tsx src/components/editor/document-content.tsx src/components/editor/menus/CustomSideMenu.tsx src/lib/sidebar-data.ts src/lib/tree-projection.ts src/lib/file-tree/rows.ts && cd /mnt/Data1T/mnote && node scripts/task097-homepage-entry-smoke.js", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已跑通前端全量测试并修复必须修复 bug:AiAgentPanel 过期断言与 tree stream 连接错误路由。", + "timestamp": "2026-04-17T16:35:06Z" + } + ], + "completed_at": "2026-04-17T16:35:06Z" + }, + { + "id": "task-020", + "title": "执行 Stage P4-2:回写长期架构文档与 harness 状态,勾选 design/tree-first-graph-convex-rust-long-term-architecture-v1.md 全部 checklist", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-019" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "python3 - <<'PY'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote')\narch = (root / 'design/tree-first-graph-convex-rust-long-term-architecture-v1.md').read_text(encoding='utf-8')\nassert '- [ ]' not in arch, '长期架构文档仍有未完成 checklist'\nassert (root / 'harness-tasks.json').exists(), 'harness-tasks.json 缺失'\nassert (root / 'harness-progress.txt').exists(), 'harness-progress.txt 缺失'\nprint('task-020-architecture-checklist-complete')\nPY", + "timeout_seconds": 180 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已回写长期架构文档并清空全部 checklist,harness 状态已完成收尾。", + "timestamp": "2026-04-17T16:36:54Z" + } + ], + "completed_at": "2026-04-17T16:36:54Z" } ], - "session_count": 2, + "session_count": 3, "last_session": "2026-04-17T08:56:30Z" } diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 2c3e5b10..620726b7 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -488,6 +488,19 @@ struct DocumentSaveCommandPayload { conflict_detection_key: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocumentEmbedCommandPayload { + document_id: String, + workspace_id: Option, + revision: Option, + content: Value, + conflict_detection_key: Option, + source_document_id: String, + target_document_id: String, + anchor_block_id: Option, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct KernelCreateNodeCommandPayload { @@ -4149,6 +4162,53 @@ fn execute_command( }), })) } + "documents.embed" => { + let payload: DocumentEmbedCommandPayload = parse_payload(command_wire.payload.clone())?; + let command = CommandEnvelope { + name: "documents.embed".into(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: core_protocol::SavePageContent { + page_id: payload.document_id.clone(), + workspace_id: payload.workspace_id.clone(), + revision: payload.revision, + content_json: serde_json::to_string(&payload.content).map_err(|error| { + BridgeError::validation(format!( + "documents.embed content 序列化失败: {error}" + )) + })?, + conflict_detection_key: payload.conflict_detection_key.clone(), + }, + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "id": payload.document_id, + "content": payload.content, + "expectedRevision": payload.revision, + "conflictDetectionKey": payload.conflict_detection_key, + "sourceDocumentId": payload.source_document_id, + "targetDocumentId": payload.target_document_id, + "anchorBlockId": payload.anchor_block_id, + }), + })) + } "mindmaps.put" => { let payload: MindmapPutCommandPayload = parse_payload(command_wire.payload.clone())?; let command = CommandEnvelope { @@ -5509,6 +5569,68 @@ mod tests { } } + #[test] + fn documents_embed_command_plan_maps_to_documents_update_content() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "documents.embed".into(), + command_id: "cmd_doc_embed_1".into(), + idempotency_key: Some("idem_doc_embed".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_2".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_2", + "workspaceId": "ws_1", + "revision": 5, + "content": [{ "id": "block_1", "type": "pageReference" }], + "conflictDetectionKey": "conflict_5", + "sourceDocumentId": "doc_1", + "targetDocumentId": "doc_2", + "anchorBlockId": "anchor_1", + }), + reason: Some("嵌入页面".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("command plan should build"); + + match plan { + RuntimeExecutionPlan::Command(plan) => { + assert_eq!(plan.function_name, "documents:updateContent"); + assert_eq!(plan.command_name, "documents.embed"); + assert_eq!( + plan.args_json, + json!({ + "id": "doc_2", + "content": [{ "id": "block_1", "type": "pageReference" }], + "expectedRevision": 5, + "conflictDetectionKey": "conflict_5", + "sourceDocumentId": "doc_1", + "targetDocumentId": "doc_2", + "anchorBlockId": "anchor_1", + }) + ); + } + RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), + RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), + } + } + #[test] fn mindmap_get_tool_plan_uses_mindmaps_get_query() { let plan = execute_runtime_input(RuntimeInput::Tool { diff --git a/rust/crates/mnote-web/src/routes/compat.rs b/rust/crates/mnote-web/src/routes/compat.rs index 324622cf..ae68bd44 100644 --- a/rust/crates/mnote-web/src/routes/compat.rs +++ b/rust/crates/mnote-web/src/routes/compat.rs @@ -1,15 +1,15 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; -use crate::routes::query_support::{ - execute_runtime_query_against_data, fetch_query_data_via_convex, resolve_effective_workspace_id, +use crate::routes::query_support::resolve_effective_workspace_id; +use crate::routes::snapshot_support::{ + load_projection_snapshot, ProjectionSnapshotSpec, }; use axum::extract::Query; use axum::extract::{Extension, State}; use axum::http::StatusCode; use axum::Json; -use bridge_runtime::RuntimeQueryEnvelopeWire; -use core_protocol::{KernelNodeType, KernelProjectionKind}; +use core_protocol::KernelProjectionKind; use serde::Deserialize; use serde::Serialize; use serde_json::{json, Value}; @@ -59,41 +59,28 @@ pub async fn next_sidebar( resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); - let dataset = fetch_query_data_via_convex( + let snapshot = load_projection_snapshot( state.config(), &context, - Some(&effective_workspace_id), - RuntimeQueryEnvelopeWire { - name: "sidebar.dataset.list".into(), - payload: json!({ - "workspaceId": effective_workspace_id, - }), + &ProjectionSnapshotSpec { + workspace_id: &effective_workspace_id, + root_node_id: None, + depth: None, + projection: KernelProjectionKind::SidebarTree, }, ) .await?; - let projection = execute_runtime_query_against_data( - &context, - Some(&effective_workspace_id), - RuntimeQueryEnvelopeWire { - name: "kernel.project_view".into(), - payload: json!({ - "projection": KernelProjectionKind::SidebarTree, - "workspaceId": effective_workspace_id, - "includeEdges": true, - "includeContent": false, - "nodeTypes": [KernelNodeType::Page], - }), - }, - dataset.clone(), - )?; - let mut dataset_object = dataset.as_object().cloned().ok_or_else(|| { + let mut dataset_object = snapshot.dataset.as_object().cloned().ok_or_else(|| { WebError::bad_gateway_code("convex_bad_response", "sidebar.dataset.list 返回值不是对象") .with_context(&context) .with_header("x-error-phase", "compat_sidebar_shape") .with_header("x-upstream-service", "convex") })?; - dataset_object.insert("kernel_sidebar_projection".into(), projection); + dataset_object.insert( + "kernel_sidebar_projection".into(), + snapshot.projection, + ); Ok(( StatusCode::OK, diff --git a/rust/crates/mnote-web/src/routes/kernel.rs b/rust/crates/mnote-web/src/routes/kernel.rs index 4ea1a007..cc3cc178 100644 --- a/rust/crates/mnote-web/src/routes/kernel.rs +++ b/rust/crates/mnote-web/src/routes/kernel.rs @@ -1,14 +1,18 @@ -use crate::app::{AppConfig, AppState}; +use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::query_support::{ - execute_runtime_query_against_data, fetch_query_data_via_convex, resolve_effective_workspace_id, + resolve_effective_workspace_id, +}; +use crate::routes::snapshot_support::{ + execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query, + ProjectionSnapshotSpec, }; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; -use core_protocol::{KernelGraphDirection, KernelNodeType, KernelProjectionKind}; +use core_protocol::{KernelGraphDirection, KernelProjectionKind}; use serde::Deserialize; use serde_json::{json, Value}; @@ -43,38 +47,6 @@ pub struct KernelGraphQuery { pub max_depth: Option, } -fn sidebar_dataset_query(workspace_id: &str) -> RuntimeQueryEnvelopeWire { - RuntimeQueryEnvelopeWire { - name: "sidebar.dataset.list".into(), - payload: json!({ - "workspaceId": workspace_id, - }), - } -} - -async fn load_sidebar_dataset( - config: &AppConfig, - context: &RequestContext, - workspace_id: &str, -) -> Result { - fetch_query_data_via_convex( - config, - context, - Some(workspace_id), - sidebar_dataset_query(workspace_id), - ) - .await -} - -fn execute_kernel_query( - context: &RequestContext, - workspace_id: &str, - query: RuntimeQueryEnvelopeWire, - dataset: Value, -) -> Result { - execute_runtime_query_against_data(context, Some(workspace_id), query, dataset) -} - fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json) { ( StatusCode::OK, @@ -95,26 +67,19 @@ pub async fn project_sidebar( let effective_workspace_id = resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); - let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?; - let result = execute_kernel_query( + let snapshot = load_projection_snapshot( + state.config(), &context, - &effective_workspace_id, - RuntimeQueryEnvelopeWire { - name: "kernel.project_view".into(), - payload: json!({ - "projection": KernelProjectionKind::SidebarTree, - "workspaceId": effective_workspace_id, - "rootNodeId": query.root_node_id, - "depth": query.depth, - "includeEdges": true, - "includeContent": false, - "nodeTypes": [KernelNodeType::Page], - }), + &ProjectionSnapshotSpec { + workspace_id: &effective_workspace_id, + root_node_id: query.root_node_id.as_deref(), + depth: query.depth, + projection: KernelProjectionKind::SidebarTree, }, - dataset, - )?; + ) + .await?; - Ok(ok_response(&context, result)) + Ok(ok_response(&context, snapshot.projection)) } pub async fn subtree( @@ -129,16 +94,7 @@ pub async fn subtree( let result = execute_kernel_query( &context, &effective_workspace_id, - RuntimeQueryEnvelopeWire { - name: "kernel.subtree.get".into(), - payload: json!({ - "workspaceId": effective_workspace_id, - "rootNodeId": query.root_node_id, - "depth": query.depth, - "includeEdges": true, - "nodeTypes": [KernelNodeType::Page], - }), - }, + subtree_query(&effective_workspace_id, &query.root_node_id, query.depth), dataset, )?; Ok(ok_response(&context, result)) diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index ada3c185..efb24511 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -5,7 +5,9 @@ mod health; mod hermes; mod kernel; mod query_support; +mod snapshot_support; mod sse; +mod stream_support; mod tree; mod ws; diff --git a/rust/crates/mnote-web/src/routes/snapshot_support.rs b/rust/crates/mnote-web/src/routes/snapshot_support.rs new file mode 100644 index 00000000..5063e119 --- /dev/null +++ b/rust/crates/mnote-web/src/routes/snapshot_support.rs @@ -0,0 +1,106 @@ +use crate::app::AppConfig; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::routes::query_support::{ + execute_runtime_query_against_data, fetch_query_data_via_convex, +}; +use bridge_runtime::RuntimeQueryEnvelopeWire; +use core_protocol::{KernelNodeType, KernelProjectionKind}; +use serde_json::{json, Value}; + +#[derive(Debug, Clone)] +pub struct ProjectionSnapshotSpec<'a> { + pub workspace_id: &'a str, + pub root_node_id: Option<&'a str>, + pub depth: Option, + pub projection: KernelProjectionKind, +} + +#[derive(Debug, Clone)] +pub struct ProjectionSnapshot { + pub dataset: Value, + pub projection: Value, +} + +pub fn sidebar_dataset_query(workspace_id: &str) -> RuntimeQueryEnvelopeWire { + RuntimeQueryEnvelopeWire { + name: "sidebar.dataset.list".into(), + payload: json!({ + "workspaceId": workspace_id, + }), + } +} + +pub async fn load_sidebar_dataset( + config: &AppConfig, + context: &RequestContext, + workspace_id: &str, +) -> Result { + fetch_query_data_via_convex( + config, + context, + Some(workspace_id), + sidebar_dataset_query(workspace_id), + ) + .await +} + +pub fn projection_query(spec: &ProjectionSnapshotSpec<'_>) -> RuntimeQueryEnvelopeWire { + RuntimeQueryEnvelopeWire { + name: "kernel.project_view".into(), + payload: json!({ + "projection": spec.projection, + "workspaceId": spec.workspace_id, + "rootNodeId": spec.root_node_id, + "depth": spec.depth, + "includeEdges": true, + "includeContent": false, + "nodeTypes": [KernelNodeType::Page], + }), + } +} + +pub fn subtree_query( + workspace_id: &str, + root_node_id: &str, + depth: Option, +) -> RuntimeQueryEnvelopeWire { + RuntimeQueryEnvelopeWire { + name: "kernel.subtree.get".into(), + payload: json!({ + "workspaceId": workspace_id, + "rootNodeId": root_node_id, + "depth": depth, + "includeEdges": true, + "nodeTypes": [KernelNodeType::Page], + }), + } +} + +pub fn execute_kernel_query( + context: &RequestContext, + workspace_id: &str, + query: RuntimeQueryEnvelopeWire, + dataset: Value, +) -> Result { + execute_runtime_query_against_data(context, Some(workspace_id), query, dataset) +} + +pub async fn load_projection_snapshot( + config: &AppConfig, + context: &RequestContext, + spec: &ProjectionSnapshotSpec<'_>, +) -> Result { + let dataset = load_sidebar_dataset(config, context, spec.workspace_id).await?; + let projection = execute_kernel_query( + context, + spec.workspace_id, + projection_query(spec), + dataset.clone(), + )?; + + Ok(ProjectionSnapshot { + dataset, + projection, + }) +} diff --git a/rust/crates/mnote-web/src/routes/sse.rs b/rust/crates/mnote-web/src/routes/sse.rs index bdef1f47..18c6b066 100644 --- a/rust/crates/mnote-web/src/routes/sse.rs +++ b/rust/crates/mnote-web/src/routes/sse.rs @@ -1,33 +1,80 @@ +use crate::app::AppState; use crate::context::RequestContext; -use axum::extract::Extension; +use crate::error::WebError; +use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery}; +use axum::extract::{Extension, Query, State}; use axum::response::sse::{Event, KeepAlive, Sse}; use futures_util::stream; -use serde_json::json; +use serde_json::Value; use std::convert::Infallible; use std::time::Duration; pub async fn events( + State(state): State, Extension(context): Extension, -) -> Sse>> { - let payload = json!({ - "kind": "sse_placeholder", - "requestId": context.trace.request_id, - "traceId": context.trace.trace_id, - "workspaceId": context.workspace.workspace_id, - "notes": [ - "当前为 task-062 最小骨架,后续在此对齐统一流式输出协议。", - "此路由预留给 Hermes token/tool/client event 回流。" - ] - }); + Query(query): Query, +) -> Result>>, WebError> { + let payload = load_stream_snapshot(state.config(), &context, &query).await?; + let event = snapshot_event(&payload); - let event = Event::default() - .event("ready") - .json_data(payload) - .expect("SSE 占位事件必须可序列化"); - - Sse::new(stream::iter(vec![Ok(event)])).keep_alive( + Ok(Sse::new(stream::iter(vec![Ok(event)])).keep_alive( KeepAlive::new() .interval(Duration::from_secs(15)) .text("keepalive"), - ) + )) +} + +fn snapshot_event(payload: &Value) -> Event { + Event::default() + .event("snapshot") + .json_data(payload) + .expect("SSE snapshot 事件必须可序列化") +} + +#[cfg(test)] +mod tests { + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; + use axum::http::{Request, StatusCode}; + use tower::util::ServiceExt; + + fn app() -> axum::Router { + build_app(AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"trashed_documents":[],"media_assets":[],"trashed_media_assets":[],"mindmap_assets":[],"trashed_mindmap_assets":[],"table_assets":[],"trashed_table_assets":[],"mindmap_docs":[],"mindmap_asset_children":{}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":"cursor_demo","has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()), + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + })) + } + + #[tokio::test] + async fn sse_route_returns_workspace_snapshot_event() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/stream/events?workspaceId=ws_demo") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let text = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(text.contains("event: snapshot") || text.contains("event:snapshot")); + assert!(text.contains("\"scope\":\"workspace\"")); + assert!(text.contains("\"workspaceId\":\"ws_demo\"")); + } } diff --git a/rust/crates/mnote-web/src/routes/stream_support.rs b/rust/crates/mnote-web/src/routes/stream_support.rs new file mode 100644 index 00000000..b1ea37e7 --- /dev/null +++ b/rust/crates/mnote-web/src/routes/stream_support.rs @@ -0,0 +1,170 @@ +use crate::app::AppConfig; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::routes::query_support::{ + execute_runtime_query_via_convex, resolve_effective_workspace_id, +}; +use crate::routes::snapshot_support::{ + execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query, + ProjectionSnapshotSpec, +}; +use bridge_runtime::RuntimeQueryEnvelopeWire; +use core_protocol::KernelProjectionKind; +use serde::Deserialize; +use serde_json::{json, Value}; + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct StreamSnapshotQuery { + pub workspace_id: Option, + pub root_node_id: Option, + pub depth: Option, + pub cursor: Option, + pub limit: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StreamSnapshotScope { + Workspace, + Subtree, +} + +impl StreamSnapshotScope { + pub fn as_str(self) -> &'static str { + match self { + Self::Workspace => "workspace", + Self::Subtree => "subtree", + } + } +} + +pub fn resolve_stream_scope(query: &StreamSnapshotQuery) -> StreamSnapshotScope { + if query + .root_node_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + { + StreamSnapshotScope::Subtree + } else { + StreamSnapshotScope::Workspace + } +} + +fn workspace_overview_query( + workspace_id: &str, + query: &StreamSnapshotQuery, +) -> RuntimeQueryEnvelopeWire { + RuntimeQueryEnvelopeWire { + name: "bridge.workspace.overview".into(), + payload: json!({ + "workspaceId": workspace_id, + "cursor": query.cursor, + "limit": query.limit.unwrap_or(20), + "commandStatus": Value::Null, + "eventStatus": Value::Null, + "targetPageId": Value::Null, + "targetBlockId": Value::Null, + "aggregateType": Value::Null, + "aggregateId": query.root_node_id, + }), + } +} + +pub async fn load_stream_snapshot( + config: &AppConfig, + context: &RequestContext, + query: &StreamSnapshotQuery, +) -> Result { + let effective_workspace_id = + resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)? + .expect("workspace_required 已确保存在"); + let scope = resolve_stream_scope(query); + + let snapshot = match scope { + StreamSnapshotScope::Workspace => { + let loaded = load_projection_snapshot( + config, + context, + &ProjectionSnapshotSpec { + workspace_id: &effective_workspace_id, + root_node_id: None, + depth: query.depth, + projection: KernelProjectionKind::SidebarTree, + }, + ) + .await?; + + json!({ + "dataset": loaded.dataset, + "tree": loaded.projection, + }) + } + StreamSnapshotScope::Subtree => { + let root_node_id = query + .root_node_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .expect("subtree scope 已确保 rootNodeId 存在"); + let dataset = load_sidebar_dataset(config, context, &effective_workspace_id).await?; + let tree = execute_kernel_query( + context, + &effective_workspace_id, + subtree_query(&effective_workspace_id, root_node_id, query.depth), + dataset.clone(), + )?; + + json!({ + "dataset": dataset, + "tree": tree, + }) + } + }; + + let overview = execute_runtime_query_via_convex( + config, + context, + Some(&effective_workspace_id), + workspace_overview_query(&effective_workspace_id, query), + ) + .await + .ok(); + + Ok(json!({ + "kind": "snapshot", + "scope": scope.as_str(), + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + "workspaceId": effective_workspace_id, + "rootNodeId": query.root_node_id, + "depth": query.depth, + "snapshot": snapshot, + "overview": overview, + })) +} + +#[cfg(test)] +mod tests { + use super::{resolve_stream_scope, StreamSnapshotQuery, StreamSnapshotScope}; + + #[test] + fn stream_scope_defaults_to_workspace() { + assert_eq!( + resolve_stream_scope(&StreamSnapshotQuery::default()), + StreamSnapshotScope::Workspace + ); + } + + #[test] + fn stream_scope_switches_to_subtree_when_root_exists() { + assert_eq!( + resolve_stream_scope(&StreamSnapshotQuery { + root_node_id: Some("page_root".into()), + ..StreamSnapshotQuery::default() + }), + StreamSnapshotScope::Subtree + ); + } +} diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index 0b14507e..ff6e5d2b 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -6,14 +6,16 @@ use crate::routes::command_support::{ read_optional_non_empty, }; use crate::routes::query_support::{ - execute_runtime_query_against_data, fetch_query_data_via_convex, resolve_effective_workspace_id, + resolve_effective_workspace_id, +}; +use crate::routes::snapshot_support::{ + load_projection_snapshot, ProjectionSnapshotSpec, }; use axum::extract::{Extension, Query, State}; use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use axum::Json; use bridge_runtime::RuntimeCommandEnvelopeWire; -use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::KernelProjectionKind; use serde::Deserialize; use serde_json::{json, Value}; @@ -73,39 +75,6 @@ pub enum TreeCommandRequest { }, } -fn tree_projection_query( - workspace_id: &str, - root_node_id: Option<&str>, - depth: Option, - mode: &str, -) -> RuntimeQueryEnvelopeWire { - RuntimeQueryEnvelopeWire { - name: "kernel.project_view".into(), - payload: json!({ - "projection": if mode == "filetree" { - KernelProjectionKind::FileTree - } else { - KernelProjectionKind::PageTree - }, - "workspaceId": workspace_id, - "rootNodeId": root_node_id, - "depth": depth, - "includeEdges": true, - "includeContent": false, - "nodeTypes": ["page"], - }), - } -} - -fn sidebar_dataset_query(workspace_id: &str) -> RuntimeQueryEnvelopeWire { - RuntimeQueryEnvelopeWire { - name: "sidebar.dataset.list".into(), - payload: json!({ - "workspaceId": workspace_id, - }), - } -} - fn escape_html(input: &str) -> String { input .replace('&', "&") @@ -1918,24 +1887,21 @@ pub async fn tree_shell( let mode = normalize_tree_mode(query.mode.as_deref()); let allow_root_pick = normalize_bool_flag(query.allow_root_pick.as_deref(), false); let exclude_ids = parse_exclude_ids(query.exclude_ids.as_deref()); - let dataset = fetch_query_data_via_convex( + let snapshot = load_projection_snapshot( state.config(), &effective_context, - Some(&effective_workspace_id), - sidebar_dataset_query(&effective_workspace_id), + &ProjectionSnapshotSpec { + workspace_id: &effective_workspace_id, + root_node_id: query.root_node_id.as_deref(), + depth: query.depth, + projection: if mode == "filetree" { + KernelProjectionKind::FileTree + } else { + KernelProjectionKind::PageTree + }, + }, ) .await?; - let projection = execute_runtime_query_against_data( - &effective_context, - Some(&effective_workspace_id), - tree_projection_query( - &effective_workspace_id, - query.root_node_id.as_deref(), - query.depth, - mode, - ), - dataset.clone(), - )?; let html = build_tree_shell_html( &effective_workspace_id, query.root_node_id.as_deref(), @@ -1943,11 +1909,11 @@ pub async fn tree_shell( &normalize_channel(query.channel), query.host.as_deref(), &effective_context, - &projection, + &snapshot.projection, mode, allow_root_pick, &exclude_ids, - &dataset, + &snapshot.dataset, ); let mut response = Html(html).into_response(); response diff --git a/rust/crates/mnote-web/src/routes/ws.rs b/rust/crates/mnote-web/src/routes/ws.rs index f6a94f84..4d150613 100644 --- a/rust/crates/mnote-web/src/routes/ws.rs +++ b/rust/crates/mnote-web/src/routes/ws.rs @@ -1,30 +1,36 @@ +use crate::app::AppState; use crate::context::RequestContext; +use crate::error::WebError; +use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery}; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; -use axum::extract::Extension; +use axum::extract::{Extension, Query, State}; use axum::response::Response; use futures_util::StreamExt; -use serde_json::json; +use serde_json::{json, Value}; pub async fn socket( ws: WebSocketUpgrade, + State(state): State, Extension(context): Extension, -) -> Response { - ws.on_upgrade(move |socket| handle_socket(socket, context)) + Query(query): Query, +) -> Result { + let snapshot = load_stream_snapshot(state.config(), &context, &query).await?; + let state = state.clone(); + let context = context.clone(); + let query = query.clone(); + + Ok(ws.on_upgrade(move |socket| handle_socket(socket, state, context, query, snapshot))) } -async fn handle_socket(mut socket: WebSocket, context: RequestContext) { - let payload = json!({ - "kind": "ws_placeholder", - "requestId": context.trace.request_id, - "traceId": context.trace.trace_id, - "workspaceId": context.workspace.workspace_id, - "notes": [ - "当前为 task-062 最小骨架,后续可承接协作推送和运行态事件。" - ] - }); - +async fn handle_socket( + mut socket: WebSocket, + state: AppState, + context: RequestContext, + query: StreamSnapshotQuery, + payload: Value, +) { let _ = socket - .send(Message::Text(payload.to_string().into())) + .send(serialize_snapshot_message(&payload)) .await; while let Some(message) = socket.next().await { @@ -34,17 +40,41 @@ async fn handle_socket(mut socket: WebSocket, context: RequestContext) { match message { Message::Text(text) => { - let echo = json!({ - "kind": "ws_echo", - "traceId": context.trace.trace_id, - "text": text.to_string(), - }); - if socket - .send(Message::Text(echo.to_string().into())) - .await - .is_err() - { - break; + if is_resync_request(&text) { + match load_stream_snapshot(state.config(), &context, &query).await { + Ok(snapshot) => { + if socket.send(serialize_resync_message(&snapshot)).await.is_err() { + break; + } + } + Err(error) => { + let payload = json!({ + "kind": "error", + "code": "snapshot_reload_failed", + "message": format!("{error:?}"), + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + }); + if socket + .send(Message::Text(payload.to_string().into())) + .await + .is_err() + { + break; + } + } + } + } else { + let ack = json!({ + "kind": "ack", + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + "accepted": false, + "reason": "unsupported_message", + }); + if socket.send(Message::Text(ack.to_string().into())).await.is_err() { + break; + } } } Message::Close(_) => break, @@ -52,3 +82,62 @@ async fn handle_socket(mut socket: WebSocket, context: RequestContext) { } } } + +fn serialize_snapshot_message(payload: &Value) -> Message { + Message::Text(payload.to_string().into()) +} + +fn serialize_resync_message(payload: &Value) -> Message { + Message::Text( + json!({ + "kind": "resync", + "snapshot": payload, + }) + .to_string() + .into(), + ) +} + +fn is_resync_request(text: &str) -> bool { + let trimmed = text.trim(); + if trimmed.eq_ignore_ascii_case("resync") { + return true; + } + + serde_json::from_str::(trimmed) + .ok() + .and_then(|value| value.get("type").and_then(Value::as_str).map(str::to_owned)) + .map(|value| value.eq_ignore_ascii_case("resync")) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::{is_resync_request, serialize_resync_message, serialize_snapshot_message}; + use axum::extract::ws::Message; + use serde_json::json; + + #[test] + fn ws_resync_detection_accepts_plain_text_and_json() { + assert!(is_resync_request("resync")); + assert!(is_resync_request(r#"{"type":"resync"}"#)); + assert!(!is_resync_request("hello")); + } + + #[test] + fn ws_snapshot_serializers_emit_text_frames() { + let snapshot = json!({ + "kind": "snapshot", + "scope": "workspace", + }); + let Message::Text(snapshot_text) = serialize_snapshot_message(&snapshot) else { + panic!("snapshot message 应该是文本帧"); + }; + assert!(snapshot_text.contains("\"kind\":\"snapshot\"")); + + let Message::Text(resync_text) = serialize_resync_message(&snapshot) else { + panic!("resync message 应该是文本帧"); + }; + assert!(resync_text.contains("\"kind\":\"resync\"")); + } +} diff --git a/rust/crates/storage-convex-bridge/src/lib.rs b/rust/crates/storage-convex-bridge/src/lib.rs index 833fb0f6..c0bc99e4 100644 --- a/rust/crates/storage-convex-bridge/src/lib.rs +++ b/rust/crates/storage-convex-bridge/src/lib.rs @@ -237,6 +237,46 @@ mod tests { assert!(request.payload_json.contains("\"name\":\"documents.save\"")); } + #[test] + fn document_embed_command_maps_to_documents_update_content() { + let command = CommandEnvelope { + name: "documents.embed".into(), + command_id: "cmd_embed_1".into(), + idempotency_key: Some("idem_embed".into()), + actor: ActorPayload { + actor_type: "human".into(), + actor_id: "user_1".into(), + session_id: Some("session_1".into()), + }, + source: SourcePayload { + channel: "next-route".into(), + client: "wolai-frontend".into(), + }, + target: Some(TargetRef { + workspace_id: Some("ws_1".into()), + page_id: Some("page_2".into()), + block_id: None, + }), + payload: SavePageContent { + page_id: "page_2".into(), + workspace_id: Some("ws_1".into()), + revision: Some(7), + content_json: "{\"blocks\":[{\"id\":\"block_1\",\"type\":\"pageReference\"}]}".into(), + conflict_detection_key: Some("rev:7".into()), + }, + reason: Some("嵌入页面".into()), + refs: vec!["checklist:p1-3".into()], + dry_run: false, + validate_only: false, + }; + + let request = + build_write_request(&demo_context(), &command).expect("write request should build"); + assert_eq!(request.function_name, "documents:updateContent"); + assert_eq!(request.workspace_id.as_deref(), Some("ws_1")); + assert!(request.payload_json.contains("\"name\":\"documents.embed\"")); + } + #[test] fn page_lifecycle_commands_map_to_document_mutations() { let base_actor = ActorPayload { diff --git a/rust/crates/storage-convex-bridge/src/mapping.rs b/rust/crates/storage-convex-bridge/src/mapping.rs index 245aa476..9b6eaa68 100644 --- a/rust/crates/storage-convex-bridge/src/mapping.rs +++ b/rust/crates/storage-convex-bridge/src/mapping.rs @@ -43,6 +43,7 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str { "documents.template" => "documents:setTemplate", "documents.emptyTrashByWorkspace" => "documents:emptyTrashByWorkspace", "documents.purge" => "documents:purge", + "documents.embed" => "documents:updateContent", "blocks.patch" => "documents:updateContent", "documents.save" => "documents:updateContent", "documents.title.update" => "documents:updateTitle", diff --git a/wolai-frontend/src/components/ai-agent/AiAgentPanel.test.tsx b/wolai-frontend/src/components/ai-agent/AiAgentPanel.test.tsx index 441c5b4f..f1681310 100644 --- a/wolai-frontend/src/components/ai-agent/AiAgentPanel.test.tsx +++ b/wolai-frontend/src/components/ai-agent/AiAgentPanel.test.tsx @@ -36,7 +36,7 @@ describe("AiAgentPanel", () => { }); expect(container.textContent).toContain("全局 AI"); - expect(container.textContent).toContain("自动工具编排"); + expect(container.textContent).toContain("前端已经退为桥接层"); expect(container.textContent).toContain("联网检索"); expect(container.textContent).toContain("LightRAG"); expect(container.textContent).toContain("跨页面文档"); @@ -50,12 +50,13 @@ describe("AiAgentPanel", () => { const toggleButton = container.querySelector('button[aria-label="切换工具活动面板"]'); expect(toggleButton).not.toBeNull(); - expect(container.textContent).toContain("本轮活动"); + expect(container.textContent).toContain("活动轨迹"); + expect(container.textContent).toContain("暂无工具活动"); act(() => { toggleButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - expect(container.textContent).not.toContain("本轮活动"); + expect(container.textContent).not.toContain("暂无工具活动"); }); }); diff --git a/wolai-frontend/src/components/editor/document-content.tsx b/wolai-frontend/src/components/editor/document-content.tsx index d7f16c7b..3437459c 100644 --- a/wolai-frontend/src/components/editor/document-content.tsx +++ b/wolai-frontend/src/components/editor/document-content.tsx @@ -21,7 +21,12 @@ import { Button } from "@/components/ui/button"; import { DocumentToc } from "@/components/editor/document-toc"; import { DocumentReadView } from "@/components/editor/document-read-view"; import { buildPageSubtreeProjection, extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree"; -import { moveDocumentCommand, renameDocumentCommand } from "@/lib/documents/tree-command-client"; +import { + deleteDocumentCommand, + embedDocumentCommand, + moveDocumentCommand, + renameDocumentCommand, +} from "@/lib/documents/tree-command-client"; const BlockNoteEditor = dynamic( () => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor), @@ -602,19 +607,16 @@ export function DocumentContent({ if (readOnly) return; const ok = window.confirm("确定删除该页面吗?删除后会进入垃圾桶。"); if (!ok) return; - const resp = await fetch("/api/documents/delete", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ documentId }), - }); - if (!resp.ok) { - const payload = await resp.json().catch(() => ({})); - window.alert(payload?.error ?? "删除失败"); + try { + await deleteDocumentCommand({ documentId, workspaceId }); + } catch (error) { + const payload = error instanceof Error ? error.message : null; + window.alert(payload ?? "删除失败"); return; } router.push("/"); router.refresh(); - }, [documentId, readOnly, router]); + }, [documentId, readOnly, router, workspaceId]); const handleOpenMoveEmbed = useCallback(() => { if (readOnly) return; @@ -642,16 +644,22 @@ export function DocumentContent({ return; } - const resp = await fetch("/api/documents/embed", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sourceId: documentId, targetId }), - }); - if (!resp.ok) { - const payload = await resp.json().catch(() => ({})); - window.alert(payload?.error ?? "嵌入失败"); + if (!targetId) { + window.alert("请选择目标页面"); return; } + + try { + await embedDocumentCommand({ + sourceId: documentId, + targetId, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "嵌入失败"; + window.alert(message); + return; + } + window.alert("已嵌入到目标页面"); }, }); diff --git a/wolai-frontend/src/components/editor/menus/CustomSideMenu.tsx b/wolai-frontend/src/components/editor/menus/CustomSideMenu.tsx index 22a27f48..b12b5151 100644 --- a/wolai-frontend/src/components/editor/menus/CustomSideMenu.tsx +++ b/wolai-frontend/src/components/editor/menus/CustomSideMenu.tsx @@ -19,7 +19,7 @@ import { deleteOnlineTable } from "@/lib/online-table"; import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events"; import { useMoveEmbedPickerStore } from "@/store/move-embed-picker"; import { useCommentsUiStore } from "@/store/comments-ui"; -import { createChildDocumentCommand } from "@/lib/documents/tree-command-client"; +import { createChildDocumentCommand, deleteDocumentCommand } from "@/lib/documents/tree-command-client"; type InlineNode = { text?: unknown }; type TableMenuBlock = Parameters< @@ -119,10 +119,9 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD if (block.type === "pageReference") { const pageId = block.props.pageId; if (pageId) { - await fetch("/api/documents/delete", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ documentId: pageId }), + await deleteDocumentCommand({ + documentId: pageId, + workspaceId, }); if (typeof window !== "undefined") { emitDocumentsChanged(pageId); @@ -131,7 +130,7 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD } editor.removeBlocks([block.id]); router.refresh(); - }, [block, editor, router]); + }, [block, editor, router, workspaceId]); const handleDeleteBlock = useCallback(async () => { if (block.type === "pageReference") { diff --git a/wolai-frontend/src/components/sidebar/sidebar.tsx b/wolai-frontend/src/components/sidebar/sidebar.tsx index 66210531..1031b010 100644 --- a/wolai-frontend/src/components/sidebar/sidebar.tsx +++ b/wolai-frontend/src/components/sidebar/sidebar.tsx @@ -67,13 +67,19 @@ import type { MediaAsset } from "@/types/media"; import { AssetContextMenu } from "@/components/sidebar/asset-context-menu"; import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; +import { useSidebarTreeStream } from "@/lib/tree-stream/use-sidebar-tree-stream"; import { DocumentShareDialog } from "@/components/sharing/document-share-dialog"; import { api } from "@/lib/convex/api"; import { GroupManagerDialog } from "@/components/groups/group-manager-dialog"; import { + copyTreeCommand, createDocumentCommand, + deleteDocumentCommand, + embedDocumentCommand, moveDocumentCommand, renameDocumentCommand, + restoreDocumentCommand, + purgeDocumentCommand, } from "@/lib/documents/tree-command-client"; const TOP_BUTTONS = [ @@ -149,16 +155,18 @@ export function Sidebar({ initialData }: SidebarProps) { // Convex 模式专用组件 - 只调用 Convex hooks function SidebarConvex({ initialData }: SidebarProps) { const sidebarData = useSidebarData(initialData); - return ; + const treeStream = useSidebarTreeStream(initialData); + return ; } // 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑 interface SidebarContentProps { initialData: SidebarInitialData; sidebarQuery: SidebarDataResult; + treeStream: ReturnType; } -function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { +function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarContentProps) { const convex = useConvex(); const { isAuthenticated } = useConvexAuth(); const currentUser = useQuery(api.users.currentUser, isAuthenticated ? {} : "skip"); @@ -174,8 +182,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { // 处理数据 const sidebarData = useMemo(() => { - return sidebarQuery.data ?? initialData; - }, [sidebarQuery, initialData]); + return treeStream.data ?? sidebarQuery.data ?? initialData; + }, [treeStream.data, sidebarQuery, initialData]); // isLoading 判断 const isLoading = sidebarQuery.isLoading; @@ -986,20 +994,17 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { }); if (docItemsMap.size > 0) { - const resp = await fetch("/api/documents/copy-tree", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ + try { + await copyTreeCommand({ items: Array.from(docItemsMap.entries()).map(([documentId, recursive]) => ({ documentId, recursive, })), targetParentId: targetDocId, - }), - }); - if (!resp.ok) { - const data = await resp.json().catch(() => ({})); - setTimeout(() => window.alert(data?.error ?? "粘贴页面失败"), 0); + }); + } catch (error) { + const message = error instanceof Error ? error.message : "粘贴页面失败"; + setTimeout(() => window.alert(message), 0); return; } await sidebarQuery.refetch(); @@ -1345,12 +1350,15 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { if (docIds.length > 0) { const results = await Promise.all( docIds.map(async (documentId) => { - const resp = await fetch("/api/documents/delete", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ documentId }), - }); - return { documentId, ok: resp.ok }; + try { + await deleteDocumentCommand({ + documentId, + workspaceId: sidebarData.activeWorkspaceId ?? null, + }); + return { documentId, ok: true }; + } catch { + return { documentId, ok: false }; + } }), ); const failed = results.filter((item) => !item.ok).map((item) => item.documentId); @@ -1666,17 +1674,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { if (args.copy) { if (docIds.length > 0) { - const resp = await fetch("/api/documents/copy-tree", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ + try { + await copyTreeCommand({ items: docIds.map((documentId) => ({ documentId, recursive: true })), targetParentId: targetDocId, - }), - }); - if (!resp.ok) { - const payload = await resp.json().catch(() => ({})); - setTimeout(() => window.alert(payload?.error ?? "复制页面失败"), 0); + }); + } catch (error) { + const message = error instanceof Error ? error.message : "复制页面失败"; + setTimeout(() => window.alert(message), 0); return; } await sidebarQuery.refetch(); @@ -1782,10 +1787,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { const handleDelete = useCallback( async (documentId: string) => { - await fetch("/api/documents/delete", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ documentId }), + await deleteDocumentCommand({ + documentId, + workspaceId: sidebarData.activeWorkspaceId ?? null, }); await refreshTree(); emitDocumentsChanged(documentId); @@ -1793,7 +1797,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { router.push("/"); } }, - [activeId, refreshTree, router], + [activeId, refreshTree, router, sidebarData.activeWorkspaceId], ); const handleDeleteFromContextMenuNode = useCallback( @@ -1879,14 +1883,13 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { if (!confirmTrashAction("确认恢复该页面吗?")) { return; } - await fetch("/api/documents/restore", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ documentId }), + await restoreDocumentCommand({ + documentId, + workspaceId: sidebarData.activeWorkspaceId ?? null, }); await Promise.all([sidebarQuery.refetch(), refreshTree()]); }, - [confirmTrashAction, refreshTree, sidebarQuery], + [confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery], ); const handlePurgeFromTrash = useCallback( @@ -1894,11 +1897,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { if (!confirmTrashAction("彻底删除后将无法找回,是否继续?")) { return; } - await fetch("/api/documents/purge", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ documentId }), - }); + await purgeDocumentCommand({ documentId }); await Promise.all([sidebarQuery.refetch(), refreshTree()]); }, [confirmTrashAction, refreshTree, sidebarQuery], @@ -2817,14 +2816,15 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { window.alert("不能嵌入到自身页面"); return; } - const response = await fetch("/api/documents/embed", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sourceId: source.id, targetId }), - }); - if (!response.ok) { + try { + await embedDocumentCommand({ + sourceId: source.id, + targetId, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "嵌入失败,请检查目标页面"; if (typeof window !== "undefined") { - window.alert("嵌入失败,请检查目标页面"); + window.alert(message); } return; } diff --git a/wolai-frontend/src/lib/documents/bridge.test.ts b/wolai-frontend/src/lib/documents/bridge.test.ts index 8b1cb7c0..512adbba 100644 --- a/wolai-frontend/src/lib/documents/bridge.test.ts +++ b/wolai-frontend/src/lib/documents/bridge.test.ts @@ -243,6 +243,61 @@ describe("documents bridge helpers", () => { }); }); + it("buildDocumentBridgeMutationRequest builds documents.embed runtime request", () => { + const payload = { + ...buildDocumentSavePayload({ + documentId: "doc_2", + workspaceId: "ws_1", + revision: 3, + content: [{ id: "block_2", type: "pageReference" }], + conflictDetectionKey: "conflict_3", + blockCount: 1, + }), + sourceDocumentId: "doc_1", + targetDocumentId: "doc_2", + anchorBlockId: "anchor_1", + }; + const envelope = buildDocumentCommandEnvelope({ + name: "documents.embed", + payload, + context: mockContext, + target: { workspaceId: "ws_1", pageId: "doc_2" }, + }); + + const request = buildDocumentBridgeMutationRequest({ + context: mockContext, + envelope, + mapConvexArgs: (nextPayload) => ({ + id: nextPayload.documentId, + content: nextPayload.content, + expectedRevision: nextPayload.revision, + conflictDetectionKey: nextPayload.conflictDetectionKey, + sourceDocumentId: nextPayload.sourceDocumentId, + targetDocumentId: nextPayload.targetDocumentId, + anchorBlockId: nextPayload.anchorBlockId, + }), + }); + + expect(request.functionName).toBe("documents:updateContent"); + expect(request.workspaceId).toBe("ws_1"); + expect(request.args).toEqual({ + id: "doc_2", + content: [{ id: "block_2", type: "pageReference" }], + expectedRevision: 3, + conflictDetectionKey: "conflict_3", + sourceDocumentId: "doc_1", + targetDocumentId: "doc_2", + anchorBlockId: "anchor_1", + }); + expect(JSON.parse(request.payloadJson)).toMatchObject({ + kind: "command", + name: "documents.embed", + workspace_id: "ws_1", + request_id: "req_1", + trace_id: "trace_1", + }); + }); + it("buildDocumentBridgeMutationRequest builds media asset writeback runtime request", () => { const envelope = buildDocumentCommandEnvelope({ name: "media.assets.replace_storage", diff --git a/wolai-frontend/src/lib/documents/bridge.ts b/wolai-frontend/src/lib/documents/bridge.ts index c1a25502..7f68ffb3 100644 --- a/wolai-frontend/src/lib/documents/bridge.ts +++ b/wolai-frontend/src/lib/documents/bridge.ts @@ -126,6 +126,7 @@ const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = { "documents.template": "documents:setTemplate", "documents.emptyTrashByWorkspace": "documents:emptyTrashByWorkspace", "documents.purge": "documents:purge", + "documents.embed": "documents:updateContent", "blocks.patch": "documents:updateContent", "blocks.move": "documents:updateContent", "blocks.embed": "documents:updateContent", diff --git a/wolai-frontend/src/lib/documents/page-command-adapter.ts b/wolai-frontend/src/lib/documents/page-command-adapter.ts index e468c85a..6c2da87c 100644 --- a/wolai-frontend/src/lib/documents/page-command-adapter.ts +++ b/wolai-frontend/src/lib/documents/page-command-adapter.ts @@ -20,7 +20,6 @@ import { executeRustBridgeMutationTransport, resolveRustBridgeCommandPlan, } from "@/lib/documents/rust-runtime"; -import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter"; import { buildDocumentSavePayload } from "@/lib/documents/save-contract"; import type { Json } from "@/types/supabase"; @@ -79,6 +78,19 @@ export type DocumentPurgePayload = { documentId: string; }; +export type DocumentEmbedPayload = { + documentId: string; + workspaceId: string | null; + revision: number | null; + content: Json; + conflictDetectionKey: string | null; + snapshotCapturedAt: string | null; + blockCount: number | null; + sourceDocumentId: string; + targetDocumentId: string; + anchorBlockId: string | null; +}; + export type PageCommandExecutionResult = { requestId: string; traceId: string; @@ -287,23 +299,28 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks); const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id) ?? normalizeWorkspaceId((targetMeta as { workspace_id?: string | null } | null)?.workspace_id); const context = await buildRuntimeContext(request, workspaceId); - const savePayload = buildDocumentSavePayload({ - documentId: normalizedTargetId, - workspaceId, - revision: - typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision) - ? targetContent.revision - : null, - content: payload, - conflictDetectionKey: - typeof targetContent.conflict_detection_key === "string" - ? targetContent.conflict_detection_key - : null, - blockCount: nextBlocks.length, - }); + const embedPayload: DocumentEmbedPayload = { + ...buildDocumentSavePayload({ + documentId: normalizedTargetId, + workspaceId, + revision: + typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision) + ? targetContent.revision + : null, + content: payload, + conflictDetectionKey: + typeof targetContent.conflict_detection_key === "string" + ? targetContent.conflict_detection_key + : null, + blockCount: nextBlocks.length, + }), + sourceDocumentId: normalizedSourceId, + targetDocumentId: normalizedTargetId, + anchorBlockId: anchorId, + }; const envelope = buildDocumentCommandEnvelope({ - name: "documents.save", - payload: savePayload, + name: "documents.embed", + payload: embedPayload, context, target: { workspaceId, @@ -311,9 +328,13 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi }, }); - const result = await executeSaveBridgeCommand({ + const result = await executePageLifecycleBridgeCommand({ context, envelope, + client, }); return NextResponse.json({ diff --git a/wolai-frontend/src/lib/documents/page-lifecycle-command-adapter.test.ts b/wolai-frontend/src/lib/documents/page-lifecycle-command-adapter.test.ts new file mode 100644 index 00000000..49862ca0 --- /dev/null +++ b/wolai-frontend/src/lib/documents/page-lifecycle-command-adapter.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildParentById } from "@/lib/file-tree/dnd"; + +vi.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: { status?: number }) => ({ + body, + status: init?.status ?? 200, + }), + }, +}), { virtual: true }); + +vi.mock("@/lib/convex/api", () => ({ + api: { + documents: { getMeta: "documents:getMeta" }, + workspaces: { ensureDefaultWorkspace: "workspaces:ensureDefaultWorkspace" }, + }, +})); + +vi.mock("@/lib/convex/route", () => ({ + getAuthedConvexClient: vi.fn(), +})); + +vi.mock("@/lib/documents/bridge", () => ({ + buildDocumentBridgeContextWithActor: vi.fn(), + buildDocumentCommandEnvelope: vi.fn(), + documentBridgeErrorResponse: vi.fn(), +})); + +vi.mock("@/lib/documents/bridge-log", () => ({ + recordBridgeCommandArtifacts: vi.fn(), + recordBridgeCommandFailureArtifacts: vi.fn(), +})); + +vi.mock("@/lib/documents/rust-runtime", () => ({ + executeRustBridgeMutationTransport: vi.fn(), + resolveRustBridgeCommandPlan: vi.fn(), +})); + +vi.mock("@/lib/server/local-paths", () => ({ + getDocumentsBaseDir: () => "/tmp/mnote-vitest-documents", +})); + +const { + normalizeDocumentCopyTreePayload, + normalizeDocumentMovePayload, + resolveSubtreeMoveLegality, +} = await import("./page-lifecycle-command-adapter"); + +describe("page-lifecycle-command-adapter", () => { + it("归一化 move payload 的 targetParentId 与 sortOrder", () => { + expect( + normalizeDocumentMovePayload({ + documentId: " doc_1 ", + parentId: " parent_1 ", + position: 2.8, + }), + ).toEqual({ + documentId: "doc_1", + parentId: "parent_1", + sortOrder: 2, + }); + + expect( + normalizeDocumentMovePayload({ + documentId: "doc_1", + parentId: " ", + position: Number.NaN, + }), + ).toEqual({ + documentId: "doc_1", + parentId: null, + sortOrder: 0, + }); + }); + + it("归一化 copy_tree payload 并去重 source ids", () => { + expect( + normalizeDocumentCopyTreePayload({ + targetParentId: " parent_1 ", + items: [ + { documentId: " doc_a ", recursive: true }, + { documentId: "doc_b", recursive: false }, + { documentId: "doc_a", recursive: false }, + { documentId: " ", recursive: true }, + ], + }), + ).toEqual({ + targetParentId: "parent_1", + items: [ + { documentId: "doc_a", recursive: true }, + { documentId: "doc_b", recursive: false }, + { documentId: "doc_a", recursive: false }, + ], + sourceIds: ["doc_a", "doc_b"], + }); + }); + + it("在 subtree move legality 中先收敛顶层 source,再拦截自拖拽/拖入后代", () => { + const parentById = buildParentById([ + { id: "a", parentId: null }, + { id: "b", parentId: "a" }, + { id: "c", parentId: "b" }, + { id: "d", parentId: null }, + ]); + + expect( + resolveSubtreeMoveLegality({ + sourceDocIds: ["a", "b", "c", "d"], + targetParentId: "b", + parentById, + }), + ).toEqual({ + sourceDocIds: ["a", "d"], + targetParentId: "b", + isInvalid: true, + }); + + expect( + resolveSubtreeMoveLegality({ + sourceDocIds: ["b", "c"], + targetParentId: "a", + parentById, + }), + ).toEqual({ + sourceDocIds: ["b"], + targetParentId: "a", + isInvalid: false, + }); + }); +}); diff --git a/wolai-frontend/src/lib/documents/page-lifecycle-command-adapter.ts b/wolai-frontend/src/lib/documents/page-lifecycle-command-adapter.ts index 7a895876..fb13d63c 100644 --- a/wolai-frontend/src/lib/documents/page-lifecycle-command-adapter.ts +++ b/wolai-frontend/src/lib/documents/page-lifecycle-command-adapter.ts @@ -1,5 +1,3 @@ -import "server-only"; - import { randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; @@ -19,6 +17,7 @@ import { } from "@/lib/documents/bridge-log"; import { executeRustBridgeMutationTransport, resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime"; import { getDocumentsBaseDir } from "@/lib/server/local-paths"; +import { filterTopLevelDocIds, isInvalidDocDrop } from "@/lib/file-tree/dnd"; type CreatePayload = { parentId?: string | null; @@ -52,8 +51,34 @@ type CopyTreePayload = { targetParentId?: string | null; }; +export type NormalizedDocumentMovePayload = { + documentId: string | null; + parentId: string | null; + sortOrder: number; +}; + +export type NormalizedCopyTreeItem = { + documentId: string; + recursive: boolean; +}; + +export type NormalizedDocumentCopyTreePayload = { + targetParentId: string | null; + items: NormalizedCopyTreeItem[]; + sourceIds: string[]; +}; + const documentsBaseDir = getDocumentsBaseDir(); +function assertServerEnvironment() { + if (typeof process !== "undefined" && process.env.VITEST === "true") { + return; + } + if (typeof window !== "undefined") { + throw new Error("page-lifecycle-command-adapter 仅允许在服务端执行"); + } +} + function trimOrNull(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); @@ -65,6 +90,52 @@ function normalizeTitle(title: string | null): string { return safe && safe.length > 0 ? safe : "无标题"; } +export function normalizeDocumentMovePayload(payload: MovePayload): NormalizedDocumentMovePayload { + return { + documentId: trimOrNull(payload.documentId), + parentId: trimOrNull(payload.parentId), + sortOrder: Number.isFinite(payload.position ?? Number.NaN) ? Math.floor(payload.position ?? 0) : 0, + }; +} + +export function normalizeDocumentCopyTreePayload( + payload: CopyTreePayload, +): NormalizedDocumentCopyTreePayload { + const items = (payload.items ?? []) + .map((item) => ({ + documentId: trimOrNull(item?.documentId), + recursive: Boolean(item?.recursive), + })) + .filter((item): item is NormalizedCopyTreeItem => Boolean(item.documentId)) + .map((item) => ({ + documentId: item.documentId, + recursive: item.recursive, + })); + + return { + targetParentId: trimOrNull(payload.targetParentId), + items, + sourceIds: Array.from(new Set(items.map((item) => item.documentId))), + }; +} + +export function resolveSubtreeMoveLegality(input: { + sourceDocIds: string[]; + targetParentId: string | null; + parentById: Map; +}) { + const topLevelSourceDocIds = filterTopLevelDocIds(input.sourceDocIds, input.parentById); + return { + sourceDocIds: topLevelSourceDocIds, + targetParentId: input.targetParentId, + isInvalid: isInvalidDocDrop({ + sourceDocIds: topLevelSourceDocIds, + targetParentId: input.targetParentId, + parentById: input.parentById, + }), + }; +} + function safeRandomId() { return typeof crypto.randomUUID === "function" ? crypto.randomUUID() @@ -138,6 +209,7 @@ async function handleLifecycleError(error: unknown) { } export async function handleDocumentCreateRequest(request: Request): Promise { + assertServerEnvironment(); try { const payload = (await request.json()) as CreatePayload; const { auth, client } = await getAuthedConvexClient(); @@ -185,7 +257,7 @@ export async function handleDocumentCreateRequest(request: Request): Promise { + assertServerEnvironment(); try { const payload = (await request.json()) as MovePayload; - const documentId = trimOrNull(payload.documentId); + const normalizedMove = normalizeDocumentMovePayload(payload); + const documentId = normalizedMove.documentId; if (!documentId) { return NextResponse.json({ error: "缺少 documentId" }, { status: 400 }); } @@ -273,8 +347,8 @@ export async function handleDocumentMoveRequest(request: Request): Promise { + assertServerEnvironment(); try { const payload = (await request.json()) as DeletePayload; const documentId = trimOrNull(payload.documentId); @@ -399,6 +474,7 @@ export async function handleDocumentDeleteRequest(request: Request): Promise { + assertServerEnvironment(); try { const payload = (await request.json()) as RestorePayload; const documentId = trimOrNull(payload.documentId); @@ -473,6 +549,7 @@ export async function handleDocumentRestoreRequest(request: Request): Promise { + assertServerEnvironment(); try { const payload = (await request.json()) as DuplicatePayload; const documentId = trimOrNull(payload.documentId); @@ -569,16 +646,15 @@ export async function handleDocumentDuplicateRequest(request: Request): Promise< } export async function handleDocumentCopyTreeRequest(request: Request): Promise { + assertServerEnvironment(); try { const payload = (await request.json()) as CopyTreePayload; const { auth, client } = await getAuthedConvexClient(); - - const normalizedItems = (payload.items ?? []).filter((it) => trimOrNull(it?.documentId)); - if (normalizedItems.length === 0) { + const normalizedCopyTree = normalizeDocumentCopyTreePayload(payload); + if (normalizedCopyTree.items.length === 0) { return NextResponse.json({ error: "items 为空" }, { status: 400 }); } - - const targetParentId = trimOrNull(payload.targetParentId); + const targetParentId = normalizedCopyTree.targetParentId; let workspaceId: string | null = null; if (targetParentId) { @@ -589,7 +665,7 @@ export async function handleDocumentCopyTreeRequest(request: Request): Promise trimOrNull(it.documentId) as string))); + const sourceIds = normalizedCopyTree.sourceIds; const firstMeta = await client.query(api.documents.getMeta, { id: sourceIds[0] }); if (!firstMeta) { return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 }); @@ -607,10 +683,7 @@ export async function handleDocumentCopyTreeRequest(request: Request): Promise ({ - documentId: trimOrNull(item.documentId) as string, - recursive: Boolean(item.recursive), - })), + items: normalizedCopyTree.items, targetParentId, }, context, diff --git a/wolai-frontend/src/lib/documents/page-subtree.ts b/wolai-frontend/src/lib/documents/page-subtree.ts index 64159f5f..3bce2536 100644 --- a/wolai-frontend/src/lib/documents/page-subtree.ts +++ b/wolai-frontend/src/lib/documents/page-subtree.ts @@ -89,6 +89,7 @@ export type PageSubtreeProjection = { }; const SNIPPET_MAX_LENGTH = 220; +const PAGE_SUBTREE_PROJECTION_ID = "kernel_projection:page_tree:document_subtree"; const pickFirstText = (...values: unknown[]) => { for (const value of values) { @@ -367,7 +368,7 @@ export function buildPageSubtreeProjection(input: { } return { - projectionId: `page_subtree:${rootNodeId}`, + projectionId: PAGE_SUBTREE_PROJECTION_ID, projection: "page_tree", rootNodeId, rootNode, diff --git a/wolai-frontend/src/lib/documents/tree-command-client.ts b/wolai-frontend/src/lib/documents/tree-command-client.ts index 343e766b..a108283a 100644 --- a/wolai-frontend/src/lib/documents/tree-command-client.ts +++ b/wolai-frontend/src/lib/documents/tree-command-client.ts @@ -43,6 +43,33 @@ type MoveDocumentInput = { workspaceId?: string | null; }; +type DeleteDocumentInput = { + documentId: string; + workspaceId?: string | null; +}; + +type RestoreDocumentInput = { + documentId: string; + workspaceId?: string | null; +}; + +type PurgeDocumentInput = { + documentId: string; +}; + +type EmbedDocumentInput = { + sourceId: string; + targetId: string; +}; + +type CopyTreeCommandInput = { + targetParentId: string | null; + items: Array<{ + documentId: string; + recursive: boolean; + }>; +}; + type CreateChildDocumentInput = { parentId: string | null; title: string; @@ -106,3 +133,73 @@ export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ o "移动失败,请稍后再试", ); } + +export async function deleteDocumentCommand( + input: DeleteDocumentInput, +): Promise<{ success: true; meta?: DocumentCommandMeta }> { + return postDocumentCommand<{ success: true; meta?: DocumentCommandMeta }>( + "/api/documents/delete", + { + documentId: input.documentId, + workspaceId: input.workspaceId ?? null, + }, + "删除失败,请稍后再试", + ); +} + +export async function restoreDocumentCommand( + input: RestoreDocumentInput, +): Promise<{ success: true; meta?: DocumentCommandMeta }> { + return postDocumentCommand<{ success: true; meta?: DocumentCommandMeta }>( + "/api/documents/restore", + { + documentId: input.documentId, + workspaceId: input.workspaceId ?? null, + }, + "恢复失败,请稍后再试", + ); +} + +export async function purgeDocumentCommand( + input: PurgeDocumentInput, +): Promise<{ success: true; purged?: boolean; meta?: DocumentCommandMeta }> { + return postDocumentCommand<{ success: true; purged?: boolean; meta?: DocumentCommandMeta }>( + "/api/documents/purge", + { + documentId: input.documentId, + }, + "彻底删除失败,请稍后再试", + ); +} + +export async function embedDocumentCommand( + input: EmbedDocumentInput, +): Promise<{ ok: true; meta?: DocumentCommandMeta }> { + return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>( + "/api/documents/embed", + { + sourceId: input.sourceId, + targetId: input.targetId, + }, + "嵌入失败,请稍后再试", + ); +} + +export async function copyTreeCommand( + input: CopyTreeCommandInput, +): Promise<{ + items: Array<{ oldId: string; newId: string }>; + meta?: DocumentCommandMeta; +}> { + return postDocumentCommand<{ + items: Array<{ oldId: string; newId: string }>; + meta?: DocumentCommandMeta; + }>( + "/api/documents/copy-tree", + { + targetParentId: input.targetParentId, + items: input.items, + }, + "复制页面失败,请稍后再试", + ); +} diff --git a/wolai-frontend/src/lib/sidebar-data.test.ts b/wolai-frontend/src/lib/sidebar-data.test.ts index 557e62ff..3d46a2ac 100644 --- a/wolai-frontend/src/lib/sidebar-data.test.ts +++ b/wolai-frontend/src/lib/sidebar-data.test.ts @@ -318,4 +318,48 @@ describe("buildSidebarInitialData", () => { mediaAssets: [], }); }); + + it("缺少 kernel projection 时不再本地补真相,而是返回空契约 projection", () => { + expect( + mapSidebarDatasetListQueryResultToInitialData({ + active_workspace_id: "ws_1", + workspaces: [], + documents: [ + { + id: "doc_1", + workspace_id: "ws_1", + title: "页面 1", + parent_id: null, + sort_order: 1, + is_starred: false, + access_scope: "private", + is_template: false, + created_at: "2026-04-14T00:00:00Z", + updated_at: null, + }, + ], + trashed_documents: [], + media_assets: [], + trashed_media_assets: [], + mindmap_assets: [], + trashed_mindmap_assets: [], + table_assets: [], + trashed_table_assets: [], + mindmap_docs: [], + mindmap_asset_children: {}, + }), + ).toMatchObject({ + activeWorkspaceId: "ws_1", + kernelSidebarProjection: { + projectionId: "kernel_projection:sidebar_tree:missing", + items: [], + edges: [], + }, + kernelSidebarTree: [ + { + id: "doc_1", + }, + ], + }); + }); }); diff --git a/wolai-frontend/src/lib/sidebar-data.ts b/wolai-frontend/src/lib/sidebar-data.ts index 14735271..e083d57c 100644 --- a/wolai-frontend/src/lib/sidebar-data.ts +++ b/wolai-frontend/src/lib/sidebar-data.ts @@ -1,7 +1,7 @@ import type { SidebarInitialData } from "@/components/sidebar/types"; import type { DocumentRecord } from "@/lib/documents"; import { - buildKernelSidebarProjection, + buildKernelSidebarProjection as buildProjectionContract, buildSidebarTreeFromKernelProjection, type KernelSidebarProjection, } from "@/lib/kernel-sidebar"; @@ -64,6 +64,41 @@ export type SidebarDatasetListQueryResult = { mindmap_asset_children: Record; }; +const EMPTY_KERNEL_SIDEBAR_PROJECTION: KernelSidebarProjection = { + projectionId: "kernel_projection:sidebar_tree:missing", + projection: "sidebar_tree", + rootNodeId: null, + items: [], + edges: [], +}; + +function isKernelSidebarProjection(value: unknown): value is KernelSidebarProjection { + return ( + Boolean(value) && + typeof value === "object" && + Array.isArray((value as KernelSidebarProjection).items) && + Array.isArray((value as KernelSidebarProjection).edges) + ); +} + +function readKernelSidebarProjection( + result: SidebarDatasetListQueryResult, +): KernelSidebarProjection { + const snakeCaseProjection = + "kernel_sidebar_projection" in result ? result.kernel_sidebar_projection : undefined; + if (isKernelSidebarProjection(snakeCaseProjection)) { + return snakeCaseProjection; + } + + const camelCaseProjection = + "kernelSidebarProjection" in result ? result.kernelSidebarProjection : undefined; + if (isKernelSidebarProjection(camelCaseProjection)) { + return camelCaseProjection; + } + + return EMPTY_KERNEL_SIDEBAR_PROJECTION; +} + function normalizeStringArray(values: Iterable): string[] { return Array.from(new Set(values)).filter((value) => value.trim().length > 0); } @@ -220,7 +255,7 @@ export function buildSidebarDatasetListQueryResult( input: SidebarDatasetInput, ): SidebarDatasetListQueryResult { const derived = deriveSidebarDataset(input); - const kernelSidebarProjection = buildKernelSidebarProjection(input.documents); + const kernelSidebarProjection = buildProjectionContract(input.documents); return { active_workspace_id: input.activeWorkspaceId, @@ -242,20 +277,7 @@ export function buildSidebarDatasetListQueryResult( export function mapSidebarDatasetListQueryResultToInitialData( result: SidebarDatasetListQueryResult, ): SidebarInitialData { - // 说明:兼容旧的 Convex `sidebar.dataset.list` 返回体。 - // 若上游暂未附带 `kernel_sidebar_projection`,这里按 documents 现算一份, - // 避免 SSR 因契约未完全切齐而直接崩掉。 - const candidateProjection = - result.kernel_sidebar_projection ?? - result.kernelSidebarProjection ?? - null; - const kernelSidebarProjection = - candidateProjection && - typeof candidateProjection === "object" && - Array.isArray(candidateProjection.items) && - Array.isArray(candidateProjection.edges) - ? candidateProjection - : buildKernelSidebarProjection(result.documents ?? []); + const kernelSidebarProjection = readKernelSidebarProjection(result); return { activeWorkspaceId: result.active_workspace_id, diff --git a/wolai-frontend/src/lib/tree-stream/protocol.ts b/wolai-frontend/src/lib/tree-stream/protocol.ts new file mode 100644 index 00000000..c775e597 --- /dev/null +++ b/wolai-frontend/src/lib/tree-stream/protocol.ts @@ -0,0 +1,135 @@ +import type { SidebarInitialData } from "@/components/sidebar/types"; +import { + mapSidebarDatasetListQueryResultToInitialData, + type SidebarDatasetListQueryResult, +} from "@/lib/sidebar-data"; + +export type TreeStreamKind = "snapshot" | "delta" | "resync"; + +export interface TreeStreamEnvelope { + stream: string; + workspaceId: string | null; + rootNodeId: string | null; + cursor: string | null; + kind: TreeStreamKind; + projection: string | null; + data: unknown; +} + +const TREE_STREAM_KINDS = new Set(["snapshot", "delta", "resync"]); + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function readKind(value: unknown): TreeStreamKind | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toLowerCase(); + return TREE_STREAM_KINDS.has(normalized as TreeStreamKind) + ? (normalized as TreeStreamKind) + : null; +} + +function readNestedPayload(record: Record): unknown { + for (const key of ["data", "payload", "snapshot", "dataset", "sidebar"]) { + if (key in record) { + return record[key]; + } + } + return record; +} + +function looksLikeSidebarInitialData(value: unknown): value is SidebarInitialData { + return ( + isRecord(value) && + typeof value.activeWorkspaceId === "string" && + Array.isArray(value.documents) + ); +} + +function looksLikeSidebarDatasetListQueryResult( + value: unknown, +): value is SidebarDatasetListQueryResult { + return ( + isRecord(value) && + typeof value.active_workspace_id === "string" && + Array.isArray(value.documents) + ); +} + +export function buildWorkspaceTreeStreamUrl( + baseUrl: string, + workspaceId: string, + cursor?: string | null, +): string { + const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, ""); + const url = new URL("/api/stream/events", `${normalizedBaseUrl}/`); + url.searchParams.set("stream", "workspace"); + url.searchParams.set("projection", "sidebar_tree"); + url.searchParams.set("workspaceId", workspaceId.trim()); + if (typeof cursor === "string" && cursor.trim()) { + url.searchParams.set("cursor", cursor.trim()); + } + return url.toString(); +} + +export function parseTreeStreamMessage(input: { + rawData: string; + eventType?: string | null; +}): TreeStreamEnvelope | null { + let parsed: unknown; + try { + parsed = JSON.parse(input.rawData); + } catch { + return null; + } + + if (!isRecord(parsed)) { + return null; + } + + const kind = readKind(parsed.kind) ?? readKind(input.eventType) ?? readKind(parsed.event); + if (!kind) { + return null; + } + + return { + stream: readString(parsed.stream) ?? "workspace", + workspaceId: readString(parsed.workspaceId) ?? readString(parsed.workspace_id), + rootNodeId: readString(parsed.rootNodeId) ?? readString(parsed.root_node_id), + cursor: readString(parsed.cursor), + kind, + projection: readString(parsed.projection), + data: readNestedPayload(parsed), + }; +} + +export function normalizeTreeStreamSnapshot(data: unknown): SidebarInitialData | null { + if (looksLikeSidebarInitialData(data)) { + return data; + } + if (looksLikeSidebarDatasetListQueryResult(data)) { + return mapSidebarDatasetListQueryResultToInitialData(data); + } + if (!isRecord(data)) { + return null; + } + + for (const key of ["snapshot", "dataset", "sidebar", "payload", "data"]) { + if (!(key in data)) { + continue; + } + const nested = normalizeTreeStreamSnapshot(data[key]); + if (nested) { + return nested; + } + } + + return null; +} diff --git a/wolai-frontend/src/lib/tree-stream/tree-delta.test.ts b/wolai-frontend/src/lib/tree-stream/tree-delta.test.ts new file mode 100644 index 00000000..552a2ffa --- /dev/null +++ b/wolai-frontend/src/lib/tree-stream/tree-delta.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import type { SidebarInitialData } from "@/components/sidebar/types"; +import { applyTreeStreamDelta } from "./tree-delta"; + +const baseSidebarData: SidebarInitialData = { + activeWorkspaceId: "ws_1", + workspaces: [], + documents: [ + { + id: "root", + workspace_id: "ws_1", + title: "Root", + parent_id: null, + sort_order: 0, + is_starred: false, + access_scope: "private", + is_template: false, + created_at: "2026-04-18T00:00:00Z", + updated_at: null, + }, + { + id: "child", + workspace_id: "ws_1", + title: "Child", + parent_id: "root", + sort_order: 1, + is_starred: false, + access_scope: "private", + is_template: false, + created_at: "2026-04-18T00:00:00Z", + updated_at: null, + }, + ], + kernelSidebarProjection: { + projectionId: "kernel_projection:sidebar_tree:workspace_root", + projection: "sidebar_tree", + rootNodeId: null, + items: [ + { + nodeId: "root", + parentNodeId: null, + nodeType: "page", + title: "Root", + depth: 0, + position: 0, + childCount: 1, + expandedByDefault: true, + }, + { + nodeId: "child", + parentNodeId: "root", + nodeType: "page", + title: "Child", + depth: 1, + position: 1, + childCount: 0, + expandedByDefault: true, + }, + ], + edges: [ + { + id: "edge:root:child:parent_of", + edgeType: "parent_of", + workspaceId: "ws_1", + fromNodeId: "root", + toNodeId: "child", + }, + ], + }, + kernelSidebarTree: [], + trashedDocuments: [], + trashedMediaAssets: [], + trashedMindmapAssets: [], + trashedTableAssets: [], + tableAssets: [], + mindmapDocs: [], + mindmapAssets: [], + mindmapAssetChildren: {}, + mediaAssets: [], +}; + +describe("tree-stream/tree-delta", () => { + it("支持 upsert_document 重建 sidebar projection", () => { + const next = applyTreeStreamDelta(baseSidebarData, { + op: "upsert_document", + document: { + id: "leaf", + workspace_id: "ws_1", + title: "Leaf", + parent_id: "child", + sort_order: 0, + is_starred: false, + access_scope: "private", + is_template: false, + created_at: "2026-04-18T00:00:00Z", + updated_at: null, + }, + }); + + expect(next.documents.map((item) => item.id)).toEqual(["root", "child", "leaf"]); + expect(next.kernelSidebarProjection.items.map((item) => item.nodeId)).toEqual([ + "root", + "child", + "leaf", + ]); + }); + + it("支持 remove_document 级联移除子树", () => { + const next = applyTreeStreamDelta(baseSidebarData, { + op: "remove_document", + documentId: "root", + }); + + expect(next.documents).toEqual([]); + expect(next.kernelSidebarProjection.items).toEqual([]); + }); + + it("支持 replace_sidebar 直接切换到 resync snapshot", () => { + const next = applyTreeStreamDelta(baseSidebarData, { + op: "replace_sidebar", + sidebar: { + activeWorkspaceId: "ws_1", + workspaces: [], + documents: [], + kernelSidebarProjection: { + projectionId: "kernel_projection:sidebar_tree:workspace_root", + projection: "sidebar_tree", + rootNodeId: null, + items: [], + edges: [], + }, + kernelSidebarTree: [], + trashedDocuments: [], + trashedMediaAssets: [], + trashedMindmapAssets: [], + trashedTableAssets: [], + tableAssets: [], + mindmapDocs: [], + mindmapAssets: [], + mindmapAssetChildren: {}, + mediaAssets: [], + }, + }); + + expect(next.documents).toEqual([]); + expect(next.kernelSidebarProjection.items).toEqual([]); + }); +}); diff --git a/wolai-frontend/src/lib/tree-stream/tree-delta.ts b/wolai-frontend/src/lib/tree-stream/tree-delta.ts new file mode 100644 index 00000000..a1b2eda8 --- /dev/null +++ b/wolai-frontend/src/lib/tree-stream/tree-delta.ts @@ -0,0 +1,155 @@ +import type { SidebarInitialData } from "@/components/sidebar/types"; +import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data"; +import { + buildSidebarDatasetListQueryResult, + mapSidebarDatasetListQueryResultToInitialData, +} from "@/lib/sidebar-data"; +import type { DocumentRecord } from "@/lib/documents"; +import type { WorkspaceSummary } from "@/lib/workspaces"; +import type { MediaAsset } from "@/types/media"; + +export type TreeStreamDeltaOp = + | "upsert_document" + | "remove_document" + | "replace_documents" + | "replace_sidebar"; + +export type TreeStreamDeltaEvent = { + op: TreeStreamDeltaOp; + node?: DocumentRecord | null; + document?: DocumentRecord | null; + documentId?: string | null; + documents?: DocumentRecord[] | null; + sidebar?: SidebarDatasetListQueryResult | SidebarInitialData | null; +}; + +function cloneSidebarData(data: SidebarInitialData): SidebarInitialData { + return { + ...data, + workspaces: [...data.workspaces], + documents: [...data.documents], + kernelSidebarProjection: { + ...data.kernelSidebarProjection, + items: [...data.kernelSidebarProjection.items], + edges: [...data.kernelSidebarProjection.edges], + }, + kernelSidebarTree: [...data.kernelSidebarTree], + trashedDocuments: [...data.trashedDocuments], + trashedMediaAssets: [...(data.trashedMediaAssets ?? [])], + trashedMindmapAssets: [...(data.trashedMindmapAssets ?? [])], + trashedTableAssets: [...(data.trashedTableAssets ?? [])], + mindmapDocs: [...(data.mindmapDocs ?? [])], + mindmapAssets: [...(data.mindmapAssets ?? [])], + mindmapAssetChildren: { ...(data.mindmapAssetChildren ?? {}) }, + tableAssets: [...(data.tableAssets ?? [])], + mediaAssets: [...(data.mediaAssets ?? [])], + }; +} + +function buildSidebarFromDocuments(input: { + base: SidebarInitialData; + documents: DocumentRecord[]; +}): SidebarInitialData { + const queryResult = buildSidebarDatasetListQueryResult({ + activeWorkspaceId: input.base.activeWorkspaceId, + workspaces: input.base.workspaces as WorkspaceSummary[], + documents: input.documents, + trashedDocuments: input.base.trashedDocuments, + mindmaps: (input.base.mindmapAssets ?? []).map((asset) => ({ + mindmap_id: asset.id, + workspace_id: asset.workspace_id, + document_id: asset.document_id, + created_at: asset.created_at, + updated_at: asset.updated_at, + deleted_at: asset.deleted_at ?? null, + deleted_by: asset.deleted_by ?? null, + })), + mediaAssets: input.base.mediaAssets as MediaAsset[] | null, + trashedMediaAssets: input.base.trashedMediaAssets as MediaAsset[] | null, + tables: (input.base.tableAssets ?? []).map((asset) => ({ + id: asset.id, + workspace_id: asset.workspace_id, + document_id: asset.document_id, + title: asset.file_name ?? null, + created_at: asset.created_at, + updated_at: asset.updated_at, + deleted_at: asset.deleted_at ?? null, + deleted_by: asset.deleted_by ?? null, + purged_at: asset.purged_at ?? null, + is_archived: Boolean(asset.deleted_at), + })), + }); + + return mapSidebarDatasetListQueryResultToInitialData(queryResult); +} + +function normalizeUpsertDocument(event: TreeStreamDeltaEvent): DocumentRecord | null { + const candidate = event.node ?? event.document ?? null; + return candidate && typeof candidate === "object" ? candidate : null; +} + +function normalizeDocumentId(event: TreeStreamDeltaEvent): string | null { + const candidate = typeof event.documentId === "string" ? event.documentId.trim() : ""; + return candidate || null; +} + +export function applyTreeStreamDelta( + base: SidebarInitialData, + event: TreeStreamDeltaEvent, +): SidebarInitialData { + if (event.op === "replace_sidebar" && event.sidebar) { + if ("activeWorkspaceId" in event.sidebar) { + return cloneSidebarData(event.sidebar as SidebarInitialData); + } + return mapSidebarDatasetListQueryResultToInitialData(event.sidebar as SidebarDatasetListQueryResult); + } + + if (event.op === "replace_documents" && Array.isArray(event.documents)) { + return buildSidebarFromDocuments({ + base, + documents: [...event.documents], + }); + } + + if (event.op === "upsert_document") { + const nextDocument = normalizeUpsertDocument(event); + if (!nextDocument) { + return base; + } + const nextDocuments = [...base.documents]; + const existingIndex = nextDocuments.findIndex((item) => item.id === nextDocument.id); + if (existingIndex >= 0) { + nextDocuments[existingIndex] = nextDocument; + } else { + nextDocuments.push(nextDocument); + } + return buildSidebarFromDocuments({ + base, + documents: nextDocuments, + }); + } + + if (event.op === "remove_document") { + const documentId = normalizeDocumentId(event); + if (!documentId) { + return base; + } + const removedIds = new Set([documentId]); + let changed = true; + while (changed) { + changed = false; + for (const document of base.documents) { + if (document.parent_id && removedIds.has(document.parent_id) && !removedIds.has(document.id)) { + removedIds.add(document.id); + changed = true; + } + } + } + return buildSidebarFromDocuments({ + base, + documents: base.documents.filter((item) => !removedIds.has(item.id)), + }); + } + + return base; +} diff --git a/wolai-frontend/src/lib/tree-stream/tree-stream.test.ts b/wolai-frontend/src/lib/tree-stream/tree-stream.test.ts new file mode 100644 index 00000000..1a789e03 --- /dev/null +++ b/wolai-frontend/src/lib/tree-stream/tree-stream.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { + buildWorkspaceTreeStreamUrl, + normalizeTreeStreamSnapshot, + parseTreeStreamMessage, +} from "./protocol"; + +describe("tree-stream/protocol", () => { + it("构造 workspace sidebar stream url", () => { + expect( + buildWorkspaceTreeStreamUrl("http://127.0.0.1:3104/", " ws_1 ", "evt_9"), + ).toBe( + "http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1&cursor=evt_9", + ); + }); + + it("解析 snapshot / delta / resync 协议消息", () => { + expect( + parseTreeStreamMessage({ + eventType: "snapshot", + rawData: JSON.stringify({ + stream: "workspace", + workspaceId: "ws_1", + cursor: "evt_1", + projection: "sidebar_tree", + data: { + active_workspace_id: "ws_1", + workspaces: [], + documents: [], + kernel_sidebar_projection: { + projectionId: "kernel_projection:sidebar_tree:workspace_root", + projection: "sidebar_tree", + rootNodeId: null, + items: [], + edges: [], + }, + trashed_documents: [], + media_assets: [], + trashed_media_assets: [], + mindmap_assets: [], + trashed_mindmap_assets: [], + table_assets: [], + trashed_table_assets: [], + mindmap_docs: [], + mindmap_asset_children: {}, + }, + }), + }), + ).toMatchObject({ + kind: "snapshot", + stream: "workspace", + workspaceId: "ws_1", + cursor: "evt_1", + projection: "sidebar_tree", + }); + + expect( + parseTreeStreamMessage({ + eventType: "delta", + rawData: JSON.stringify({ + kind: "delta", + workspace_id: "ws_1", + cursor: "evt_2", + payload: { op: "remove_document", documentId: "doc_1" }, + }), + }), + ).toMatchObject({ + kind: "delta", + workspaceId: "ws_1", + cursor: "evt_2", + data: { op: "remove_document", documentId: "doc_1" }, + }); + }); + + it("把 sidebar.dataset.list snapshot 归一化成 SidebarInitialData", () => { + const snapshot = normalizeTreeStreamSnapshot({ + active_workspace_id: "ws_1", + workspaces: [], + documents: [], + kernel_sidebar_projection: { + projectionId: "kernel_projection:sidebar_tree:workspace_root", + projection: "sidebar_tree", + rootNodeId: null, + items: [], + edges: [], + }, + trashed_documents: [], + media_assets: [], + trashed_media_assets: [], + mindmap_assets: [], + trashed_mindmap_assets: [], + table_assets: [], + trashed_table_assets: [], + mindmap_docs: [], + mindmap_asset_children: {}, + }); + + expect(snapshot).toMatchObject({ + activeWorkspaceId: "ws_1", + kernelSidebarProjection: { + projection: "sidebar_tree", + }, + }); + }); +}); diff --git a/wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.ts b/wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.ts new file mode 100644 index 00000000..a2b1c29b --- /dev/null +++ b/wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.ts @@ -0,0 +1,146 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import type { SidebarInitialData } from "@/components/sidebar/types"; +import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; +import { + buildWorkspaceTreeStreamUrl, + normalizeTreeStreamSnapshot, + parseTreeStreamMessage, +} from "@/lib/tree-stream/protocol"; +import { applyTreeStreamDelta, type TreeStreamDeltaEvent } from "@/lib/tree-stream/tree-delta"; + +export interface SidebarTreeStreamState { + data: SidebarInitialData | null; + status: "idle" | "connecting" | "live" | "fallback"; + cursor: string | null; + error: Error | null; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizeDeltaEvent(input: unknown): TreeStreamDeltaEvent | null { + if (!isRecord(input) || typeof input.op !== "string") { + return null; + } + return { + op: input.op as TreeStreamDeltaEvent["op"], + node: isRecord(input.node) ? (input.node as TreeStreamDeltaEvent["node"]) : null, + document: isRecord(input.document) ? (input.document as TreeStreamDeltaEvent["document"]) : null, + documentId: typeof input.documentId === "string" ? input.documentId : null, + documents: Array.isArray(input.documents) ? (input.documents as TreeStreamDeltaEvent["documents"]) : null, + sidebar: isRecord(input.sidebar) ? input.sidebar : null, + }; +} + +export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTreeStreamState { + const runtime = useMemo(() => getMnoteRuntimeConfig(), []); + const workspaceId = initialData.activeWorkspaceId; + const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, ""); + const streamEnabled = Boolean(baseUrl && workspaceId); + + const [state, setState] = useState({ + data: null, + status: streamEnabled ? "connecting" : "idle", + cursor: null, + error: null, + }); + const eventSourceRef = useRef(null); + + useEffect(() => { + if (!streamEnabled) { + setState({ + data: null, + status: "idle", + cursor: null, + error: null, + }); + return undefined; + } + + const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, state.cursor); + const eventSource = new EventSource(url, { withCredentials: true }); + eventSourceRef.current = eventSource; + + const handleMessage = (event: MessageEvent) => { + const envelope = parseTreeStreamMessage({ + rawData: event.data, + eventType: event.type, + }); + if (!envelope) { + return; + } + + setState((previous) => { + const nextCursor = envelope.cursor ?? previous.cursor; + + if (envelope.kind === "snapshot" || envelope.kind === "resync") { + const snapshot = normalizeTreeStreamSnapshot(envelope.data); + if (!snapshot) { + return { + ...previous, + cursor: nextCursor, + }; + } + return { + data: snapshot, + status: "live", + cursor: nextCursor, + error: null, + }; + } + + if (envelope.kind === "delta" && previous.data) { + const deltaEvent = normalizeDeltaEvent(envelope.data); + if (!deltaEvent) { + return { + ...previous, + cursor: nextCursor, + }; + } + return { + data: applyTreeStreamDelta(previous.data, deltaEvent), + status: "live", + cursor: nextCursor, + error: null, + }; + } + + return { + ...previous, + cursor: nextCursor, + }; + }); + }; + + const handleError = () => { + setState((previous) => ({ + ...previous, + status: previous.data ? "live" : "fallback", + error: previous.error ?? new Error("tree stream 连接失败"), + })); + eventSource.close(); + eventSourceRef.current = null; + }; + + eventSource.addEventListener("snapshot", handleMessage as EventListener); + eventSource.addEventListener("delta", handleMessage as EventListener); + eventSource.addEventListener("resync", handleMessage as EventListener); + eventSource.onmessage = handleMessage; + eventSource.onerror = handleError; + + return () => { + eventSource.removeEventListener("snapshot", handleMessage as EventListener); + eventSource.removeEventListener("delta", handleMessage as EventListener); + eventSource.removeEventListener("resync", handleMessage as EventListener); + eventSource.close(); + if (eventSourceRef.current === eventSource) { + eventSourceRef.current = null; + } + }; + }, [baseUrl, state.cursor, streamEnabled, workspaceId]); + + return state; +} diff --git a/wolai-frontend/vitest.config.ts b/wolai-frontend/vitest.config.ts index 59f151e9..a330b340 100644 --- a/wolai-frontend/vitest.config.ts +++ b/wolai-frontend/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ resolve: { alias: { "@": resolve(dirname(fileURLToPath(import.meta.url)), "./src"), + "server-only": resolve(dirname(fileURLToPath(import.meta.url)), "./test/server-only.ts"), }, }, test: {