feat: 收口 tree-first graph 主链与前端测试修复

This commit is contained in:
lix-2026
2026-04-18 05:43:49 +08:00
parent d8de820d93
commit d3876e56eb
33 changed files with 2421 additions and 345 deletions
+46 -27
View File
@@ -1,48 +1,59 @@
# 仓库协作指南(AGENTS # 仓库协作指南(AGENTS
## 当前主线事实 ## 当前主线
- 当前仓库已经做过精简,旧设计稿已移动到 `/mnt/Data1T/mnote/recycle/design/` - 当前长期方向以 `tree-first graph kernel` 为准,不以 `BlockNote-first``Mindmap-first` 为准
- 当前主前端在 `/mnt/Data1T/mnote/wolai-frontend/`,这是默认优先修改的位置 - `Convex` 继续保留为本地自托管的存储、实时、文件协作底座,不因为推进 Rust 主线而先拆掉
- 当前辅助后端在 `/mnt/Data1T/mnote/wolai-backend/` - `Rust kernel` 持有树、子树、边、projection、query、command 的语义主导权;新增树规则不要继续散落到前端、Next route 或临时 compat 层
- 当前 Convex 自托管配置在 `/mnt/Data1T/mnote/infra/convex/` - `mnote-web` 是当前 Rust Web 承载层,负责 transport、projection 分发、兼容切流;`compat``fixture` 只用于过渡和测试,不应继续承载长期业务语义
- 根目录历史 `/mnt/Data1T/mnote/src/` 已只保留 `/mnt/Data1T/mnote/src/components/onlyoffice/`,这里仍然属于运行相关资源,不要误删 - 前端主路径应消费稳定 projection,不应在 UI 层重新拼出第二份对象真相
- `/mnt/Data1T/mnote/ARCHITECTURE.md` 是当前仓库结构与嵌入关系的最新说明,涉及 BlockNote、Mindmap、OnlyOffice 时优先参考它。
## 目录约定 ## 组件定位
- `BlockNote` 是当前文档页编辑器内核,但不是系统事实源。
- `Mindmap``tree-first graph` 的一种视图和编辑挂件,不是对象真相层。
- `OnlyOffice` 是独立页面型编辑器,不直接嵌入 `BlockNote` 画布;正文中通常通过附件块跳转进入。
## 目录优先级
- `/mnt/Data1T/mnote/wolai-frontend/src/` - `/mnt/Data1T/mnote/wolai-frontend/src/`
当前前端主源码目录 页面、编辑器、Sidebar、树视图、阅读态、交互壳默认先看这里
- `/mnt/Data1T/mnote/wolai-frontend/convex/` - `/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/wolai-backend/app/`
当前后端主源码目录 辅助后端、异步处理、接口配合先看这里
- `/mnt/Data1T/mnote/infra/convex/`
Convex 自托管部署与本地基础设施先看这里。
- `/mnt/Data1T/mnote/src/components/onlyoffice/` - `/mnt/Data1T/mnote/src/components/onlyoffice/`
当前保留的 OnlyOffice 静态资源、插件与相关数据目录 只在处理 OnlyOffice 静态资源、插件或兼容问题时进入
- `/mnt/Data1T/mnote/recycle/` - `/mnt/Data1T/mnote/recycle/`
已废弃或已下线内容的回收区,默认不要基于这里继续扩展实现 默认视为历史回收区,不作为当前实现依据,除非任务明确要求
## 开发优先级 ## 架构约束
1. 涉及页面、编辑器、块系统、思维导图、OnlyOffice 页面时,优先检查 `/mnt/Data1T/mnote/wolai-frontend/src/` - 涉及树、页面结构、文件树、Sidebar、阅读投影、搜索投影、引用边语义时,优先判断是否应落到 Rust kernel,而不是直接改前端拼装逻辑
2. 涉及数据模型、查询、动作、任务编排时,优先检查 `/mnt/Data1T/mnote/wolai-frontend/convex/` - 前端可以做展示、交互和局部适配,但不要新增第二套树真相、排序真相或 projection 契约
3. 涉及辅助服务、接口配合、异步处理时,检查 `/mnt/Data1T/mnote/wolai-backend/app/` - `mnote-web``compat route` 可以承接过渡流量,但不要把新的长期业务逻辑继续堆进 compat
4. 只有在处理 OnlyOffice 静态资源、插件或兼容问题时,才进入根目录 `/mnt/Data1T/mnote/src/components/onlyoffice/` - 需要架构判断时,优先参考:
- `/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`
- `BlockNote` 是当前文档页主编辑器内核。 - `/mnt/Data1T/mnote/design/tree-first-graph-convex-rust-long-term-architecture-v1.md`
- `Mindmap` 是 BlockNote 的自定义 block,同时支持内嵌和独立全屏页,两者共用同一核心组件。
- `OnlyOffice` 是独立页面型编辑器,不直接嵌入 BlockNote 编辑画布;正文中通常通过附件块跳转进入。
## 协作边界 ## 协作边界
- 仅修改与当前任务直接相关的文件。 - 仅修改与当前任务直接相关的文件。
- 不要擅自恢复、覆盖、删除用户已有改动。 - 不要擅自恢复、覆盖、删除用户已有改动。
- 看到 `/mnt/Data1T/mnote/recycle/` 中的内容时,默认视为历史资料,不作为当前实现依据,除非任务明确要求 - 若发现与当前任务无关的脏改动,保持不动;若怀疑会影响当前任务,先确认再处理
-任务涉及架构判断,优先以当前代码与 `/mnt/Data1T/mnote/ARCHITECTURE.md` 为准,不要依赖旧文档记忆 -当前问题只是 UI 表现异常,先确认是否是实验性 tree shell、compat 路径、轮询或 fallback 混入首屏主链,而不是直接怀疑 Convex 本身
## 常用开发命令 ## 常用命令
- 根目录热启动:`npm run desktop:hot` - 根目录热启动:`npm run desktop:hot`
- 前端开发:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm dev` - 前端开发:`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` - 后端开发:`cd /mnt/Data1T/mnote/wolai-backend && uvicorn app.main:app --reload --port 8000`
- Convex 自托管:参考 `/mnt/Data1T/mnote/infra/convex/README.md` - 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。 - 所有新增或修改文件统一使用 UTF-8。
+43
View File
@@ -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] 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] 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-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 显式门控与首页 smoke3104 不可达时 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 idsidebar/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
+386 -1
View File
@@ -191,8 +191,393 @@
} }
], ],
"completed_at": "2026-04-17T08:56:30Z" "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 显式门控与首页 smoke3104 不可达时 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 idsidebar/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" "last_session": "2026-04-17T08:56:30Z"
} }
+122
View File
@@ -488,6 +488,19 @@ struct DocumentSaveCommandPayload {
conflict_detection_key: Option<String>, conflict_detection_key: Option<String>,
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DocumentEmbedCommandPayload {
document_id: String,
workspace_id: Option<String>,
revision: Option<u64>,
content: Value,
conflict_detection_key: Option<String>,
source_document_id: String,
target_document_id: String,
anchor_block_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct KernelCreateNodeCommandPayload { 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" => { "mindmaps.put" => {
let payload: MindmapPutCommandPayload = parse_payload(command_wire.payload.clone())?; let payload: MindmapPutCommandPayload = parse_payload(command_wire.payload.clone())?;
let command = CommandEnvelope { 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] #[test]
fn mindmap_get_tool_plan_uses_mindmaps_get_query() { fn mindmap_get_tool_plan_uses_mindmaps_get_query() {
let plan = execute_runtime_input(RuntimeInput::Tool { let plan = execute_runtime_input(RuntimeInput::Tool {
+15 -28
View File
@@ -1,15 +1,15 @@
use crate::app::AppState; use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::query_support::{ use crate::routes::query_support::resolve_effective_workspace_id;
execute_runtime_query_against_data, fetch_query_data_via_convex, resolve_effective_workspace_id, use crate::routes::snapshot_support::{
load_projection_snapshot, ProjectionSnapshotSpec,
}; };
use axum::extract::Query; use axum::extract::Query;
use axum::extract::{Extension, State}; use axum::extract::{Extension, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json; use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::KernelProjectionKind;
use core_protocol::{KernelNodeType, KernelProjectionKind};
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use serde_json::{json, Value}; 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)? resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在"); .expect("workspace_required 已确保存在");
let dataset = fetch_query_data_via_convex( let snapshot = load_projection_snapshot(
state.config(), state.config(),
&context, &context,
Some(&effective_workspace_id), &ProjectionSnapshotSpec {
RuntimeQueryEnvelopeWire { workspace_id: &effective_workspace_id,
name: "sidebar.dataset.list".into(), root_node_id: None,
payload: json!({ depth: None,
"workspaceId": effective_workspace_id, projection: KernelProjectionKind::SidebarTree,
}),
}, },
) )
.await?; .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 返回值不是对象") WebError::bad_gateway_code("convex_bad_response", "sidebar.dataset.list 返回值不是对象")
.with_context(&context) .with_context(&context)
.with_header("x-error-phase", "compat_sidebar_shape") .with_header("x-error-phase", "compat_sidebar_shape")
.with_header("x-upstream-service", "convex") .with_header("x-upstream-service", "convex")
})?; })?;
dataset_object.insert("kernel_sidebar_projection".into(), projection); dataset_object.insert(
"kernel_sidebar_projection".into(),
snapshot.projection,
);
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
+18 -62
View File
@@ -1,14 +1,18 @@
use crate::app::{AppConfig, AppState}; use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::query_support::{ 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::extract::{Extension, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json; use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::{KernelGraphDirection, KernelNodeType, KernelProjectionKind}; use core_protocol::{KernelGraphDirection, KernelProjectionKind};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -43,38 +47,6 @@ pub struct KernelGraphQuery {
pub max_depth: Option<u32>, pub max_depth: Option<u32>,
} }
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<Value, WebError> {
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<Value, WebError> {
execute_runtime_query_against_data(context, Some(workspace_id), query, dataset)
}
fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) { fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) {
( (
StatusCode::OK, StatusCode::OK,
@@ -95,26 +67,19 @@ pub async fn project_sidebar(
let effective_workspace_id = let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在"); .expect("workspace_required 已确保存在");
let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?; let snapshot = load_projection_snapshot(
let result = execute_kernel_query( state.config(),
&context, &context,
&effective_workspace_id, &ProjectionSnapshotSpec {
RuntimeQueryEnvelopeWire { workspace_id: &effective_workspace_id,
name: "kernel.project_view".into(), root_node_id: query.root_node_id.as_deref(),
payload: json!({ depth: query.depth,
"projection": KernelProjectionKind::SidebarTree, projection: KernelProjectionKind::SidebarTree,
"workspaceId": effective_workspace_id,
"rootNodeId": query.root_node_id,
"depth": query.depth,
"includeEdges": true,
"includeContent": false,
"nodeTypes": [KernelNodeType::Page],
}),
}, },
dataset, )
)?; .await?;
Ok(ok_response(&context, result)) Ok(ok_response(&context, snapshot.projection))
} }
pub async fn subtree( pub async fn subtree(
@@ -129,16 +94,7 @@ pub async fn subtree(
let result = execute_kernel_query( let result = execute_kernel_query(
&context, &context,
&effective_workspace_id, &effective_workspace_id,
RuntimeQueryEnvelopeWire { subtree_query(&effective_workspace_id, &query.root_node_id, query.depth),
name: "kernel.subtree.get".into(),
payload: json!({
"workspaceId": effective_workspace_id,
"rootNodeId": query.root_node_id,
"depth": query.depth,
"includeEdges": true,
"nodeTypes": [KernelNodeType::Page],
}),
},
dataset, dataset,
)?; )?;
Ok(ok_response(&context, result)) Ok(ok_response(&context, result))
+2
View File
@@ -5,7 +5,9 @@ mod health;
mod hermes; mod hermes;
mod kernel; mod kernel;
mod query_support; mod query_support;
mod snapshot_support;
mod sse; mod sse;
mod stream_support;
mod tree; mod tree;
mod ws; mod ws;
@@ -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<u32>,
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<Value, WebError> {
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<u32>,
) -> 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<Value, WebError> {
execute_runtime_query_against_data(context, Some(workspace_id), query, dataset)
}
pub async fn load_projection_snapshot(
config: &AppConfig,
context: &RequestContext,
spec: &ProjectionSnapshotSpec<'_>,
) -> Result<ProjectionSnapshot, WebError> {
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,
})
}
+67 -20
View File
@@ -1,33 +1,80 @@
use crate::app::AppState;
use crate::context::RequestContext; 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 axum::response::sse::{Event, KeepAlive, Sse};
use futures_util::stream; use futures_util::stream;
use serde_json::json; use serde_json::Value;
use std::convert::Infallible; use std::convert::Infallible;
use std::time::Duration; use std::time::Duration;
pub async fn events( pub async fn events(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
) -> Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>> { Query(query): Query<StreamSnapshotQuery>,
let payload = json!({ ) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, WebError> {
"kind": "sse_placeholder", let payload = load_stream_snapshot(state.config(), &context, &query).await?;
"requestId": context.trace.request_id, let event = snapshot_event(&payload);
"traceId": context.trace.trace_id,
"workspaceId": context.workspace.workspace_id,
"notes": [
"当前为 task-062 最小骨架,后续在此对齐统一流式输出协议。",
"此路由预留给 Hermes token/tool/client event 回流。"
]
});
let event = Event::default() Ok(Sse::new(stream::iter(vec![Ok(event)])).keep_alive(
.event("ready")
.json_data(payload)
.expect("SSE 占位事件必须可序列化");
Sse::new(stream::iter(vec![Ok(event)])).keep_alive(
KeepAlive::new() KeepAlive::new()
.interval(Duration::from_secs(15)) .interval(Duration::from_secs(15))
.text("keepalive"), .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\""));
}
} }
@@ -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<String>,
pub root_node_id: Option<String>,
pub depth: Option<u32>,
pub cursor: Option<String>,
pub limit: Option<u32>,
}
#[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<Value, WebError> {
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
);
}
}
+17 -51
View File
@@ -6,14 +6,16 @@ use crate::routes::command_support::{
read_optional_non_empty, read_optional_non_empty,
}; };
use crate::routes::query_support::{ 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::extract::{Extension, Query, State};
use axum::http::{header, HeaderValue, StatusCode}; use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response}; use axum::response::{Html, IntoResponse, Response};
use axum::Json; use axum::Json;
use bridge_runtime::RuntimeCommandEnvelopeWire; use bridge_runtime::RuntimeCommandEnvelopeWire;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::KernelProjectionKind; use core_protocol::KernelProjectionKind;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; 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<u32>,
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 { fn escape_html(input: &str) -> String {
input input
.replace('&', "&amp;") .replace('&', "&amp;")
@@ -1918,24 +1887,21 @@ pub async fn tree_shell(
let mode = normalize_tree_mode(query.mode.as_deref()); let mode = normalize_tree_mode(query.mode.as_deref());
let allow_root_pick = normalize_bool_flag(query.allow_root_pick.as_deref(), false); 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 exclude_ids = parse_exclude_ids(query.exclude_ids.as_deref());
let dataset = fetch_query_data_via_convex( let snapshot = load_projection_snapshot(
state.config(), state.config(),
&effective_context, &effective_context,
Some(&effective_workspace_id), &ProjectionSnapshotSpec {
sidebar_dataset_query(&effective_workspace_id), 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?; .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( let html = build_tree_shell_html(
&effective_workspace_id, &effective_workspace_id,
query.root_node_id.as_deref(), query.root_node_id.as_deref(),
@@ -1943,11 +1909,11 @@ pub async fn tree_shell(
&normalize_channel(query.channel), &normalize_channel(query.channel),
query.host.as_deref(), query.host.as_deref(),
&effective_context, &effective_context,
&projection, &snapshot.projection,
mode, mode,
allow_root_pick, allow_root_pick,
&exclude_ids, &exclude_ids,
&dataset, &snapshot.dataset,
); );
let mut response = Html(html).into_response(); let mut response = Html(html).into_response();
response response
+116 -27
View File
@@ -1,30 +1,36 @@
use crate::app::AppState;
use crate::context::RequestContext; 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::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::Extension; use axum::extract::{Extension, Query, State};
use axum::response::Response; use axum::response::Response;
use futures_util::StreamExt; use futures_util::StreamExt;
use serde_json::json; use serde_json::{json, Value};
pub async fn socket( pub async fn socket(
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
) -> Response { Query(query): Query<StreamSnapshotQuery>,
ws.on_upgrade(move |socket| handle_socket(socket, context)) ) -> Result<Response, WebError> {
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) { async fn handle_socket(
let payload = json!({ mut socket: WebSocket,
"kind": "ws_placeholder", state: AppState,
"requestId": context.trace.request_id, context: RequestContext,
"traceId": context.trace.trace_id, query: StreamSnapshotQuery,
"workspaceId": context.workspace.workspace_id, payload: Value,
"notes": [ ) {
"当前为 task-062 最小骨架,后续可承接协作推送和运行态事件。"
]
});
let _ = socket let _ = socket
.send(Message::Text(payload.to_string().into())) .send(serialize_snapshot_message(&payload))
.await; .await;
while let Some(message) = socket.next().await { while let Some(message) = socket.next().await {
@@ -34,17 +40,41 @@ async fn handle_socket(mut socket: WebSocket, context: RequestContext) {
match message { match message {
Message::Text(text) => { Message::Text(text) => {
let echo = json!({ if is_resync_request(&text) {
"kind": "ws_echo", match load_stream_snapshot(state.config(), &context, &query).await {
"traceId": context.trace.trace_id, Ok(snapshot) => {
"text": text.to_string(), if socket.send(serialize_resync_message(&snapshot)).await.is_err() {
}); break;
if socket }
.send(Message::Text(echo.to_string().into())) }
.await Err(error) => {
.is_err() let payload = json!({
{ "kind": "error",
break; "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, 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::<Value>(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\""));
}
}
@@ -237,6 +237,46 @@ mod tests {
assert!(request.payload_json.contains("\"name\":\"documents.save\"")); 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] #[test]
fn page_lifecycle_commands_map_to_document_mutations() { fn page_lifecycle_commands_map_to_document_mutations() {
let base_actor = ActorPayload { let base_actor = ActorPayload {
@@ -43,6 +43,7 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
"documents.template" => "documents:setTemplate", "documents.template" => "documents:setTemplate",
"documents.emptyTrashByWorkspace" => "documents:emptyTrashByWorkspace", "documents.emptyTrashByWorkspace" => "documents:emptyTrashByWorkspace",
"documents.purge" => "documents:purge", "documents.purge" => "documents:purge",
"documents.embed" => "documents:updateContent",
"blocks.patch" => "documents:updateContent", "blocks.patch" => "documents:updateContent",
"documents.save" => "documents:updateContent", "documents.save" => "documents:updateContent",
"documents.title.update" => "documents:updateTitle", "documents.title.update" => "documents:updateTitle",
@@ -36,7 +36,7 @@ describe("AiAgentPanel", () => {
}); });
expect(container.textContent).toContain("全局 AI"); expect(container.textContent).toContain("全局 AI");
expect(container.textContent).toContain("自动工具编排"); expect(container.textContent).toContain("前端已经退为桥接层");
expect(container.textContent).toContain("联网检索"); expect(container.textContent).toContain("联网检索");
expect(container.textContent).toContain("LightRAG"); expect(container.textContent).toContain("LightRAG");
expect(container.textContent).toContain("跨页面文档"); expect(container.textContent).toContain("跨页面文档");
@@ -50,12 +50,13 @@ describe("AiAgentPanel", () => {
const toggleButton = container.querySelector('button[aria-label="切换工具活动面板"]'); const toggleButton = container.querySelector('button[aria-label="切换工具活动面板"]');
expect(toggleButton).not.toBeNull(); expect(toggleButton).not.toBeNull();
expect(container.textContent).toContain("本轮活动"); expect(container.textContent).toContain("活动轨迹");
expect(container.textContent).toContain("暂无工具活动");
act(() => { act(() => {
toggleButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); toggleButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
}); });
expect(container.textContent).not.toContain("本轮活动"); expect(container.textContent).not.toContain("暂无工具活动");
}); });
}); });
@@ -21,7 +21,12 @@ import { Button } from "@/components/ui/button";
import { DocumentToc } from "@/components/editor/document-toc"; import { DocumentToc } from "@/components/editor/document-toc";
import { DocumentReadView } from "@/components/editor/document-read-view"; import { DocumentReadView } from "@/components/editor/document-read-view";
import { buildPageSubtreeProjection, extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree"; 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( const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor), () => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
@@ -602,19 +607,16 @@ export function DocumentContent({
if (readOnly) return; if (readOnly) return;
const ok = window.confirm("确定删除该页面吗?删除后会进入垃圾桶。"); const ok = window.confirm("确定删除该页面吗?删除后会进入垃圾桶。");
if (!ok) return; if (!ok) return;
const resp = await fetch("/api/documents/delete", { try {
method: "POST", await deleteDocumentCommand({ documentId, workspaceId });
headers: { "Content-Type": "application/json" }, } catch (error) {
body: JSON.stringify({ documentId }), const payload = error instanceof Error ? error.message : null;
}); window.alert(payload ?? "删除失败");
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除失败");
return; return;
} }
router.push("/"); router.push("/");
router.refresh(); router.refresh();
}, [documentId, readOnly, router]); }, [documentId, readOnly, router, workspaceId]);
const handleOpenMoveEmbed = useCallback(() => { const handleOpenMoveEmbed = useCallback(() => {
if (readOnly) return; if (readOnly) return;
@@ -642,16 +644,22 @@ export function DocumentContent({
return; return;
} }
const resp = await fetch("/api/documents/embed", { if (!targetId) {
method: "POST", window.alert("请选择目标页面");
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceId: documentId, targetId }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "嵌入失败");
return; return;
} }
try {
await embedDocumentCommand({
sourceId: documentId,
targetId,
});
} catch (error) {
const message = error instanceof Error ? error.message : "嵌入失败";
window.alert(message);
return;
}
window.alert("已嵌入到目标页面"); window.alert("已嵌入到目标页面");
}, },
}); });
@@ -19,7 +19,7 @@ import { deleteOnlineTable } from "@/lib/online-table";
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events"; import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker"; import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
import { useCommentsUiStore } from "@/store/comments-ui"; 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 InlineNode = { text?: unknown };
type TableMenuBlock = Parameters< type TableMenuBlock = Parameters<
@@ -119,10 +119,9 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
if (block.type === "pageReference") { if (block.type === "pageReference") {
const pageId = block.props.pageId; const pageId = block.props.pageId;
if (pageId) { if (pageId) {
await fetch("/api/documents/delete", { await deleteDocumentCommand({
method: "POST", documentId: pageId,
headers: { "Content-Type": "application/json" }, workspaceId,
body: JSON.stringify({ documentId: pageId }),
}); });
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
emitDocumentsChanged(pageId); emitDocumentsChanged(pageId);
@@ -131,7 +130,7 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
} }
editor.removeBlocks([block.id]); editor.removeBlocks([block.id]);
router.refresh(); router.refresh();
}, [block, editor, router]); }, [block, editor, router, workspaceId]);
const handleDeleteBlock = useCallback(async () => { const handleDeleteBlock = useCallback(async () => {
if (block.type === "pageReference") { if (block.type === "pageReference") {
@@ -67,13 +67,19 @@ import type { MediaAsset } from "@/types/media";
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu"; import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events"; import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; 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 { DocumentShareDialog } from "@/components/sharing/document-share-dialog";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { GroupManagerDialog } from "@/components/groups/group-manager-dialog"; import { GroupManagerDialog } from "@/components/groups/group-manager-dialog";
import { import {
copyTreeCommand,
createDocumentCommand, createDocumentCommand,
deleteDocumentCommand,
embedDocumentCommand,
moveDocumentCommand, moveDocumentCommand,
renameDocumentCommand, renameDocumentCommand,
restoreDocumentCommand,
purgeDocumentCommand,
} from "@/lib/documents/tree-command-client"; } from "@/lib/documents/tree-command-client";
const TOP_BUTTONS = [ const TOP_BUTTONS = [
@@ -149,16 +155,18 @@ export function Sidebar({ initialData }: SidebarProps) {
// Convex 模式专用组件 - 只调用 Convex hooks // Convex 模式专用组件 - 只调用 Convex hooks
function SidebarConvex({ initialData }: SidebarProps) { function SidebarConvex({ initialData }: SidebarProps) {
const sidebarData = useSidebarData(initialData); const sidebarData = useSidebarData(initialData);
return <SidebarContent initialData={initialData} sidebarQuery={sidebarData} />; const treeStream = useSidebarTreeStream(initialData);
return <SidebarContent initialData={initialData} sidebarQuery={sidebarData} treeStream={treeStream} />;
} }
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑 // 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
interface SidebarContentProps { interface SidebarContentProps {
initialData: SidebarInitialData; initialData: SidebarInitialData;
sidebarQuery: SidebarDataResult; sidebarQuery: SidebarDataResult;
treeStream: ReturnType<typeof useSidebarTreeStream>;
} }
function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarContentProps) {
const convex = useConvex(); const convex = useConvex();
const { isAuthenticated } = useConvexAuth(); const { isAuthenticated } = useConvexAuth();
const currentUser = useQuery(api.users.currentUser, isAuthenticated ? {} : "skip"); const currentUser = useQuery(api.users.currentUser, isAuthenticated ? {} : "skip");
@@ -174,8 +182,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
// 处理数据 // 处理数据
const sidebarData = useMemo(() => { const sidebarData = useMemo(() => {
return sidebarQuery.data ?? initialData; return treeStream.data ?? sidebarQuery.data ?? initialData;
}, [sidebarQuery, initialData]); }, [treeStream.data, sidebarQuery, initialData]);
// isLoading 判断 // isLoading 判断
const isLoading = sidebarQuery.isLoading; const isLoading = sidebarQuery.isLoading;
@@ -986,20 +994,17 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
}); });
if (docItemsMap.size > 0) { if (docItemsMap.size > 0) {
const resp = await fetch("/api/documents/copy-tree", { try {
method: "POST", await copyTreeCommand({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: Array.from(docItemsMap.entries()).map(([documentId, recursive]) => ({ items: Array.from(docItemsMap.entries()).map(([documentId, recursive]) => ({
documentId, documentId,
recursive, recursive,
})), })),
targetParentId: targetDocId, targetParentId: targetDocId,
}), });
}); } catch (error) {
if (!resp.ok) { const message = error instanceof Error ? error.message : "粘贴页面失败";
const data = await resp.json().catch(() => ({})); setTimeout(() => window.alert(message), 0);
setTimeout(() => window.alert(data?.error ?? "粘贴页面失败"), 0);
return; return;
} }
await sidebarQuery.refetch(); await sidebarQuery.refetch();
@@ -1345,12 +1350,15 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
if (docIds.length > 0) { if (docIds.length > 0) {
const results = await Promise.all( const results = await Promise.all(
docIds.map(async (documentId) => { docIds.map(async (documentId) => {
const resp = await fetch("/api/documents/delete", { try {
method: "POST", await deleteDocumentCommand({
headers: { "Content-Type": "application/json" }, documentId,
body: JSON.stringify({ documentId }), workspaceId: sidebarData.activeWorkspaceId ?? null,
}); });
return { documentId, ok: resp.ok }; return { documentId, ok: true };
} catch {
return { documentId, ok: false };
}
}), }),
); );
const failed = results.filter((item) => !item.ok).map((item) => item.documentId); const failed = results.filter((item) => !item.ok).map((item) => item.documentId);
@@ -1666,17 +1674,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
if (args.copy) { if (args.copy) {
if (docIds.length > 0) { if (docIds.length > 0) {
const resp = await fetch("/api/documents/copy-tree", { try {
method: "POST", await copyTreeCommand({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: docIds.map((documentId) => ({ documentId, recursive: true })), items: docIds.map((documentId) => ({ documentId, recursive: true })),
targetParentId: targetDocId, targetParentId: targetDocId,
}), });
}); } catch (error) {
if (!resp.ok) { const message = error instanceof Error ? error.message : "复制页面失败";
const payload = await resp.json().catch(() => ({})); setTimeout(() => window.alert(message), 0);
setTimeout(() => window.alert(payload?.error ?? "复制页面失败"), 0);
return; return;
} }
await sidebarQuery.refetch(); await sidebarQuery.refetch();
@@ -1782,10 +1787,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const handleDelete = useCallback( const handleDelete = useCallback(
async (documentId: string) => { async (documentId: string) => {
await fetch("/api/documents/delete", { await deleteDocumentCommand({
method: "POST", documentId,
headers: { "Content-Type": "application/json" }, workspaceId: sidebarData.activeWorkspaceId ?? null,
body: JSON.stringify({ documentId }),
}); });
await refreshTree(); await refreshTree();
emitDocumentsChanged(documentId); emitDocumentsChanged(documentId);
@@ -1793,7 +1797,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
router.push("/"); router.push("/");
} }
}, },
[activeId, refreshTree, router], [activeId, refreshTree, router, sidebarData.activeWorkspaceId],
); );
const handleDeleteFromContextMenuNode = useCallback( const handleDeleteFromContextMenuNode = useCallback(
@@ -1879,14 +1883,13 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
if (!confirmTrashAction("确认恢复该页面吗?")) { if (!confirmTrashAction("确认恢复该页面吗?")) {
return; return;
} }
await fetch("/api/documents/restore", { await restoreDocumentCommand({
method: "POST", documentId,
headers: { "Content-Type": "application/json" }, workspaceId: sidebarData.activeWorkspaceId ?? null,
body: JSON.stringify({ documentId }),
}); });
await Promise.all([sidebarQuery.refetch(), refreshTree()]); await Promise.all([sidebarQuery.refetch(), refreshTree()]);
}, },
[confirmTrashAction, refreshTree, sidebarQuery], [confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery],
); );
const handlePurgeFromTrash = useCallback( const handlePurgeFromTrash = useCallback(
@@ -1894,11 +1897,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
if (!confirmTrashAction("彻底删除后将无法找回,是否继续?")) { if (!confirmTrashAction("彻底删除后将无法找回,是否继续?")) {
return; return;
} }
await fetch("/api/documents/purge", { await purgeDocumentCommand({ documentId });
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId }),
});
await Promise.all([sidebarQuery.refetch(), refreshTree()]); await Promise.all([sidebarQuery.refetch(), refreshTree()]);
}, },
[confirmTrashAction, refreshTree, sidebarQuery], [confirmTrashAction, refreshTree, sidebarQuery],
@@ -2817,14 +2816,15 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
window.alert("不能嵌入到自身页面"); window.alert("不能嵌入到自身页面");
return; return;
} }
const response = await fetch("/api/documents/embed", { try {
method: "POST", await embedDocumentCommand({
headers: { "Content-Type": "application/json" }, sourceId: source.id,
body: JSON.stringify({ sourceId: source.id, targetId }), targetId,
}); });
if (!response.ok) { } catch (error) {
const message = error instanceof Error ? error.message : "嵌入失败,请检查目标页面";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.alert("嵌入失败,请检查目标页面"); window.alert(message);
} }
return; return;
} }
@@ -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", () => { it("buildDocumentBridgeMutationRequest builds media asset writeback runtime request", () => {
const envelope = buildDocumentCommandEnvelope({ const envelope = buildDocumentCommandEnvelope({
name: "media.assets.replace_storage", name: "media.assets.replace_storage",
@@ -126,6 +126,7 @@ const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
"documents.template": "documents:setTemplate", "documents.template": "documents:setTemplate",
"documents.emptyTrashByWorkspace": "documents:emptyTrashByWorkspace", "documents.emptyTrashByWorkspace": "documents:emptyTrashByWorkspace",
"documents.purge": "documents:purge", "documents.purge": "documents:purge",
"documents.embed": "documents:updateContent",
"blocks.patch": "documents:updateContent", "blocks.patch": "documents:updateContent",
"blocks.move": "documents:updateContent", "blocks.move": "documents:updateContent",
"blocks.embed": "documents:updateContent", "blocks.embed": "documents:updateContent",
@@ -20,7 +20,6 @@ import {
executeRustBridgeMutationTransport, executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan, resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime"; } from "@/lib/documents/rust-runtime";
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract"; import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import type { Json } from "@/types/supabase"; import type { Json } from "@/types/supabase";
@@ -79,6 +78,19 @@ export type DocumentPurgePayload = {
documentId: string; 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<TResult> = { export type PageCommandExecutionResult<TResult> = {
requestId: string; requestId: string;
traceId: string; traceId: string;
@@ -287,23 +299,28 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks); const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id) ?? normalizeWorkspaceId((targetMeta as { workspace_id?: string | null } | null)?.workspace_id); const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id) ?? normalizeWorkspaceId((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
const context = await buildRuntimeContext(request, workspaceId); const context = await buildRuntimeContext(request, workspaceId);
const savePayload = buildDocumentSavePayload({ const embedPayload: DocumentEmbedPayload = {
documentId: normalizedTargetId, ...buildDocumentSavePayload({
workspaceId, documentId: normalizedTargetId,
revision: workspaceId,
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision) revision:
? targetContent.revision typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
: null, ? targetContent.revision
content: payload, : null,
conflictDetectionKey: content: payload,
typeof targetContent.conflict_detection_key === "string" conflictDetectionKey:
? targetContent.conflict_detection_key typeof targetContent.conflict_detection_key === "string"
: null, ? targetContent.conflict_detection_key
blockCount: nextBlocks.length, : null,
}); blockCount: nextBlocks.length,
}),
sourceDocumentId: normalizedSourceId,
targetDocumentId: normalizedTargetId,
anchorBlockId: anchorId,
};
const envelope = buildDocumentCommandEnvelope({ const envelope = buildDocumentCommandEnvelope({
name: "documents.save", name: "documents.embed",
payload: savePayload, payload: embedPayload,
context, context,
target: { target: {
workspaceId, workspaceId,
@@ -311,9 +328,13 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
}, },
}); });
const result = await executeSaveBridgeCommand({ const result = await executePageLifecycleBridgeCommand<DocumentEmbedPayload, {
revision?: number | null;
conflict_detection_key?: string | null;
}>({
context, context,
envelope, envelope,
client,
}); });
return NextResponse.json({ return NextResponse.json({
@@ -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,
});
});
});
@@ -1,5 +1,3 @@
import "server-only";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
@@ -19,6 +17,7 @@ import {
} from "@/lib/documents/bridge-log"; } from "@/lib/documents/bridge-log";
import { executeRustBridgeMutationTransport, resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime"; import { executeRustBridgeMutationTransport, resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
import { getDocumentsBaseDir } from "@/lib/server/local-paths"; import { getDocumentsBaseDir } from "@/lib/server/local-paths";
import { filterTopLevelDocIds, isInvalidDocDrop } from "@/lib/file-tree/dnd";
type CreatePayload = { type CreatePayload = {
parentId?: string | null; parentId?: string | null;
@@ -52,8 +51,34 @@ type CopyTreePayload = {
targetParentId?: string | null; 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(); 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 { function trimOrNull(value: unknown): string | null {
if (typeof value !== "string") return null; if (typeof value !== "string") return null;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -65,6 +90,52 @@ function normalizeTitle(title: string | null): string {
return safe && safe.length > 0 ? safe : "无标题"; 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<string, string | null>;
}) {
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() { function safeRandomId() {
return typeof crypto.randomUUID === "function" return typeof crypto.randomUUID === "function"
? crypto.randomUUID() ? crypto.randomUUID()
@@ -138,6 +209,7 @@ async function handleLifecycleError(error: unknown) {
} }
export async function handleDocumentCreateRequest(request: Request): Promise<NextResponse> { export async function handleDocumentCreateRequest(request: Request): Promise<NextResponse> {
assertServerEnvironment();
try { try {
const payload = (await request.json()) as CreatePayload; const payload = (await request.json()) as CreatePayload;
const { auth, client } = await getAuthedConvexClient(); const { auth, client } = await getAuthedConvexClient();
@@ -185,7 +257,7 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
pageId: documentId, pageId: documentId,
}, },
}); });
const { context, plan } = await resolveCommandPlan({ const { context: runtimeContext, plan } = await resolveCommandPlan({
request, request,
workspaceId, workspaceId,
authUserId: auth.userId, authUserId: auth.userId,
@@ -213,7 +285,7 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
} }
await recordBridgeCommandArtifacts({ await recordBridgeCommandArtifacts({
context, context: runtimeContext,
envelope, envelope,
client, client,
}); });
@@ -255,9 +327,11 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
} }
export async function handleDocumentMoveRequest(request: Request): Promise<NextResponse> { export async function handleDocumentMoveRequest(request: Request): Promise<NextResponse> {
assertServerEnvironment();
try { try {
const payload = (await request.json()) as MovePayload; const payload = (await request.json()) as MovePayload;
const documentId = trimOrNull(payload.documentId); const normalizedMove = normalizeDocumentMovePayload(payload);
const documentId = normalizedMove.documentId;
if (!documentId) { if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 }); return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
} }
@@ -273,8 +347,8 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
name: "documents.move", name: "documents.move",
payload: { payload: {
documentId, documentId,
parentId: trimOrNull(payload.parentId), parentId: normalizedMove.parentId,
sortOrder: Number.isFinite(payload.position ?? Number.NaN) ? Math.floor(payload.position ?? 0) : 0, sortOrder: normalizedMove.sortOrder,
}, },
context, context,
target: { target: {
@@ -329,6 +403,7 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
} }
export async function handleDocumentDeleteRequest(request: Request): Promise<NextResponse> { export async function handleDocumentDeleteRequest(request: Request): Promise<NextResponse> {
assertServerEnvironment();
try { try {
const payload = (await request.json()) as DeletePayload; const payload = (await request.json()) as DeletePayload;
const documentId = trimOrNull(payload.documentId); const documentId = trimOrNull(payload.documentId);
@@ -399,6 +474,7 @@ export async function handleDocumentDeleteRequest(request: Request): Promise<Nex
} }
export async function handleDocumentRestoreRequest(request: Request): Promise<NextResponse> { export async function handleDocumentRestoreRequest(request: Request): Promise<NextResponse> {
assertServerEnvironment();
try { try {
const payload = (await request.json()) as RestorePayload; const payload = (await request.json()) as RestorePayload;
const documentId = trimOrNull(payload.documentId); const documentId = trimOrNull(payload.documentId);
@@ -473,6 +549,7 @@ export async function handleDocumentRestoreRequest(request: Request): Promise<Ne
} }
export async function handleDocumentDuplicateRequest(request: Request): Promise<NextResponse> { export async function handleDocumentDuplicateRequest(request: Request): Promise<NextResponse> {
assertServerEnvironment();
try { try {
const payload = (await request.json()) as DuplicatePayload; const payload = (await request.json()) as DuplicatePayload;
const documentId = trimOrNull(payload.documentId); const documentId = trimOrNull(payload.documentId);
@@ -569,16 +646,15 @@ export async function handleDocumentDuplicateRequest(request: Request): Promise<
} }
export async function handleDocumentCopyTreeRequest(request: Request): Promise<NextResponse> { export async function handleDocumentCopyTreeRequest(request: Request): Promise<NextResponse> {
assertServerEnvironment();
try { try {
const payload = (await request.json()) as CopyTreePayload; const payload = (await request.json()) as CopyTreePayload;
const { auth, client } = await getAuthedConvexClient(); const { auth, client } = await getAuthedConvexClient();
const normalizedCopyTree = normalizeDocumentCopyTreePayload(payload);
const normalizedItems = (payload.items ?? []).filter((it) => trimOrNull(it?.documentId)); if (normalizedCopyTree.items.length === 0) {
if (normalizedItems.length === 0) {
return NextResponse.json({ error: "items 为空" }, { status: 400 }); return NextResponse.json({ error: "items 为空" }, { status: 400 });
} }
const targetParentId = normalizedCopyTree.targetParentId;
const targetParentId = trimOrNull(payload.targetParentId);
let workspaceId: string | null = null; let workspaceId: string | null = null;
if (targetParentId) { if (targetParentId) {
@@ -589,7 +665,7 @@ export async function handleDocumentCopyTreeRequest(request: Request): Promise<N
workspaceId = targetDoc.workspace_id; workspaceId = targetDoc.workspace_id;
} }
const sourceIds = Array.from(new Set(normalizedItems.map((it) => trimOrNull(it.documentId) as string))); const sourceIds = normalizedCopyTree.sourceIds;
const firstMeta = await client.query(api.documents.getMeta, { id: sourceIds[0] }); const firstMeta = await client.query(api.documents.getMeta, { id: sourceIds[0] });
if (!firstMeta) { if (!firstMeta) {
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 }); return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
@@ -607,10 +683,7 @@ export async function handleDocumentCopyTreeRequest(request: Request): Promise<N
const outerEnvelope = buildDocumentCommandEnvelope({ const outerEnvelope = buildDocumentCommandEnvelope({
name: "documents.copy_tree", name: "documents.copy_tree",
payload: { payload: {
items: normalizedItems.map((item) => ({ items: normalizedCopyTree.items,
documentId: trimOrNull(item.documentId) as string,
recursive: Boolean(item.recursive),
})),
targetParentId, targetParentId,
}, },
context, context,
@@ -89,6 +89,7 @@ export type PageSubtreeProjection = {
}; };
const SNIPPET_MAX_LENGTH = 220; const SNIPPET_MAX_LENGTH = 220;
const PAGE_SUBTREE_PROJECTION_ID = "kernel_projection:page_tree:document_subtree";
const pickFirstText = (...values: unknown[]) => { const pickFirstText = (...values: unknown[]) => {
for (const value of values) { for (const value of values) {
@@ -367,7 +368,7 @@ export function buildPageSubtreeProjection(input: {
} }
return { return {
projectionId: `page_subtree:${rootNodeId}`, projectionId: PAGE_SUBTREE_PROJECTION_ID,
projection: "page_tree", projection: "page_tree",
rootNodeId, rootNodeId,
rootNode, rootNode,
@@ -43,6 +43,33 @@ type MoveDocumentInput = {
workspaceId?: string | null; 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 = { type CreateChildDocumentInput = {
parentId: string | null; parentId: string | null;
title: string; 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,
},
"复制页面失败,请稍后再试",
);
}
@@ -318,4 +318,48 @@ describe("buildSidebarInitialData", () => {
mediaAssets: [], 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",
},
],
});
});
}); });
+38 -16
View File
@@ -1,7 +1,7 @@
import type { SidebarInitialData } from "@/components/sidebar/types"; import type { SidebarInitialData } from "@/components/sidebar/types";
import type { DocumentRecord } from "@/lib/documents"; import type { DocumentRecord } from "@/lib/documents";
import { import {
buildKernelSidebarProjection, buildKernelSidebarProjection as buildProjectionContract,
buildSidebarTreeFromKernelProjection, buildSidebarTreeFromKernelProjection,
type KernelSidebarProjection, type KernelSidebarProjection,
} from "@/lib/kernel-sidebar"; } from "@/lib/kernel-sidebar";
@@ -64,6 +64,41 @@ export type SidebarDatasetListQueryResult = {
mindmap_asset_children: Record<string, string[]>; mindmap_asset_children: Record<string, string[]>;
}; };
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>): string[] { function normalizeStringArray(values: Iterable<string>): string[] {
return Array.from(new Set(values)).filter((value) => value.trim().length > 0); return Array.from(new Set(values)).filter((value) => value.trim().length > 0);
} }
@@ -220,7 +255,7 @@ export function buildSidebarDatasetListQueryResult(
input: SidebarDatasetInput, input: SidebarDatasetInput,
): SidebarDatasetListQueryResult { ): SidebarDatasetListQueryResult {
const derived = deriveSidebarDataset(input); const derived = deriveSidebarDataset(input);
const kernelSidebarProjection = buildKernelSidebarProjection(input.documents); const kernelSidebarProjection = buildProjectionContract(input.documents);
return { return {
active_workspace_id: input.activeWorkspaceId, active_workspace_id: input.activeWorkspaceId,
@@ -242,20 +277,7 @@ export function buildSidebarDatasetListQueryResult(
export function mapSidebarDatasetListQueryResultToInitialData( export function mapSidebarDatasetListQueryResultToInitialData(
result: SidebarDatasetListQueryResult, result: SidebarDatasetListQueryResult,
): SidebarInitialData { ): SidebarInitialData {
// 说明:兼容旧的 Convex `sidebar.dataset.list` 返回体。 const kernelSidebarProjection = readKernelSidebarProjection(result);
// 若上游暂未附带 `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 ?? []);
return { return {
activeWorkspaceId: result.active_workspace_id, activeWorkspaceId: result.active_workspace_id,
@@ -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<TreeStreamKind>(["snapshot", "delta", "resync"]);
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>): 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;
}
@@ -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([]);
});
});
@@ -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<string>([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;
}
@@ -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",
},
});
});
});
@@ -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<string, unknown> {
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<SidebarTreeStreamState>({
data: null,
status: streamEnabled ? "connecting" : "idle",
cursor: null,
error: null,
});
const eventSourceRef = useRef<EventSource | null>(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<string>) => {
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;
}
+1
View File
@@ -6,6 +6,7 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
"@": resolve(dirname(fileURLToPath(import.meta.url)), "./src"), "@": resolve(dirname(fileURLToPath(import.meta.url)), "./src"),
"server-only": resolve(dirname(fileURLToPath(import.meta.url)), "./test/server-only.ts"),
}, },
}, },
test: { test: {