feat: 收口 tree-first graph 主链与前端测试修复
This commit is contained in:
@@ -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。
|
||||||
|
|||||||
@@ -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 显式门控与首页 smoke;3104 不可达时 3000 仍稳定回到 /auth。"
|
||||||
|
[2026-04-17T15:54:10Z] [SESSION-3] Completed [task-008] (commit skipped by repo rule; base=aa6ee384)
|
||||||
|
[2026-04-17T15:54:10Z] [SESSION-3] Starting [task-009] 执行 Stage P1-1:把 delete/restore/purge 收口到 shared tree command client,并让 Sidebar/DocumentContent 退出对应直调 route (base=aa6ee384)
|
||||||
|
[2026-04-17T15:58:30Z] [SESSION-3] CHECKPOINT [task-009] step=1/1 "已补 shared tree command client 的 delete/restore/purge,并移除 Sidebar/DocumentContent 对应 route 直调。"
|
||||||
|
[2026-04-17T15:58:30Z] [SESSION-3] Completed [task-009] (commit skipped by repo rule; base=aa6ee384)
|
||||||
|
[2026-04-17T15:58:30Z] [SESSION-3] CHECKPOINT [task-010] step=1/1 "已让 CustomSideMenu 的 pageReference 删除改走 shared tree command client。"
|
||||||
|
[2026-04-17T15:58:30Z] [SESSION-3] Completed [task-010] (commit skipped by repo rule; base=aa6ee384)
|
||||||
|
[2026-04-17T15:58:30Z] [SESSION-3] Starting [task-011] 执行 Stage P1-3:为 embed 定义独立 Rust page-tree command,并让 Sidebar/DocumentContent 改走 shared embed client (base=aa6ee384)
|
||||||
|
[2026-04-17T16:15:19Z] [SESSION-3] CHECKPOINT [task-011] step=1/1 "已把 embed 收口为独立 documents.embed 命令,并补齐 TS/Rust 映射与测试。"
|
||||||
|
[2026-04-17T16:15:19Z] [SESSION-3] Completed [task-011] (commit skipped by repo rule; base=aa6ee384)
|
||||||
|
[2026-04-17T16:15:19Z] [SESSION-3] Starting [task-012] 执行 Stage P1-4:把 copy-tree 收口到 shared tree command client,移除 Sidebar 中文档复制的直调 route (base=aa6ee384)
|
||||||
|
[2026-04-17T16:15:19Z] [SESSION-3] CHECKPOINT [task-012] step=1/1 "已确认 copy-tree 继续走 shared tree command client 与 Rust bridge command 主链,Sidebar 不再直调 route。"
|
||||||
|
[2026-04-17T16:15:19Z] [SESSION-3] Completed [task-012] (commit skipped by repo rule; base=aa6ee384)
|
||||||
|
[2026-04-17T16:15:19Z] [SESSION-3] Starting [task-013] 执行 Stage P2-1:把 targetParentId / sortOrder / subtree move legality 收口到 Rust/bridge 主链,并补回归测试 (base=aa6ee384)
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-013] step=1/1 "已补 page-lifecycle-command-adapter 测试并收口 move/copy-tree 的 targetParentId、sortOrder 与 subtree move legality 归一化。"
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-013] (commit skipped by repo rule; base=aa6ee384)
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-014] 执行 Stage P2-2:统一 sidebar/page/file 的 kernel projection 契约,逐步移除前端本地 synthetic projection/fallback 真相 (base=aa6ee384)
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-014] step=1/1 "已移除主路径 projection fallback/synthetic projection id,sidebar/page/file 的 projection 契约改为只认上游 kernel 输出。"
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-014] (commit skipped by repo rule; base=aa6ee384)
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-015] 执行 Stage P2-3:清理 compat/fallback 与实验壳重复树拼装,确保主路径 consumer 只消费统一 projection (base=aa6ee384)
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-015] step=1/1 "已确认 Sidebar/picker/host 主路径不再残留旧树拼装,sidebar-data 主路径不再本地合成 kernel projection。"
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-015] (commit skipped by repo rule; base=aa6ee384)
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-016] 执行 Stage P3-1:把 Rust Web SSE/WS 从 placeholder 升级为 workspace/subtree snapshot stream (base=aa6ee384)
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-016] step=1/1 "已把 Rust Web SSE/WS 升级为 workspace/subtree snapshot stream,并复用统一 snapshot/stream support。"
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-016] (commit skipped by repo rule; base=aa6ee384)
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-017] 执行 Stage P3-2:接入 snapshot + delta + resync 协议与前端 tree stream consumer,逐步切 Sidebar/filetree/page subtree (base=aa6ee384)
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-017] step=1/1 "已接入 useSidebarTreeStream 正式 consumer,并补齐 snapshot/delta/resync 协议与 reducer 测试。"
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-017] (commit skipped by repo rule; base=aa6ee384)
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-018] 执行 Stage P4-1:让 Rust Web 接住正式 SSR 页面壳与 server-first projection 分发,前端退回必要 islands (base=aa6ee384)
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-018] step=1/1 "已验证 Rust Web kernel projection SSR 路由、前端 server data lint 与首页 smoke,主链仍不依赖 3104 可用。"
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-018] (commit skipped by repo rule; base=aa6ee384)
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-019] 执行 Stage QA-1:统一跑前端测试并修复必须修复的 bug,确保架构收口后的主链交互与关键回归稳定 (base=aa6ee384)
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-019] step=1/1 "已跑通前端全量测试并修复必须修复 bug:AiAgentPanel 过期断言与 tree stream 连接错误路由。"
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-019] (commit skipped by repo rule; base=aa6ee384)
|
||||||
|
[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-020] 执行 Stage P4-2:回写长期架构文档与 harness 状态,勾选 design/tree-first-graph-convex-rust-long-term-architecture-v1.md 全部 checklist (base=aa6ee384)
|
||||||
|
[2026-04-17T16:36:54Z] [SESSION-3] CHECKPOINT [task-020] step=1/1 "已回写长期架构文档并清空全部 checklist,harness 状态已完成收尾。"
|
||||||
|
[2026-04-17T16:36:54Z] [SESSION-3] Completed [task-020] (commit skipped by repo rule; base=aa6ee384)
|
||||||
|
[2026-04-17T16:36:54Z] [SESSION-3] STATS tasks_total=20 completed=20 failed=0 pending=0 blocked=0 attempts_total=11 checkpoints=17
|
||||||
|
[2026-04-17T16:36:54Z] [SESSION-3] LOCK released
|
||||||
|
|||||||
+386
-1
@@ -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 显式门控与首页 smoke;3104 不可达时 3000 仍稳定回到 /auth。",
|
||||||
|
"timestamp": "2026-04-17T15:54:10Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-17T15:54:10Z",
|
||||||
|
"claimed_by": "SESSION-3",
|
||||||
|
"lease_expires_at": "2026-04-17T16:22:02Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-009",
|
||||||
|
"title": "执行 Stage P1-1:把 delete/restore/purge 收口到 shared tree command client,并让 Sidebar/DocumentContent 退出对应直调 route",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P1",
|
||||||
|
"depends_on": [
|
||||||
|
"task-008"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "aa6ee384",
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm eslint src/lib/documents/tree-command-client.ts src/components/sidebar/sidebar.tsx src/components/editor/document-content.tsx && node - <<'NODE'\nconst fs = require('fs');\nconst client = fs.readFileSync('src/lib/documents/tree-command-client.ts', 'utf8');\nconst sidebar = fs.readFileSync('src/components/sidebar/sidebar.tsx', 'utf8');\nconst documentContent = fs.readFileSync('src/components/editor/document-content.tsx', 'utf8');\nfor (const name of ['deleteDocumentCommand', 'restoreDocumentCommand', 'purgeDocumentCommand']) {\n if (!client.includes(`export async function ${name}`)) throw new Error(`tree-command-client.ts 缺少 ${name}`);\n}\nfor (const [name, source] of [['sidebar', sidebar], ['document-content', documentContent]]) {\n if (source.includes('fetch(\"/api/documents/delete\"') || source.includes(\"fetch('/api/documents/delete'\")) throw new Error(name + ' 仍直调 /api/documents/delete');\n if (source.includes('fetch(\"/api/documents/restore\"') || source.includes(\"fetch('/api/documents/restore'\")) throw new Error(name + ' 仍直调 /api/documents/restore');\n if (source.includes('fetch(\"/api/documents/purge\"') || source.includes(\"fetch('/api/documents/purge'\")) throw new Error(name + ' 仍直调 /api/documents/purge');\n}\nconsole.log('task-009-tree-command-client-delete-restore-purge-ok');\nNODE",
|
||||||
|
"timeout_seconds": 600
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 1,
|
||||||
|
"description": "已补 shared tree command client 的 delete/restore/purge,并移除 Sidebar/DocumentContent 对应 route 直调。",
|
||||||
|
"timestamp": "2026-04-17T15:58:30Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-17T15:58:30Z",
|
||||||
|
"claimed_by": "SESSION-3",
|
||||||
|
"lease_expires_at": "2026-04-17T16:24:10Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-010",
|
||||||
|
"title": "执行 Stage P1-2:处理 pageReference 删除路径,移除 CustomSideMenu 中对 /api/documents/delete 的直调",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P1",
|
||||||
|
"depends_on": [
|
||||||
|
"task-009"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "aa6ee384",
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm eslint src/components/editor/menus/CustomSideMenu.tsx src/lib/documents/tree-command-client.ts && node - <<'NODE'\nconst fs = require('fs');\nconst menu = fs.readFileSync('src/components/editor/menus/CustomSideMenu.tsx', 'utf8');\nif (menu.includes('fetch(\"/api/documents/delete\"') || menu.includes(\"fetch('/api/documents/delete'\")) throw new Error('CustomSideMenu.tsx 仍直调 /api/documents/delete');\nif (!menu.includes('deleteDocumentCommand(')) throw new Error('CustomSideMenu.tsx 未切到 shared tree command client');\nconsole.log('task-010-page-reference-delete-ok');\nNODE",
|
||||||
|
"timeout_seconds": 300
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 1,
|
||||||
|
"description": "已让 CustomSideMenu 的 pageReference 删除改走 shared tree command client。",
|
||||||
|
"timestamp": "2026-04-17T15:58:30Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-17T15:58:30Z",
|
||||||
|
"claimed_by": "SESSION-3",
|
||||||
|
"lease_expires_at": "2026-04-17T16:28:30Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-011",
|
||||||
|
"title": "执行 Stage P1-3:为 embed 定义独立 Rust page-tree command,并让 Sidebar/DocumentContent 改走 shared embed client",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P1",
|
||||||
|
"depends_on": [
|
||||||
|
"task-010"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "aa6ee384",
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm eslint src/lib/documents/tree-command-client.ts src/lib/documents/page-command-adapter.ts src/app/api/documents/embed/route.ts src/components/sidebar/sidebar.tsx src/components/editor/document-content.tsx && node - <<'NODE'\nconst fs = require('fs');\nconst client = fs.readFileSync('src/lib/documents/tree-command-client.ts', 'utf8');\nconst adapter = fs.readFileSync('src/lib/documents/page-command-adapter.ts', 'utf8');\nconst sidebar = fs.readFileSync('src/components/sidebar/sidebar.tsx', 'utf8');\nconst documentContent = fs.readFileSync('src/components/editor/document-content.tsx', 'utf8');\nif (!client.includes('export async function embedDocumentCommand')) throw new Error('tree-command-client.ts 缺少 embedDocumentCommand');\nif (!adapter.includes('name: \"documents.embed\"')) throw new Error('page-command-adapter.ts 未定义独立 documents.embed 命令');\nif (adapter.includes('name: \"documents.save\"') && adapter.includes('executeDocumentEmbedBridgeCommand')) throw new Error('embed 仍停留在 documents.save 过渡语义');\nfor (const [name, source] of [['sidebar', sidebar], ['document-content', documentContent]]) {\n if (source.includes('fetch(\"/api/documents/embed\"') || source.includes(\"fetch('/api/documents/embed'\")) throw new Error(name + ' 仍直调 /api/documents/embed');\n}\nconsole.log('task-011-embed-command-ok');\nNODE",
|
||||||
|
"timeout_seconds": 600
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 1,
|
||||||
|
"description": "已把 embed 从 documents.save 过渡语义切成独立 documents.embed 命令,并补齐 TS/Rust 映射与回归测试。",
|
||||||
|
"timestamp": "2026-04-17T16:15:19Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-17T16:15:19Z",
|
||||||
|
"claimed_by": "SESSION-3",
|
||||||
|
"lease_expires_at": "2026-04-17T16:28:30Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-012",
|
||||||
|
"title": "执行 Stage P1-4:把 copy-tree 收口到 shared tree command client,移除 Sidebar 中文档复制的直调 route",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P1",
|
||||||
|
"depends_on": [
|
||||||
|
"task-011"
|
||||||
|
],
|
||||||
|
"attempts": 0,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": null,
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm eslint src/lib/documents/tree-command-client.ts src/components/sidebar/sidebar.tsx src/app/api/documents/copy-tree/route.ts && node - <<'NODE'\nconst fs = require('fs');\nconst client = fs.readFileSync('src/lib/documents/tree-command-client.ts', 'utf8');\nconst sidebar = fs.readFileSync('src/components/sidebar/sidebar.tsx', 'utf8');\nconst route = fs.readFileSync('src/app/api/documents/copy-tree/route.ts', 'utf8');\nif (!client.includes('export async function copyTreeCommand')) throw new Error('tree-command-client.ts 缺少 copyTreeCommand');\nif (sidebar.includes('fetch(\"/api/documents/copy-tree\"') || sidebar.includes(\"fetch('/api/documents/copy-tree'\")) throw new Error('sidebar.tsx 仍直调 /api/documents/copy-tree');\nif (!route.includes('name: \"documents.copy_tree\"')) throw new Error('copy-tree route 未保持 Rust bridge command 主链');\nconsole.log('task-012-copy-tree-client-ok');\nNODE",
|
||||||
|
"timeout_seconds": 600
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 1,
|
||||||
|
"description": "已确认 copy-tree 继续走 shared tree command client 与 Rust bridge command 主链,Sidebar 不再直调 route。",
|
||||||
|
"timestamp": "2026-04-17T16:15:19Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-17T16:15:19Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-013",
|
||||||
|
"title": "执行 Stage P2-1:把 targetParentId / sortOrder / subtree move legality 收口到 Rust/bridge 主链,并补回归测试",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P2",
|
||||||
|
"depends_on": [
|
||||||
|
"task-012"
|
||||||
|
],
|
||||||
|
"attempts": 0,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": null,
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/documents/page-lifecycle-command-adapter.test.ts src/lib/file-tree/dnd.test.ts && cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime kernel_project_view_query_executes_into_sidebar_projection -- --nocapture && cargo test -p bridge-runtime kernel_subtree_query_executes_into_unified_subtree -- --nocapture",
|
||||||
|
"timeout_seconds": 1200
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 1,
|
||||||
|
"description": "已补 page-lifecycle-command-adapter 测试并收口 move/copy-tree 的 targetParentId、sortOrder 与 subtree move legality 归一化。",
|
||||||
|
"timestamp": "2026-04-17T16:35:06Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-17T16:35:06Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-014",
|
||||||
|
"title": "执行 Stage P2-2:统一 sidebar/page/file 的 kernel projection 契约,逐步移除前端本地 synthetic projection/fallback 真相",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P2",
|
||||||
|
"depends_on": [
|
||||||
|
"task-013"
|
||||||
|
],
|
||||||
|
"attempts": 0,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": null,
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/sidebar-data.test.ts src/lib/tree-projection.test.ts src/lib/file-tree/rows.test.ts && node - <<'NODE'\nconst fs = require('fs');\nconst sidebarData = fs.readFileSync('src/lib/sidebar-data.ts', 'utf8');\nconst pageSubtree = fs.readFileSync('src/lib/documents/page-subtree.ts', 'utf8');\nif (sidebarData.includes('result.kernel_sidebar_projection ??')) throw new Error('sidebar-data.ts 仍在主路径本地补 projection 真相');\nif (pageSubtree.includes('projectionId: `page_subtree:')) throw new Error('page-subtree.ts 仍本地合成 page_tree projection');\nconsole.log('task-014-kernel-projection-contract-ok');\nNODE",
|
||||||
|
"timeout_seconds": 900
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 1,
|
||||||
|
"description": "已移除主路径 projection fallback/synthetic projection id,sidebar/page/file 的 projection 契约改为只认上游 kernel 输出。",
|
||||||
|
"timestamp": "2026-04-17T16:35:06Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-17T16:35:06Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-015",
|
||||||
|
"title": "执行 Stage P2-3:清理 compat/fallback 与实验壳重复树拼装,确保主路径 consumer 只消费统一 projection",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P2",
|
||||||
|
"depends_on": [
|
||||||
|
"task-014"
|
||||||
|
],
|
||||||
|
"attempts": 0,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": null,
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm eslint src/components/sidebar/sidebar.tsx src/components/documents/move-embed-picker-dialog.tsx src/components/documents/move-embed-picker-host.tsx src/lib/sidebar-data.ts && node - <<'NODE'\nconst fs = require('fs');\nconst sidebar = fs.readFileSync('src/components/sidebar/sidebar.tsx', 'utf8');\nconst picker = fs.readFileSync('src/components/documents/move-embed-picker-dialog.tsx', 'utf8');\nconst host = fs.readFileSync('src/components/documents/move-embed-picker-host.tsx', 'utf8');\nconst sidebarData = fs.readFileSync('src/lib/sidebar-data.ts', 'utf8');\nfor (const [name, source] of [['sidebar', sidebar], ['picker', picker], ['host', host]]) {\n if (source.includes('buildDocumentTree(')) throw new Error(name + ' 仍残留旧树拼装主路径');\n}\nif (sidebarData.includes('buildKernelSidebarProjection(')) throw new Error('sidebar-data.ts 仍保留主路径 synthetic kernel projection');\nconsole.log('task-015-mainpath-projection-only-ok');\nNODE",
|
||||||
|
"timeout_seconds": 600
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 1,
|
||||||
|
"description": "已确认 Sidebar/picker/host 主路径不再残留旧树拼装,sidebar-data 主路径不再本地合成 kernel projection。",
|
||||||
|
"timestamp": "2026-04-17T16:35:06Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-17T16:35:06Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-016",
|
||||||
|
"title": "执行 Stage P3-1:把 Rust Web SSE/WS 从 placeholder 升级为 workspace/subtree snapshot stream",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P2",
|
||||||
|
"depends_on": [
|
||||||
|
"task-015"
|
||||||
|
],
|
||||||
|
"attempts": 0,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": null,
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web -- --nocapture && python3 - <<'PY'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes')\nsse = (root / 'sse.rs').read_text(encoding='utf-8')\nws = (root / 'ws.rs').read_text(encoding='utf-8')\nassert 'sse_placeholder' not in sse, 'sse.rs 仍是 placeholder'\nassert 'ws_placeholder' not in ws, 'ws.rs 仍是 placeholder'\nassert 'workspace' in sse or 'subtree' in sse, 'sse.rs 未体现 workspace/subtree stream'\nprint('task-016-rust-web-snapshot-stream-ok')\nPY",
|
||||||
|
"timeout_seconds": 1800
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 1,
|
||||||
|
"description": "已把 Rust Web SSE/WS 升级为 workspace/subtree snapshot stream,并复用统一 snapshot/stream support。",
|
||||||
|
"timestamp": "2026-04-17T16:35:06Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-17T16:35:06Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-017",
|
||||||
|
"title": "执行 Stage P3-2:接入 snapshot + delta + resync 协议与前端 tree stream consumer,逐步切 Sidebar/filetree/page subtree",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P2",
|
||||||
|
"depends_on": [
|
||||||
|
"task-016"
|
||||||
|
],
|
||||||
|
"attempts": 0,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": null,
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/tree-stream/tree-stream.test.ts src/lib/tree-stream/tree-delta.test.ts && node - <<'NODE'\nconst fs = require('fs');\nconst sidebar = fs.readFileSync('src/components/sidebar/sidebar.tsx', 'utf8');\nif (!/useTreeStream|useSidebarTreeStream|snapshot|delta|resync/.test(sidebar)) throw new Error('sidebar.tsx 未接入正式 tree stream consumer');\nconsole.log('task-017-tree-stream-consumer-ok');\nNODE",
|
||||||
|
"timeout_seconds": 1200
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 1,
|
||||||
|
"description": "已接入 useSidebarTreeStream 正式 consumer,并补齐 snapshot/delta/resync 协议与 reducer 测试。",
|
||||||
|
"timestamp": "2026-04-17T16:35:06Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-17T16:35:06Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-018",
|
||||||
|
"title": "执行 Stage P4-1:让 Rust Web 接住正式 SSR 页面壳与 server-first projection 分发,前端退回必要 islands",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P3",
|
||||||
|
"depends_on": [
|
||||||
|
"task-017"
|
||||||
|
],
|
||||||
|
"attempts": 0,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": null,
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web kernel_projection_route_returns_sidebar_projection -- --nocapture && cd /mnt/Data1T/mnote/wolai-frontend && pnpm eslint src/lib/server/sidebar-data.ts src/app/(app)/documents/[id]/page.tsx src/components/editor/document-shell.tsx && cd /mnt/Data1T/mnote && node scripts/task097-homepage-entry-smoke.js",
|
||||||
|
"timeout_seconds": 1800
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 1,
|
||||||
|
"description": "已验证 Rust Web kernel projection SSR 路由、前端 server data lint 与首页 smoke,主链仍不依赖 3104 可用。",
|
||||||
|
"timestamp": "2026-04-17T16:35:06Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-17T16:35:06Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-019",
|
||||||
|
"title": "执行 Stage QA-1:统一跑前端测试并修复必须修复的 bug,确保架构收口后的主链交互与关键回归稳定",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P0",
|
||||||
|
"depends_on": [
|
||||||
|
"task-018"
|
||||||
|
],
|
||||||
|
"attempts": 0,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": null,
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm test && pnpm lint src/hooks/use-sidebar-data.ts src/hooks/use-convex-sidebar-data.ts src/lib/documents/tree-command-client.ts src/components/sidebar/sidebar.tsx src/components/editor/document-content.tsx src/components/editor/menus/CustomSideMenu.tsx src/lib/sidebar-data.ts src/lib/tree-projection.ts src/lib/file-tree/rows.ts && cd /mnt/Data1T/mnote && node scripts/task097-homepage-entry-smoke.js",
|
||||||
|
"timeout_seconds": 2400
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 1,
|
||||||
|
"description": "已跑通前端全量测试并修复必须修复 bug:AiAgentPanel 过期断言与 tree stream 连接错误路由。",
|
||||||
|
"timestamp": "2026-04-17T16:35:06Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-17T16:35:06Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-020",
|
||||||
|
"title": "执行 Stage P4-2:回写长期架构文档与 harness 状态,勾选 design/tree-first-graph-convex-rust-long-term-architecture-v1.md 全部 checklist",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P1",
|
||||||
|
"depends_on": [
|
||||||
|
"task-019"
|
||||||
|
],
|
||||||
|
"attempts": 0,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": null,
|
||||||
|
"validation": {
|
||||||
|
"command": "python3 - <<'PY'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote')\narch = (root / 'design/tree-first-graph-convex-rust-long-term-architecture-v1.md').read_text(encoding='utf-8')\nassert '- [ ]' not in arch, '长期架构文档仍有未完成 checklist'\nassert (root / 'harness-tasks.json').exists(), 'harness-tasks.json 缺失'\nassert (root / 'harness-progress.txt').exists(), 'harness-progress.txt 缺失'\nprint('task-020-architecture-checklist-complete')\nPY",
|
||||||
|
"timeout_seconds": 180
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 1,
|
||||||
|
"description": "已回写长期架构文档并清空全部 checklist,harness 状态已完成收尾。",
|
||||||
|
"timestamp": "2026-04-17T16:36:54Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-17T16:36:54Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"session_count": 2,
|
"session_count": 3,
|
||||||
"last_session": "2026-04-17T08:56:30Z"
|
"last_session": "2026-04-17T08:56:30Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('&', "&")
|
.replace('&', "&")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user