feat: 接入 mnote web tree shell 与主页链路整理

- 增加 mnote-web tree/command 支持与前端 MnoteWebTreeShell 集成
- 调整 sidebar、documents、runtime config 与 dev/prod server 配套逻辑
- 补充 homepage/tree shell smoke 脚本并更新 harness 进度文件
This commit is contained in:
lix-2026
2026-04-17 23:36:24 +08:00
parent cfc3af8984
commit d8de820d93
40 changed files with 4668 additions and 4143 deletions
+28 -458
View File
@@ -1,458 +1,28 @@
[2026-04-14T05:21:15Z] [SESSION-0] INIT Harness initialized for project /mnt/Data1T/mnote [2026-04-17T07:58:16Z] [SESSION-0] INIT Archived previous harness state to /mnt/Data1T/mnote/tmp/harness-backups/20260417T075718Z-pre-longterm-reset
[[2026-04-14T05:26:16Z] [SESSION-0] INIT Added [task-001] via harness add [2026-04-17T07:58:16Z] [SESSION-0] INIT Initialized new harness batch from /mnt/Data1T/mnote/design/tree-first-graph-convex-rust-long-term-architecture-v1.md
[2026-04-14T05:27:30Z] [SESSION-0] WARN Previous log line has malformed prefix from add command; corrected entry appended [2026-04-17T08:02:25Z] [SESSION-1] LOCK acquired (pid=640651)
[2026-04-14T05:27:30Z] [SESSION-0] INIT Added [task-001] via harness add [2026-04-17T08:02:25Z] [SESSION-1] INIT Environment health check: PASS
[2026-04-14T05:34:11Z] [SESSION-1] LOCK acquired (pid=2142754) [2026-04-17T08:02:25Z] [SESSION-1] Starting [task-001] 执行 Stage A-1:固定首页与 /api/sidebar 主路径只走 Convex/Rust 主链,禁止默认回落到 mnote-web compat/sidebar,并把 tree shell 默认运行态切成显式开关 (base=4538f85d)
[2026-04-14T05:34:11Z] [SESSION-1] INIT Environment health check: PASS [2026-04-17T08:03:15Z] [SESSION-1] CHECKPOINT [task-001] step=1/1 "已验证 /api/sidebar 不再默认走 mnote-web compattree shell 默认改为显式关闭,主链边界符合长期架构文档。"
[2026-04-14T05:34:11Z] [SESSION-1] Starting [task-001] 请参考/mnt/Data1T/mnote/design/rust-kernel-backport-plan.md完成单仓收口 Rust 内核回迁计划,这是详细checklist/mnt/Data1T/mnote/design/rust-kernel-backport-phase-checklist.md,里面已经完成了部分内容,你需要继续完成 (base=fc26ed21) [2026-04-17T08:03:15Z] [SESSION-1] Completed [task-001] (commit skipped by repo rule; base=4538f85d)
[2026-04-14T05:40:04Z] [SESSION-1] CHECKPOINT [task-001] step=1/4 "完成 Rust 协议层对 documents.meta/content/save 的建模与映射,cargo test/check 已通过" [2026-04-17T08:03:15Z] [SESSION-1] Starting [task-002] 执行 Stage A-2:收紧 move/embed picker 的实验壳边界,tree shell 关闭时不再额外请求 viewer/tree-shell 依赖,空查询路径继续只消费稳定 projection 数据 (base=4538f85d)
[2026-04-14T05:40:04Z] [SESSION-1] ERROR [task-001] [CONFIG] Missing validation.command for umbrella task; execution decomposed into task-002..task-004 [2026-04-17T08:06:06Z] [SESSION-1] CHECKPOINT [task-002] step=1/1 "已让 move/embed picker 在 tree shell 默认关闭时直接走稳定 projection fallback,不再额外请求 viewer/tree-shell 依赖。"
[2026-04-14T05:40:04Z] [SESSION-1] Starting [task-002] 补齐 Rust core-protocol/storage-convex-bridge 对 documents.meta/content/save 的协议与映射 (base=fc26ed21) [2026-04-17T08:06:06Z] [SESSION-1] Completed [task-002] (commit skipped by repo rule; base=4538f85d)
[2026-04-14T05:40:04Z] [SESSION-1] CHECKPOINT [task-002] step=1/1 "已补齐 documents.meta.get / documents.content.get / documents.save 的 Rust 协议对象、Convex 映射与单测" [2026-04-17T08:06:06Z] [SESSION-1] Starting [task-003] 执行 Stage A-3:新增并跑通 3000 首页入口 smoke,验证在 3104 不可用时首页主链仍可进入 /auth 或已登录文档页,不因实验壳阻塞 (base=4538f85d)
[2026-04-14T05:40:04Z] [SESSION-1] Completed [task-002] (commit skipped by repo rule; base=fc26ed21) [2026-04-17T08:12:27Z] [SESSION-1] CHECKPOINT [task-003] step=1/1 "已新增 task097 首页入口 smoke,并验证 3104 不可达时 3000 仍可进入 /auth;问题落在 dev 进程健康度,而非首页主链再次依赖实验壳。"
[2026-04-14T05:40:04Z] [SESSION-1] STATS tasks_total=4 completed=1 failed=1 pending=2 blocked=0 attempts_total=2 checkpoints=2 [2026-04-17T08:12:27Z] [SESSION-1] Completed [task-003] (commit skipped by repo rule; base=4538f85d)
[2026-04-14T05:40:04Z] [SESSION-1] LOCK released [2026-04-17T08:12:27Z] [SESSION-1] Starting [task-004] 执行 Stage B-1:继续收缩前端树域真相泄漏,确认主路径 consumer 只消费 kernel projection,兼容 helper 不再回到运行时主链 (base=4538f85d)
[2026-04-14T05:42:39Z] [SESSION-1] WARN Structured state synchronized after session-1 shell export bug; future Rust validation switched to CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness [2026-04-17T08:11:55Z] [SESSION-1] CHECKPOINT [task-004] step=1/1 "已确认 sidebar / picker / host 主路径不再回退到 buildDocumentTree;树域主消费面继续停留在 kernel projection 上。"
[2026-04-14T05:42:53Z] [SESSION-2] LOCK acquired (pid=2188805) [2026-04-17T08:11:55Z] [SESSION-1] Completed [task-004] (commit skipped by repo rule; base=4538f85d)
[2026-04-14T05:42:53Z] [SESSION-2] INIT Environment health check: PASS [2026-04-17T08:11:55Z] [SESSION-1] Starting [task-005] 执行 Stage B-2:梳理 sidebar/page/filetree 的 create/rename/move 命令入口,给出并落第一批 Rust command envelope 收口边界,避免前端继续扩散树语义 (base=4538f85d)
[2026-04-14T05:42:53Z] [SESSION-2] Starting [task-003] 为 blocks.patch 与正文保存补最小 Rust command 协议和 storage-convex-bridge 映射 (base=fc26ed21) [2026-04-17T08:28:00Z] [SESSION-2] LOCK acquired (pid=resume)
[2026-04-14T05:43:41Z] [SESSION-2] CHECKPOINT [task-003] step=1/2 "已把 blocks.patch 映射纳入 storage-convex-bridge,目标仍对齐当前 documents:updateContent 写链" [2026-04-17T08:28:10Z] [SESSION-2] INIT Resumed long-term harness batch after state cleanup; fixed harness-tasks.json quoting corruption and removed stale harness-tasks.json.tmp.
[2026-04-14T05:43:54Z] [SESSION-2] CHECKPOINT [task-003] step=2/2 "storage-convex-bridge 单测与 workspace cargo check 已通过" [2026-04-17T08:40:10Z] [SESSION-2] CHECKPOINT [task-005] step=1/2 "已完成 tree command 入口与 Rust envelope 接缝审计,并把 create/rename/move 的前端直连入口收成共享 tree command client。"
[2026-04-14T05:43:54Z] [SESSION-2] Completed [task-003] (commit skipped by repo rule; base=fc26ed21) [2026-04-17T08:40:10Z] [SESSION-2] Completed [task-005] (commit skipped by repo rule; base=4538f85d)
[2026-04-14T05:43:54Z] [SESSION-2] STATS tasks_total=4 completed=2 failed=1 pending=1 blocked=0 attempts_total=3 checkpoints=4 [2026-04-17T08:42:00Z] [SESSION-2] CHECKPOINT [task-006] step=1/1 "已产出 Rust Web 正式实时树事件流方案文档,明确 Convex substrate 与 Rust semantic owner 的职责边界。"
[2026-04-14T05:43:54Z] [SESSION-2] LOCK released [2026-04-17T08:42:00Z] [SESSION-2] Completed [task-006] (commit skipped by repo rule; base=4538f85d)
[2026-04-14T06:09:58Z] [SESSION-3] LOCK acquired (pid=manual-sync) [2026-04-17T08:55:41Z] [SESSION-2] CHECKPOINT [task-007] step=1/2 "已定位 3000 无响应根因为 wolai-frontend/scripts/dev-server.js 的 Convex HTTP 代理未显式结束无请求体转发,导致 /convex/* 与 /auth 挂起。"
[2026-04-14T06:09:58Z] [SESSION-3] INIT Environment health check: PASS [2026-04-17T08:56:10Z] [SESSION-2] CHECKPOINT [task-007] step=2/2 "已修复 dev server 的 Convex HTTP 代理并完成回归验证:/convex/version=200, /auth=200, 首页 smoke 通过。"
[2026-04-14T06:09:58Z] [SESSION-3] INFO Previous early stop root cause confirmed: umbrella task lacked validation.command and prior run ended after task-003 without continuing to next eligible task [2026-04-17T08:56:30Z] [SESSION-2] Completed [task-007] (commit skipped by repo rule; base=4538f85d)
[2026-04-14T06:10:01Z] [SESSION-3] Starting [task-004] 为 sidebar.dataset.list 固定 Rust query 契约并对齐 SidebarInitialData 聚合边界 (base=fc26ed21) [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-14T06:10:45Z] [SESSION-3] CHECKPOINT [task-004] step=1/2 "已为 sidebar.dataset.list 冻结共享 query payload/result 契约,并让 route、server helper、实时 hook 复用同一映射边界" [2026-04-17T08:56:30Z] [SESSION-2] LOCK released
[2026-04-14T06:11:23Z] [SESSION-3] CHECKPOINT [task-004] step=2/2 "pnpm test src/lib/sidebar-data.test.ts 与定向 eslint 已通过,Sidebar 聚合边界收口完成"
[2026-04-14T06:11:23Z] [SESSION-3] Completed [task-004] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T06:14:52Z] [SESSION-3] Starting [task-005] 为 documents.save 固定共享 payload 契约并抽出 save command adapter,收口正文保存前后端边界 (base=fc26ed21)
[2026-04-14T06:15:20Z] [SESSION-3] CHECKPOINT [task-005] step=1/2 "已新增 documents.save 共享 payload contract,补齐 revision/conflictDetectionKey 的最小边界并让 BlockNote 保存请求复用"
[2026-04-14T06:16:40Z] [SESSION-3] CHECKPOINT [task-005] step=2/2 "save-contract/bridge 定向测试与 eslint 已通过,正文保存 route 已抽出 save command adapter"
[2026-04-14T06:16:40Z] [SESSION-3] Completed [task-005] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T06:18:30Z] [SESSION-3] STATS tasks_total=6 completed=4 failed=1 pending=1 blocked=0 attempts_total=5 checkpoints=8
[2026-04-14T06:18:30Z] [SESSION-3] LOCK released
[2026-04-14T06:42:18Z] [SESSION-4] CHECKPOINT [task-006] step=1/2 "已补 documents.content/save 的 revision 与 conflictDetectionKey 链路,并让 BlockNote 保存时携带快照采集元数据"
[2026-04-14T06:44:13Z] [SESSION-4] CHECKPOINT [task-006] step=2/2 "已通过 save-contract/bridge 定向测试,正文保存冲突会返回稳定 bridge 错误,checklist 已同步到 C3"
[2026-04-14T06:44:13Z] [SESSION-4] Completed [task-006] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T06:44:13Z] [SESSION-4] STATS tasks_total=6 completed=5 failed=1 pending=0 blocked=0 attempts_total=6 checkpoints=9
[2026-04-14T06:44:13Z] [SESSION-4] LOCK released
[2026-04-14T06:47:35Z] [SESSION-4] INIT Added [task-007] via harness add
[2026-04-14T07:25:47Z] [SESSION-5] LOCK acquired (pid=2564404)
[2026-04-14T07:25:48Z] [SESSION-5] INIT Environment health check: PASS
[2026-04-14T07:25:49Z] [SESSION-5] Starting [task-007] 请完成该checklist /mnt/Data1T/mnote/design/rust-kernel-backport-phase-checklist.md (base=fc26ed21)
[2026-04-14T07:25:50Z] [SESSION-5] CHECKPOINT [task-007] step=1/2 "确认该 checklist 为 umbrella 任务,拆分为 task-008..task-017 的可验证子任务"
[2026-04-14T07:25:51Z] [SESSION-5] ERROR [task-007] [CONFIG] Missing validation.command for umbrella task; execution decomposed into task-008..task-017
[2026-04-14T07:25:52Z] [SESSION-5] Starting [task-008] 统一单仓执行口径并补主仓维护边界文档,清理 ARCHITECTURE 中残留的 mnote-rust 承载叙事 (base=fc26ed21)
[2026-04-14T07:25:54Z] [SESSION-5] CHECKPOINT [task-008] step=1/1 "已新增单仓维护边界文档,并同步 ARCHITECTURE/plan/checklist 的主仓执行口径"
[2026-04-14T07:25:55Z] [SESSION-5] Completed [task-008] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T07:25:55Z] [SESSION-5] Starting [task-009] 固定 Mindmap 与 OnlyOffice 当前边界,明确历史仓对象页壳与第二套 sidebar 不进入主线 (base=fc26ed21)
[2026-04-14T07:25:56Z] [SESSION-5] CHECKPOINT [task-009] step=1/1 "已新增 Mindmap/OnlyOffice 边界说明,并把 Phase D 中可由源码证明的项补到账"
[2026-04-14T07:25:57Z] [SESSION-5] Completed [task-009] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T07:25:57Z] [SESSION-5] Starting [task-010] 冻结 Sidebar Rust query 目标文档,收口 sidebar.dataset.list 的最小 payload/result 契约 (base=fc26ed21)
[2026-04-14T07:25:58Z] [SESSION-5] CHECKPOINT [task-010] step=1/1 "已把 Sidebar Rust query 接入目标、前后端职责边界与禁止事项固定成独立文档"
[2026-04-14T07:25:59Z] [SESSION-5] Completed [task-010] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T07:25:59Z] [SESSION-5] Starting [task-011] 补主仓回迁资产与发布前源码审计文档,完成单仓启动路径、保存口径、trace 一致性与禁止事项复查 (base=fc26ed21)
[2026-04-14T07:26:00Z] [SESSION-5] CHECKPOINT [task-011] step=1/1 "已新增回迁资产清单与发布前源代码审计文档,并同步发布前 checklist 中源码可验证项"
[2026-04-14T07:26:01Z] [SESSION-5] Completed [task-011] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T07:26:01Z] [SESSION-5] Starting [task-012] 补 documents.save 成功路径 bridge 单测,确认低风险保存模式、identity 口径与回查链闭环 (base=fc26ed21)
[2026-04-14T07:26:02Z] [SESSION-5] CHECKPOINT [task-012] step=1/1 "已补 documents.save 成功路径 bridge 测试,并把 C3 的低风险保存模式、identity 口径与可回查项同步到 checklist"
[2026-04-14T07:26:03Z] [SESSION-5] Completed [task-012] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T07:26:05Z] [SESSION-5] STATS tasks_total=17 completed=10 failed=2 pending=5 blocked=0 attempts_total=12 checkpoints=15
[2026-04-14T07:26:06Z] [SESSION-5] LOCK released
[2026-04-14T07:42:15Z] [SESSION-6] LOCK acquired (pid=2803230)
[2026-04-14T07:42:15Z] [SESSION-6] INIT Environment health check: PASS
[2026-04-14T07:42:15Z] [SESSION-6] Starting [task-013] 将至少一条 Sidebar 主查询改为真实 Rust query 供给,并验证文档树/文件树/回收站语义不变 (base=fc26ed21)
[2026-04-14T07:56:00Z] [SESSION-6] CHECKPOINT [task-013] step=1/1 "已新增单个 sidebar.datasetList 主查询,并让 server helper / 实时 hook 统一消费该数据集;sidebar-data/file-tree 定向测试、eslint 与 storage-convex-bridge 单测通过"
[2026-04-14T07:56:00Z] [SESSION-6] Completed [task-013] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T07:56:00Z] [SESSION-6] STATS tasks_total=17 completed=11 failed=2 pending=4 blocked=0 attempts_total=13 checkpoints=16
[2026-04-14T07:56:00Z] [SESSION-6] LOCK released
[2026-04-14T08:13:25Z] [SESSION-7] LOCK acquired (pid=23844)
[2026-04-14T08:13:25Z] [SESSION-7] RECOVERY [task-014] action="resume_validation" reason="exclusive mode found stale in_progress task with no active lock; validating uncommitted implementation"
[2026-04-14T08:13:25Z] [SESSION-7] INIT Environment health check: PASS
[2026-04-14T08:13:25Z] [SESSION-7] Starting [task-014] 统一所有新命令和查询先进入 core-protocol envelope,再转给 storage-convex-bridge (base=fc26ed21)
[2026-04-14T08:14:45Z] [SESSION-7] CHECKPOINT [task-014] step=1/1 "已确认新增命令/查询统一先进入 core-protocol envelope,并完成 storage-convex-bridge 映射与 Rust 单测/检查闭环"
[2026-04-14T08:14:45Z] [SESSION-7] Completed [task-014] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T08:14:45Z] [SESSION-7] Starting [task-015] 补文档页、Sidebar、BlockNote 保存的端到端 UI 回归,并确认外观与交互未退化 (base=fc26ed21)
[2026-04-14T08:14:45Z] [SESSION-7] CHECKPOINT [task-015] step=1/3 "已修复 layout/sidebar route 中未定义 auth 的直接回归点,开始执行前端 lint/test"
[2026-04-14T08:15:16Z] [SESSION-7] CHECKPOINT [task-015] step=2/3 "pnpm lint 已通过(0 error,保留仓库既有 warnings),继续执行全量测试"
[2026-04-14T08:48:19Z] [SESSION-7] Starting [task-016] 统一 Mindmap 与 OnlyOffice 的页面标识、附件标识、页面归属和追踪字段,并补 Mindmap 交互回归 (base=fc26ed21)
[2026-04-14T08:48:19Z] [SESSION-7] CHECKPOINT [task-016] step=1/2 "已为 Mindmap API 与组件侧补统一 meta 口径,对齐 pageId/documentId、attachmentId/mindmapId、workspaceId 与 requestId/traceId"
[2026-04-14T08:50:25Z] [SESSION-7] CHECKPOINT [task-015] step=3/3 "已完成全量前端回归:pnpm lint 0 errorvitest 76/76 通过,文档页/Sidebar/正文保存链未见退化"
[2026-04-14T08:50:25Z] [SESSION-7] Completed [task-015] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T08:50:25Z] [SESSION-7] CHECKPOINT [task-016] step=2/2 "已通过定向 eslint 验证 Mindmap API 与组件侧的统一标识/追踪元信息口径,OnlyOffice 共享口径已同步到设计文档"
[2026-04-14T08:50:25Z] [SESSION-7] Completed [task-016] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T08:50:25Z] [SESSION-7] Starting [task-017] 补 OnlyOffice 打开、签名、代理、callback、forcesave 与静态资源回归,完成单仓发布前收口标准 (base=fc26ed21)
[2026-04-14T08:50:25Z] [SESSION-7] CHECKPOINT [task-017] step=1/1 "开始执行 OnlyOffice 定向 eslint 回归,验证页面、签名、代理、callback、forcesave 边界"
[2026-04-14T08:51:34Z] [SESSION-7] CHECKPOINT [task-017] step=2/2 "已完成 OnlyOffice 页面/API 定向 eslint,且根目录 onlyoffice-web-apps/onlyoffice-plugins/onlyoffice-data 关键静态资源目录仍在"
[2026-04-14T08:51:34Z] [SESSION-7] Completed [task-017] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T08:51:34Z] [SESSION-7] STATS tasks_total=17 completed=15 failed=2 pending=0 blocked=0 attempts_total=17 checkpoints=20
[2026-04-14T08:51:34Z] [SESSION-7] LOCK released
[2026-04-14T11:18:19Z] [SESSION-8] LOCK acquired (pid=3857410)
[2026-04-14T11:18:19Z] [SESSION-8] INIT Environment health check: PASS
[2026-04-14T11:18:19Z] [SESSION-8] Starting [task-018] 把 documents.title/stats/options/save 与 sidebar.dataset.list 的当前 route/adapter 契约同步回 checklist 和计划文档,明确哪些链路已 envelope 化、哪些仍未切到真实 Rust 执行链 (base=fc26ed21)
[2026-04-14T11:20:16Z] [SESSION-8] CHECKPOINT [task-018] step=1/1 "已确认 checklist 与计划文档同步包含 documents.title/stats/options/save、sidebar.dataset.list 的 envelope/真实 Rust bridge 现状说明,无需再补正文"
[2026-04-14T11:20:16Z] [SESSION-8] Completed [task-018] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T11:23:47Z] [SESSION-8] Starting [task-019] 为文档页元信息、Sidebar、BlockNote 保存链补真实浏览器端到端回归,完成发布前 UI 验收缺口并把 checklist 对应项打勾 (base=fc26ed21)
[2026-04-14T11:53:03Z] [SESSION-8] CHECKPOINT [task-019] step=1/2 "已修复真实浏览器回归前置环境:重新注册 Convex functions,首页从 500 恢复为正常 307 跳转到文档页"
[2026-04-14T11:53:03Z] [SESSION-8] CHECKPOINT [task-019] step=2/2 "已新增并跑通 scripts/task019-document-ui-regression.js:真实浏览器验证 Sidebar/页面标题/BlockNote 正文保存与刷新保留,临时页面已自动清理"
[2026-04-14T11:56:09Z] [SESSION-8] Completed [task-019] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T12:25:20Z] [SESSION-8] Starting [task-020] 把 pages 元信息与正文保存链从当前 TypeScript adapter 再向前推进一小步:至少选一条 documents.title.update 或 documents.save,做真实 Rust bridge 执行样板或最小可运行接缝,避免 route->TS adapter 长期停留在临时态 (base=fc26ed21)\n[2026-04-14T12:43:51Z] [SESSION-8] CHECKPOINT [task-020] step=1/2 "已新增 buildDocumentBridgeMutationRequest 与统一执行器,让 documents.title/stats/options/save 先生成与 storage-convex-bridge 对齐的 functionName/payloadJson/args runtime request"
[2026-04-14T12:43:51Z] [SESSION-8] CHECKPOINT [task-020] step=2/2 "已通过 task-020 指定的 cargo test、bridge vitest 与定向 eslint,并同步 plan/checklist 说明最小真实 bridge 运行接缝"
[2026-04-14T12:43:51Z] [SESSION-8] Completed [task-020] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T12:43:51Z] [SESSION-8] Starting [task-021] 补 Mindmap 真实交互回归并同步 checklist,确认当前页面、保存、节点操作与追踪字段口径在浏览器层未退化 (base=fc26ed21)
[2026-04-14T14:01:55Z] [SESSION-8] CHECKPOINT [task-021] step=1/2 "已新增并跑通 scripts/task021-mindmap-ui-regression.js:真实浏览器验证 Mindmap 全屏页元信息、工具栏/大纲侧栏、子节点新增删除与保存刷新链"
[2026-04-14T14:01:55Z] [SESSION-8] CHECKPOINT [task-021] step=2/2 "已通过 task-021 指定的定向 eslint0 error,保留仓库既有 warnings),并同步 checklist 中的 Mindmap 浏览器回归结论"
[2026-04-14T14:01:55Z] [SESSION-8] Completed [task-021] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T14:01:55Z] [SESSION-8] Starting [task-022] 补 OnlyOffice 真实页面打开与回调链回归,确认签名、代理、callback、forcesave 与静态资源在浏览器层可用,并把发布前 checklist 对应项收口 (base=fc26ed21)
[2026-04-14T18:08:23Z] [SESSION-9] LOCK acquired (pid=1767411)
[2026-04-14T18:08:23Z] [SESSION-9] INIT Environment health check: PASS
[2026-04-14T18:08:23Z] [SESSION-9] RECOVERY [task-022] action="reprioritize_to_upgrade_first" reason="user requested ONLYOFFICE upgrade before reconnecting Rust bridge"
[2026-04-14T18:08:23Z] [SESSION-9] WARN [task-022] strategy changed: split upgrade into [task-023] and reconnect regression into [task-024]
[2026-04-14T18:08:23Z] [SESSION-9] STATS tasks_total=24 completed=19 failed=3 pending=2 blocked=0 attempts_total=22 checkpoints=28
[2026-04-14T18:20:18Z] [SESSION-9] Starting [task-023] 升级 ONLYOFFICE 文档服务到当前可用稳定版本,并对齐静态资源、WebSocket 代理、自定义插件与 callback/forcesave 基线 (base=fc26ed21)\n[2026-04-14T18:20:18Z] [SESSION-9] CHECKPOINT [task-023] step=1/3 "已确认升级目标为 GitHub release v9.3.1,且当前 8082 容器仍由旧仓 mnote-rust 的 compose 与插件挂载驱动。"\n[2026-04-14T18:25:24Z] [SESSION-9] CHECKPOINT [task-023] step=2/3 "已在当前主仓补齐 ONLYOFFICE 部署文件,并拉取 onlyoffice/documentserver:9.3.1 镜像,开始接管 8082 容器。"\n[2026-04-14T18:40:31Z] [SESSION-9] CHECKPOINT [task-023] step=3/3 "已用当前主仓 compose 接管 8082 并升级到 onlyoffice/documentserver:9.3.1api.js 探测与 3001 代理均恢复正常。"\n[2026-04-14T18:40:31Z] [SESSION-9] Completed [task-023] (commit skipped by repo rule; base=fc26ed21)\n[2026-04-14T18:40:31Z] [SESSION-9] Starting [task-024] 在升级后的 ONLYOFFICE 基线上,重新连接页面打开、签名、代理、callback、forcesave、自定义插件桥与 Rust 内核接缝,并完成真实浏览器回归与 checklist 收口 (base=fc26ed21)\n[2026-04-14T18:40:31Z] [SESSION-9] CHECKPOINT [task-024] step=1/1 "已修复 /onlyoffice/plugins/* 放行与 agent-tools 非可视插件配置,升级后真实浏览器回归通过,签名/代理/callback/forcesave/插件桥闭环正常。"\n[2026-04-14T18:40:31Z] [SESSION-9] Completed [task-024] (commit skipped by repo rule; base=fc26ed21)\n[2026-04-14T18:40:31Z] [SESSION-9] STATS tasks_total=24 completed=21 failed=3 pending=0 blocked=0 attempts_total=24 checkpoints=32\n[2026-04-14T18:43:06Z] [SESSION-9] Starting [task-025] 把 OnlyOffice callback 附件写回链接到统一 bridge/Rust 协议边界:补最小 media asset writeback command、storage-convex-bridge 映射与 Next route 执行器,替换 callback 里的直连 Convex mutation (base=fc26ed21)\n[2026-04-14T18:43:06Z] [SESSION-9] CHECKPOINT [task-025] step=1/3 "已锁定 OnlyOffice callback 写回链为下一条 Rust 接缝推进目标,因为它仍直接调用 Convex mediaAssets mutation。"\n[2026-04-14T20:07:41Z] [SESSION-9] CHECKPOINT [task-025] step=2/3 "已把 OnlyOffice callback 写回链收口到 media.assets.replace_storage:补齐 core-protocol/storage-convex-bridge 映射、显式 actor bridge context 与 callback 写回执行器。"
[2026-04-14T20:07:41Z] [SESSION-9] CHECKPOINT [task-025] step=3/3 "已通过 cargo test -p core-protocol -p storage-convex-bridge、bridge/callback route 定向测试、定向 eslint 与真实 task022 浏览器回归;中途通过重新注册 Convex functions 修复了 auth:signIn 缺失导致的登录页卡死。"
[2026-04-14T20:07:41Z] [SESSION-9] Completed [task-025] (commit skipped by repo rule; base=fc26ed21)
[2026-04-14T20:07:41Z] [SESSION-9] STATS tasks_total=25 completed=22 failed=3 pending=0 blocked=0 attempts_total=25 checkpoints=34
[2026-04-14T20:07:41Z] [SESSION-9] LOCK released
[2026-04-14T22:51:41Z] [SESSION-10] LOCK acquired (pid=3163948)
[2026-04-14T22:51:41Z] [SESSION-10] INIT Environment health check: PASS
[2026-04-14T22:52:10Z] [SESSION-10] Starting [task-026] 基于 design/rust-kernel-replacement-roadmap-v1.md 建立真实 Rust bridge/runtime 目录与最小执行样板,让至少一条读链和两条写链从 Web route 真实进入 Rust 执行器 (base=de35d423)
[2026-04-14T22:59:47Z] [SESSION-10] CHECKPOINT [task-026] step=1/3 "已确定 task-026 最小样板边界:新增 Rust bridge runtime 二进制,先接管 documents.content.get、documents.title.update、documents.save 的执行计划与参数映射,TS 仅保留 Convex transport。"
[2026-04-14T23:32:53Z] [SESSION-10] CHECKPOINT [task-026] step=2/3 "已完成 Phase 1 最小样板:新增 bridge-runtime crate 与 rust/bridge 目录说明,documents.content.get / documents.title.update / documents.save 现已先经 Rust 生成执行计划,再由 TS 仅做 Convex transportcargo test 全 workspace、bridge.test.ts、定向 eslint 与 runtime stdin 烟测均通过。"
[2026-04-14T23:32:53Z] [SESSION-10] Completed [task-026] (commit skipped by repo rule; base=de35d423)
[2026-04-14T23:33:12Z] [SESSION-10] Starting [task-027] 把 mnote-cli 并入主仓 Rust workspace,先冻结 page/block/search/sidebar/tool 的最小命令面与 --json 输出协议 (base=de35d423)
[2026-04-14T23:49:31Z] [SESSION-10] CHECKPOINT [task-027] step=1/1 "已并入 mnote-cli:新增主仓 workspace crate,冻结 page/block/search/sidebar/tool 五类最小命令面与 --json 输出协议,cargo check/test -p mnote-cli 通过,workspace 全量 cargo test 通过,CLI page get --json 烟测输出稳定。"
[2026-04-14T23:49:31Z] [SESSION-10] Completed [task-027] (commit skipped by repo rule; base=de35d423)
[2026-04-14T23:51:48Z] [SESSION-10] Starting [task-028] 完成页面系统 Rust 化第一批:把 documents.create/move/delete/restore/duplicate/copy-tree 收口到 Rust commandNext route 仅保留 transport 包装 (base=de35d423)
[2026-04-15T00:47:59Z] [SESSION-11] LOCK acquired (pid=main-resume)
[2026-04-15T00:47:59Z] [SESSION-11] RECOVERY [task-028] action="resume_from_uncommitted_state" reason="exclusive mode found stale lock and task remained in_progress with local implementation changes present"
[2026-04-15T00:47:59Z] [SESSION-11] INIT Environment health check: PASS
[2026-04-15T01:36:12Z] [SESSION-11] CHECKPOINT [task-028] step=4/4 "已把 documents.create/move/delete/restore/duplicate/copy-tree 收口到 Rust command + page adapterConvex functions 刷新、cargo test、bridge vitest 与定向 eslint0 error)已通过"
[2026-04-15T01:36:12Z] [SESSION-11] Completed [task-028] (commit skipped by repo rule; base=de35d423)
[2026-04-15T01:36:12Z] [SESSION-11] STATS tasks_total=35 completed=25 failed=3 pending=7 blocked=0 attempts_total=28 checkpoints=69
[2026-04-15T01:36:12Z] [SESSION-11] LOCK released
[2026-04-15T01:45:50Z] [SESSION-12] LOCK acquired (pid=main-session12)
[2026-04-15T01:45:50Z] [SESSION-12] INIT Environment health check: PASS
[2026-04-15T01:45:50Z] [SESSION-12] Starting [task-029] 完成块系统 Rust 化第一批:把 blocks.get/patch/move/embed 与 AI 文档写入工具统一到 Rust block ops 协议 (base=de35d423)
[2026-04-15T01:49:32Z] [SESSION-12] CHECKPOINT [task-029] step=1/3 "已确认 block route/AI 文档写入仍存在 TS 业务拼接,开始收口到统一 block ops 适配层"
[2026-04-15T01:56:41Z] [SESSION-13] LOCK acquired (pid=4119291)
[2026-04-15T01:56:41Z] [SESSION-13] RECOVERY [task-029] action="resume_from_uncommitted_state" reason="exclusive mode found stale task-029 in_progress with no active lock; continuing unfinished Rust block ops integration"
[2026-04-15T01:56:41Z] [SESSION-13] INIT Environment health check: PASS
[2026-04-15T02:17:43Z] [SESSION-12] CHECKPOINT [task-029] step=2/3 "AI 文档写入工具改为复用纯 block ops 适配层,不再依赖长期的 loadBlocks/saveBlocks 直连路径"
[2026-04-15T02:17:43Z] [SESSION-12] CHECKPOINT [task-029] step=3/3 "已补定向 vitesteslint 仅剩仓库既有 warnings,未引入新 error"
[2026-04-15T02:17:43Z] [SESSION-12] STATS tasks_total=35 completed=25 failed=3 pending=6 blocked=0 attempts_total=28 checkpoints=70
[2026-04-15T02:51:38Z] [SESSION-13] CHECKPOINT [task-029] step=1/3 "已补 blocks.get/patch/move/embed 的 Rust 契约与共享 block adapter4 个 route 收口为 transport 薄层"
[2026-04-15T02:51:38Z] [SESSION-13] CHECKPOINT [task-029] step=2/3 "AI doc_insert_blocks/doc_replace_range 已切到共享块快照 adapter,不再直连旧 saveBlocks 注入"
[2026-04-15T02:51:38Z] [SESSION-13] CHECKPOINT [task-029] step=3/3 "已通过 cargo test --manifest-path rust/Cargo.toml 与 task-029 指定 eslint(仅仓库既有 warnings,无新增 error"
[2026-04-15T02:51:38Z] [SESSION-13] Completed [task-029] (commit skipped by repo rule; base=de35d423)
[2026-04-15T02:51:38Z] [SESSION-13] STATS tasks_total=35 completed=26 failed=3 pending=6 blocked=0 attempts_total=28 checkpoints=70
[2026-04-15T02:52:21Z] [SESSION-13] LOCK released
[2026-04-15T03:02:25Z] [SESSION-14] LOCK acquired (pid=300141)
[2026-04-15T03:02:25Z] [SESSION-14] INIT Environment health check: PASS
[2026-04-15T03:55:00Z] [SESSION-14] CHECKPOINT [task-030] step=1/3 "已让 search.documents、search.recent、sidebar.dataset.list 进入 Rust query/runtime 主链,route 不再持有核心 ranking/snippet 逻辑"
[2026-04-15T03:55:30Z] [SESSION-14] CHECKPOINT [task-030] step=2/3 "已把 index-fts 接入产品搜索主路径,新增 evaluate_search_documents 负责召回合并、高亮 snippet 与 OCR 待补队列决策"
[2026-04-15T03:56:00Z] [SESSION-14] CHECKPOINT [task-030] step=3/3 "已通过 cargo test -p index-fts -p storage-convex-bridge -p bridge-runtime 与 task-030 指定 eslinttsc 失败仍为仓库既有 Convex/类型问题,非本次新增"
[2026-04-15T03:56:00Z] [SESSION-14] Completed [task-030] (commit skipped by repo rule; base=de35d423)
[2026-04-15T03:56:00Z] [SESSION-14] STATS tasks_total=35 completed=27 failed=3 pending=5 blocked=0 attempts_total=28 checkpoints=70
[2026-04-15T03:56:05Z] [SESSION-14] LOCK released
[2026-04-15T05:23:51Z] [SESSION-15] LOCK acquired (pid=1000177)
[2026-04-15T05:23:51Z] [SESSION-15] INIT Environment health check: PASS
[2026-04-15T05:23:51Z] [SESSION-15] Starting [task-031] 为 Rust 内核建立统一 Tool 协议与注册表,让 CLI 与 AI 共享 doc_get/doc_find/doc_insert_blocks/doc_replace_range 等一组同源工具 (base=de35d423)
[2026-04-15T05:58:48Z] [SESSION-15] CHECKPOINT [task-031] step=1/3 "已在 Rust 侧补齐 tool registry、Tool execution mode 与 bridge-runtime Tool 计划/结果执行链"
[2026-04-15T05:58:48Z] [SESSION-15] CHECKPOINT [task-031] step=2/3 "CLI tool run 与 /api/ai-agent/run 的 doc_* 已切到统一 Rust tool protocol,不再以前端私有 docTools 作为 Convex 主路径真入口"
[2026-04-15T05:58:48Z] [SESSION-15] CHECKPOINT [task-031] step=3/3 "已补 explain-plan/result 模式并通过 cargo test、eslint、CLI/runtime 烟测;trace/request 字段沿用统一 bridge context 口径"
[2026-04-15T06:01:57Z] [SESSION-15] Completed [task-031] (commit skipped by repo rule; base=de35d423)
[2026-04-15T06:01:57Z] [SESSION-15] STATS tasks_total=35 completed=28 failed=3 pending=4 blocked=0 attempts_total=29 checkpoints=46
[2026-04-15T06:01:57Z] [SESSION-15] LOCK released
[2026-04-15T06:26:48Z] [SESSION-16] LOCK acquired (pid=main-session16)
[2026-04-15T06:26:48Z] [SESSION-16] INIT Environment health check: PASS
[2026-04-15T06:26:48Z] [SESSION-16] Starting [task-032] 并入 adapter-mindmap,形成可被 Web/CLI/AI 共用的 Rust mindmap ops 协议与执行层 (base=de35d423)
[2026-04-15T07:07:41Z] [SESSION-17] LOCK acquired (pid=main-session17)
[2026-04-15T07:07:41Z] [SESSION-17] RECOVERY [task-032] action="resume_from_uncommitted_state" reason="exclusive mode found task-032 in_progress with no active lock; continuing unfinished Rust mindmap integration"
[2026-04-15T07:07:41Z] [SESSION-17] INIT Environment health check: PASS
[2026-04-15T07:15:09Z] [SESSION-17] CHECKPOINT [task-032] step=1/3 "已修复 Rust mindmap 协议兼容层:MindmapOp 支持 camelCase/历史 aliasbridge-runtime 与 CLI 定向 cargo test 全部通过"
[2026-04-15T07:21:09Z] [SESSION-17] CHECKPOINT [task-032] step=2/3 "已把 legacy mindmap route 收口到 Rust query/command planAI mindmap tools 已接入 executeRustBridgeTool,核心读写与高阶包装工具统一落到 Rust tool runtime"
[2026-04-15T07:24:31Z] [SESSION-17] CHECKPOINT [task-032] step=3/3 "已通过 workspace cargo test、task-032 指定 eslint、CLI smokelegacy mindmap route 与 AI mindmap tools 均已切到 Rust bridge/runtime 主链"
[2026-04-15T07:24:31Z] [SESSION-17] Completed [task-032] (commit skipped by repo rule; base=de35d423)
[$TS] [SESSION-17] STATS tasks_total=35 completed=29 failed=3 pending=3 blocked=0 attempts_total=30 checkpoints=70
[2026-04-15T07:24:31Z] [SESSION-17] LOCK released
[2026-04-15T07:29:56Z] [SESSION-18] LOCK acquired (pid=1699064)
[2026-04-15T07:29:56Z] [SESSION-18] INIT Environment health check: PASS
[2026-04-15T07:29:56Z] [SESSION-18] Starting [task-033] 并入 adapter-onlyoffice,把 sign/proxy/callback/forcesave/session 收口到 Rust 对象适配层,OnlyOffice 前端页只保留 transport 与展示职责 (base=de35d423)
[2026-04-15T08:02:28Z] [SESSION-18] CHECKPOINT [task-033] step=1/3 "已并入 adapter-onlyoffice,补齐 asset/session/sign/proxy/callback/forcesave 的 Rust 对象边界与 tool registry"
[2026-04-15T08:02:28Z] [SESSION-18] CHECKPOINT [task-033] step=2/3 "OnlyOffice sign/proxy/callback/forcesave route 已收口为共享 adapter + Rust tool 包装,前端页只保留展示与 transport 组装"
[2026-04-15T08:02:28Z] [SESSION-18] CHECKPOINT [task-033] step=3/3 "已通过 Rust workspace 测试、OnlyOffice 定向 vitest/eslint 与真实 task022 浏览器回归;并修正 mnote-env.json 与回归脚本默认端口到 3000"
[2026-04-15T08:02:28Z] [SESSION-18] Completed [task-033] (commit skipped by repo rule; base=de35d423)
[2026-04-15T08:02:28Z] [SESSION-18] STATS tasks_total=35 completed=30 failed=3 pending=2 blocked=0 attempts_total=31 checkpoints=70
[2026-04-15T08:02:28Z] [SESSION-18] LOCK released
[2026-04-15T08:21:36Z] [SESSION-19] LOCK acquired (pid=1981918)
[2026-04-15T08:21:36Z] [SESSION-19] INIT Environment health check: PASS
[2026-04-15T08:21:36Z] [SESSION-19] Starting [task-034] 建立 Rust 内核统一观测面:补全 command/event/request/trace 的全域落账、幂等、冲突、回放和索引重建命令,作为 CLI 与 AI 稳定运行前置条件 (base=de35d423)
[2026-04-15T08:40:21Z] [SESSION-19] CHECKPOINT [task-034] step=1/3 "已统一 bridge request/trace/command 的 Rust query/tool 协议,并让 /api/bridge/request 与 /api/bridge/trace 切到 Rust runtime transport 主链"
[2026-04-15T08:40:21Z] [SESSION-19] CHECKPOINT [task-034] step=2/3 "已补 event_replay 与 index_rebuild 恢复命令,bridge-runtime 可直接输出回放/重建摘要,cargo test 与 bridge 定向 eslint 已通过"
[2026-04-15T08:40:21Z] [SESSION-19] CHECKPOINT [task-034] step=3/3 "已同步路线图、missing-targets、phase checklist 与 core 设计文档,把统一观测面写成 Rust cutover 的硬前置条件"
[2026-04-15T08:40:21Z] [SESSION-19] Completed [task-034] (commit skipped by repo rule; base=de35d423)
[2026-04-15T08:40:21Z] [SESSION-19] STATS tasks_total=35 completed=31 failed=3 pending=1 blocked=0 attempts_total=32 checkpoints=70
[2026-04-15T08:56:43Z] [SESSION-20] LOCK acquired (pid=2168549)
[2026-04-15T08:56:43Z] [SESSION-20] INIT Environment health check: PASS
[2026-04-15T08:56:43Z] [SESSION-20] Starting [task-035] 制定并执行旧 TS 执行面退役清单:按路线图阶段标记哪些 route 仅剩 transport,哪些业务逻辑允许删除,并形成 Rust 成为唯一业务执行平面的最终 cutover 文档 (base=de35d423)
[2026-04-15T09:01:04Z] [SESSION-20] CHECKPOINT [task-035] step=1/3 "已按能力域盘点主仓 route,明确页面、块、查询、AI、Mindmap、OnlyOffice 分别属于 Rust owner、transport keep 或旧 TS 兼容面"
[2026-04-15T09:01:04Z] [SESSION-20] CHECKPOINT [task-035] step=2/3 "已补最终 cutover 文档,写清 Phase 8 四组 gate、允许删除与必须保留的 Web 壳,以及旧 TS 执行面退役顺序"
[2026-04-15T09:01:04Z] [SESSION-20] CHECKPOINT [task-035] step=3/3 "已同步 roadmap、missing-targets、phase checklist 与 release audit,并通过 task-035 验证脚本"
[2026-04-15T09:01:04Z] [SESSION-20] Completed [task-035] (commit skipped by repo rule; base=de35d423)
[2026-04-15T09:01:04Z] [SESSION-20] STATS tasks_total=35 completed=32 failed=3 pending=0 blocked=0 attempts_total=33 checkpoints=70
[2026-04-15T10:04:37Z] [SESSION-20] INFO Pending queue rebuilt from rust-kernel-cutover-v1.md; appended [task-036]..[task-044] to cover remaining TS_COMPAT_PENDING page routes, AI builtins/tool cutover, mindmap leftovers, observability gaps, status logging, TS_LEGACY_DELETE cleanup, and final Phase 8 verification
[2026-04-15T10:50:15Z] [SESSION-21] LOCK acquired (pid=main-session21)
[2026-04-15T10:50:15Z] [SESSION-21] INIT Environment health check: PASS
[2026-04-15T10:50:15Z] [SESSION-21] Starting [task-041] 补齐统一观测面 workspace 总览、分页与按对象/状态过滤,让 bridge 查询成为 cutover 阶段的正式运营与回查入口 (base=de35d423)
[2026-04-15T10:50:15Z] [SESSION-21] CHECKPOINT [task-041] step=1/2 "已补 bridge.workspace.overviewConvex bridgeLogs 支持 workspace 总览、cursor 分页、command/event 状态过滤、对象过滤;Rust query/runtime 与 /api/bridge/workspace 已接通。"
[2026-04-15T10:50:15Z] [SESSION-21] CHECKPOINT [task-041] step=2/2 "已通过 cargo test -p bridge-runtime -p storage-convex-bridge、src/lib/documents/bridge.test.ts、bridge workspace/request/trace 定向 eslint;仅剩仓库既有 any warnings。"
[2026-04-15T10:50:15Z] [SESSION-21] Completed [task-041] (commit skipped by repo rule; base=de35d423)
[2026-04-15T11:03:14Z] [SESSION-21] Starting [task-036] 页面兼容旧面第一批 Rust 化:把 documents/create-child、documents/embed、documents/template 收口到统一 page command/query adapterroute 仅保留 transport (base=de35d423)
[2026-04-15T11:03:14Z] [SESSION-21] CHECKPOINT [task-036] step=1/2 "已把 create-child/embed/template 三条页面旧路由收口到统一 page adapterroute 仅保留 transport。"
[2026-04-15T11:03:14Z] [SESSION-21] CHECKPOINT [task-036] step=2/2 "已通过 route-adapters/bridge 定向测试、cargo test -p bridge-runtime -p storage-convex-bridge 与 task-036 指定 eslint。"
[2026-04-15T11:03:14Z] [SESSION-21] Completed [task-036] (commit skipped by repo rule; base=de35d423)
[2026-04-15T11:03:14Z] [SESSION-21] Starting [task-037] 页面兼容旧面第二批 Rust 化:把 documents/empty-trash、documents/purge 收口到 Rust lifecycle command,并切断 route 内直连 Convex 业务逻辑 (base=de35d423)
[2026-04-15T11:03:14Z] [SESSION-21] CHECKPOINT [task-037] step=1/2 "已把 empty-trash/purge 收口到统一 page adapter,并补统一失败态落账接缝。"
[2026-04-15T11:03:14Z] [SESSION-21] CHECKPOINT [task-037] step=2/2 "已通过 task-037 指定 cargo test 与 eslint;页面旧面第二批已降级为 transport route。"
[2026-04-15T11:03:14Z] [SESSION-21] Completed [task-037] (commit skipped by repo rule; base=de35d423)
[2026-04-15T11:03:14Z] [SESSION-21] Starting [task-039] 建立 AI 工具 cutover 矩阵:盘点 builtins/**、ai-agent/run 余下工具与 Rust Tool registry 的一一对应,并冻结迁移顺序与删除清单 (base=de35d423)
[2026-04-15T11:03:14Z] [SESSION-21] CHECKPOINT [task-039] step=1/2 "已新增 ai-tool-cutover-matrix,覆盖 docsServerTools/mediaServerTools/mindmapServerTools/onlyofficeServerTools/registryBuiltins/ai-agent/run 的映射关系。"
[2026-04-15T11:03:14Z] [SESSION-21] CHECKPOINT [task-039] step=2/2 "已补 search_web/image_read/slash_run 的 Rust tool registry 对照,并通过 task-039 文档校验。"
[2026-04-15T11:03:14Z] [SESSION-21] Completed [task-039] (commit skipped by repo rule; base=de35d423)
[2026-04-15T11:03:14Z] [SESSION-21] ERROR [task-038] [TASK_EXEC] delegated subagent failed before code delivery: Missing required parameter: input[204].name; task reclaimed by main session for local execution
[2026-04-15T11:06:05Z] [SESSION-21] Starting [task-038] Mindmap 剩余旧面收口:把 mindmap-trash/empty 与 mindmap-ai/* 迁到 Rust mindmap adapter/tool,明确 route 只做产品编排而不是对象规则真入口 (base=de35d423)
[2026-04-15T11:06:05Z] [SESSION-21] CHECKPOINT [task-038] step=1/2 "已确认 mindmap-trash/empty 与 mindmap-ai 主路径接入 mindmap_empty_trash、mindmap_apply_ops 等 Rust tool/runtime。"
[2026-04-15T11:06:05Z] [SESSION-21] CHECKPOINT [task-038] step=2/2 "已通过 task-038 指定 cargo test 与 eslint;仅保留仓库既有 unused/no-explicit-any warnings。"
[2026-04-15T11:06:05Z] [SESSION-21] Completed [task-038] (commit skipped by repo rule; base=de35d423)
[2026-04-15T11:32:44Z] [SESSION-21] Starting [task-040] 迁移 AI 非 doc_* 核心工具到 Rust Tool runtime,收缩 ai-agent/run 与 builtins/** 私有真入口,只保留 client-tool-result 这类 transport 壳 (base=de35d423)
[2026-04-15T11:32:44Z] [SESSION-21] CHECKPOINT [task-040] step=1/2 "已补 bridge-runtime 对 search_web/image_read/slash_run 的 tool plan 与 slash 结果解析,ai-agent/run 不再保留 search_web 的 TS fallbackmedia/slash builtins 只剩 transport helper。"
[2026-04-15T11:32:44Z] [SESSION-21] CHECKPOINT [task-040] step=2/2 "已通过 cargo test -p bridge-runtime -p mnote-cli 与 task-040 指定 eslint;仅剩仓库既有 any/unused warnings。"
[2026-04-15T11:32:44Z] [SESSION-21] Completed [task-040] (commit skipped by repo rule; base=de35d423)
[2026-04-15T11:32:44Z] [SESSION-21] Starting [task-042] 把失败态、冲突态与补偿态统一落账到 bridge-log/runtime,覆盖页面主写链与 AI、Mindmap 写链,完成 cutover 所需状态语义闭环 (base=de35d423)
[2026-04-15T11:32:44Z] [SESSION-21] CHECKPOINT [task-042] step=1/2 "已补 bridge-log 失败/补偿状态语义、metadata command 失败落账,以及 AI/Mindmap 写链的统一失败态记录;mindmap 本地 ops 冲突不再伪装成功。"
[2026-04-15T11:32:44Z] [SESSION-21] CHECKPOINT [task-042] step=2/2 "已通过 cargo test -p bridge-runtime -p storage-convex-bridge、src/lib/documents/bridge.test.ts 与 task-042 指定 eslint;仅剩仓库既有 any/unused warnings。"
[2026-04-15T11:32:44Z] [SESSION-21] Completed [task-042] (commit skipped by repo rule; base=de35d423)
[2026-04-15T11:32:44Z] [SESSION-21] Starting [task-043] 按 cutover 文档执行第一批 TS_LEGACY_DELETE:先停写并切调用方,再删除已被 Rust 替代的 builtins 服务端真入口与过渡 helper (base=de35d423)
[2026-04-15T11:32:44Z] [SESSION-21] CHECKPOINT [task-043] step=1/2 "已新增 rust-kernel-legacy-delete-list.md,冻结第一批 TS_LEGACY_DELETE 清单:documents/create-child/embed/empty-trash/purge/template、mindmap-trash/empty、builtins/**。"
[2026-04-15T11:32:44Z] [SESSION-21] CHECKPOINT [task-043] step=2/2 "已通过 task-043 文档校验与指定 eslint;仅剩仓库既有 any/unused warnings。"
[2026-04-15T11:32:44Z] [SESSION-21] Completed [task-043] (commit skipped by repo rule; base=de35d423)
[2026-04-15T11:32:44Z] [SESSION-21] Starting [task-044] 完成 Phase 8 cutover 最终验证与文档回写,确认剩余 Web route 全部降级为 TS_TRANSPORT_KEEP 或 Rust owner,并形成可对外宣布的统一口径 (base=de35d423)
[2026-04-15T11:32:44Z] [SESSION-21] CHECKPOINT [task-044] step=1/2 "已回写 cutover/release-audit/phase-checklist/missing-targets,补齐 Phase 8 最终口径、TS_TRANSPORT_KEEP/TS_LEGACY_DELETE 边界与可对外宣布文案。"
[2026-04-15T11:32:44Z] [SESSION-21] CHECKPOINT [task-044] step=2/2 "已通过 task-044 文档校验脚本。"
[2026-04-15T11:32:44Z] [SESSION-21] Completed [task-044] (commit skipped by repo rule; base=de35d423)
[2026-04-15T11:32:44Z] [SESSION-21] STATS tasks_total=44 completed=41 failed=3 pending=0 blocked=0 attempts_total=42 checkpoints=88
[2026-04-15T11:53:44Z] [SESSION-22] LOCK acquired (pid=main-session22)
[2026-04-15T11:53:44Z] [SESSION-22] INIT Environment health check: PASS
[2026-04-15T11:53:44Z] [SESSION-22] RECOVERY [task-001] action="mark_completed_from_decomposed_subtasks" reason="task-002..task-006 all completed; umbrella task retained only for audit trace"
[2026-04-15T11:53:44Z] [SESSION-22] RECOVERY [task-007] action="mark_completed_from_decomposed_subtasks" reason="task-008..task-015 all completed; umbrella task retained only for audit trace"
[2026-04-15T11:53:44Z] [SESSION-22] RECOVERY [task-022] action="mark_completed_from_replacement_tasks" reason="task-023/task-024 completed after ONLYOFFICE upgrade and reconnect flow"
[2026-04-15T11:53:44Z] [SESSION-22] STATS tasks_total=44 completed=44 failed=0 pending=0 blocked=0 attempts_total=42 checkpoints=91
[2026-04-15T11:53:44Z] [SESSION-22] LOCK released
[2026-04-15T13:15:38Z] [SESSION-23] LOCK acquired (pid=manual-task045)
[2026-04-15T13:15:38Z] [SESSION-23] CHECKPOINT [task-045] step=1/3 "已把第一批 TS_LEGACY_DELETE 的真实边界写实:当前不可直接批量删除 route 文件,必须先清 adapter 内 TS 真执行,并去掉 mindmap-trash/empty 的伪 Rust 包装。"
[2026-04-15T13:15:38Z] [SESSION-23] Completed [task-045] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-15T13:15:38Z] [SESSION-23] STATS tasks_total=53 completed=45 failed=0 pending=8 blocked=0 attempts_total=43 checkpoints=119
[2026-04-15T13:15:38Z] [SESSION-23] LOCK released
[2026-04-15T13:57:26Z] [SESSION-24] LOCK acquired (pid=3830030)
[2026-04-15T13:57:26Z] [SESSION-24] INIT Environment health check: PASS
[2026-04-15T13:57:26Z] [SESSION-24] Starting [task-046] 执行最终收尾 F2-A:清理页面系统残留的“Rust 包装 + TS 真执行”双路径,把 create-child/embed/empty-trash/purge/template 从 adapter 内直连 Convex mutation 推进到真正 Rust owner 或明确 transport 壳 (base=4fbab6dd)
[2026-04-15T13:57:26Z] [SESSION-24] CHECKPOINT [task-046] step=1/3 "已盘点 page-command-adapter 真实残留:create-child/template/empty-trash/purge 为假 cutoverembed 仍保留 TS 内容编排。"
[2026-04-15T13:57:26Z] [SESSION-24] CHECKPOINT [task-046] step=2/3 "已将 create-child 切到 documents.createtemplate/empty-trash/purge 切到统一 Rust runtime + transportembed 改走 documents.save,不再直连 Convex mutation。"
[2026-04-15T13:57:26Z] [SESSION-24] CHECKPOINT [task-046] step=3/3 "已更新 cutover/legacy-delete/release-audit 文档,并通过 task-046 指定 eslint、route-adapters 测试、bridge-runtime/storage-convex-bridge cargo test。"
[2026-04-15T13:57:26Z] [SESSION-24] Completed [task-046] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-15T15:43:04Z] [SESSION-25] LOCK acquired (pid=manual-session25)
[2026-04-15T15:43:04Z] [SESSION-25] INIT Environment health check: PASS
[2026-04-15T15:43:04Z] [SESSION-25] Starting [task-047] 执行最终收尾 F2-B:清理块系统与 Mindmap 残留双路径,重点处理 block-command-adapter 与 mindmap-trash/empty 中“先走 Rust 再直接 Convex mutation”的假完成状态 (base=4fbab6dd)
[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-047] step=1/3 "已确认现网 /api/blocks/* 主链统一走 src/lib/blocks/block-command-adapter.tssrc/lib/documents/block-command-adapter.ts 仅作为遗留兼容 helper 冻结。"
[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-047] step=2/3 "已把 mindmap-trash/empty 与 mindmap 删除/恢复/清空回收站路径收口到 Rust runtime/transport,不再由 route 直接调用 Convex mutation。"
[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-047] step=3/3 "已通过 task-047 指定 eslint 与 bridge-runtime/storage-convex-bridge cargo test,并同步 release audit/cutover 文档口径。"
[2026-04-15T15:43:04Z] [SESSION-25] Completed [task-047] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-050] step=1/3 "asset_to_mindmap 的实际导图写入已切到 Rust mindmap_apply_opsoutline-to-mindmap 的 Rust 结果读取接缝已修正。"
[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-050] step=2/3 "已把 docsServerTools/onlyofficeServerTools/ai-agent-run 的当前边界写实:docs_* 仍是 TS 兼容查询层,asset_extract_outline 仍依赖 TS/MinerU。"
[2026-04-15T15:43:04Z] [SESSION-25] WARN [task-050] 保持 pendingdocs_search/docs_read 仍缺少 Rust runtimeAI 工具链运行态证据未闭合
[2026-04-15T15:43:04Z] [SESSION-25] Starting [task-051] 执行最终收尾 F5-A:做文档页、Sidebar 与主写链的发布前运行态复核,确认 legacy delete 与双路径收缩后页面编辑、保存、回查、刷新未退化 (base=4fbab6dd)
[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-051] step=1/3 "已复跑 scripts/task019-document-ui-regression.js,确认文档页标题、正文、保存、刷新与回查链通过。"
[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-051] step=2/3 "已通过 blocknote-editor、documents/save、sidebar route 定向 eslint;仅剩仓库既有 warnings。"
[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-051] step=3/3 "已把文档页运行态结果同步到 release audit 与 final closure checklist,并移除对 CLI 线的无关依赖。"
[2026-04-15T15:43:04Z] [SESSION-25] Completed [task-051] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-048] step=1/3 "已直接核实 mnote-cli 当前能编译并输出稳定 JSON,但 page get / block insert 仍只返回 transport plan,而不是真执行结果。"
[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-052] step=1/3 "已复跑 scripts/task021-mindmap-ui-regression.js,确认新增/删除节点、保存链、刷新回查与 requestId/traceId 元信息同步通过。"
[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-052] step=2/3 "已复跑 scripts/task022-onlyoffice-ui-regression.js,确认打开、插件桥、callback、forcesave 与 storage_id 变化通过;定向 eslint 无 error。"
[2026-04-15T15:43:04Z] [SESSION-25] WARN [task-052] 保持 pending:AI 工具链仍缺独立运行态通过证据,需等待 task-050 继续收口
[2026-04-15T15:43:04Z] [SESSION-25] STATS tasks_total=58 completed=48 failed=0 pending=10 blocked=0 attempts_total=46 checkpoints=145
[2026-04-15T15:43:04Z] [SESSION-25] LOCK released
[2026-04-16T02:29:29Z] [SESSION-26] LOCK acquired (pid=manual-session26)
[2026-04-16T02:29:30Z] [SESSION-26] INIT Environment health check: PASS
[2026-04-16T02:29:31Z] [SESSION-26] Starting [task-048] 执行最终收尾 F3-A:把 mnote-cli 从“计划输出/协议冻结器”推进到页面与块系统的最小真实执行 CLI,至少覆盖 page get/title/save/create/delete/move 与 block insert/patch/move/embed (base=4fbab6dd)
[2026-04-16T02:29:32Z] [SESSION-26] CHECKPOINT [task-048] step=1/3 "已修复 mnote-cli 的 execute_block_patch 块树回写形状问题,block move --execute 不再因 snapshot 损坏失败。"
[2026-04-16T02:29:33Z] [SESSION-26] CHECKPOINT [task-048] step=2/3 "已扩展 rust/scripts/task049-cli-smoke.sh,覆盖 page get/create/title/save/move/delete/restore 与 block insert/patch/move/embed,并实跑通过。"
[2026-04-16T02:29:34Z] [SESSION-26] CHECKPOINT [task-048] step=3/3 "已通过 cargo test -p mnote-cli;页面与块系统 CLI 已进入最小真实执行态。"
[2026-04-16T02:29:35Z] [SESSION-26] Completed [task-048] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T02:29:36Z] [SESSION-26] Starting [task-049] 执行最终收尾 F3-B:继续把 search/sidebar/mindmap/tool 主链推进到可真实执行 CLI,并补齐 rust/scripts、tests、fixtures 的最小 CLI 验收骨架 (base=4fbab6dd)
[2026-04-16T02:29:37Z] [SESSION-26] CHECKPOINT [task-049] step=1/3 "已把 search documents、sidebar dataset、tool run docs_search/docs_read 与 mindmap put/get/op 纳入仓库内 smoke。"
[2026-04-16T02:29:38Z] [SESSION-26] CHECKPOINT [task-049] step=2/3 "已更新 rust/tests/README.md,记录 /tmp/task049-cli-smoke-* 输出与 mindmap op 的 result-only 验收边界。"
[2026-04-16T02:29:39Z] [SESSION-26] CHECKPOINT [task-049] step=3/3 "已通过 cargo test -p bridge-runtime 与新版 task049-cli-smoke.shCLI 主链验收骨架闭合。"
[2026-04-16T02:29:40Z] [SESSION-26] Completed [task-049] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T02:29:41Z] [SESSION-26] Starting [task-050] 执行最终收尾 F4:继续收掉 AI 工具矩阵中的 TS_COMPAT_PENDING,优先处理 docs_search/docs_read/asset_to_mindmap,并把 remaining builtins 全部归类为 Rust owner 或 TS_TRANSPORT_KEEP (base=4fbab6dd)
[2026-04-16T02:29:42Z] [SESSION-26] CHECKPOINT [task-050] step=1/3 "已核实 Convex 主链下 ai-agent/run 通过 executeRustBridgeTool(...) 执行 docs_search/docs_readdocsServerTools 仅保留 fallback。"
[2026-04-16T02:29:43Z] [SESSION-26] CHECKPOINT [task-050] step=2/3 "已将 AI 工具矩阵回写为 docs_search/docs_read=RUST_OWNERasset_extract_outline/asset_to_mindmap/oo_*=TS_TRANSPORT_KEEPbuiltins 主链不再保留 TS_COMPAT_PENDING。"
[2026-04-16T02:29:44Z] [SESSION-26] CHECKPOINT [task-050] step=3/3 "已通过 bridge-runtime 测试、runAgent.test.ts 与 task-050 关联 eslintAI 工具矩阵收口完成。"
[2026-04-16T02:29:45Z] [SESSION-26] Completed [task-050] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T02:29:46Z] [SESSION-26] Starting [task-052] 执行最终收尾 F5-B:做 Mindmap、OnlyOffice 与 AI 工具链的发布前运行态复核,确认对象域与工具域在最终收尾后仍可稳定工作 (base=4fbab6dd)
[2026-04-16T02:29:47Z] [SESSION-26] CHECKPOINT [task-052] step=3/3 "已通过 runAgent.test.ts 补齐 docs_search -> docs_read 的 AI runtime 证据;结合 task021/task022 浏览器回归,Mindmap/OnlyOffice/AI 工具链运行态闭合。"
[2026-04-16T02:29:48Z] [SESSION-26] Completed [task-052] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T02:29:49Z] [SESSION-26] Starting [task-053] 执行最终收尾 F6:统一最终宣布口径,确保 cutover/release-audit/final-closure-checklist/harness 任务状态与真实代码完全一致,并给出可对外使用的最终完成结论 (base=4fbab6dd)
[2026-04-16T02:29:50Z] [SESSION-26] CHECKPOINT [task-053] step=1/3 "已回写 ai-tool-cutover-matrix、release-readiness-source-audit、rust-kernel-final-closure-checklist、rust-kernel-cutover-v1 到同一最终口径。"
[2026-04-16T02:29:51Z] [SESSION-26] CHECKPOINT [task-053] step=2/3 "已把 task-048/049/050/052/053 的验证命令更新为当前真实 smoke/test,并同步完成状态。"
[2026-04-16T02:29:52Z] [SESSION-26] CHECKPOINT [task-053] step=3/3 "统一结论:Rust 已成为 mnote 的主业务执行平面,TS 仅保留 transport、外部服务编排、客户端桥与冻结兼容壳。"
[2026-04-16T02:29:53Z] [SESSION-26] Completed [task-053] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T02:29:54Z] [SESSION-26] STATS tasks_total=58 completed=53 failed=0 pending=5 blocked=0 attempts_total=51 checkpoints=158
[2026-04-16T02:29:55Z] [SESSION-26] LOCK released
[2026-04-16T04:51:54Z] [SESSION-27] LOCK acquired (pid=manual-session27)
[2026-04-16T04:51:55Z] [SESSION-27] INIT Environment health check: PASS
[2026-04-16T04:51:56Z] [SESSION-27] Starting [task-054] 按 AI 前端精简方案 v1 执行第一阶段:启用并验证本机 Hermes API Server,把 /api/ai-agent/run 从前端 AI 编排器收口为 Hermes bridge,停止在 Next route 内继续扩展 tool registry、builtin toolset 和 create*ServerTools 编排逻辑 (base=4fbab6dd)
[2026-04-16T04:51:57Z] [SESSION-27] CHECKPOINT [task-054] step=1/3 "已启用并验证 Hermes API Server/health、/v1/models 与 /v1/runs 最小链路均通过,run_fc6786126fe645b8af6aa65d1c8d4627 返回‘你好’。"
[2026-04-16T04:51:58Z] [SESSION-27] CHECKPOINT [task-054] step=2/3 "已把 /api/ai-agent/run 收口为 Hermes bridgeroute 仅保留鉴权、上下文规范化、Hermes 转发、SSE 回流与 Codex 兼容桥。"
[2026-04-16T04:51:59Z] [SESSION-27] CHECKPOINT [task-054] step=3/3 "已冻结前端 registry/builtins/runAgent 为遗留兼容,run route 不再导入 createToolRegistry、builtinToolSets 或 create*ServerTools。"
[2026-04-16T04:52:00Z] [SESSION-27] Completed [task-054] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T04:52:01Z] [SESSION-27] Starting [task-058] 按 AI 前端精简方案 v1 补齐 Hermes 与 mnote Rust 的业务能力桥:明确并落地 Hermes 调用 mnote 文档/导图/OnlyOffice Rust 能力的最小边界,优先评估 MCP server、Hermes plugin/tool adapter 或最小业务 bridge (base=4fbab6dd)
[2026-04-16T04:52:02Z] [SESSION-27] CHECKPOINT [task-058] step=1/3 "已明确 Hermes 不复制 mnote 业务真逻辑,mnote Rust 继续作为文档、块、导图与 OnlyOffice 的真执行面。"
[2026-04-16T04:52:03Z] [SESSION-27] CHECKPOINT [task-058] step=2/3 "已在 AI 前端精简方案中固定首选接缝:优先最小业务 bridge,并记录 MCP server 与 Hermes plugin/tool adapter 的评估边界。"
[2026-04-16T04:52:04Z] [SESSION-27] CHECKPOINT [task-058] step=3/3 "已补齐 Hermes API Server -> mnote Rust 的最小业务桥边界,避免 /api/ai-agent/run 收口后出现业务能力真空。"
[2026-04-16T04:52:05Z] [SESSION-27] Completed [task-058] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T04:52:06Z] [SESSION-27] Starting [task-055] 按 AI 前端精简方案 v1 执行第二阶段:把全局 AI、页面 AI、Mindmap AI、OnlyOffice AI 收口为一个通用 AiBridgePanel,加上少量 context adapter,删除四套重复状态机 (base=4fbab6dd)
[2026-04-16T04:52:07Z] [SESSION-27] CHECKPOINT [task-055] step=1/3 "已提炼 AiBridgePanel 与 panelShared,统一面板壳、SSE 解析和 provider/model/maxSteps 偏好读写。"
[2026-04-16T04:52:08Z] [SESSION-27] CHECKPOINT [task-055] step=2/3 "已让 global/document/mindmap/onlyoffice 四类场景接入 AiBridgePanel,并保留各自的 context adapter 与内部业务流。"
[2026-04-16T04:52:09Z] [SESSION-27] CHECKPOINT [task-055] step=3/3 "已删除 Mindmap 隐藏旧壳并收缩重复 helper,四套面板不再各自维护独立的壳与基础状态读写。"
[2026-04-16T04:52:10Z] [SESSION-27] Completed [task-055] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T04:52:11Z] [SESSION-27] Starting [task-056] 按 AI 前端精简方案 v1 执行第三阶段:弱化或下线 GlobalAiAgentHost,把全局 AI 从主布局常驻入口降级为实验入口或可选入口,保留页面 AI 作为主场景 (base=4fbab6dd)
[2026-04-16T04:52:12Z] [SESSION-27] CHECKPOINT [task-056] step=1/3 "已从 app layout 移除 GlobalAiAgentHost 常驻挂载,主布局不再继续保留全局 AI 壳。"
[2026-04-16T04:52:13Z] [SESSION-27] CHECKPOINT [task-056] step=2/3 "已保留页面 AI 作为 breadcrumb 主入口,全局 AI 仅保留为实验入口组件,不再影响主场景布局。"
[2026-04-16T04:52:14Z] [SESSION-27] CHECKPOINT [task-056] step=3/3 "已同步方案文档与当前代码口径:页面 AI 为主场景,全局 AI 退为实验/开发入口。"
[2026-04-16T04:52:15Z] [SESSION-27] Completed [task-056] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T04:52:16Z] [SESSION-27] Starting [task-057] 按 AI 前端精简方案 v1 执行第四阶段:清理前端 AI 遗留 registry、builtins、重复 localStorage/session 管理与历史兼容 helper,仅保留 Hermes bridge、mnote Rust 业务执行面与浏览器专属 client capability bridge (base=4fbab6dd)
[2026-04-16T04:52:17Z] [SESSION-27] CHECKPOINT [task-057] step=1/3 "已确认 /api/ai-agent/run 主链不再依赖 registry/builtins/runAgent,旧文件全部标记为遗留兼容。"
[2026-04-16T04:52:18Z] [SESSION-27] CHECKPOINT [task-057] step=2/3 "已保留 OnlyOffice 插件桥与 /api/ai-agent/client-tool-result,前端只继续承担浏览器专属 client capability bridge。"
[2026-04-16T04:52:19Z] [SESSION-27] CHECKPOINT [task-057] step=3/3 "已通过 task-057 定向 eslint,前端 AI 收口为 Hermes bridge + mnote Rust 执行面 + 浏览器专属 client bridge。"
[2026-04-16T04:52:20Z] [SESSION-27] Completed [task-057] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T04:52:21Z] [SESSION-27] INFO Removed .harness-active after task-054..058 completion; all harness tasks are now completed
[2026-04-16T04:52:22Z] [SESSION-27] STATS tasks_total=58 completed=58 failed=0 pending=0 blocked=0 attempts_total=56 checkpoints=173
[2026-04-16T04:52:23Z] [SESSION-27] LOCK released
[2026-04-16T07:14:55Z] [SESSION-28] LOCK acquired (pid=1066951)
[2026-04-16T07:14:55Z] [SESSION-28] INIT Environment health check: PASS
[2026-04-16T07:25:39Z] [SESSION-28] CHECKPOINT [task-059] step=1/1 "已补长期路线阶段边界、依赖顺序、并行规则与阶段输出输入。"
[2026-04-16T07:25:39Z] [SESSION-28] Completed [task-059] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T07:25:39Z] [SESSION-28] CHECKPOINT [task-060] step=1/1 "已补 Phase 0 重模块审计表、性能基线、阅读态/编辑态边界与 islands 候选清单。"
[2026-04-16T07:25:39Z] [SESSION-28] Completed [task-060] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T07:25:39Z] [SESSION-28] CHECKPOINT [task-061] step=1/1 "已补横向能力 H1 的观测、缓存预取、权限边界与开发规范最低交付物。"
[2026-04-16T07:25:39Z] [SESSION-28] Completed [task-061] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T07:36:07Z] [SESSION-28] CHECKPOINT [task-062] step=1/1 "已新增 mnote-web axum 承载层骨架,统一 request/trace/auth/workspace context,并补 SSE/WS/Hermes/compat 占位路由。"
[2026-04-16T07:36:07Z] [SESSION-28] Completed [task-062] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T07:41:38Z] [SESSION-28] CHECKPOINT [task-067] step=1/1 "已让独立导图页走 StandaloneMindmapView,并把内嵌导图收口为轻预览/轻交互卡片。"
[2026-04-16T07:41:38Z] [SESSION-28] Completed [task-067] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T08:01:56Z] [SESSION-28] CHECKPOINT [task-063] step=1/1 "已将文档页改造成 server-first 阅读入口:服务端预取 meta+content,去掉 mounted gating,阅读态默认不再依赖 BlockNote。"
[2026-04-16T08:01:56Z] [SESSION-28] Completed [task-063] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T08:01:56Z] [SESSION-28] CHECKPOINT [task-064] step=1/1 "已确认 Sidebar/页面树/文件树具备服务端首包 + 共享 Rust query 契约 + 局部 island 的最小落账证据,并通过定向测试与 eslint。"
[2026-04-16T08:01:56Z] [SESSION-28] Completed [task-064] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T08:01:56Z] [SESSION-28] CHECKPOINT [task-065] step=1/1 "已将 SearchPalette 收口为轻量 host + 按需 runtime island,修复自引用懒加载问题,主布局只保留轻接线。"
[2026-04-16T08:01:56Z] [SESSION-28] Completed [task-065] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T08:01:56Z] [SESSION-28] CHECKPOINT [task-066] step=1/1 "已将文档页/导图/OnlyOffice AI 面板收口为轻 host + runtime islandHermes bridge 与 client action 主链保持不变。"
[2026-04-16T08:01:56Z] [SESSION-28] Completed [task-066] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T08:01:56Z] [SESSION-28] CHECKPOINT [task-068] step=1/1 "已完成文档阅读态/编辑态分离与 BlockNote 孤岛化:默认阅读、显式进入编辑、grace unmount 与外围初始化链外移。"
[2026-04-16T08:01:56Z] [SESSION-28] Completed [task-068] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T08:01:56Z] [SESSION-28] CHECKPOINT [task-069] step=1/1 "已统一回写长期 architecture/checklist 与 harness 状态,明确当前旧前端壳保留边界、实验入口边界与下一步 Phase 8 收缩口径。"
[2026-04-16T08:01:56Z] [SESSION-28] Completed [task-069] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T08:01:56Z] [SESSION-28] STATS tasks_total=69 completed=69 failed=0 pending=0 blocked=0 attempts_total=62 checkpoints=179
[2026-04-16T08:01:56Z] [SESSION-28] INFO Removed .harness-active after task-063..069 completion; all harness tasks are now completed
[2026-04-16T08:06:20Z] [SESSION-28] LOCK released
[2026-04-16T09:06:15Z] [SESSION-29] LOCK acquired (pid=manual-session29)
[2026-04-16T09:06:15Z] [SESSION-29] INIT Environment health check: PASS
[2026-04-16T09:06:15Z] [SESSION-29] INIT Added task-070..073 for tree-first graph kernel checklist execution
[2026-04-16T09:30:55Z] [SESSION-29] CHECKPOINT [task-070] step=1/1 "已在 tree-first-graph-kernel-v1.md 冻结 node/edge/projection/subtree 等术语,并明确事实源/投影/编辑器/外挂四层边界与并入策略。"
[2026-04-16T09:30:55Z] [SESSION-29] Completed [task-070] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T09:30:55Z] [SESSION-29] CHECKPOINT [task-071] step=1/1 "已在 core-protocol 新增 kernel.rs,落地 KernelNode/KernelEdge/KernelProjectionRequest/KernelSubtreeRef/KernelAuditStamp 等统一类型。"
[2026-04-16T09:30:55Z] [SESSION-29] Completed [task-071] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T09:30:55Z] [SESSION-29] CHECKPOINT [task-072] step=1/1 "已在 bridge-runtime 接入 kernel.node.get/kernel.subtree.get/kernel.children.list/kernel.edges.list/kernel.graph.traverse/kernel.project_view 与对应写协议,并让 sidebar dataset 可投成统一 kernel subtree/projection 结果。"
[2026-04-16T09:30:55Z] [SESSION-29] Completed [task-072] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T09:30:55Z] [SESSION-29] CHECKPOINT [task-073] step=1/1 "已在 mnote-web 新增 /api/kernel/projections/sidebar、/api/kernel/subtree、/api/kernel/edges、/api/kernel/graph,并补路由测试与 checklist 回写。"
[2026-04-16T09:30:55Z] [SESSION-29] Completed [task-073] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T09:30:55Z] [SESSION-29] STATS tasks_total=73 completed=73 failed=0 pending=0 blocked=0 attempts_total=71 checkpoints=173
[2026-04-16T09:30:55Z] [SESSION-29] INFO Removed .harness-active after task-070..073 completion; current harness batch completed
[2026-04-16T09:30:55Z] [SESSION-29] LOCK released
[2026-04-16T11:09:41Z] [SESSION-31] LOCK acquired (pid=manual-session31)
[2026-04-16T11:09:41Z] [SESSION-31] WARN structured state ahead of progress log; harness-tasks.json shows session_count=30/last_session=2026-04-16T10:28:02Z but progress log has no SESSION-30, proceeding conservatively with SESSION-31
[2026-04-16T11:09:41Z] [SESSION-31] INIT Environment health check: PASS
[2026-04-16T11:09:41Z] [SESSION-31] Starting [task-074] 按 Tree-First Graph 内核实施清单 v2 收尾 Kernel Phase 3:替换 mnote-web kernel route 中的 demo dataset,接入真实 workspace/storage/bridge 数据源,并让至少一条非 demo kernel 查询主链稳定运行 (base=2c643e5f)
[2026-04-16T11:52:00Z] [SESSION-31] CHECKPOINT [task-074] step=1/1 "已删除 mnote-web kernel route 的 demo_sidebar_dataset,改为真实 sidebar.dataset.list -> kernel projection/subtree 归一化主链,并通过 cargo test -p mnote-web。"
[2026-04-16T11:52:00Z] [SESSION-31] Completed [task-074] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T11:52:00Z] [SESSION-31] Starting [task-075] 执行 Tree-First Graph Kernel Phase 4:让 Sidebar、页面树、文件树直接消费 kernel projection,移除主路径上的旧拼树逻辑,并把树交互收口为局部 UI 状态 (base=2c643e5f)
[2026-04-16T11:52:00Z] [SESSION-31] CHECKPOINT [task-075] step=1/1 "已让主 Sidebar、页面树与文件树切到 kernelSidebarProjection/kernelSidebarTree,主路径不再从 documents 调 buildDocumentTree,并通过 sidebar/file-tree 定向测试与 eslint(仅剩 warnings,无 error)。"
[2026-04-16T11:52:00Z] [SESSION-31] Completed [task-075] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T11:52:00Z] [SESSION-31] STATS tasks_total=80 completed=75 failed=0 pending=5 blocked=0 attempts_total=73 checkpoints=175
[2026-04-16T13:18:24Z] [SESSION-31] WARN continuing SESSION-31 after interim STATS to sync task-076..080 completion and final checklist status
[2026-04-16T13:18:25Z] [SESSION-31] Starting [task-076] 执行 Tree-First Graph Kernel Phase 5:建立 cron 驱动的结构知识刷新与 kernel-aware 检索主链,把 summary node、ai_note node、index node、reference edge 与 BookMindmap/章节树接入统一索引 (base=2c643e5f)
[2026-04-16T13:18:26Z] [SESSION-31] CHECKPOINT [task-076] step=1/1 "已确认 cron 驱动的 kernel_aware_refresh 过渡链与搜索 nodeId/subtreeRootId/evidence 结果形状落地,并把 Kernel Phase 5 文档回写为 PARTIALcargo check 与定向 eslint 通过(仅 warnings,无 error)。"
[2026-04-16T13:18:27Z] [SESSION-31] Completed [task-076] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T13:18:28Z] [SESSION-31] Starting [task-077] 执行 Tree-First Graph Kernel Phase 6:把 Mindmap 从重前端对象页收口为 kernel subtree projection/editor,独立页读取 kernel projection,内嵌形态降为轻预览或轻编辑入口 (base=2c643e5f)
[2026-04-16T13:18:29Z] [SESSION-31] CHECKPOINT [task-077] step=1/1 "已确认独立导图页服务端 initial projection、StandaloneMindmapView 与 preview-first 内嵌入口已落地,并回写 Phase 6 文档;定向 eslint 通过(仅 warnings,无 error)。"
[2026-04-16T13:18:30Z] [SESSION-31] Completed [task-077] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T13:18:31Z] [SESSION-31] Starting [task-078] 执行 Tree-First Graph Kernel Phase 7:让文档阅读页与 AI 面板直接消费 page subtree projection 和 kernel-aware 检索结果,统一 node/subtree/edge 操作协议 (base=2c643e5f)
[2026-04-16T13:18:32Z] [SESSION-31] CHECKPOINT [task-078] step=1/1 "已确认 pageSubtree 阅读链与 AI node/subtree/outline/evidence Hermes 上下文链落地,并把 Kernel Phase 7 文档维持为 PARTIAL;定向 eslint 通过(仅 warnings,无 error)。"
[2026-04-16T13:18:33Z] [SESSION-31] Completed [task-078] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T13:18:34Z] [SESSION-31] Starting [task-079] 执行 Tree-First Graph Kernel Phase 8:定义 page subtree 与 content node 的边界,让 BlockNote 退化为内容节点编辑挂件,并继续拆走 DocumentContent 上的外围 panel/drawer (base=2c643e5f)
[2026-04-16T13:18:35Z] [SESSION-31] CHECKPOINT [task-079] step=1/1 "已回写 Phase 8 为 PARTIAL,明确 pageSubtree/content node 边界、阅读态先于编辑态与 BlockNote 按需挂载现状;定向 eslint 通过(仅 warnings,无 error)。"
[2026-04-16T13:18:36Z] [SESSION-31] Completed [task-079] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T13:18:37Z] [SESSION-31] Starting [task-080] 执行 Tree-First Graph Kernel Phase 9:在 kernel projection 成为主路径后收缩旧 Next/React 页面壳与旧对象模型,完成双栈切流、兼容层清理与最终架构口径统一 (base=2c643e5f)
[2026-04-16T13:18:38Z] [SESSION-31] CHECKPOINT [task-080] step=1/1 "已统一回写 tree-first 与 rust-web long-term checklist,并同步 harness 状态,明确 Phase 9 仍 NOT_STARTED、当前不能宣称旧前端壳已下线;文档校验通过。"
[2026-04-16T13:18:39Z] [SESSION-31] Completed [task-080] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T13:18:40Z] [SESSION-31] STATS tasks_total=80 completed=80 failed=0 pending=0 blocked=0 attempts_total=78 checkpoints=180
[2026-04-16T13:18:41Z] [SESSION-31] INFO Removed .harness-active after task-076..080 completion; all harness tasks are now completed
[2026-04-16T13:18:42Z] [SESSION-31] LOCK released
[2026-04-16T15:00:53Z] [SESSION-32] LOCK acquired (pid=manual-session32)
[2026-04-16T15:00:54Z] [SESSION-32] INIT Environment health check: PASS
[2026-04-16T15:00:55Z] [SESSION-32] INIT Added task-081..084 for Tree-First Graph Kernel Phase 3 completion
[2026-04-16T15:00:56Z] [SESSION-32] Starting [task-081] 执行 Kernel Phase 3 P3-1/P3-2:把 mnote-web query transport 从 Sidebar 单点实现提升为通用 Convex query 执行层,并把 fixture fallback 收紧到测试/开发边界 (base=17afde7d)
[2026-04-16T15:00:56Z] [SESSION-32] CHECKPOINT [task-081] step=1/1 "已把 mnote-web transport 收口为通用 execute_convex_query_plan,并以 allow_dev_fixtures + MNOTE_WEB_QUERY_FIXTURES_JSON 将 fixture 限定到测试/开发边界;mnote-web 定向 cargo test/check 通过。"
[2026-04-16T15:00:57Z] [SESSION-32] Completed [task-081] (commit skipped by repo rule; base=17afde7d)
[2026-04-16T15:00:58Z] [SESSION-32] Starting [task-082] 执行 Kernel Phase 3 P3-3:为 mnote-web 新增 bridge.workspace/request/trace 真实查询主链,证明 Rust Web 不再只承接 sidebar 数据集 (base=17afde7d)
[2026-04-16T15:00:59Z] [SESSION-32] CHECKPOINT [task-082] step=1/1 "已新增 /api/bridge/workspace、/api/bridge/request、/api/bridge/trace,并复用通用 query transport 读取真实 bridgeLogs 查询;mnote-web route test 通过。"
[2026-04-16T15:01:00Z] [SESSION-32] Completed [task-082] (commit skipped by repo rule; base=17afde7d)
[2026-04-16T15:01:01Z] [SESSION-32] Starting [task-083] 执行 Kernel Phase 3 P3-4/P3-5:补齐 workspace/request/trace/error 语义,并把 Sidebar 首包与 /api/sidebar 通过 compat sidebar 接到 mnote-web,形成第一条真实切流边界 (base=17afde7d)
[2026-04-16T15:01:02Z] [SESSION-32] CHECKPOINT [task-083] step=1/1 "已统一 workspace 解析与错误码,transport 优先转发真实 AuthorizationSidebar 服务端首包与 /api/sidebar 在配置 MNOTE_WEB_BASE_URL 后默认经 /api/compat/next/sidebar 走 mnote-web。前端定向 vitest / eslint 通过(eslint 仅 baseline-browser-mapping 提示,无 error)。"
[2026-04-16T15:01:03Z] [SESSION-32] Completed [task-083] (commit skipped by repo rule; base=17afde7d)
[2026-04-16T15:01:04Z] [SESSION-32] Starting [task-084] 执行 Kernel Phase 3 P3-6:回写 phase3 拆解/checklist 与 harness 状态,确认 Tree-First Graph Kernel Phase 3 已满足 DONE 判定 (base=17afde7d)
[2026-04-16T15:01:04Z] [SESSION-32] CHECKPOINT [task-084] step=1/1 "已回写 tree-first kernel checklist、phase3 breakdown 与 rust-web long-term checklist,统一 Phase 3 DONE / Rust Web Phase 1 仍 PARTIAL 的真实口径,并同步 harness 完成状态。"
[2026-04-16T15:01:05Z] [SESSION-32] Completed [task-084] (commit skipped by repo rule; base=17afde7d)
[2026-04-16T15:01:06Z] [SESSION-32] STATS tasks_total=84 completed=84 failed=0 pending=0 blocked=0 attempts_total=82 checkpoints=184
[2026-04-16T15:01:06Z] [SESSION-32] INFO Removed .harness-active after task-081..084 completion; current harness batch completed
[2026-04-16T15:01:07Z] [SESSION-32] LOCK released
[2026-04-16T16:11:17Z] [SESSION-33] LOCK acquired (pid=manual-session33)
[2026-04-16T16:11:18Z] [SESSION-33] INIT Environment health check: PASS
[2026-04-16T16:11:19Z] [SESSION-33] INIT Added task-085..090 for Tree-First Graph Kernel Phase 4 completion
[2026-04-16T16:11:20Z] [SESSION-33] Starting [task-085] 执行 Kernel Phase 4 P4-1:冻结树域 page_tree projection protocol,并补齐 Sidebar / 页面树共享字段与类型边界 (base=17afde7d)
[2026-04-16T16:11:20Z] [SESSION-33] CHECKPOINT [task-085] step=1/1 "已新增 tree-projection.ts,冻结 rowId/nodeId/parentNodeId/projectionKind/depth/position/capabilities/resourceMeta 等 page_tree protocol,并补充对应单测。"
[2026-04-16T16:11:21Z] [SESSION-33] Completed [task-085] (commit skipped by repo rule; base=17afde7d)
[2026-04-16T16:11:22Z] [SESSION-33] Starting [task-086] 执行 Kernel Phase 4 P4-2:统一 Sidebar 主树与页面树 consumer,让 PrivateTree 与相关树列表直接消费 page_tree projection (base=17afde7d)
[2026-04-16T16:11:23Z] [SESSION-33] CHECKPOINT [task-086] step=1/1 "已让 Sidebar 顶部树列表、PrivateTree 与 convert-to-child 等页面树 consumer 统一消费 page_tree projection,可见行只保留 expanded 等 UI 状态。"
[2026-04-16T16:11:24Z] [SESSION-33] Completed [task-086] (commit skipped by repo rule; base=17afde7d)
[2026-04-16T16:11:25Z] [SESSION-33] Starting [task-087] 执行 Kernel Phase 4 P4-3:把文件树收口为统一 projection consumer,让 buildVisibleRows 只负责 projection 到 visible rows 的映射 (base=17afde7d)
[2026-04-16T16:11:26Z] [SESSION-33] CHECKPOINT [task-087] step=1/1 "文件树已改为只消费 page_tree projection + asset 映射;buildVisibleRows 不再递归定义树结构真相,相关 rows 单测通过。"
[2026-04-16T16:11:27Z] [SESSION-33] Completed [task-087] (commit skipped by repo rule; base=17afde7d)
[2026-04-16T16:11:28Z] [SESSION-33] Starting [task-088] 执行 Kernel Phase 4 P4-4:统一 move/embed picker 等兼容树域,结束 documents[] 到 buildDocumentTree 的双轨主路径 (base=17afde7d)
[2026-04-16T16:11:29Z] [SESSION-33] CHECKPOINT [task-088] step=1/1 "move-embed picker 空查询态已直接消费 kernelSidebarTree -> page_tree projection,不再调用 buildDocumentTree(...)。"
[2026-04-16T16:11:30Z] [SESSION-33] Completed [task-088] (commit skipped by repo rule; base=17afde7d)
[2026-04-16T16:11:31Z] [SESSION-33] Starting [task-089] 执行 Kernel Phase 4 P4-5:收敛旧 helper 与兼容逻辑,把 buildDocumentTree 退回兼容边界并收紧 sidebar-data 主路径投影要求 (base=17afde7d)
[2026-04-16T16:11:32Z] [SESSION-33] CHECKPOINT [task-089] step=1/1 "已将 SidebarInitialData 的 kernelSidebarProjection/kernelSidebarTree 收紧为主路径必备字段,并给 buildDocumentTree 标明仅保留兼容 helper 边界。"
[2026-04-16T16:11:33Z] [SESSION-33] Completed [task-089] (commit skipped by repo rule; base=17afde7d)
[2026-04-16T16:11:34Z] [SESSION-33] Starting [task-090] 执行 Kernel Phase 4 P4-6:回写 Phase 4 checklist/breakdown 与 harness 状态,确认树域 consumer/protocol 已满足 DONE 判定并交接下一阶段 Rust Web tree shell 重构 (base=17afde7d)
[2026-04-16T16:11:35Z] [SESSION-33] CHECKPOINT [task-090] step=1/1 "已回写 Phase 4 checklist/breakdown 与 harness 状态,统一 Phase 4 DONE 口径,并明确下一阶段才是 sidebar-pagetree-filetree-rust-web-rebuild-v1.md。"
[2026-04-16T16:11:36Z] [SESSION-33] Completed [task-090] (commit skipped by repo rule; base=17afde7d)
[2026-04-16T16:11:37Z] [SESSION-33] STATS tasks_total=90 completed=90 failed=0 pending=0 blocked=0 attempts_total=88 checkpoints=190
[2026-04-16T16:11:37Z] [SESSION-33] INFO Removed .harness-active after task-085..090 completion; current harness batch completed
[2026-04-16T16:11:38Z] [SESSION-33] LOCK released
+61 -3136
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -3362,6 +3362,12 @@ fn normalize_kernel_node_from_record(
if let Some(is_starred) = bool_field(value, "is_starred").or_else(|| bool_field(value, "isStarred")) { if let Some(is_starred) = bool_field(value, "is_starred").or_else(|| bool_field(value, "isStarred")) {
extra.insert("isStarred".into(), json!(is_starred)); extra.insert("isStarred".into(), json!(is_starred));
} }
if let Some(sort_order) = record_field(value, "sort_order")
.and_then(|field| field.as_i64().or_else(|| field.as_f64().map(|number| number as i64)))
.or_else(|| record_field(value, "sortOrder").and_then(Value::as_i64))
{
extra.insert("sortOrder".into(), json!(sort_order));
}
let content = value let content = value
.get("content") .get("content")
@@ -5712,7 +5718,9 @@ mod tests {
assert_eq!(result["projection"], json!("sidebar_tree")); assert_eq!(result["projection"], json!("sidebar_tree"));
assert_eq!(result["rootNodeId"], json!("page_root")); assert_eq!(result["rootNodeId"], json!("page_root"));
assert_eq!(result["items"][0]["nodeId"], json!("page_root")); assert_eq!(result["items"][0]["nodeId"], json!("page_root"));
assert_eq!(result["items"][0]["position"], json!(0));
assert_eq!(result["items"][1]["parentNodeId"], json!("page_root")); assert_eq!(result["items"][1]["parentNodeId"], json!("page_root"));
assert_eq!(result["items"][1]["position"], json!(1));
} }
#[test] #[test]
+57 -3
View File
@@ -2,6 +2,7 @@ use crate::middleware::request_context::inject_request_context;
use crate::routes::build_router; use crate::routes::build_router;
use axum::Router; use axum::Router;
use std::env; use std::env;
use std::fs;
use std::sync::Arc; use std::sync::Arc;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
@@ -15,6 +16,8 @@ pub struct AppConfig {
pub convex_url: Option<String>, pub convex_url: Option<String>,
pub convex_admin_key: Option<String>, pub convex_admin_key: Option<String>,
pub allow_dev_fixtures: bool, pub allow_dev_fixtures: bool,
pub query_fixtures_json: Option<String>,
pub mutation_fixtures_json: Option<String>,
pub dev_user_id: String, pub dev_user_id: String,
pub dev_user_name: String, pub dev_user_name: String,
pub dev_user_email: String, pub dev_user_email: String,
@@ -34,23 +37,74 @@ impl AppConfig {
convex_url: env::var("CONVEX_SELF_HOSTED_URL") convex_url: env::var("CONVEX_SELF_HOSTED_URL")
.ok() .ok()
.or_else(|| env::var("NEXT_PUBLIC_CONVEX_URL").ok()) .or_else(|| env::var("NEXT_PUBLIC_CONVEX_URL").ok())
.or_else(|| read_env_or_dotenv("CONVEX_SELF_HOSTED_URL"))
.or_else(|| read_env_or_dotenv("NEXT_PUBLIC_CONVEX_URL"))
.map(|value| value.trim().trim_end_matches('/').to_string()) .map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty()), .filter(|value| !value.is_empty()),
convex_admin_key: env::var("CONVEX_SELF_HOSTED_ADMIN_KEY") convex_admin_key: env::var("CONVEX_SELF_HOSTED_ADMIN_KEY")
.ok() .ok()
.or_else(|| read_env_or_dotenv("CONVEX_SELF_HOSTED_ADMIN_KEY"))
.map(|value| value.trim().to_string()) .map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()), .filter(|value| !value.is_empty()),
allow_dev_fixtures: env::var("MNOTE_WEB_ALLOW_DEV_FIXTURES") allow_dev_fixtures: env::var("MNOTE_WEB_ALLOW_DEV_FIXTURES")
.ok() .ok()
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false), .unwrap_or(false),
dev_user_id: env::var("DEV_USER_ID").unwrap_or_else(|_| "dev-user".into()), query_fixtures_json: env::var("MNOTE_WEB_QUERY_FIXTURES_JSON")
dev_user_name: env::var("DEV_USER_NAME").unwrap_or_else(|_| "开发用户".into()), .ok()
dev_user_email: env::var("DEV_USER_EMAIL").unwrap_or_else(|_| "dev@mnote.local".into()), .map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
mutation_fixtures_json: env::var("MNOTE_WEB_MUTATION_FIXTURES_JSON")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
dev_user_id: env::var("DEV_USER_ID")
.ok()
.or_else(|| read_env_or_dotenv("DEV_USER_ID"))
.unwrap_or_else(|| "dev-user".into()),
dev_user_name: env::var("DEV_USER_NAME")
.ok()
.or_else(|| read_env_or_dotenv("DEV_USER_NAME"))
.unwrap_or_else(|| "开发用户".into()),
dev_user_email: env::var("DEV_USER_EMAIL")
.ok()
.or_else(|| read_env_or_dotenv("DEV_USER_EMAIL"))
.unwrap_or_else(|| "dev@mnote.local".into()),
} }
} }
} }
fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = env::var(key) {
let trimmed = value.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.join(".env.all");
let content = fs::read_to_string(root).ok()?;
for line in content.lines() {
let line = line.trim_end_matches('\r');
if line.starts_with('#') || line.trim().is_empty() {
continue;
}
let Some((k, v)) = line.split_once('=') else {
continue;
};
if k.trim() != key {
continue;
}
let trimmed = v.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
None
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AppState { pub struct AppState {
config: Arc<AppConfig>, config: Arc<AppConfig>,
+10 -9
View File
@@ -73,13 +73,13 @@ fn workspace_query_payload(
} }
} }
fn execute_bridge_query( async fn execute_bridge_query(
config: &AppConfig, config: &AppConfig,
context: &RequestContext, context: &RequestContext,
workspace_id: &str, workspace_id: &str,
query: RuntimeQueryEnvelopeWire, query: RuntimeQueryEnvelopeWire,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
execute_runtime_query_via_convex(config, context, Some(workspace_id), query) execute_runtime_query_via_convex(config, context, Some(workspace_id), query).await
} }
pub async fn workspace( pub async fn workspace(
@@ -95,7 +95,8 @@ pub async fn workspace(
&context, &context,
&effective_workspace_id, &effective_workspace_id,
workspace_query_payload(&effective_workspace_id, &query), workspace_query_payload(&effective_workspace_id, &query),
)?; )
.await?;
Ok(ok_response(&context, result)) Ok(ok_response(&context, result))
} }
@@ -119,7 +120,8 @@ pub async fn request(
"commandId": query.command_id, "commandId": query.command_id,
}), }),
}, },
)?; )
.await?;
Ok(ok_response(&context, result)) Ok(ok_response(&context, result))
} }
@@ -143,7 +145,8 @@ pub async fn trace(
"commandId": query.command_id, "commandId": query.command_id,
}), }),
}, },
)?; )
.await?;
Ok(ok_response(&context, result)) Ok(ok_response(&context, result))
} }
@@ -155,10 +158,6 @@ mod tests {
use tower::util::ServiceExt; use tower::util::ServiceExt;
fn app() -> axum::Router { fn app() -> axum::Router {
std::env::set_var(
"MNOTE_WEB_QUERY_FIXTURES_JSON",
r#"{"sidebar:datasetList":{"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"}]},"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":null,"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"}}"#,
);
build_app(AppState::new(AppConfig { build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(), service_name: "mnote-web".into(),
service_version: "0.1.0".into(), service_version: "0.1.0".into(),
@@ -168,6 +167,8 @@ mod tests {
convex_url: None, convex_url: None,
convex_admin_key: None, convex_admin_key: None,
allow_dev_fixtures: true, allow_dev_fixtures: true,
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"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"}]},"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":null,"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_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
@@ -0,0 +1,110 @@
use crate::app::AppConfig;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::transport::convex::execute_convex_command_plan;
use bridge_runtime::{
execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire,
RuntimeTargetWire,
};
use serde_json::Value;
pub fn runtime_context(
context: &RequestContext,
effective_workspace_id: Option<&str>,
) -> RuntimeBridgeContextWire {
RuntimeBridgeContextWire {
deployment_id: context.workspace.deployment_id.clone(),
project_id: context.workspace.project_id.clone(),
workspace_id: effective_workspace_id
.map(ToOwned::to_owned)
.or_else(|| context.workspace.workspace_id.clone()),
request_id: context.trace.request_id.clone(),
trace_id: context.trace.trace_id.clone(),
actor: RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
},
tenant_id: context.workspace.tenant_id.clone(),
auth_token: context.auth.authorization.clone(),
idempotency_key: context.source.idempotency_key.clone(),
validate_only: false,
dry_run: false,
}
}
pub fn build_runtime_command_plan(
context: &RequestContext,
effective_workspace_id: Option<&str>,
command: RuntimeCommandEnvelopeWire,
) -> Result<RuntimeCommandExecutionPlan, WebError> {
let runtime_input = RuntimeInput::Command {
context: runtime_context(context, effective_workspace_id),
command,
};
let RuntimeExecutionPlan::Command(plan) = execute_runtime_input(runtime_input)
.map_err(|error| WebError::bad_request(error.message).with_context(context))?
else {
return Err(WebError::internal("runtime command 未返回 command plan").with_context(context));
};
Ok(plan)
}
pub async fn execute_runtime_command_via_convex(
config: &AppConfig,
context: &RequestContext,
effective_workspace_id: Option<&str>,
command: RuntimeCommandEnvelopeWire,
) -> Result<Value, WebError> {
let plan = build_runtime_command_plan(context, effective_workspace_id, command)?;
execute_convex_command_plan(config, context, &plan).await
}
pub fn build_tree_target(
workspace_id: &str,
page_id: Option<&str>,
block_id: Option<&str>,
) -> RuntimeTargetWire {
RuntimeTargetWire {
workspace_id: Some(workspace_id.to_string()),
page_id: page_id.map(ToOwned::to_owned),
block_id: block_id.map(ToOwned::to_owned),
}
}
pub fn ensure_non_empty(value: &str, field: &'static str, context: &RequestContext) -> Result<String, WebError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(WebError::bad_request_code(
"tree_command_validation",
format!("{field} 不能为空"),
)
.with_context(context)
.with_header("x-error-phase", "tree_command_validate"));
}
Ok(trimmed.to_string())
}
pub fn read_optional_non_empty(value: Option<String>) -> Option<String> {
value
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
}
pub fn ensure_sort_order(sort_order: i64, context: &RequestContext) -> Result<i64, WebError> {
if sort_order < 0 {
return Err(WebError::bad_request_code(
"tree_command_validation",
"sortOrder 不能小于 0",
)
.with_context(context)
.with_header("x-error-phase", "tree_command_validate"));
}
Ok(sort_order)
}
+4 -5
View File
@@ -69,7 +69,8 @@ pub async fn next_sidebar(
"workspaceId": effective_workspace_id, "workspaceId": effective_workspace_id,
}), }),
}, },
)?; )
.await?;
let projection = execute_runtime_query_against_data( let projection = execute_runtime_query_against_data(
&context, &context,
Some(&effective_workspace_id), Some(&effective_workspace_id),
@@ -115,10 +116,6 @@ mod tests {
use tower::util::ServiceExt; use tower::util::ServiceExt;
fn app() -> axum::Router { fn app() -> axum::Router {
std::env::set_var(
"MNOTE_WEB_QUERY_FIXTURES_JSON",
r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[],"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":[],"domain_events":[],"next_cursor":null,"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"}}"#,
);
build_app(AppState::new(AppConfig { build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(), service_name: "mnote-web".into(),
service_version: "0.1.0".into(), service_version: "0.1.0".into(),
@@ -128,6 +125,8 @@ mod tests {
convex_url: None, convex_url: None,
convex_admin_key: None, convex_admin_key: None,
allow_dev_fixtures: true, allow_dev_fixtures: true,
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[],"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":[],"domain_events":[],"next_cursor":null,"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_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
+8 -9
View File
@@ -52,7 +52,7 @@ fn sidebar_dataset_query(workspace_id: &str) -> RuntimeQueryEnvelopeWire {
} }
} }
fn load_sidebar_dataset( async fn load_sidebar_dataset(
config: &AppConfig, config: &AppConfig,
context: &RequestContext, context: &RequestContext,
workspace_id: &str, workspace_id: &str,
@@ -63,6 +63,7 @@ fn load_sidebar_dataset(
Some(workspace_id), Some(workspace_id),
sidebar_dataset_query(workspace_id), sidebar_dataset_query(workspace_id),
) )
.await
} }
fn execute_kernel_query( fn execute_kernel_query(
@@ -94,7 +95,7 @@ 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)?; let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?;
let result = execute_kernel_query( let result = execute_kernel_query(
&context, &context,
&effective_workspace_id, &effective_workspace_id,
@@ -124,7 +125,7 @@ pub async fn subtree(
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)?; let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?;
let result = execute_kernel_query( let result = execute_kernel_query(
&context, &context,
&effective_workspace_id, &effective_workspace_id,
@@ -151,7 +152,7 @@ pub async fn edges(
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)?; let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?;
let result = execute_kernel_query( let result = execute_kernel_query(
&context, &context,
&effective_workspace_id, &effective_workspace_id,
@@ -176,7 +177,7 @@ pub async fn graph(
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)?; let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?;
let result = execute_kernel_query( let result = execute_kernel_query(
&context, &context,
&effective_workspace_id, &effective_workspace_id,
@@ -202,10 +203,6 @@ mod tests {
use tower::util::ServiceExt; use tower::util::ServiceExt;
fn app() -> axum::Router { fn app() -> axum::Router {
std::env::set_var(
"MNOTE_WEB_QUERY_FIXTURES_JSON",
r#"{"sidebar:datasetList":{"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"}]},"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":null,"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"}}"#,
);
build_app(AppState::new(AppConfig { build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(), service_name: "mnote-web".into(),
service_version: "0.1.0".into(), service_version: "0.1.0".into(),
@@ -215,6 +212,8 @@ mod tests {
convex_url: None, convex_url: None,
convex_admin_key: None, convex_admin_key: None,
allow_dev_fixtures: true, allow_dev_fixtures: true,
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"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"}]},"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":null,"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_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
+4
View File
@@ -1,10 +1,12 @@
mod bridge; mod bridge;
mod command_support;
mod compat; mod compat;
mod health; mod health;
mod hermes; mod hermes;
mod kernel; mod kernel;
mod query_support; mod query_support;
mod sse; mod sse;
mod tree;
mod ws; mod ws;
use crate::app::AppState; use crate::app::AppState;
@@ -17,6 +19,7 @@ pub fn build_router(state: AppState) -> Router {
Router::new() Router::new()
.route("/health", get(health::health)) .route("/health", get(health::health))
.route("/tree", get(tree::tree_shell))
.route( .route(
"/api/kernel/projections/sidebar", "/api/kernel/projections/sidebar",
get(kernel::project_sidebar), get(kernel::project_sidebar),
@@ -24,6 +27,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/kernel/subtree", get(kernel::subtree)) .route("/api/kernel/subtree", get(kernel::subtree))
.route("/api/kernel/edges", get(kernel::edges)) .route("/api/kernel/edges", get(kernel::edges))
.route("/api/kernel/graph", get(kernel::graph)) .route("/api/kernel/graph", get(kernel::graph))
.route("/api/tree/commands", post(tree::tree_command))
.route("/api/bridge/workspace", get(bridge::workspace)) .route("/api/bridge/workspace", get(bridge::workspace))
.route("/api/bridge/request", get(bridge::request)) .route("/api/bridge/request", get(bridge::request))
.route("/api/bridge/trace", get(bridge::trace)) .route("/api/bridge/trace", get(bridge::trace))
@@ -96,14 +96,14 @@ pub fn build_runtime_query_plan(
Ok(plan) Ok(plan)
} }
pub fn fetch_query_data_via_convex( pub async fn fetch_query_data_via_convex(
config: &AppConfig, config: &AppConfig,
context: &RequestContext, context: &RequestContext,
effective_workspace_id: Option<&str>, effective_workspace_id: Option<&str>,
query: RuntimeQueryEnvelopeWire, query: RuntimeQueryEnvelopeWire,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let plan = build_runtime_query_plan(context, effective_workspace_id, query)?; let plan = build_runtime_query_plan(context, effective_workspace_id, query)?;
execute_convex_query_plan(config, context, &plan) execute_convex_query_plan(config, context, &plan).await
} }
pub fn execute_runtime_query_against_data( pub fn execute_runtime_query_against_data(
@@ -120,12 +120,12 @@ pub fn execute_runtime_query_against_data(
.map_err(|error| WebError::bad_request(error.message).with_context(context)) .map_err(|error| WebError::bad_request(error.message).with_context(context))
} }
pub fn execute_runtime_query_via_convex( pub async fn execute_runtime_query_via_convex(
config: &AppConfig, config: &AppConfig,
context: &RequestContext, context: &RequestContext,
effective_workspace_id: Option<&str>, effective_workspace_id: Option<&str>,
query: RuntimeQueryEnvelopeWire, query: RuntimeQueryEnvelopeWire,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let data = fetch_query_data_via_convex(config, context, effective_workspace_id, query.clone())?; let data = fetch_query_data_via_convex(config, context, effective_workspace_id, query.clone()).await?;
execute_runtime_query_against_data(context, effective_workspace_id, query, data) execute_runtime_query_against_data(context, effective_workspace_id, query, data)
} }
File diff suppressed because it is too large Load Diff
+173 -10
View File
@@ -3,7 +3,7 @@ use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use base64::engine::general_purpose::STANDARD; use base64::engine::general_purpose::STANDARD;
use base64::Engine; use base64::Engine;
use bridge_runtime::RuntimeQueryExecutionPlan; use bridge_runtime::{RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::fs; use std::fs;
use std::time::Duration; use std::time::Duration;
@@ -13,6 +13,7 @@ const HEADER_TRACE_ID: &str = "x-trace-id";
const HEADER_WORKSPACE_ID: &str = "x-mnote-workspace-id"; const HEADER_WORKSPACE_ID: &str = "x-mnote-workspace-id";
const HEADER_SOURCE_CHANNEL: &str = "x-mnote-source-channel"; const HEADER_SOURCE_CHANNEL: &str = "x-mnote-source-channel";
const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client"; const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client";
const HEADER_IDEMPOTENCY_KEY: &str = "x-idempotency-key";
fn read_env_or_dotenv(key: &str) -> Option<String> { fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = std::env::var(key) { if let Ok(value) = std::env::var(key) {
@@ -70,10 +71,18 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
.with_header("x-upstream-service", "convex") .with_header("x-upstream-service", "convex")
})?; })?;
let effective_actor_id = if context.auth.actor_id.trim().is_empty()
|| context.auth.actor_id == "anonymous"
{
config.dev_user_id.clone()
} else {
context.auth.actor_id.clone()
};
let identity = json!({ let identity = json!({
"subject": config.dev_user_id, "subject": effective_actor_id,
"issuer": "https://mnote.local/dev-auth", "issuer": "https://mnote.local/dev-auth",
"tokenIdentifier": format!("dev-user|{}", config.dev_user_id), "tokenIdentifier": format!("dev-user|{}", effective_actor_id),
"name": config.dev_user_name, "name": config.dev_user_name,
"email": config.dev_user_email, "email": config.dev_user_email,
}); });
@@ -113,7 +122,7 @@ fn load_query_fixture(
return Ok(None); return Ok(None);
} }
let Ok(raw) = std::env::var("MNOTE_WEB_QUERY_FIXTURES_JSON") else { let Some(raw) = config.query_fixtures_json.as_deref() else {
return Ok(None); return Ok(None);
}; };
let trimmed = raw.trim(); let trimmed = raw.trim();
@@ -133,7 +142,36 @@ fn load_query_fixture(
.cloned()) .cloned())
} }
pub fn execute_convex_query_plan( fn load_mutation_fixture(
config: &AppConfig,
context: &RequestContext,
plan: &RuntimeCommandExecutionPlan,
) -> Result<Option<Value>, WebError> {
if !config.allow_dev_fixtures {
return Ok(None);
}
let Some(raw) = config.mutation_fixtures_json.as_deref() else {
return Ok(None);
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
let fixtures: Value = serde_json::from_str(trimmed).map_err(|error| {
WebError::internal(format!("MNOTE_WEB_MUTATION_FIXTURES_JSON 非法: {error}"))
.with_context(context)
.with_header("x-error-phase", "fixture_parse")
})?;
Ok(fixtures
.as_object()
.and_then(|map| map.get(plan.function_name.as_str()))
.cloned())
}
pub async fn execute_convex_query_plan(
config: &AppConfig, config: &AppConfig,
context: &RequestContext, context: &RequestContext,
plan: &RuntimeQueryExecutionPlan, plan: &RuntimeQueryExecutionPlan,
@@ -157,7 +195,7 @@ pub fn execute_convex_query_plan(
"args": plan.args_json, "args": plan.args_json,
}); });
let client = reqwest::blocking::Client::builder() let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20)) .timeout(Duration::from_secs(20))
.build() .build()
.map_err(|error| { .map_err(|error| {
@@ -186,7 +224,7 @@ pub fn execute_convex_query_plan(
request = request.header(HEADER_WORKSPACE_ID, workspace_id); request = request.header(HEADER_WORKSPACE_ID, workspace_id);
} }
let response = request.send().map_err(|error| { let response = request.send().await.map_err(|error| {
let base = if error.is_timeout() { let base = if error.is_timeout() {
WebError::gateway_timeout_code("convex_timeout", format!("Convex query 超时: {error}")) WebError::gateway_timeout_code("convex_timeout", format!("Convex query 超时: {error}"))
} else { } else {
@@ -201,7 +239,7 @@ pub fn execute_convex_query_plan(
})?; })?;
let status = response.status(); let status = response.status();
let body: Value = response.json().map_err(|error| { let body: Value = response.json().await.map_err(|error| {
WebError::bad_gateway_code( WebError::bad_gateway_code(
"convex_bad_response", "convex_bad_response",
format!("Convex 响应解析失败: {error}"), format!("Convex 响应解析失败: {error}"),
@@ -249,10 +287,135 @@ pub fn execute_convex_query_plan(
} }
} }
pub fn execute_sidebar_dataset_query( pub async fn execute_sidebar_dataset_query(
config: &AppConfig, config: &AppConfig,
context: &RequestContext, context: &RequestContext,
plan: &RuntimeQueryExecutionPlan, plan: &RuntimeQueryExecutionPlan,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
execute_convex_query_plan(config, context, plan) execute_convex_query_plan(config, context, plan).await
}
pub async fn execute_convex_command_plan(
config: &AppConfig,
context: &RequestContext,
plan: &RuntimeCommandExecutionPlan,
) -> Result<Value, WebError> {
if plan.function_name.trim().is_empty() || plan.function_name.ends_with(":unknown") {
return Err(WebError::bad_request_code(
"transport_command_unsupported",
format!("mnote-web transport 暂不支持 command: {}", plan.function_name),
)
.with_context(context)
.with_header("x-error-phase", "plan_validation"));
}
if let Some(fixture) = load_mutation_fixture(config, context, plan)? {
return Ok(fixture);
}
let payload = json!({
"path": plan.function_name,
"format": "convex_encoded_json",
"args": [plan.args_json.clone()],
});
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.map_err(|error| {
WebError::internal(format!("Convex HTTP 客户端创建失败: {error}"))
.with_context(context)
.with_header("x-error-phase", "client_build")
.with_header("x-upstream-service", "convex")
})?;
let mut request = client
.post(format!("{}/api/mutation", convex_url(config, context)?))
.header("Authorization", build_authorization(config, context)?)
.header("Content-Type", "application/json")
.header("Convex-Client", "mnote-web")
.header(HEADER_REQUEST_ID, &context.trace.request_id)
.header(HEADER_TRACE_ID, &context.trace.trace_id)
.header(HEADER_SOURCE_CHANNEL, context.source.channel.as_str())
.header(HEADER_SOURCE_CLIENT, context.source.client.as_str())
.json(&payload);
if let Some(workspace_id) = plan
.workspace_id
.as_deref()
.or(context.workspace.workspace_id.as_deref())
{
request = request.header(HEADER_WORKSPACE_ID, workspace_id);
}
if let Some(idempotency_key) = plan
.idempotency_key
.as_deref()
.or(context.source.idempotency_key.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
{
request = request.header(HEADER_IDEMPOTENCY_KEY, idempotency_key);
}
let response = request.send().await.map_err(|error| {
let base = if error.is_timeout() {
WebError::gateway_timeout_code("convex_timeout", format!("Convex mutation 超时: {error}"))
} else {
WebError::service_unavailable_code(
"convex_unavailable",
format!("Convex mutation 请求失败: {error}"),
)
};
base.with_context(context)
.with_header("x-error-phase", "mutation_send")
.with_header("x-upstream-service", "convex")
})?;
let status = response.status();
let body: Value = response.json().await.map_err(|error| {
WebError::bad_gateway_code(
"convex_bad_response",
format!("Convex mutation 响应解析失败: {error}"),
)
.with_context(context)
.with_header("x-error-phase", "mutation_decode")
.with_header("x-upstream-service", "convex")
.with_header("x-upstream-status", status.as_u16().to_string())
})?;
if !status.is_success() {
let message = body
.get("errorMessage")
.and_then(Value::as_str)
.unwrap_or("Convex mutation 失败");
return Err(
WebError::bad_gateway_code("convex_upstream_error", message.to_string())
.with_context(context)
.with_header("x-error-phase", "mutation_status")
.with_header("x-upstream-service", "convex")
.with_header("x-upstream-status", status.as_u16().to_string()),
);
}
match body.get("status").and_then(Value::as_str) {
Some("success") => Ok(body.get("value").cloned().unwrap_or(Value::Null)),
Some("error") => Err(WebError::bad_gateway_code(
"convex_upstream_error",
body.get("errorMessage")
.and_then(Value::as_str)
.unwrap_or("Convex 返回 error")
.to_string(),
)
.with_context(context)
.with_header("x-error-phase", "mutation_payload")
.with_header("x-upstream-service", "convex")
.with_header("x-upstream-status", status.as_u16().to_string())),
_ => Err(WebError::bad_gateway_code(
"convex_bad_response",
format!("未知 Convex 响应: {body}"),
)
.with_context(context)
.with_header("x-error-phase", "mutation_payload")
.with_header("x-upstream-service", "convex")
.with_header("x-upstream-status", status.as_u16().to_string())),
}
} }
+363
View File
@@ -0,0 +1,363 @@
"use strict";
// 说明:
// - 这是 Sidebar / PageTree Rust Web tree shell 的真实网页 smoke 脚本。
// - 目标覆盖:主页面已挂载 iframe tree shell、展开/折叠、在 shell 内创建子页面、重命名、移动、导航。
// - 脚本会创建临时页面并在结束后清理,避免污染现有数据。
const { chromium } = require("playwright");
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const MNOTE_WEB_BASE_URL = process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3104";
const REQUEST_TIMEOUT_MS = 20_000;
const UI_TIMEOUT_MS = 30_000;
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function requestJson(requestContext, path, init = {}) {
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
...init,
headers:
init.data !== undefined
? {
"content-type": "application/json",
...(init.headers || {}),
}
: {
...(init.headers || {}),
},
timeout: REQUEST_TIMEOUT_MS,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(
`${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
);
}
const contentType = response.headers()["content-type"] || "";
if (!contentType.includes("application/json")) {
const snippet = typeof payload === "string" ? payload.slice(0, 200) : JSON.stringify(payload).slice(0, 200);
throw new Error(`${path} 返回了非 JSON 内容:${snippet}`);
}
return payload;
}
async function createTempDocument(requestContext, parentId = null) {
const payload = await requestJson(requestContext, "/api/documents/create", {
method: "POST",
data: { parentId },
});
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id");
return {
documentId: payload.id,
workspaceId: payload.workspace_id,
};
}
async function renameDocument(requestContext, workspaceId, documentId, title) {
await requestJson(requestContext, "/api/documents/title", {
method: "POST",
data: {
workspaceId,
documentId,
title,
},
});
}
async function purgeDocument(requestContext, documentId) {
await requestJson(requestContext, "/api/documents/purge", {
method: "POST",
data: { documentId },
});
}
async function getViewerIdentity(requestContext) {
const payload = await requestJson(requestContext, "/api/auth/whoami", { method: "GET" });
assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId");
return payload;
}
async function ensureAuthenticated(page, requestContext) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS });
if (page.url().includes("/auth")) {
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => !url.toString().includes("/auth"), {
timeout: UI_TIMEOUT_MS,
});
}
return await getViewerIdentity(requestContext);
}
async function runTreeShellRegression(page, requestContext, viewer, target) {
const uniqueSuffix = Date.now().toString();
const rootTitle = `task091-root-${uniqueSuffix}`;
const childTitle = `task091-child-${uniqueSuffix}`;
const createdTitle = `task091-created-${uniqueSuffix}`;
const renamedTitle = `task091-renamed-${uniqueSuffix}`;
await renameDocument(requestContext, target.workspaceId, target.parentId, rootTitle);
await renameDocument(requestContext, target.workspaceId, target.childId, childTitle);
const documentUrl = `${BASE_URL}/documents/${target.parentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const groupButton = page.getByRole("button", { name: "分组" });
await groupButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await groupButton.click({ timeout: UI_TIMEOUT_MS });
const sidebarPanel = page.getByText("页面树");
await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const iframe = page.locator('iframe[title="mnote-web tree shell"]');
await iframe.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const iframeSrc = await iframe.getAttribute("src");
assert(iframeSrc && iframeSrc.includes(`${MNOTE_WEB_BASE_URL}/tree`), `Sidebar 未挂载 mnote-web tree shell${iframeSrc}`);
assert(iframeSrc.includes(`actorId=${encodeURIComponent(viewer.userId)}`), `tree shell 未透传当前用户 actorId${iframeSrc}`);
const getTreeFrame = () => page.frameLocator('iframe[title="mnote-web tree shell"]');
const waitForTreeReady = async () => {
const frame = getTreeFrame();
await frame.locator('[data-testid="tree-create-root"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
return frame;
};
const getFirstChildId = async (parentId) => {
const frame = await waitForTreeReady();
return await frame
.locator(`.tree-node[data-node-id="${parentId}"] > .tree-children > .tree-node`)
.first()
.getAttribute("data-node-id");
};
const waitForFirstChildId = async (parentId, expectedId) => {
const deadline = Date.now() + UI_TIMEOUT_MS;
while (Date.now() < deadline) {
const actualId = await getFirstChildId(parentId);
if (actualId === expectedId) {
return actualId;
}
await page.waitForTimeout(250);
}
return await getFirstChildId(parentId);
};
let treeFrame = await waitForTreeReady();
const parentRow = treeFrame.locator(`.tree-row[data-node-id="${target.parentId}"]`);
const childRow = treeFrame.locator(`.tree-row[data-node-id="${target.childId}"]`);
await parentRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await childRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const toggleButton = parentRow.locator('[data-testid="tree-node-toggle"]');
await toggleButton.click({ timeout: UI_TIMEOUT_MS });
await childRow.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
await toggleButton.click({ timeout: UI_TIMEOUT_MS });
await childRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
page.once("dialog", (dialog) => dialog.accept(createdTitle));
const createResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`${MNOTE_WEB_BASE_URL}/api/tree/commands`) &&
response.request().method() === "POST" &&
response.status() === 200 &&
(response.request().postData() || "").includes('"action":"create"'),
{ timeout: UI_TIMEOUT_MS },
);
await parentRow.locator('[data-testid="tree-action-create"]').click({ timeout: UI_TIMEOUT_MS });
const createResponse = await createResponsePromise;
const createPayload = await createResponse.json();
const createdDocumentId = createPayload?.result?.documentId;
assert(typeof createdDocumentId === "string" && createdDocumentId, "tree shell 创建子页面失败:缺少 documentId");
await page.waitForURL((url) => url.toString().includes(`/documents/${createdDocumentId}`), {
timeout: UI_TIMEOUT_MS,
});
target.createdIds.push(createdDocumentId);
treeFrame = await waitForTreeReady();
const createdRow = treeFrame.locator(`.tree-row[data-node-id="${createdDocumentId}"]`);
await createdRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
page.once("dialog", (dialog) => dialog.accept(renamedTitle));
const renameResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`${MNOTE_WEB_BASE_URL}/api/tree/commands`) &&
response.request().method() === "POST" &&
response.status() === 200 &&
(response.request().postData() || "").includes('"action":"rename"'),
{ timeout: UI_TIMEOUT_MS },
);
await createdRow.locator('[data-testid="tree-action-rename"]').click({ timeout: UI_TIMEOUT_MS });
await renameResponsePromise;
treeFrame = await waitForTreeReady();
const renamedRow = treeFrame.locator(`.tree-row[data-node-id="${createdDocumentId}"]`);
await renamedRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const childOrderBeforeMove = await getFirstChildId(target.parentId);
assert(
childOrderBeforeMove === target.childId,
`移动前的首个子节点异常:期望 ${target.childId},实际 ${childOrderBeforeMove}`,
);
const moveResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`${MNOTE_WEB_BASE_URL}/api/tree/commands`) &&
response.request().method() === "POST" &&
response.status() === 200 &&
(response.request().postData() || "").includes('"action":"move"'),
{ timeout: UI_TIMEOUT_MS },
);
await renamedRow.locator('[data-testid="tree-action-move-up"]').click({ timeout: UI_TIMEOUT_MS });
await moveResponsePromise;
const firstChildAfterMove = await waitForFirstChildId(target.parentId, createdDocumentId);
assert(
firstChildAfterMove === createdDocumentId,
`移动后排序未生效:期望首个子节点为 ${createdDocumentId},实际 ${firstChildAfterMove}`,
);
treeFrame = await waitForTreeReady();
const latestChildRow = treeFrame.locator(`.tree-row[data-node-id="${target.childId}"]`);
await latestChildRow.locator('[data-testid="tree-node-open"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.toString().includes(`/documents/${target.childId}`), {
timeout: UI_TIMEOUT_MS,
});
return {
documentUrl,
rootTitle,
childTitle,
createdTitle,
renamedTitle,
createdDocumentId,
};
}
async function runPickerRegression(page, target) {
const documentUrl = `${BASE_URL}/documents/${target.parentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const inspectorToggle = page.getByRole("button", { name: /显示页面选项|隐藏页面选项/ });
await inspectorToggle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const currentLabel = (await inspectorToggle.getAttribute("aria-label")) || "";
if (currentLabel.includes("显示")) {
await inspectorToggle.click({ timeout: UI_TIMEOUT_MS });
}
const moveEmbedButton = page.getByRole("button", { name: "移动/嵌入到..." });
await moveEmbedButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await moveEmbedButton.click({ timeout: UI_TIMEOUT_MS });
const dialog = page.getByRole("dialog");
await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const pickerIframe = dialog.locator('iframe[title="mnote-web tree shell"]');
await pickerIframe.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const iframeSrc = await pickerIframe.getAttribute("src");
assert(iframeSrc && iframeSrc.includes("mode=picker"), `picker 未以 tree shell 轻量模式挂载:${iframeSrc}`);
assert(iframeSrc.includes("allowRootPick=1"), `picker 未透传 allowRootPick${iframeSrc}`);
assert(
iframeSrc.includes(`excludeIds=${encodeURIComponent(target.parentId)}`),
`picker 未透传 excludeIds${iframeSrc}`,
);
const frame = dialog.frameLocator('iframe[title="mnote-web tree shell"]');
const rootPick = frame.locator('[data-testid="tree-picker-root"]');
await rootPick.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await rootPick.click({ timeout: UI_TIMEOUT_MS });
await dialog.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let caughtError = null;
let result = null;
const createdIds = [];
try {
const viewer = await ensureAuthenticated(page, context.request);
const parent = await createTempDocument(context.request, null);
createdIds.push(parent.documentId);
const child = await createTempDocument(context.request, parent.documentId);
createdIds.push(child.documentId);
result = await runTreeShellRegression(page, context.request, viewer, {
workspaceId: parent.workspaceId,
parentId: parent.documentId,
childId: child.documentId,
createdIds,
});
await runPickerRegression(page, {
workspaceId: parent.workspaceId,
parentId: parent.documentId,
});
console.log(
JSON.stringify(
{
ok: true,
workspaceId: parent.workspaceId,
viewerUserId: viewer.userId,
parentId: parent.documentId,
childId: child.documentId,
...result,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
for (const documentId of [...createdIds].reverse()) {
try {
await purgeDocument(context.request, documentId);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
} else {
console.error(
`清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`,
);
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
+124
View File
@@ -0,0 +1,124 @@
"use strict";
// 说明:
// - 这是一条最小首页入口 smoke,不验证完整业务流。
// - 它只验证 3000 主站入口链路是否可达,并明确 3104 不是首页可进入的前置条件。
// - 未登录场景允许 "/" 返回 30x 到 "/auth",也允许直接返回 HTML。
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const MNOTE_WEB_BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3104").replace(/\/+$/, "");
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 8000);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function fetchWithTimeout(url, init = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error(`请求超时: ${url}`)), TIMEOUT_MS);
try {
return await fetch(url, {
redirect: "manual",
cache: "no-store",
...init,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
async function readSnippet(response) {
const text = await response.text().catch(() => "");
return text.slice(0, 200);
}
async function probeMnoteWeb() {
try {
const response = await fetchWithTimeout(`${MNOTE_WEB_BASE_URL}/health`, {
method: "GET",
});
return {
reachable: true,
status: response.status,
};
} catch (error) {
return {
reachable: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
async function validateAuthEntry() {
const response = await fetchWithTimeout(`${BASE_URL}/auth`, { method: "GET" });
const contentType = response.headers.get("content-type") || "";
const snippet = await readSnippet(response);
assert(response.ok, `/auth 请求失败: ${response.status} ${snippet}`);
assert(
contentType.includes("text/html"),
`/auth 返回了非 HTML 内容: ${contentType || "<empty>"} ${snippet}`,
);
return {
status: response.status,
contentType,
};
}
async function validateRootEntry() {
const response = await fetchWithTimeout(`${BASE_URL}/`, { method: "GET" });
const location = response.headers.get("location");
const contentType = response.headers.get("content-type") || "";
const snippet = await readSnippet(response);
const isRedirectToAllowedTarget =
response.status >= 300 &&
response.status < 400 &&
typeof location === "string" &&
(location.includes("/auth") || location.includes("/documents/"));
const isHtmlOk =
response.ok &&
contentType.includes("text/html");
assert(
isRedirectToAllowedTarget || isHtmlOk,
`/ 返回了不符合预期的结果: status=${response.status} location=${location || "<empty>"} contentType=${contentType || "<empty>"} snippet=${snippet}`,
);
return {
status: response.status,
location,
contentType,
};
}
async function main() {
const mnoteWeb = await probeMnoteWeb();
const authEntry = await validateAuthEntry();
const rootEntry = await validateRootEntry();
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
mnoteWebBaseUrl: MNOTE_WEB_BASE_URL,
mnoteWeb,
authEntry,
rootEntry,
},
null,
2,
),
);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
+2
View File
@@ -4,6 +4,8 @@
"supabaseInternalUrl": "http://127.0.0.1:18000", "supabaseInternalUrl": "http://127.0.0.1:18000",
"supabaseAnonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc2NDExNTA0OSwiZXhwIjoyMDc5NDc1MDQ5fQ.18ohcQZXVkoR1TIF56QWJxHyoVnA9aarH-XfTyBJn1Y", "supabaseAnonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc2NDExNTA0OSwiZXhwIjoyMDc5NDc1MDQ5fQ.18ohcQZXVkoR1TIF56QWJxHyoVnA9aarH-XfTyBJn1Y",
"backendUrl": "https://frp-dry.com:44399", "backendUrl": "https://frp-dry.com:44399",
"mnoteWebBaseUrl": "http://127.0.0.1:3104",
"mnoteWebTreeShellEnabled": false,
"onlyofficeBaseUrlWeb": "/onlyoffice-server", "onlyofficeBaseUrlWeb": "/onlyoffice-server",
"onlyofficeBaseUrlDesktop": "http://localhost:8082", "onlyofficeBaseUrlDesktop": "http://localhost:8082",
"onlyofficeProxyOrigin": "http://host.docker.internal:3000", "onlyofficeProxyOrigin": "http://host.docker.internal:3000",
+28 -17
View File
@@ -9,7 +9,7 @@
* - node scripts/dev-server.js -p 3000 * - node scripts/dev-server.js -p 3000
* *
* 依赖环境变量 * 依赖环境变量
* - ONLYOFFICE_INTERNAL_URL可显式指定未指定或失效时会自动探测 8081/8082 * - ONLYOFFICE_INTERNAL_URL可显式指定未指定或失效时优先探测 8082再回退 8081
*/ */
const http = require("http"); const http = require("http");
@@ -20,7 +20,7 @@ const { parse: parseUrl } = require("url");
const ONLYOFFICE_PREFIX = "/onlyoffice-server"; const ONLYOFFICE_PREFIX = "/onlyoffice-server";
const CONVEX_PREFIX = "/convex"; const CONVEX_PREFIX = "/convex";
const DEFAULT_ONLYOFFICE_INTERNAL_URL = "http://127.0.0.1:8081"; const DEFAULT_ONLYOFFICE_INTERNAL_URL = "http://127.0.0.1:8082";
const ONLYOFFICE_PROBE_PATH = "/web-apps/apps/api/documents/api.js"; const ONLYOFFICE_PROBE_PATH = "/web-apps/apps/api/documents/api.js";
const ONLYOFFICE_RESOLVE_CACHE_TTL_MS = 30_000; const ONLYOFFICE_RESOLVE_CACHE_TTL_MS = 30_000;
@@ -92,9 +92,9 @@ function listOnlyOfficeInternalUrlCandidates() {
} }
push(DEFAULT_ONLYOFFICE_INTERNAL_URL); push(DEFAULT_ONLYOFFICE_INTERNAL_URL);
push("http://127.0.0.1:8082"); push("http://127.0.0.1:8081");
push("http://localhost:8081");
push("http://localhost:8082"); push("http://localhost:8082");
push("http://localhost:8081");
return candidates.length > 0 ? candidates : [DEFAULT_ONLYOFFICE_INTERNAL_URL]; return candidates.length > 0 ? candidates : [DEFAULT_ONLYOFFICE_INTERNAL_URL];
} }
@@ -313,12 +313,12 @@ function proxyConvexUpgrade(req, socket, head) {
socket.on("error", onError); socket.on("error", onError);
} }
function proxyConvexHttp(req, res) { function proxyConvexHttp(req, res) {
const target = resolveConvexInternalUrl(); const target = resolveConvexInternalUrl();
const incoming = new URL(req.url || "/", "http://localhost"); const incoming = new URL(req.url || "/", "http://localhost");
const rawPath = incoming.pathname || "/"; const rawPath = incoming.pathname || "/";
const stripped = rawPath === CONVEX_PREFIX ? "/" : rawPath.slice(CONVEX_PREFIX.length) || "/"; const stripped = rawPath === CONVEX_PREFIX ? "/" : rawPath.slice(CONVEX_PREFIX.length) || "/";
const upstreamPath = stripped + (incoming.search || ""); const upstreamPath = stripped + (incoming.search || "");
const isHttps = target.protocol === "https:"; const isHttps = target.protocol === "https:";
const mod = isHttps ? require("https") : require("http"); const mod = isHttps ? require("https") : require("http");
@@ -359,19 +359,30 @@ function proxyConvexHttp(req, res) {
}, },
); );
upstreamReq.on("error", (err) => { upstreamReq.on("error", (err) => {
try { try {
console.log("[dev-server][convex-http] proxy error", err && err.message ? err.message : String(err || "")); console.log("[dev-server][convex-http] proxy error", err && err.message ? err.message : String(err || ""));
} catch {} } catch {}
try { try {
res.statusCode = 502; res.statusCode = 502;
res.end("Bad Gateway"); res.end("Bad Gateway");
} catch {} } catch {}
}); });
req.pipe(upstreamReq); const method = String(req.method || "GET").toUpperCase();
} const hasBody =
!["GET", "HEAD"].includes(method) &&
((typeof req.headers["content-length"] === "string" && req.headers["content-length"] !== "0") ||
String(req.headers["transfer-encoding"] || "").trim().length > 0);
if (!hasBody) {
upstreamReq.end();
return;
}
req.pipe(upstreamReq);
}
async function main() { async function main() {
const port = resolvePort(); const port = resolvePort();
+4 -4
View File
@@ -9,7 +9,7 @@
* - pnpm start 默认会执行本脚本 * - pnpm start 默认会执行本脚本
* *
* 依赖环境变量 * 依赖环境变量
* - ONLYOFFICE_INTERNAL_URL可显式指定未指定或失效时会自动探测 8081/8082 * - ONLYOFFICE_INTERNAL_URL可显式指定未指定或失效时优先探测 8082再回退 8081
* - CONVEX_INTERNAL_URL默认 http://127.0.0.1:3210 * - CONVEX_INTERNAL_URL默认 http://127.0.0.1:3210
*/ */
@@ -21,7 +21,7 @@ const { parse: parseUrl } = require("url");
const ONLYOFFICE_PREFIX = "/onlyoffice-server"; const ONLYOFFICE_PREFIX = "/onlyoffice-server";
const CONVEX_PREFIX = "/convex"; const CONVEX_PREFIX = "/convex";
const DEFAULT_ONLYOFFICE_INTERNAL_URL = "http://127.0.0.1:8081"; const DEFAULT_ONLYOFFICE_INTERNAL_URL = "http://127.0.0.1:8082";
const ONLYOFFICE_PROBE_PATH = "/web-apps/apps/api/documents/api.js"; const ONLYOFFICE_PROBE_PATH = "/web-apps/apps/api/documents/api.js";
const ONLYOFFICE_RESOLVE_CACHE_TTL_MS = 30_000; const ONLYOFFICE_RESOLVE_CACHE_TTL_MS = 30_000;
@@ -93,9 +93,9 @@ function listOnlyOfficeInternalUrlCandidates() {
} }
push(DEFAULT_ONLYOFFICE_INTERNAL_URL); push(DEFAULT_ONLYOFFICE_INTERNAL_URL);
push("http://127.0.0.1:8082"); push("http://127.0.0.1:8081");
push("http://localhost:8081");
push("http://localhost:8082"); push("http://localhost:8082");
push("http://localhost:8081");
return candidates.length > 0 ? candidates : [DEFAULT_ONLYOFFICE_INTERNAL_URL]; return candidates.length > 0 ? candidates : [DEFAULT_ONLYOFFICE_INTERNAL_URL];
} }
+17 -7
View File
@@ -31,14 +31,22 @@ export default function AuthPage() {
const currentUser = useQuery(api.users.currentUser, isAuthenticated ? {} : "skip"); const currentUser = useQuery(api.users.currentUser, isAuthenticated ? {} : "skip");
const navigateToHome = useCallback(() => {
if (typeof window !== "undefined") {
window.location.assign("/");
return;
}
router.replace("/");
}, [router]);
useEffect(() => { useEffect(() => {
if (isLoading) return; if (isLoading) return;
if (!isAuthenticated) return; if (!isAuthenticated) return;
if (currentUser === undefined) return; if (currentUser === undefined) return;
if (currentUser && currentUser.name) { if (currentUser && currentUser.name) {
router.replace("/"); navigateToHome();
} }
}, [currentUser, isAuthenticated, isLoading, router]); }, [currentUser, isAuthenticated, isLoading, navigateToHome]);
const [flow, setFlow] = useState<AuthStep>("signIn"); const [flow, setFlow] = useState<AuthStep>("signIn");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@@ -70,7 +78,7 @@ export default function AuthPage() {
if (cancelled) return; if (cancelled) return;
setPendingUsernameToSave(null); setPendingUsernameToSave(null);
setMessage({ type: "success", text: "注册成功!" }); setMessage({ type: "success", text: "注册成功!" });
router.replace("/"); navigateToHome();
} catch (err: unknown) { } catch (err: unknown) {
if (cancelled) return; if (cancelled) return;
setPendingUsernameToSave(null); setPendingUsernameToSave(null);
@@ -83,7 +91,7 @@ export default function AuthPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [currentUser, isAuthenticated, pendingUsernameToSave, router, setMyUsername]); }, [currentUser, isAuthenticated, navigateToHome, pendingUsernameToSave, setMyUsername]);
// 执行登录的核心逻辑 // 执行登录的核心逻辑
const performSignIn = useCallback(async (email: string, password: string, flow: AuthStep) => { const performSignIn = useCallback(async (email: string, password: string, flow: AuthStep) => {
@@ -110,8 +118,10 @@ export default function AuthPage() {
setPendingUsernameToSave(username.trim()); setPendingUsernameToSave(username.trim());
return; return;
} }
setMessage({ type: "success", text: flow === "signIn" ? "登录成功!" : "注册成功!" }); // 说明:登录成功后不要立刻强推 `/`。
setTimeout(() => router.push("/"), 500); // Convex Auth cookie 与 currentUser 查询存在短暂同步窗口,过早跳转会把页面推进到根路由空白态。
// 这里仅提示“正在同步”,真正跳转交给上面的 useEffect 在登录态稳定后执行。
setMessage({ type: "info", text: "登录成功,正在同步登录状态..." });
return; return;
} }
@@ -187,7 +197,7 @@ export default function AuthPage() {
setIsSavingUsername(true); setIsSavingUsername(true);
await setMyUsername({ username: username.trim() }); await setMyUsername({ username: username.trim() });
setMessage({ type: "success", text: "用户名已保存!" }); setMessage({ type: "success", text: "用户名已保存!" });
router.replace("/"); navigateToHome();
} catch (error: any) { } catch (error: any) {
setMessage({ type: "error", text: getUserFacingErrorMessage(error, "保存失败,请重试") }); setMessage({ type: "error", text: getUserFacingErrorMessage(error, "保存失败,请重试") });
} finally { } finally {
+3 -24
View File
@@ -13,14 +13,9 @@ import {
resolveRustBridgeQueryPlan, resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime"; } from "@/lib/documents/rust-runtime";
import { import {
buildSidebarDatasetListQueryPayload,
mapSidebarDatasetListQueryResultToInitialData, mapSidebarDatasetListQueryResultToInitialData,
type SidebarDatasetListQueryResult, type SidebarDatasetListQueryResult,
} from "@/lib/sidebar-data"; } from "@/lib/sidebar-data";
import {
fetchSidebarDatasetFromMnoteWeb,
getMnoteWebBaseUrl,
} from "@/lib/server/mnote-web";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -41,31 +36,15 @@ export async function GET(request: Request) {
} }
try { try {
if (getMnoteWebBaseUrl()) {
const proxied = await fetchSidebarDatasetFromMnoteWeb({
workspaceId: targetWorkspaceId,
request,
});
const result = mapSidebarDatasetListQueryResultToInitialData(proxied.dataset);
return NextResponse.json({
...result,
meta: {
requestId: proxied.meta.requestId,
traceId: proxied.meta.traceId,
queryName: "compat.next.sidebar",
workspaceId: proxied.meta.workspaceId,
},
});
}
const bridgeContext = await buildDocumentBridgeContext({ const bridgeContext = await buildDocumentBridgeContext({
request, request,
workspaceId: targetWorkspaceId, workspaceId: targetWorkspaceId,
}); });
const envelope = buildDocumentQueryEnvelope({ const envelope = buildDocumentQueryEnvelope({
name: "sidebar.dataset.list", name: "sidebar.dataset.list",
payload: buildSidebarDatasetListQueryPayload(targetWorkspaceId), payload: {
workspaceId: targetWorkspaceId,
},
}); });
const plan = await resolveRustBridgeQueryPlan({ const plan = await resolveRustBridgeQueryPlan({
context: bridgeContext, context: bridgeContext,
@@ -3,11 +3,13 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { MnoteWebTreeShell } from "@/components/sidebar/MnoteWebTreeShell";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { SidebarInitialData } from "@/components/sidebar/types"; import type { SidebarInitialData } from "@/components/sidebar/types";
import { buildPageTreeProjectionItems, buildPickerTreeItems } from "@/lib/tree-projection"; import { buildPageTreeProjectionItems, buildPickerTreeItems } from "@/lib/tree-projection";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDocumentSearch } from "@/hooks/use-document-search"; import { useDocumentSearch } from "@/hooks/use-document-search";
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search"; import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
@@ -27,6 +29,10 @@ type PickerItem =
| { kind: "root"; id: null; title: string; subtitle?: string } | { kind: "root"; id: null; title: string; subtitle?: string }
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number; raw?: DocumentSearchResult }; | { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number; raw?: DocumentSearchResult };
type ViewerIdentity = {
userId: string;
};
async function fetchSidebarData(workspaceId: string): Promise<SidebarInitialData> { async function fetchSidebarData(workspaceId: string): Promise<SidebarInitialData> {
const response = await fetch(`/api/sidebar?workspaceId=${encodeURIComponent(workspaceId)}`, { const response = await fetch(`/api/sidebar?workspaceId=${encodeURIComponent(workspaceId)}`, {
method: "GET", method: "GET",
@@ -42,6 +48,18 @@ async function fetchSidebarData(workspaceId: string): Promise<SidebarInitialData
return response.json(); return response.json();
} }
async function fetchViewerIdentity(): Promise<ViewerIdentity> {
const response = await fetch("/api/auth/whoami", {
method: "GET",
credentials: "include",
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || typeof payload?.userId !== "string" || !payload.userId.trim()) {
throw new Error(payload?.error ?? "获取当前用户失败");
}
return { userId: payload.userId.trim() };
}
interface MoveEmbedPickerDialogProps { interface MoveEmbedPickerDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -129,6 +147,10 @@ function MoveEmbedPickerDialogBody({
const trimmed = query.trim(); const trimmed = query.trim();
const isEmptyQuery = trimmed.length === 0; const isEmptyQuery = trimmed.length === 0;
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
const treeShellEnabled = Boolean(
(runtimeConfig.mnoteWebBaseUrl ?? "").trim() && runtimeConfig.mnoteWebTreeShellEnabled === true,
);
const sidebarQuery = useQuery({ const sidebarQuery = useQuery({
queryKey: ["move-embed-picker-sidebar", workspaceId], queryKey: ["move-embed-picker-sidebar", workspaceId],
@@ -143,6 +165,14 @@ function MoveEmbedPickerDialogBody({
gcTime: 60_000, gcTime: 60_000,
}); });
const viewerQuery = useQuery({
queryKey: ["move-embed-picker-viewer"],
queryFn: fetchViewerIdentity,
enabled: Boolean(workspaceId) && isEmptyQuery && treeShellEnabled,
staleTime: 60_000,
gcTime: 120_000,
});
const { data, isLoading, error } = useDocumentSearch(payload, Boolean(payload) && !isEmptyQuery); const { data, isLoading, error } = useDocumentSearch(payload, Boolean(payload) && !isEmptyQuery);
const items = useMemo<PickerItem[]>(() => { const items = useMemo<PickerItem[]>(() => {
@@ -190,6 +220,39 @@ function MoveEmbedPickerDialogBody({
}, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.kernelSidebarTree]); }, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.kernelSidebarTree]);
const placeholder = mode === "move" ? "移动到..." : "嵌入到..."; const placeholder = mode === "move" ? "移动到..." : "嵌入到...";
const allowRootPick = allowRoot && mode === "move";
const pickerFallback = (
sidebarQuery.isLoading ? (
<div className="p-4 text-sm text-gray-400">...</div>
) : sidebarQuery.error ? (
<div className="p-4 text-sm text-red-600">{String(sidebarQuery.error)}</div>
) : items.length === 0 ? (
<div className="p-4 text-sm text-gray-400"></div>
) : (
<div className="py-2">
{items.map((item, idx) => (
<button
key={item.kind === "root" ? "root" : item.id}
type="button"
className={cn(
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
idx === highlighted && "bg-[#e3ecff]",
)}
onMouseEnter={() => setHighlighted(idx)}
onClick={() => void handlePick(item.id)}
>
<div
className="text-sm font-medium text-gray-900"
style={item.kind === "doc" ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
>
{item.title}
</div>
{item.subtitle && <div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>}
</button>
))}
</div>
)
);
const handlePick = useCallback( const handlePick = useCallback(
async (targetId: string | null) => { async (targetId: string | null) => {
@@ -232,6 +295,9 @@ function MoveEmbedPickerDialogBody({
<div <div
className="flex-1 overflow-y-auto" className="flex-1 overflow-y-auto"
onKeyDown={(event) => { onKeyDown={(event) => {
if (isEmptyQuery) {
return;
}
if (event.key === "ArrowDown") { if (event.key === "ArrowDown") {
event.preventDefault(); event.preventDefault();
setHighlighted((prev) => Math.min(items.length - 1, prev + 1)); setHighlighted((prev) => Math.min(items.length - 1, prev + 1));
@@ -248,10 +314,34 @@ function MoveEmbedPickerDialogBody({
> >
{!workspaceId ? ( {!workspaceId ? (
<div className="p-4 text-sm text-gray-500"> workspaceId</div> <div className="p-4 text-sm text-gray-500"> workspaceId</div>
) : (isEmptyQuery ? sidebarQuery.isLoading : isLoading) ? ( ) : isEmptyQuery ? (
<div className="h-full p-3">
{!treeShellEnabled ? (
pickerFallback
) : viewerQuery.isLoading ? (
<div className="p-4 text-sm text-gray-400">...</div>
) : viewerQuery.error ? (
<div className="p-4 text-sm text-red-600">{String(viewerQuery.error)}</div>
) : (
<MnoteWebTreeShell
workspaceId={workspaceId}
actorId={viewerQuery.data?.userId ?? null}
mode="picker"
allowRootPick={allowRootPick}
excludeIds={excludeIds}
onNavigate={() => {}}
onPick={(targetId) => {
void handlePick(targetId);
}}
onRefresh={async () => undefined}
fallback={pickerFallback}
/>
)}
</div>
) : isLoading ? (
<div className="p-4 text-sm text-gray-400">...</div> <div className="p-4 text-sm text-gray-400">...</div>
) : (isEmptyQuery ? sidebarQuery.error : error) ? ( ) : error ? (
<div className="p-4 text-sm text-red-600">{String(isEmptyQuery ? sidebarQuery.error : error)}</div> <div className="p-4 text-sm text-red-600">{String(error)}</div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="p-4 text-sm text-gray-400"></div> <div className="p-4 text-sm text-gray-400"></div>
) : ( ) : (
@@ -21,7 +21,6 @@ import type { MediaAsset } from "@/types/media";
import { customBlockSchema, type CustomBlockSchema } from "./schema"; import { customBlockSchema, type CustomBlockSchema } from "./schema";
import { CustomSideMenu } from "./menus/CustomSideMenu"; import { CustomSideMenu } from "./menus/CustomSideMenu";
import { CustomSlashMenu } from "./menus/CustomSlashMenu"; import { CustomSlashMenu } from "./menus/CustomSlashMenu";
import { MoveEmbedPickerHost } from "@/components/documents/move-embed-picker-host";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc"; import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
import { useSearchPaletteStore } from "@/store/search-palette"; import { useSearchPaletteStore } from "@/store/search-palette";
@@ -36,6 +35,7 @@ import { useCommentsUiStore } from "@/store/comments-ui";
import { useConvexAuth, useQuery } from "convex/react"; import { useConvexAuth, useQuery } from "convex/react";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract"; import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import type { EditorReferenceBridge } from "@/store/editor-bridge";
interface BlockNoteEditorProps { interface BlockNoteEditorProps {
documentId: string; documentId: string;
@@ -197,6 +197,10 @@ export function BlockNoteEditor({
const isFullScreenTableOpen = fullScreenTableId !== null; const isFullScreenTableOpen = fullScreenTableId !== null;
const revisionRef = useRef<number | null>(initialRevision); const revisionRef = useRef<number | null>(initialRevision);
const conflictDetectionKeyRef = useRef<string | null>(initialConflictDetectionKey); const conflictDetectionKeyRef = useRef<string | null>(initialConflictDetectionKey);
const onStatsChangeRef = useRef(onStatsChange);
const onSnapshotRef = useRef(onSnapshot);
const onPersistedMetaChangeRef = useRef(onPersistedMetaChange);
const stableInitialContentRef = useRef<{ documentId: string; content: Json | undefined } | null>(null);
const openReferencePalette = useSearchPaletteStore((state) => state.openReference); const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge); const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
@@ -226,6 +230,16 @@ export function BlockNoteEditor({
[initialContent], [initialContent],
); );
if (
!stableInitialContentRef.current ||
stableInitialContentRef.current.documentId !== documentId
) {
stableInitialContentRef.current = {
documentId,
content: normalizedInitialContent,
};
}
const collaboration = useMemo(() => { const collaboration = useMemo(() => {
const url = process.env.NEXT_PUBLIC_HOCUSPOCUS_URL; const url = process.env.NEXT_PUBLIC_HOCUSPOCUS_URL;
if (!url) return null; if (!url) return null;
@@ -240,7 +254,7 @@ export function BlockNoteEditor({
const editor = useCreateBlockNote( const editor = useCreateBlockNote(
{ {
initialContent: normalizedInitialContent as never, initialContent: stableInitialContentRef.current?.content as never,
schema: customBlockSchema, schema: customBlockSchema,
placeholders: { placeholders: {
default: "输入'/'选择,按 空格 打开AI...", default: "输入'/'选择,按 空格 打开AI...",
@@ -257,7 +271,7 @@ export function BlockNoteEditor({
} }
: undefined, : undefined,
}, },
[documentId, normalizedInitialContent], [documentId],
); );
useEffect( useEffect(
@@ -268,6 +282,20 @@ export function BlockNoteEditor({
[collaboration], [collaboration],
); );
const editorRef = useRef(editor);
const insertMediaAssetRef = useRef<(asset: MediaAsset) => void>(() => {});
const insertMindmapRef = useRef<(args: { documentId: string; mindmapId: string }) => void>(() => {});
const insertOnlineTableRef = useRef<(args: { documentId: string; tableId: string }) => void>(() => {});
const setFullScreenTableIdRef = useRef(setFullScreenTableId);
useEffect(() => {
editorRef.current = editor;
}, [editor]);
useEffect(() => {
setFullScreenTableIdRef.current = setFullScreenTableId;
}, [setFullScreenTableId]);
useEffect(() => { useEffect(() => {
revisionRef.current = initialRevision; revisionRef.current = initialRevision;
}, [initialRevision]); }, [initialRevision]);
@@ -276,6 +304,18 @@ export function BlockNoteEditor({
conflictDetectionKeyRef.current = initialConflictDetectionKey; conflictDetectionKeyRef.current = initialConflictDetectionKey;
}, [initialConflictDetectionKey]); }, [initialConflictDetectionKey]);
useEffect(() => {
onStatsChangeRef.current = onStatsChange;
}, [onStatsChange]);
useEffect(() => {
onSnapshotRef.current = onSnapshot;
}, [onSnapshot]);
useEffect(() => {
onPersistedMetaChangeRef.current = onPersistedMetaChange;
}, [onPersistedMetaChange]);
const saveContent = useCallback( const saveContent = useCallback(
async (content: Json) => { async (content: Json) => {
setIsSaving(true); setIsSaving(true);
@@ -324,7 +364,7 @@ export function BlockNoteEditor({
: conflictDetectionKeyRef.current; : conflictDetectionKeyRef.current;
revisionRef.current = nextRevision ?? null; revisionRef.current = nextRevision ?? null;
conflictDetectionKeyRef.current = nextConflictDetectionKey ?? null; conflictDetectionKeyRef.current = nextConflictDetectionKey ?? null;
onPersistedMetaChange?.({ onPersistedMetaChangeRef.current?.({
revision: revisionRef.current, revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current, conflictDetectionKey: conflictDetectionKeyRef.current,
}); });
@@ -337,7 +377,7 @@ export function BlockNoteEditor({
setIsSaving(false); setIsSaving(false);
} }
}, },
[documentId, onPersistedMetaChange, workspaceId], [documentId, workspaceId],
); );
const debouncedSave = useDebouncedCallback(saveContent, 800); const debouncedSave = useDebouncedCallback(saveContent, 800);
@@ -705,8 +745,8 @@ export function BlockNoteEditor({
setTocEntries(buildHeadingToc(typedBlocks)); setTocEntries(buildHeadingToc(typedBlocks));
syncProgressMeters(editor); syncProgressMeters(editor);
const stats = computeDocumentStats(typedBlocks); const stats = computeDocumentStats(typedBlocks);
onStatsChange?.(stats); onStatsChangeRef.current?.(stats);
onSnapshot?.({ blocks: blocks as Json, stats }); onSnapshotRef.current?.({ blocks: blocks as Json, stats });
// 检测块删除 -> 同步删除附件/思维导图并刷新侧边栏 // 检测块删除 -> 同步删除附件/思维导图并刷新侧边栏
const { assetIds, mindmapBlockIds, onlineTableIds } = collectAssets(typedBlocks); const { assetIds, mindmapBlockIds, onlineTableIds } = collectAssets(typedBlocks);
@@ -745,7 +785,7 @@ export function BlockNoteEditor({
disposed = true; disposed = true;
unsubscribe?.(); unsubscribe?.();
}; };
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, deleteOnlineTables, editor, onSnapshot, onStatsChange, restoreOnlineTableIfNeeded]); }, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, deleteOnlineTables, editor, restoreOnlineTableIfNeeded]);
const jumpToHeading = useCallback((headingId: string) => { const jumpToHeading = useCallback((headingId: string) => {
const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`); const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
@@ -1003,6 +1043,18 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
[documentId, editor], [documentId, editor],
); );
useEffect(() => {
insertMediaAssetRef.current = insertMediaAssetBlock;
}, [insertMediaAssetBlock]);
useEffect(() => {
insertMindmapRef.current = insertMindmapBlock;
}, [insertMindmapBlock]);
useEffect(() => {
insertOnlineTableRef.current = insertOnlineTableBlock;
}, [insertOnlineTableBlock]);
useEffect(() => { useEffect(() => {
if (!editor) return undefined; if (!editor) return undefined;
@@ -1090,46 +1142,48 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
[documentId, workspaceId], [documentId, workspaceId],
); );
useEffect(() => { const stableEditorBridge = useMemo<EditorReferenceBridge>(
if (!editor) { () => ({
registerEditorBridge(null);
return;
}
const bridge = {
undo: () => { undo: () => {
try { try {
editor.focus(); const currentEditor = editorRef.current;
editor.undo(); if (!currentEditor) return;
currentEditor.focus();
currentEditor.undo();
} catch { } catch {
// ignore // ignore
} }
}, },
redo: () => { redo: () => {
try { try {
editor.focus(); const currentEditor = editorRef.current;
editor.redo(); if (!currentEditor) return;
currentEditor.focus();
currentEditor.redo();
} catch { } catch {
// ignore // ignore
} }
}, },
openTableFullScreen: (tableId: string) => { openTableFullScreen: (tableId: string) => {
setFullScreenTableId(tableId); setFullScreenTableIdRef.current(tableId);
}, },
insertMediaAsset: (asset: MediaAsset) => { insertMediaAsset: (asset: MediaAsset) => {
insertMediaAssetBlock(asset); insertMediaAssetRef.current(asset);
}, },
insertMindmapAsset: (args: { documentId: string; mindmapId: string }) => { insertMindmapAsset: (args: { documentId: string; mindmapId: string }) => {
insertMindmapBlock(args); insertMindmapRef.current(args);
}, },
insertOnlineTableAsset: (args: { documentId: string; tableId: string }) => { insertOnlineTableAsset: (args: { documentId: string; tableId: string }) => {
insertOnlineTableBlock(args); insertOnlineTableRef.current(args);
}, },
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => { insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
editor.focus(); const currentEditor = editorRef.current;
const cursor = editor.getTextCursorPosition(); if (!currentEditor) return { blockId: null };
currentEditor.focus();
const cursor = currentEditor.getTextCursorPosition();
const blockId = cursor?.block?.id ?? null; const blockId = cursor?.block?.id ?? null;
const text = aliasText || target.title || "无标题"; const text = aliasText || target.title || "无标题";
editor.insertInlineContent([ currentEditor.insertInlineContent([
{ {
type: "link", type: "link",
href: buildDocumentPath(target.id), href: buildDocumentPath(target.id),
@@ -1140,12 +1194,14 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
return { blockId }; return { blockId };
}, },
insertEmbedReference: (target: ReferenceTarget) => { insertEmbedReference: (target: ReferenceTarget) => {
editor.focus(); const currentEditor = editorRef.current;
const cursor = editor.getTextCursorPosition(); if (!currentEditor) return { blockId: null };
currentEditor.focus();
const cursor = currentEditor.getTextCursorPosition();
const referenceBlock = const referenceBlock =
cursor?.block ?? editor.topLevelBlocks[editor.topLevelBlocks.length - 1]; cursor?.block ?? currentEditor.topLevelBlocks[currentEditor.topLevelBlocks.length - 1];
const blockId = generateBlockId(); const blockId = generateBlockId();
editor.insertBlocks( currentEditor.insertBlocks(
[ [
{ {
id: blockId, id: blockId,
@@ -1162,7 +1218,9 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
return { blockId }; return { blockId };
}, },
replaceWithSnapshot: (payload: Json) => { replaceWithSnapshot: (payload: Json) => {
editor.focus(); const currentEditor = editorRef.current;
if (!currentEditor) return;
currentEditor.focus();
const nextBlocks = Array.isArray(payload) const nextBlocks = Array.isArray(payload)
? payload ? payload
: Array.isArray((payload as { blocks?: Json }).blocks) : Array.isArray((payload as { blocks?: Json }).blocks)
@@ -1171,20 +1229,29 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
if (!Array.isArray(nextBlocks)) { if (!Array.isArray(nextBlocks)) {
return; return;
} }
editor.replaceBlocks(editor.topLevelBlocks, nextBlocks as never); currentEditor.replaceBlocks(currentEditor.topLevelBlocks, nextBlocks as never);
}, },
getCursorBlockId: () => { getCursorBlockId: () => {
try { try {
const cursor = editor.getTextCursorPosition(); const currentEditor = editorRef.current;
if (!currentEditor) return null;
const cursor = currentEditor.getTextCursorPosition();
return cursor?.block?.id ?? null; return cursor?.block?.id ?? null;
} catch { } catch {
return null; return null;
} }
}, },
}; }),
registerEditorBridge(bridge); [],
);
useEffect(() => {
registerEditorBridge(editor ? stableEditorBridge : null);
}, [editor, registerEditorBridge, stableEditorBridge]);
useEffect(() => {
return () => registerEditorBridge(null); return () => registerEditorBridge(null);
}, [editor, insertMediaAssetBlock, insertMindmapBlock, insertOnlineTableBlock, registerEditorBridge]); }, [registerEditorBridge]);
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
@@ -1336,9 +1403,6 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
</div> </div>
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} onClose={onCloseToc} /> <DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} onClose={onCloseToc} />
</div> </div>
<MoveEmbedPickerHost />
{/* 全屏表格编辑器 Modal */} {/* 全屏表格编辑器 Modal */}
{fullScreenTableId && ( {fullScreenTableId && (
<FullScreenTableEditor <FullScreenTableEditor
@@ -3,14 +3,10 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
import type { BooleanPageOptionKey, DocumentStats, PageOptionsState, PageFont, PageLayoutDensity } from "@/types/page-options"; import type { BooleanPageOptionKey, DocumentStats, PageOptionsState, PageFont, PageLayoutDensity } from "@/types/page-options";
import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
import { PageBacklinksPanel } from "@/components/editor/page-backlinks-panel";
import { useEditorBridgeStore } from "@/store/editor-bridge"; import { useEditorBridgeStore } from "@/store/editor-bridge";
import { usePageLayoutStore } from "@/store/page-layout"; import { usePageLayoutStore } from "@/store/page-layout";
import { useCurrentDocumentStore } from "@/store/current-document"; import { useCurrentDocumentStore } from "@/store/current-document";
import type { Json } from "@/types/supabase"; import type { Json } from "@/types/supabase";
import { DocumentHistoryDrawer } from "@/components/editor/document-history-drawer";
import { DocumentCommentsDrawer } from "@/components/editor/document-comments-drawer";
import type { DocumentSnapshot } from "@/types/document"; import type { DocumentSnapshot } from "@/types/document";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { ImagePickerProvider } from "@/components/media/image-picker-context"; import { ImagePickerProvider } from "@/components/media/image-picker-context";
@@ -25,6 +21,7 @@ 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";
const BlockNoteEditor = dynamic( const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor), () => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
@@ -36,6 +33,45 @@ const BlockNoteEditor = dynamic(
}, },
); );
// 说明:这些组件都不是“进入文档页首屏”所必需。
// 先拆成独立 chunk,避免 /documents/[id] 首次编译时把评论、历史、弹层、检查器等整串依赖一并拉进来。
const PageOptionsSidebar = dynamic(
() => import("@/components/editor/page-options-sidebar").then((mod) => mod.PageOptionsSidebar),
{ ssr: false },
);
const PageBacklinksPanel = dynamic(
() => import("@/components/editor/page-backlinks-panel").then((mod) => mod.PageBacklinksPanel),
{
ssr: false,
loading: () => null,
},
);
const DocumentHistoryDrawer = dynamic(
() => import("@/components/editor/document-history-drawer").then((mod) => mod.DocumentHistoryDrawer),
{
ssr: false,
loading: () => null,
},
);
const MoveEmbedPickerHost = dynamic(
() => import("@/components/documents/move-embed-picker-host").then((mod) => mod.MoveEmbedPickerHost),
{
ssr: false,
loading: () => null,
},
);
const DocumentCommentsDrawer = dynamic(
() => import("@/components/editor/document-comments-drawer").then((mod) => mod.DocumentCommentsDrawer),
{
ssr: false,
loading: () => null,
},
);
export interface DocumentContentProps { export interface DocumentContentProps {
documentId: string; documentId: string;
workspaceId: string; workspaceId: string;
@@ -358,11 +394,11 @@ export function DocumentContent({
async (nextTitle: string) => { async (nextTitle: string) => {
if (readOnly) return; if (readOnly) return;
const payload = nextTitle.trim() || "无标题"; const payload = nextTitle.trim() || "无标题";
await fetch("/api/documents/title", { try {
method: "POST", await renameDocumentCommand({ documentId, workspaceId, title: payload });
headers: { "Content-Type": "application/json" }, } catch (error) {
body: JSON.stringify({ documentId, workspaceId, title: payload }), console.error("更新页面标题失败", error);
}); }
}, },
[documentId, readOnly, workspaceId], [documentId, readOnly, workspaceId],
); );
@@ -590,14 +626,15 @@ export function DocumentContent({
excludeIds: [documentId], excludeIds: [documentId],
onPick: async (mode, targetId) => { onPick: async (mode, targetId) => {
if (mode === "move") { if (mode === "move") {
const resp = await fetch("/api/documents/move", { try {
method: "POST", await moveDocumentCommand({
headers: { "Content-Type": "application/json" }, documentId,
body: JSON.stringify({ documentId, parentId: targetId ?? null, position: 999999 }), parentId: targetId ?? null,
}); position: 999999,
if (!resp.ok) { workspaceId,
const payload = await resp.json().catch(() => ({})); });
window.alert(payload?.error ?? "移动失败"); } catch (error) {
window.alert(error instanceof Error ? error.message : "移动失败");
return; return;
} }
window.alert("移动成功"); window.alert("移动成功");
@@ -640,7 +677,18 @@ export function DocumentContent({
const formattedUpdatedAt = useMemo(() => { const formattedUpdatedAt = useMemo(() => {
if (!updatedAt) return ""; if (!updatedAt) return "";
return new Date(updatedAt).toLocaleString(); const date = new Date(updatedAt);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(date);
}, [updatedAt]); }, [updatedAt]);
const handleExport = useCallback(() => { const handleExport = useCallback(() => {
@@ -924,6 +972,7 @@ export function DocumentContent({
history={history} history={history}
onRestore={handleRestoreSnapshot} onRestore={handleRestoreSnapshot}
/> />
<MoveEmbedPickerHost />
<DocumentCommentsDrawer /> <DocumentCommentsDrawer />
<DocumentAiAgentPanel <DocumentAiAgentPanel
documentId={documentId} documentId={documentId}
@@ -19,6 +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";
type InlineNode = { text?: unknown }; type InlineNode = { text?: unknown };
type TableMenuBlock = Parameters< type TableMenuBlock = Parameters<
@@ -196,32 +197,26 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
}, [block, currentDocumentId, editor, removePageReference]); }, [block, currentDocumentId, editor, removePageReference]);
const turnToPage = useCallback(async () => { const turnToPage = useCallback(async () => {
const response = await fetch("/api/documents/create-child", { try {
method: "POST", const payload = await createChildDocumentCommand({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
parentId: currentDocumentId, parentId: currentDocumentId,
title: extractText(block), title: extractText(block),
blocks: [block], blocks: [block],
}), });
});
if (!response.ok) { editor.replaceBlocks(
return; [block.id],
[
{
type: "pageReference",
props: { pageId: payload.pageId, title: payload.title, asChildPage: true },
} as PartialBlock<CustomBlockSchema>,
],
);
router.refresh();
} catch (error) {
console.error("块转页面失败", error);
} }
const { pageId, title } = await response.json();
editor.replaceBlocks(
[block.id],
[
{
type: "pageReference",
props: { pageId, title, asChildPage: true },
} as PartialBlock<CustomBlockSchema>,
],
);
router.refresh();
}, [block, currentDocumentId, editor, router]); }, [block, currentDocumentId, editor, router]);
const handleMoveEmbedPick = useCallback( const handleMoveEmbedPick = useCallback(
@@ -39,15 +39,26 @@ const BacklinkItem = ({ record }: { record: BacklinkRecord }) => (
); );
export function PageBacklinksPanel({ workspaceId, documentId, className, defaultCollapsed }: PageBacklinksPanelProps) { export function PageBacklinksPanel({ workspaceId, documentId, className, defaultCollapsed }: PageBacklinksPanelProps) {
const [isReady, setIsReady] = useState(false);
const { data, isLoading, error, refetch, isFetching } = useBacklinks({ const { data, isLoading, error, refetch, isFetching } = useBacklinks({
workspaceId, workspaceId,
documentId, documentId,
enabled: isReady,
}); });
const records = useMemo(() => data ?? [], [data]); const records = useMemo(() => data ?? [], [data]);
// 说明:collapsed 需要可交互;这里用一个轻量的局部状态,但默认值来自 props(用于“自定义页面:折叠引用列表”)。 // 说明:collapsed 需要可交互;这里用一个轻量的局部状态,但默认值来自 props(用于“自定义页面:折叠引用列表”)。
const [isCollapsed, setIsCollapsed] = useState(Boolean(defaultCollapsed)); const [isCollapsed, setIsCollapsed] = useState(Boolean(defaultCollapsed));
useEffect(() => setIsCollapsed(Boolean(defaultCollapsed)), [defaultCollapsed]); useEffect(() => setIsCollapsed(Boolean(defaultCollapsed)), [defaultCollapsed]);
useEffect(() => {
setIsReady(false);
const timer = window.setTimeout(() => {
setIsReady(true);
}, 600);
return () => {
window.clearTimeout(timer);
};
}, [documentId, workspaceId]);
// 避免页面切换时“先出现加载态、随后又消失”的闪烁: // 避免页面切换时“先出现加载态、随后又消失”的闪烁:
// 当尚未拿到任何记录且正在加载时,直接不渲染面板。 // 当尚未拿到任何记录且正在加载时,直接不渲染面板。
if (!error && records.length === 0 && (isLoading || isFetching)) { if (!error && records.length === 0 && (isLoading || isFetching)) {
@@ -0,0 +1,353 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
const TREE_SHELL_CHANNEL = "mnote-tree-shell-v1";
const TREE_SHELL_PATH = "/tree";
export type MnoteTreeShellMode = "page" | "picker" | "filetree";
type TreeShellMessage =
| {
channel?: string;
type?: string;
documentId?: string;
assetId?: string;
rowId?: string;
rowKind?: string;
x?: number;
y?: number;
target?: { documentId?: string };
payload?: {
documentId?: string;
assetId?: string;
rowId?: string;
rowKind?: string;
x?: number;
y?: number;
};
}
| null
| undefined;
type MnoteWebTreeShellProps = {
workspaceId: string | null;
activeDocumentId?: string;
actorId?: string | null;
mode?: MnoteTreeShellMode;
allowRootPick?: boolean;
excludeIds?: string[];
reloadToken?: number;
onNavigate: (documentId: string) => void;
onPick?: (documentId: string | null) => void;
onOpenAsset?: (assetId: string) => void;
onOpenContextMenu?: (args: { documentId: string; x: number; y: number }) => void;
onOpenFileTreeContextMenu?: (args: {
documentId?: string;
assetId?: string;
rowId?: string;
rowKind?: string;
x: number;
y: number;
}) => void;
onRefresh: () => Promise<void>;
fallback: React.ReactNode;
};
function buildShellUrl(
baseUrl: string,
workspaceId: string | null,
activeDocumentId: string | undefined,
actorId: string | null | undefined,
mode: MnoteTreeShellMode,
allowRootPick: boolean,
excludeIds: string[],
refreshKey: number,
reloadToken: number,
) {
const url = new URL(TREE_SHELL_PATH, `${baseUrl}/`);
if (workspaceId) {
url.searchParams.set("workspaceId", workspaceId);
}
if (activeDocumentId) {
url.searchParams.set("activeDocumentId", activeDocumentId);
}
if (actorId && actorId.trim()) {
url.searchParams.set("actorId", actorId.trim());
}
url.searchParams.set("mode", mode);
if (mode === "picker") {
url.searchParams.set("allowRootPick", allowRootPick ? "1" : "0");
if (excludeIds.length > 0) {
url.searchParams.set("excludeIds", excludeIds.join(","));
}
}
url.searchParams.set("host", "wolai-frontend");
url.searchParams.set("channel", TREE_SHELL_CHANNEL);
url.searchParams.set("v", `${refreshKey}-${reloadToken}`);
return url.toString();
}
function extractDocumentId(message: Exclude<TreeShellMessage, null | undefined>) {
const direct = typeof message.documentId === "string" ? message.documentId.trim() : "";
if (direct) return direct;
const fromTarget =
message.target && typeof message.target.documentId === "string"
? message.target.documentId.trim()
: "";
if (fromTarget) return fromTarget;
const fromPayload =
message.payload && typeof message.payload.documentId === "string"
? message.payload.documentId.trim()
: "";
return fromPayload;
}
function extractAssetId(message: Exclude<TreeShellMessage, null | undefined>) {
const direct = typeof message.assetId === "string" ? message.assetId.trim() : "";
if (direct) return direct;
const fromPayload =
message.payload && typeof message.payload.assetId === "string"
? message.payload.assetId.trim()
: "";
return fromPayload;
}
function extractCoordinate(
message: Exclude<TreeShellMessage, null | undefined>,
axis: "x" | "y",
) {
const direct = typeof message[axis] === "number" ? message[axis] : null;
if (typeof direct === "number" && Number.isFinite(direct)) return direct;
const fromPayload =
message.payload && typeof message.payload[axis] === "number"
? message.payload[axis]
: null;
if (typeof fromPayload === "number" && Number.isFinite(fromPayload)) return fromPayload;
return null;
}
function extractTextField(
message: Exclude<TreeShellMessage, null | undefined>,
field: "rowId" | "rowKind",
) {
const direct = typeof message[field] === "string" ? message[field].trim() : "";
if (direct) return direct;
const fromPayload =
message.payload && typeof message.payload[field] === "string"
? message.payload[field].trim()
: "";
return fromPayload;
}
export function MnoteWebTreeShell({
workspaceId,
activeDocumentId,
actorId,
mode = "page",
allowRootPick = false,
excludeIds = [],
reloadToken = 0,
onNavigate,
onPick,
onOpenAsset,
onOpenContextMenu,
onOpenFileTreeContextMenu,
onRefresh,
fallback,
}: MnoteWebTreeShellProps) {
const runtime = useMemo(() => getMnoteRuntimeConfig(), []);
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const treeShellEnabled = runtime.mnoteWebTreeShellEnabled === true;
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const readyTimerRef = useRef<number | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [failedShellUrl, setFailedShellUrl] = useState<string | null>(null);
const shellUrl = useMemo(() => {
if (!baseUrl || !treeShellEnabled) return null;
return buildShellUrl(
baseUrl,
workspaceId,
activeDocumentId,
actorId,
mode,
allowRootPick,
excludeIds,
refreshKey,
reloadToken,
);
}, [
activeDocumentId,
actorId,
allowRootPick,
baseUrl,
excludeIds,
mode,
reloadToken,
refreshKey,
treeShellEnabled,
workspaceId,
]);
useEffect(() => {
if (!shellUrl) return;
if (readyTimerRef.current) {
window.clearTimeout(readyTimerRef.current);
}
// 说明:当前 shell 仍处于渐进接入期,若路由不存在或页面未按协议 ready,
// 前端应自动回退到旧 React 树,而不是让用户看到空白 iframe。
readyTimerRef.current = window.setTimeout(() => {
setFailedShellUrl(shellUrl);
}, 2500);
return () => {
if (readyTimerRef.current) {
window.clearTimeout(readyTimerRef.current);
readyTimerRef.current = null;
}
};
}, [shellUrl]);
useEffect(() => {
if (!baseUrl || !treeShellEnabled) return;
const expectedOrigin = (() => {
try {
return new URL(baseUrl).origin;
} catch {
return "";
}
})();
const onMessage = (event: MessageEvent<TreeShellMessage>) => {
if (!expectedOrigin || event.origin !== expectedOrigin) return;
if (event.source !== iframeRef.current?.contentWindow) return;
const message = event.data;
if (!message || typeof message !== "object") return;
if (message.channel !== TREE_SHELL_CHANNEL) return;
const type = typeof message.type === "string" ? message.type.trim() : "";
if (!type) return;
if (type === "tree.ready" || type === "ready") {
if (readyTimerRef.current) {
window.clearTimeout(readyTimerRef.current);
readyTimerRef.current = null;
}
return;
}
if (type === "tree.navigate" || type === "navigate") {
const documentId = extractDocumentId(message);
if (documentId) {
onNavigate(documentId);
}
return;
}
if (type === "tree.pick" || type === "picker.pick") {
onPick?.(extractDocumentId(message) || null);
return;
}
if (type === "tree.pick.root" || type === "picker.pick.root") {
onPick?.(null);
return;
}
if (type === "tree.asset.open" || type === "filetree.asset.open") {
const assetId = extractAssetId(message);
if (assetId) {
onOpenAsset?.(assetId);
}
return;
}
if (type === "tree.context-menu" || type === "tree.page.context-menu") {
const documentId = extractDocumentId(message);
const x = extractCoordinate(message, "x");
const y = extractCoordinate(message, "y");
if (!documentId || x === null || y === null) {
return;
}
const iframeRect = iframeRef.current?.getBoundingClientRect();
if (!iframeRect) {
return;
}
onOpenContextMenu?.({
documentId,
x: iframeRect.left + x,
y: iframeRect.top + y,
});
return;
}
if (type === "tree.filetree.context-menu") {
const documentId = extractDocumentId(message) || undefined;
const assetId = extractAssetId(message) || undefined;
const rowId = extractTextField(message, "rowId") || undefined;
const rowKind = extractTextField(message, "rowKind") || undefined;
const x = extractCoordinate(message, "x");
const y = extractCoordinate(message, "y");
if (x === null || y === null) {
return;
}
const iframeRect = iframeRef.current?.getBoundingClientRect();
if (!iframeRect) {
return;
}
onOpenFileTreeContextMenu?.({
documentId,
assetId,
rowId,
rowKind,
x: iframeRect.left + x,
y: iframeRect.top + y,
});
return;
}
if (
type === "tree.node.created" ||
type === "tree.node.renamed" ||
type === "tree.subtree.moved" ||
type === "tree.refresh" ||
type === "refresh"
) {
void onRefresh().finally(() => {
setRefreshKey((value) => value + 1);
});
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [baseUrl, onNavigate, onOpenAsset, onOpenContextMenu, onOpenFileTreeContextMenu, onPick, onRefresh, treeShellEnabled]);
if (!shellUrl || failedShellUrl === shellUrl) {
return <>{fallback}</>;
}
return (
<div className="h-full w-full min-w-0 overflow-hidden bg-white">
<iframe
ref={iframeRef}
title="mnote-web tree shell"
src={shellUrl}
className="h-full w-full border-0 bg-white"
loading="lazy"
onError={() => {
setFailedShellUrl(shellUrl);
}}
/>
</div>
);
}
+251 -107
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation"; import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { useAuthActions } from "@convex-dev/auth/react"; import { useAuthActions } from "@convex-dev/auth/react";
import { useConvex } from "convex/react"; import { useConvex } from "convex/react";
import { useConvexAuth, useQuery } from "convex/react";
import { import {
ArrowRightLeft, ArrowRightLeft,
ArrowUpRight, ArrowUpRight,
@@ -36,7 +37,7 @@ import { cn } from "@/lib/utils";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar"; import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { useSidebarStore } from "@/store/sidebar"; import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types"; import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
import { useSidebarData } from "@/hooks/use-sidebar-data"; import { useSidebarData, type SidebarDataResult } from "@/hooks/use-sidebar-data";
import { PrivateTree } from "@/components/sidebar/private-tree"; import { PrivateTree } from "@/components/sidebar/private-tree";
import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree"; import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree";
import { import {
@@ -47,6 +48,7 @@ import { useSearchPaletteStore } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge"; import { useEditorBridgeStore } from "@/store/editor-bridge";
import { useCurrentDocumentStore } from "@/store/current-document"; import { useCurrentDocumentStore } from "@/store/current-document";
import { FileTree } from "@/components/sidebar/file-tree"; import { FileTree } from "@/components/sidebar/file-tree";
import { MnoteWebTreeShell } from "@/components/sidebar/MnoteWebTreeShell";
import { buildVisibleRows } from "@/lib/file-tree/rows"; import { buildVisibleRows } from "@/lib/file-tree/rows";
import type { FileTreeSelectionState } from "@/lib/file-tree/selection"; import type { FileTreeSelectionState } from "@/lib/file-tree/selection";
import { normalizeFileTreeSelectionForVisibleRows, reduceFileTreeSelection } from "@/lib/file-tree/selection"; import { normalizeFileTreeSelectionForVisibleRows, reduceFileTreeSelection } from "@/lib/file-tree/selection";
@@ -68,6 +70,11 @@ import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
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 {
createDocumentCommand,
moveDocumentCommand,
renameDocumentCommand,
} from "@/lib/documents/tree-command-client";
const TOP_BUTTONS = [ const TOP_BUTTONS = [
{ id: "search", icon: SearchIcon, label: "搜索" }, { id: "search", icon: SearchIcon, label: "搜索" },
@@ -148,20 +155,22 @@ function SidebarConvex({ initialData }: SidebarProps) {
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑 // 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
interface SidebarContentProps { interface SidebarContentProps {
initialData: SidebarInitialData; initialData: SidebarInitialData;
sidebarQuery: { sidebarQuery: SidebarDataResult;
data: SidebarInitialData | null | undefined;
isLoading: boolean;
refetch: () => Promise<unknown>;
};
} }
function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const convex = useConvex(); const convex = useConvex();
const { isAuthenticated } = useConvexAuth();
const currentUser = useQuery(api.users.currentUser, isAuthenticated ? {} : "skip");
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } = const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } =
useSidebarStore(); useSidebarStore();
const viewMode = useSidebarStore((state) => state.viewMode); const viewMode = useSidebarStore((state) => state.viewMode);
const setViewMode = useSidebarStore((state) => state.setViewMode); const setViewMode = useSidebarStore((state) => state.setViewMode);
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch); const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
const treeShellAvailable = Boolean(
(runtimeConfig.mnoteWebBaseUrl ?? "").trim() && runtimeConfig.mnoteWebTreeShellEnabled === true,
);
// 处理数据 // 处理数据
const sidebarData = useMemo(() => { const sidebarData = useMemo(() => {
@@ -234,6 +243,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>( const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
null, null,
); );
const [treeShellReloadToken, setTreeShellReloadToken] = useState(0);
const [fileTreeSelection, setFileTreeSelection] = useState<FileTreeSelectionState>(() => ({ const [fileTreeSelection, setFileTreeSelection] = useState<FileTreeSelectionState>(() => ({
selectedRowIds: new Set(), selectedRowIds: new Set(),
anchorRowId: null, anchorRowId: null,
@@ -304,6 +314,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const refreshTree = useCallback(async () => { const refreshTree = useCallback(async () => {
await sidebarQuery.refetch(); await sidebarQuery.refetch();
setTreeShellReloadToken((prev) => prev + 1);
}, [sidebarQuery]); }, [sidebarQuery]);
const refreshShareSummary = useCallback(async () => { const refreshShareSummary = useCallback(async () => {
@@ -585,6 +596,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
return map; return map;
}, [mediaAssets, mindmapAssets, tableAssets, mindmapChildrenSnapshot.childIds]); }, [mediaAssets, mindmapAssets, tableAssets, mindmapChildrenSnapshot.childIds]);
const assetById = useMemo(() => {
const map = new Map<string, MediaAsset>();
[...mediaAssets, ...mindmapAssets, ...tableAssets].forEach((asset) => {
map.set(asset.id, asset);
});
return map;
}, [mediaAssets, mindmapAssets, tableAssets]);
const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set()); const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());
const fileTreeRows = useMemo( const fileTreeRows = useMemo(
@@ -652,6 +671,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
[router, setOpen], [router, setOpen],
); );
const handleNavigateFromTreeShell = useCallback(
(documentId: string) => {
if (!documentId) return;
handleOpenDocument(documentId, "main");
},
[handleOpenDocument],
);
const handleCopyLink = useCallback(async (node: SidebarTreeNode, includeTitle = false) => { const handleCopyLink = useCallback(async (node: SidebarTreeNode, includeTitle = false) => {
const url = buildDocumentUrl(node.id); const url = buildDocumentUrl(node.id);
const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url; const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url;
@@ -1264,9 +1291,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
setAssetMenu(null); setAssetMenu(null);
mindmapIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, undefined, false, ids)); mindmapIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, undefined, false, ids));
fileAssetIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, ids)); fileAssetIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, ids));
await sidebarQuery.refetch(); await refreshTree();
}, },
[mediaAssets, mindmapAssets, sidebarQuery, tableAssets], [mediaAssets, mindmapAssets, refreshTree, tableAssets],
); );
const handleDeleteFileTreeSelection = useCallback(async () => { const handleDeleteFileTreeSelection = useCallback(async () => {
@@ -1389,60 +1416,50 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
} }
creatingDocumentUnderParentRef.current.add(creatingKey); creatingDocumentUnderParentRef.current.add(creatingKey);
try { try {
const response = await fetch("/api/documents/create", { const payload = (await createDocumentCommand(parentId)) as SidebarTreeNode;
method: "POST", const nextNode: SidebarTreeNode = {
headers: { "Content-Type": "application/json" }, ...payload,
body: JSON.stringify({ parentId }), access_scope: payload.access_scope ?? "private",
}); is_template: payload.is_template ?? false,
updated_at: payload.updated_at ?? payload.created_at,
if (!response.ok) { title: payload.title ?? "无标题",
const payload = await response.json().catch(() => ({})); children: [],
window.alert(payload?.error ?? "新建页面失败,请稍后再试"); kernel: {
return; nodeType: "page",
} depth: 0,
position: payload.sort_order ?? null,
const payload = (await response.json()) as SidebarTreeNode; childCount: 0,
const nextNode: SidebarTreeNode = { expandedByDefault: true,
...payload, },
access_scope: payload.access_scope ?? "private",
is_template: payload.is_template ?? false,
updated_at: payload.updated_at ?? payload.created_at,
title: payload.title ?? "无标题",
children: [],
kernel: {
nodeType: "page",
depth: 0,
position: payload.sort_order ?? null,
childCount: 0,
expandedByDefault: true,
},
};
setTree((prev) => {
// 防止同一节点被插入多次(会导致重复 key / 列表出现“同一个页面两条记录”)
const exists = (nodes: SidebarTreeNode[]): boolean => {
for (const node of nodes) {
if (node.id === nextNode.id) return true;
if (node.children.length > 0 && exists(node.children)) return true;
}
return false;
}; };
if (exists(prev)) return prev;
return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode);
});
setExpanded((prev) => {
const next = new Set(prev);
if (parentId) {
next.add(parentId);
}
if (!parentId) {
next.add(nextNode.id);
}
return next;
});
await refreshTree(); setTree((prev) => {
router.push(`/documents/${nextNode.id}`); // 防止同一节点被插入多次(会导致重复 key / 列表出现“同一个页面两条记录”)
const exists = (nodes: SidebarTreeNode[]): boolean => {
for (const node of nodes) {
if (node.id === nextNode.id) return true;
if (node.children.length > 0 && exists(node.children)) return true;
}
return false;
};
if (exists(prev)) return prev;
return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode);
});
setExpanded((prev) => {
const next = new Set(prev);
if (parentId) {
next.add(parentId);
}
if (!parentId) {
next.add(nextNode.id);
}
return next;
});
await refreshTree();
router.push(`/documents/${nextNode.id}`);
} catch (error) {
window.alert(error instanceof Error ? error.message : "新建页面失败,请稍后再试");
} finally { } finally {
creatingDocumentUnderParentRef.current.delete(creatingKey); creatingDocumentUnderParentRef.current.delete(creatingKey);
} }
@@ -1456,18 +1473,19 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
if (!title.trim()) { if (!title.trim()) {
return; return;
} }
await fetch("/api/documents/title", { try {
method: "POST", await renameDocumentCommand({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
documentId, documentId,
workspaceId: sidebarData.activeWorkspaceId ?? null, workspaceId: sidebarData.activeWorkspaceId ?? null,
title: title.trim(), title: title.trim(),
}), });
}); } catch (error) {
window.alert(error instanceof Error ? error.message : "重命名失败,请稍后再试");
return;
}
await refreshTree(); await refreshTree();
}, },
[refreshTree], [refreshTree, sidebarData.activeWorkspaceId],
); );
const moveLocalNode = useCallback((currentTree: SidebarTreeNode[], nodeId: string, parentId: string | null, index: number) => { const moveLocalNode = useCallback((currentTree: SidebarTreeNode[], nodeId: string, parentId: string | null, index: number) => {
@@ -1486,15 +1504,17 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
if (parentId) { if (parentId) {
setExpanded((prev) => new Set(prev).add(parentId)); setExpanded((prev) => new Set(prev).add(parentId));
} }
await fetch("/api/documents/move", { try {
method: "POST", await moveDocumentCommand({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
documentId, documentId,
parentId, parentId,
position: index, position: index,
}), });
}); } catch (error) {
window.alert(error instanceof Error ? error.message : "移动失败,请稍后再试");
await refreshTree();
return;
}
await refreshTree(); await refreshTree();
}, },
[moveLocalNode, refreshTree, setExpanded], [moveLocalNode, refreshTree, setExpanded],
@@ -1710,14 +1730,10 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
setExpanded((prev) => new Set(prev).add(targetDocId)); setExpanded((prev) => new Set(prev).add(targetDocId));
for (let i = 0; i < topLevelDocIds.length; i += 1) { for (let i = 0; i < topLevelDocIds.length; i += 1) {
await fetch("/api/documents/move", { await moveDocumentCommand({
method: "POST", documentId: topLevelDocIds[i],
headers: { "Content-Type": "application/json" }, parentId: targetDocId,
body: JSON.stringify({ position: baseIndex + i,
documentId: topLevelDocIds[i],
parentId: targetDocId,
position: baseIndex + i,
}),
}); });
} }
await refreshTree(); await refreshTree();
@@ -2178,6 +2194,68 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
}); });
}, []); }, []);
const openContextMenuFromTreeShell = useCallback(
({ documentId, x, y }: { documentId: string; x: number; y: number }) => {
const node = nodeById.get(documentId);
if (!node) {
return;
}
setContextMenu({
node,
x,
y,
});
},
[nodeById],
);
const openFileTreeContextMenuFromTreeShell = useCallback(
({
documentId,
assetId,
rowId,
x,
y,
}: {
documentId?: string;
assetId?: string;
rowId?: string;
rowKind?: string;
x: number;
y: number;
}) => {
if (assetId) {
const asset = assetById.get(assetId);
if (asset) {
if (rowId) {
setFileTreeSelection((prev) =>
reduceFileTreeSelection(prev, { type: "contextmenu", rowId }),
);
}
setAssetMenu({ asset, x, y });
return;
}
}
if (documentId) {
const node = nodeById.get(documentId);
if (node) {
if (rowId) {
setFileTreeSelection((prev) =>
reduceFileTreeSelection(prev, { type: "contextmenu", rowId }),
);
}
setContextMenu({
node,
x,
y,
});
}
}
},
[assetById, nodeById],
);
const openShareDialog = useCallback((node: SidebarTreeNode) => { const openShareDialog = useCallback((node: SidebarTreeNode) => {
setShareTarget({ setShareTarget({
id: node.id, id: node.id,
@@ -2519,17 +2597,42 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
</button> </button>
{!collapsedSections.private ? ( {!collapsedSections.private ? (
<div className="flex-1 px-1 pb-2"> <div className="flex-1 px-1 pb-2">
<div className="h-full rounded-md border border-[#eff2f6] bg-white"> {treeShellAvailable ? (
<PrivateTree <MnoteWebTreeShell
rows={visibleFilteredPrivatePageRows} workspaceId={sidebarData.activeWorkspaceId ?? null}
expanded={expanded} activeDocumentId={activeId}
activeId={activeId} actorId={typeof currentUser?._id === "string" ? currentUser._id : null}
onToggleExpand={toggleExpand} reloadToken={treeShellReloadToken}
onMove={handleMove} onNavigate={handleNavigateFromTreeShell}
onCreateChild={handleCreate} onOpenContextMenu={openContextMenuFromTreeShell}
onContextMenu={openContextMenu} onRefresh={refreshTree}
fallback={
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
<PrivateTree
rows={visibleFilteredPrivatePageRows}
expanded={expanded}
activeId={activeId}
onToggleExpand={toggleExpand}
onMove={handleMove}
onCreateChild={handleCreate}
onContextMenu={openContextMenu}
/>
</div>
}
/> />
</div> ) : (
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
<PrivateTree
rows={visibleFilteredPrivatePageRows}
expanded={expanded}
activeId={activeId}
onToggleExpand={toggleExpand}
onMove={handleMove}
onCreateChild={handleCreate}
onContextMenu={openContextMenu}
/>
</div>
)}
</div> </div>
) : ( ) : (
<div className="px-4 pb-2 text-xs text-gray-400"></div> <div className="px-4 pb-2 text-xs text-gray-400"></div>
@@ -2542,23 +2645,64 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
<div <div
ref={fileTreeContainerRef} ref={fileTreeContainerRef}
data-testid="file-tree-container" data-testid="file-tree-container"
className="h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white" className="h-full w-full min-w-0 overflow-x-hidden"
> >
<FileTree {treeShellAvailable ? (
rows={fileTreeRows} <MnoteWebTreeShell
activeId={activeId} workspaceId={sidebarData.activeWorkspaceId ?? null}
selectedRowIds={fileTreeSelection.selectedRowIds} activeDocumentId={activeId}
onRowClick={handleFileTreeRowClick} actorId={typeof currentUser?._id === "string" ? currentUser._id : null}
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)} mode="filetree"
onRowContextMenu={handleFileTreeRowContextMenu} reloadToken={treeShellReloadToken}
onRowDragStart={(row) => handleFileTreeRowDragStart(row)} onNavigate={handleNavigateFromTreeShell}
onToggleExpand={toggleExpand} onOpenAsset={(assetId) => {
onToggleAssetFolderExpand={toggleAssetFolderExpand} const asset = assetById.get(assetId);
onCreateChild={handleCreate} if (asset) {
onBlankMouseDown={handleFileTreeBlankMouseDown} handleOpenAsset(asset);
onDropFiles={handleFileTreeDropFiles} }
onInternalDrop={handleFileTreeInternalDrop} }}
/> onOpenContextMenu={openContextMenuFromTreeShell}
onOpenFileTreeContextMenu={openFileTreeContextMenuFromTreeShell}
onRefresh={refreshTree}
fallback={
<div className="h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white">
<FileTree
rows={fileTreeRows}
activeId={activeId}
selectedRowIds={fileTreeSelection.selectedRowIds}
onRowClick={handleFileTreeRowClick}
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
onRowContextMenu={handleFileTreeRowContextMenu}
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
onToggleExpand={toggleExpand}
onToggleAssetFolderExpand={toggleAssetFolderExpand}
onCreateChild={handleCreate}
onBlankMouseDown={handleFileTreeBlankMouseDown}
onDropFiles={handleFileTreeDropFiles}
onInternalDrop={handleFileTreeInternalDrop}
/>
</div>
}
/>
) : (
<div className="h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white">
<FileTree
rows={fileTreeRows}
activeId={activeId}
selectedRowIds={fileTreeSelection.selectedRowIds}
onRowClick={handleFileTreeRowClick}
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
onRowContextMenu={handleFileTreeRowContextMenu}
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
onToggleExpand={toggleExpand}
onToggleAssetFolderExpand={toggleAssetFolderExpand}
onCreateChild={handleCreate}
onBlankMouseDown={handleFileTreeBlankMouseDown}
onDropFiles={handleFileTreeDropFiles}
onInternalDrop={handleFileTreeInternalDrop}
/>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
+62 -24
View File
@@ -2,33 +2,71 @@ import { useEffect, useState } from "react";
type Status = "idle" | "ok" | "error" | "disabled"; type Status = "idle" | "ok" | "error" | "disabled";
const HEALTH_CACHE_TTL_MS = 15_000;
let cachedStatus: Status = "idle";
let cachedAt = 0;
let inflightCheck: Promise<Status> | null = null;
const listeners = new Set<(status: Status) => void>();
function publishStatus(status: Status) {
cachedStatus = status;
cachedAt = Date.now();
listeners.forEach((listener) => listener(status));
}
async function requestBackendHealth(): Promise<Status> {
const controller = new AbortController();
try {
const response = await fetch("/api/backend/health", { signal: controller.signal });
const payload = (await response.json().catch(() => null)) as { status?: Status } | null;
return payload?.status ?? "error";
} catch {
return "error";
} finally {
controller.abort();
}
}
async function ensureBackendHealthFresh(force = false): Promise<Status> {
const now = Date.now();
if (!force && cachedStatus !== "idle" && now - cachedAt < HEALTH_CACHE_TTL_MS) {
return cachedStatus;
}
if (!inflightCheck) {
inflightCheck = requestBackendHealth()
.then((status) => {
publishStatus(status);
return status;
})
.finally(() => {
inflightCheck = null;
});
}
return inflightCheck;
}
export function useBackendHealth() { export function useBackendHealth() {
const [status, setStatus] = useState<Status>(() => { const [status, setStatus] = useState<Status>(() => cachedStatus);
return "idle";
});
useEffect(() => { useEffect(() => {
let destroyed = false; let destroyed = false;
const controller = new AbortController(); const listener = (nextStatus: Status) => {
const check = async () => { if (!destroyed) {
try { setStatus(nextStatus);
const response = await fetch("/api/backend/health", { signal: controller.signal }); }
const payload = (await response.json().catch(() => null)) as { status?: Status } | null; };
if (!destroyed) { listeners.add(listener);
setStatus(payload?.status ?? "error"); void ensureBackendHealthFresh().then((nextStatus) => {
} if (!destroyed) {
} catch { setStatus(nextStatus);
if (!destroyed) { }
setStatus("error"); });
} return () => {
} destroyed = true;
}; listeners.delete(listener);
check(); };
return () => { }, []);
destroyed = true;
controller.abort();
};
}, []);
return status; return status;
} }
+3 -2
View File
@@ -6,6 +6,7 @@ import type { BacklinkRecord } from "@/types/references";
interface UseBacklinksOptions { interface UseBacklinksOptions {
workspaceId: string | null; workspaceId: string | null;
documentId: string | null; documentId: string | null;
enabled?: boolean;
} }
interface RawBacklink { interface RawBacklink {
@@ -46,7 +47,7 @@ const fetchBacklinks = async (workspaceId: string, documentId: string): Promise<
return (payload.backlinks ?? []).map(mapBacklink); return (payload.backlinks ?? []).map(mapBacklink);
}; };
export function useBacklinks({ workspaceId, documentId }: UseBacklinksOptions) { export function useBacklinks({ workspaceId, documentId, enabled = true }: UseBacklinksOptions) {
return useQuery({ return useQuery({
queryKey: ["page-backlinks", workspaceId, documentId], queryKey: ["page-backlinks", workspaceId, documentId],
queryFn: () => { queryFn: () => {
@@ -55,7 +56,7 @@ export function useBacklinks({ workspaceId, documentId }: UseBacklinksOptions) {
} }
return fetchBacklinks(workspaceId, documentId); return fetchBacklinks(workspaceId, documentId);
}, },
enabled: Boolean(workspaceId && documentId), enabled: Boolean(enabled && workspaceId && documentId),
staleTime: 15_000, staleTime: 15_000,
}); });
} }
@@ -19,6 +19,17 @@ const sidebarDatasetListQuery = ((api as unknown as Record<string, unknown>).sid
SidebarDatasetListQueryResult SidebarDatasetListQueryResult
>; >;
export interface ConvexSidebarDataResult {
data: SidebarInitialData | null;
isLoading: boolean;
isAuthLoading: boolean;
isAuthenticated: boolean;
hasLiveSubscription: boolean;
canUseHttpFallback: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/** /**
* Convex hook * Convex hook
* 使 Convex useQuery refetch * 使 Convex useQuery refetch
@@ -26,13 +37,8 @@ const sidebarDatasetListQuery = ((api as unknown as Record<string, unknown>).sid
* hook Convex 使 * hook Convex 使
* ctx.auth.getUserIdentity() userId * ctx.auth.getUserIdentity() userId
*/ */
export function useConvexSidebarData(workspaceId: string): { export function useConvexSidebarData(workspaceId: string): ConvexSidebarDataResult {
data: SidebarInitialData | null; const { isAuthenticated, isLoading: isAuthLoading } = useConvexAuth();
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
} {
const { isAuthenticated } = useConvexAuth();
// 显式转为 boolean,确保类型正确 // 显式转为 boolean,确保类型正确
const shouldFetch = Boolean(isAuthenticated && workspaceId); const shouldFetch = Boolean(isAuthenticated && workspaceId);
@@ -81,13 +87,22 @@ export function useConvexSidebarData(workspaceId: string): {
// 使用 === undefined 判断,因为 skip 时返回 undefined // 使用 === undefined 判断,因为 skip 时返回 undefined
const isLoading = shouldFetch && sidebarDataset === undefined; const isLoading = shouldFetch && sidebarDataset === undefined;
const error = null; const error = null;
const hasLiveSubscription = shouldFetch;
// Convex 模式下数据自动实时同步,refetch 是空操作 const canUseHttpFallback = Boolean(workspaceId) && !isAuthLoading && !isAuthenticated;
// Convex 的 useQuery 会自动实时同步,无需手动 refetch
// 这个方法只是为了保持与 useSidebarData 的接口兼容 // Convex 模式下数据自动实时同步,refetch 保持空操作以兼容旧接口。
const refetch = async () => { const refetch = async () => {
// 空操作 - Convex 会自动同步数据 // 空操作 - Convex 会自动同步数据
}; };
return { data, isLoading, error, refetch }; return {
} data,
isLoading,
isAuthLoading,
isAuthenticated,
hasLiveSubscription,
canUseHttpFallback,
error,
refetch,
};
}
+55 -3
View File
@@ -1,5 +1,15 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query"; import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type { SidebarInitialData } from "@/components/sidebar/types"; import type { SidebarInitialData } from "@/components/sidebar/types";
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
export interface SidebarDataResult {
data: SidebarInitialData;
isLoading: boolean;
error: Error | null;
refetch: () => Promise<unknown>;
source: "convex-live" | "http-fallback" | "initial";
}
async function requestSidebarData(workspaceId: string): Promise<SidebarInitialData> { async function requestSidebarData(workspaceId: string): Promise<SidebarInitialData> {
const response = await fetch(`/api/sidebar?workspaceId=${workspaceId}`, { const response = await fetch(`/api/sidebar?workspaceId=${workspaceId}`, {
@@ -16,13 +26,55 @@ async function requestSidebarData(workspaceId: string): Promise<SidebarInitialDa
return response.json(); return response.json();
} }
export function useSidebarData(initialData: SidebarInitialData): UseQueryResult<SidebarInitialData> { export function useSidebarData(initialData: SidebarInitialData): SidebarDataResult {
const workspaceId = initialData.activeWorkspaceId; const workspaceId = initialData.activeWorkspaceId;
const convexSidebar = useConvexSidebarData(workspaceId);
const shouldUseHttpFallback =
!convexSidebar.hasLiveSubscription && convexSidebar.canUseHttpFallback;
return useQuery({ const httpQuery = useQuery({
queryKey: ["sidebar", workspaceId], queryKey: ["sidebar", workspaceId],
queryFn: () => requestSidebarData(workspaceId), queryFn: () => requestSidebarData(workspaceId),
initialData, initialData,
staleTime: 1000 * 60, staleTime: 1000 * 60,
enabled: shouldUseHttpFallback,
}); });
return useMemo<SidebarDataResult>(() => {
const liveData = convexSidebar.data ?? httpQuery.data ?? initialData;
const isLoading =
convexSidebar.hasLiveSubscription
? convexSidebar.isLoading
: shouldUseHttpFallback
? httpQuery.isLoading
: false;
const source: SidebarDataResult["source"] = convexSidebar.data
? "convex-live"
: shouldUseHttpFallback && httpQuery.data
? "http-fallback"
: "initial";
const refetch = async () => {
if (convexSidebar.hasLiveSubscription) {
await convexSidebar.refetch();
return liveData;
}
if (shouldUseHttpFallback) {
return httpQuery.refetch();
}
return liveData;
};
return {
data: liveData,
isLoading,
error: convexSidebar.error ?? httpQuery.error ?? null,
refetch,
source,
};
}, [
convexSidebar,
httpQuery,
initialData,
shouldUseHttpFallback,
]);
} }
-130
View File
@@ -1,130 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildDocumentTree, type DocumentRecord } from "./documents";
type TreeLike = { id: string; children: TreeLike[] };
function collectIds(nodes: TreeLike[]): string[] {
const ids: string[] = [];
const walk = (list: TreeLike[]) => {
list.forEach((node) => {
ids.push(node.id);
if (node.children.length > 0) {
walk(node.children);
}
});
};
walk(nodes);
return ids;
}
describe("buildDocumentTree", () => {
let warnSpy: ReturnType<typeof vi.spyOn> | null = null;
beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
});
afterEach(() => {
warnSpy?.mockRestore();
warnSpy = null;
});
it("应当去重重复的记录 id(根节点)", () => {
const base: DocumentRecord = {
access_scope: "private",
id: "doc-1",
workspace_id: "ws-1",
title: "A",
parent_id: null,
sort_order: null,
is_starred: null,
is_template: false,
created_at: "2025-01-01T00:00:00.000Z",
updated_at: null,
};
const tree = buildDocumentTree([base, { ...base, title: "B" }]);
expect(tree).toHaveLength(1);
expect(tree[0].id).toBe("doc-1");
expect(tree[0].title).toBe("B");
});
it("应当去重重复的记录 id(子节点)", () => {
const parent: DocumentRecord = {
access_scope: "private",
id: "doc-p",
workspace_id: "ws-1",
title: "P",
parent_id: null,
sort_order: null,
is_starred: null,
is_template: false,
created_at: "2025-01-01T00:00:00.000Z",
updated_at: null,
};
const child: DocumentRecord = {
access_scope: "private",
id: "doc-c",
workspace_id: "ws-1",
title: "C",
parent_id: "doc-p",
sort_order: null,
is_starred: null,
is_template: false,
created_at: "2025-01-01T00:00:01.000Z",
updated_at: null,
};
const tree = buildDocumentTree([parent, child, { ...child, title: "C2" }]);
expect(tree).toHaveLength(1);
expect(tree[0].children).toHaveLength(1);
expect(tree[0].children[0].id).toBe("doc-c");
expect(tree[0].children[0].title).toBe("C2");
});
it("生成的树中不应出现重复 id", () => {
const records: DocumentRecord[] = [
{
access_scope: "private",
id: "a",
workspace_id: "ws-1",
title: "A",
parent_id: null,
sort_order: null,
is_starred: null,
is_template: false,
created_at: "2025-01-01T00:00:00.000Z",
updated_at: null,
},
{
access_scope: "private",
id: "b",
workspace_id: "ws-1",
title: "B",
parent_id: "a",
sort_order: null,
is_starred: null,
is_template: false,
created_at: "2025-01-01T00:00:01.000Z",
updated_at: null,
},
{
access_scope: "private",
id: "b",
workspace_id: "ws-1",
title: "B2",
parent_id: "a",
sort_order: null,
is_starred: null,
is_template: false,
created_at: "2025-01-01T00:00:01.000Z",
updated_at: "2025-01-01T00:00:02.000Z",
},
];
const tree = buildDocumentTree(records);
const ids = collectIds(tree);
const unique = new Set(ids);
expect(unique.size).toBe(ids.length);
});
});
-57
View File
@@ -15,63 +15,6 @@ export interface DocumentNode extends DocumentRecord {
children: DocumentNode[]; children: DocumentNode[];
} }
// 过渡兼容 helper:保留给旧单测和非树域场景使用。
// Sidebar / 页面树 / 文件树 / move-embed picker 主路径已统一改读 projection family。
export function buildDocumentTree(records: DocumentRecord[]): DocumentNode[] {
// 防御性处理:当上游数据意外包含重复 id 时,避免生成重复节点导致渲染 key 冲突。
// 以“最后一次出现”为准(与原先 nodeMap.set 的覆盖行为保持一致)。
const seen = new Set<string>();
const duplicatedIds = new Set<string>();
const uniqueRecords: DocumentRecord[] = [];
for (let i = records.length - 1; i >= 0; i--) {
const record = records[i];
if (seen.has(record.id)) {
duplicatedIds.add(record.id);
continue;
}
seen.add(record.id);
uniqueRecords.push(record);
}
uniqueRecords.reverse();
if (duplicatedIds.size > 0 && process.env.NODE_ENV !== "production") {
console.warn(
`[buildDocumentTree] 检测到重复文档 id(已自动去重):${Array.from(duplicatedIds).slice(0, 10).join(", ")}${duplicatedIds.size > 10 ? "…" : ""}`,
);
}
const nodeMap = new Map<string, DocumentNode>();
uniqueRecords.forEach((record) => {
nodeMap.set(record.id, { ...record, children: [] });
});
const roots: DocumentNode[] = [];
uniqueRecords.forEach((record) => {
const node = nodeMap.get(record.id);
if (!node) return;
if (record.parent_id && nodeMap.has(record.parent_id)) {
nodeMap.get(record.parent_id)!.children.push(node);
} else {
roots.push(node);
}
});
const sortTree = (nodes: DocumentNode[]) => {
nodes.sort((a, b) => {
const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) {
return orderA - orderB;
}
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
});
nodes.forEach((child) => sortTree(child.children));
};
sortTree(roots);
return roots;
}
export function findBreadcrumb(records: DocumentRecord[], targetId: string): DocumentRecord[] { export function findBreadcrumb(records: DocumentRecord[], targetId: string): DocumentRecord[] {
const map = new Map<string, DocumentRecord>(); const map = new Map<string, DocumentRecord>();
records.forEach((item) => map.set(item.id, item)); records.forEach((item) => map.set(item.id, item));
@@ -0,0 +1,108 @@
"use client";
type DocumentCommandMeta = {
requestId?: string;
traceId?: string;
commandId?: string;
commandName?: string;
};
type DocumentCommandErrorPayload = {
error?: string;
};
export type DocumentCreateCommandResult = {
id: string;
title?: string | null;
parent_id?: string | null;
sort_order?: number | null;
workspace_id?: string;
access_scope?: "private" | "shared" | "public";
is_template?: boolean;
created_at?: string | null;
updated_at?: string | null;
meta?: DocumentCommandMeta;
};
export type DocumentCreateChildCommandResult = {
pageId: string;
title?: string | null;
meta?: DocumentCommandMeta;
};
type RenameDocumentInput = {
documentId: string;
workspaceId?: string | null;
title: string;
};
type MoveDocumentInput = {
documentId: string;
parentId?: string | null;
position: number;
workspaceId?: string | null;
};
type CreateChildDocumentInput = {
parentId: string | null;
title: string;
blocks: unknown[];
};
async function postDocumentCommand<TResult>(path: string, payload: unknown, fallbackMessage: string): Promise<TResult> {
const response = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = (await response.json().catch(() => null)) as TResult | DocumentCommandErrorPayload | null;
if (!response.ok) {
const message =
body && typeof body === "object" && "error" in body && typeof body.error === "string"
? body.error
: fallbackMessage;
throw new Error(message);
}
return body as TResult;
}
export async function createDocumentCommand(parentId: string | null): Promise<DocumentCreateCommandResult> {
return postDocumentCommand<DocumentCreateCommandResult>("/api/documents/create", { parentId }, "新建页面失败,请稍后再试");
}
export async function createChildDocumentCommand(
input: CreateChildDocumentInput,
): Promise<DocumentCreateChildCommandResult> {
return postDocumentCommand<DocumentCreateChildCommandResult>(
"/api/documents/create-child",
input,
"创建子页面失败,请稍后再试",
);
}
export async function renameDocumentCommand(input: RenameDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
"/api/documents/title",
{
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
title: input.title,
},
"重命名失败,请稍后再试",
);
}
export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
"/api/documents/move",
{
documentId: input.documentId,
parentId: input.parentId ?? null,
position: input.position,
workspaceId: input.workspaceId ?? null,
},
"移动失败,请稍后再试",
);
}
+6 -3
View File
@@ -136,11 +136,14 @@ export function buildSidebarTreeFromKernelProjection(input: {
records: DocumentRecord[]; records: DocumentRecord[];
projection: KernelSidebarProjection; projection: KernelSidebarProjection;
}): SidebarTreeNode[] { }): SidebarTreeNode[] {
const projectionItems = Array.isArray(input.projection?.items)
? input.projection.items
: [];
const recordById = new Map(input.records.map((record) => [record.id, record])); const recordById = new Map(input.records.map((record) => [record.id, record]));
const nodeMap = new Map<string, SidebarTreeNode>(); const nodeMap = new Map<string, SidebarTreeNode>();
const itemById = new Map(input.projection.items.map((item) => [item.nodeId, item])); const itemById = new Map(projectionItems.map((item) => [item.nodeId, item]));
for (const item of input.projection.items) { for (const item of projectionItems) {
const record = recordById.get(item.nodeId); const record = recordById.get(item.nodeId);
nodeMap.set(item.nodeId, { nodeMap.set(item.nodeId, {
access_scope: record?.access_scope ?? "private", access_scope: record?.access_scope ?? "private",
@@ -165,7 +168,7 @@ export function buildSidebarTreeFromKernelProjection(input: {
} }
const roots: SidebarTreeNode[] = []; const roots: SidebarTreeNode[] = [];
for (const item of input.projection.items) { for (const item of projectionItems) {
const node = nodeMap.get(item.nodeId); const node = nodeMap.get(item.nodeId);
if (!node) continue; if (!node) continue;
const parentId = item.parentNodeId; const parentId = item.parentNodeId;
@@ -1,12 +1,12 @@
const DEFAULT_ONLYOFFICE_INTERNAL_URL = "http://127.0.0.1:8081"; const DEFAULT_ONLYOFFICE_INTERNAL_URL = "http://127.0.0.1:8082";
const ONLYOFFICE_PROBE_PATH = "/web-apps/apps/api/documents/api.js"; const ONLYOFFICE_PROBE_PATH = "/web-apps/apps/api/documents/api.js";
const RESOLVE_CACHE_TTL_MS = 30_000; const RESOLVE_CACHE_TTL_MS = 30_000;
const DEFAULT_ONLYOFFICE_INTERNAL_URL_CANDIDATES = [ const DEFAULT_ONLYOFFICE_INTERNAL_URL_CANDIDATES = [
DEFAULT_ONLYOFFICE_INTERNAL_URL, DEFAULT_ONLYOFFICE_INTERNAL_URL,
"http://127.0.0.1:8082", "http://127.0.0.1:8081",
"http://localhost:8081",
"http://localhost:8082", "http://localhost:8082",
"http://localhost:8081",
]; ];
let cachedOnlyOfficeInternalUrl = ""; let cachedOnlyOfficeInternalUrl = "";
@@ -90,4 +90,3 @@ export const resolveOnlyOfficeInternalUrl = async () => {
pendingOnlyOfficeInternalUrl = null; pendingOnlyOfficeInternalUrl = null;
} }
}; };
+60 -3
View File
@@ -22,6 +22,16 @@ export type MnoteRuntimeConfig = {
onlyofficeProxyOriginWeb?: string; onlyofficeProxyOriginWeb?: string;
onlyofficeCallbackOriginWeb?: string; onlyofficeCallbackOriginWeb?: string;
onlyofficeCallbackOriginDesktop?: string; onlyofficeCallbackOriginDesktop?: string;
/**
* Rust Web tree shell
* SSR
*/
mnoteWebBaseUrl?: string;
/**
* Rust Web tree shell
* mnoteWebBaseUrl
*/
mnoteWebTreeShellEnabled?: boolean;
/** /**
* Electron * Electron
*/ */
@@ -43,12 +53,50 @@ declare global {
} }
} }
const parseRuntimeBoolean = (value: unknown): boolean | undefined => {
if (typeof value === "boolean") {
return value;
}
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return undefined;
}
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "off"].includes(normalized)) {
return false;
}
return undefined;
};
const readFromEnv = (): MnoteRuntimeConfig => ({ const readFromEnv = (): MnoteRuntimeConfig => ({
useConvex: process.env.USE_CONVEX === "1" || process.env.NEXT_PUBLIC_USE_CONVEX === "1", useConvex: process.env.USE_CONVEX === "1" || process.env.NEXT_PUBLIC_USE_CONVEX === "1",
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL, supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL, supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL, backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL,
...((process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL ?? process.env.MNOTE_WEB_BASE_URL) !== undefined
? {
mnoteWebBaseUrl:
process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL ??
process.env.MNOTE_WEB_BASE_URL,
}
: {}),
...(parseRuntimeBoolean(
process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED ??
process.env.MNOTE_WEB_TREE_SHELL_ENABLED,
) !== undefined
? {
mnoteWebTreeShellEnabled: parseRuntimeBoolean(
process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED ??
process.env.MNOTE_WEB_TREE_SHELL_ENABLED,
),
}
: {}),
onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL, onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE, onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE,
onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN, onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN,
@@ -63,9 +111,12 @@ const readFromPublicJson = (): Partial<MnoteRuntimeConfig> => {
// 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。 // 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。
// 网页端运行时 process.cwd() 通常为 wolai-frontend。 // 网页端运行时 process.cwd() 通常为 wolai-frontend。
const fs = require("fs") as typeof import("fs"); // 说明:这里不能直接写 `require("fs")` / `require("path")`
// 否则客户端 bundle 在解析该模块时会把它们也当成浏览器依赖,触发持续重编译或空白页。
const path = require("path") as typeof import("path"); // 仅在服务端运行时通过惰性 require 读取本地 public/mnote-env.json。
const runtimeRequire = new Function("return require")() as NodeJS.Require;
const fs = runtimeRequire("node:fs") as typeof import("fs");
const path = runtimeRequire("node:path") as typeof import("path");
// 说明:Next standalone 产物的 server.js 会执行 `process.chdir(__dirname)` // 说明:Next standalone 产物的 server.js 会执行 `process.chdir(__dirname)`
// 导致 process.cwd() 变成 `.next/standalone`,此时 public/mnote-env.json 位于上层目录。 // 导致 process.cwd() 变成 `.next/standalone`,此时 public/mnote-env.json 位于上层目录。
@@ -152,9 +203,15 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
cfg.onlyofficeCallbackOriginDesktop || cfg.onlyofficeCallbackOriginDesktop ||
""; "";
const mnoteWebBaseUrl = (cfg.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const mnoteWebTreeShellEnabled =
parseRuntimeBoolean(cfg.mnoteWebTreeShellEnabled) ?? false;
return { return {
...cfg, ...cfg,
isDesktop, isDesktop,
mnoteWebBaseUrl,
mnoteWebTreeShellEnabled,
onlyofficeBaseUrl, onlyofficeBaseUrl,
onlyofficeStorageHostOverride, onlyofficeStorageHostOverride,
onlyofficeProxyOrigin, onlyofficeProxyOrigin,
+18 -7
View File
@@ -1,6 +1,8 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server"; import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
import { isDevAuthEnabled } from "@/lib/auth/devUser"; import { isDevAuthEnabled } from "@/lib/auth/devUser";
import { getAuthContext } from "@/lib/auth/authContext";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data"; import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
type MnoteWebSidebarCompatResponse = { type MnoteWebSidebarCompatResponse = {
@@ -11,12 +13,6 @@ type MnoteWebSidebarCompatResponse = {
result?: SidebarDatasetListQueryResult; result?: SidebarDatasetListQueryResult;
}; };
const MNOTE_WEB_BASE_URL = (
process.env.MNOTE_WEB_BASE_URL ??
process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL ??
""
).trim();
function copyHeaderIfPresent(target: Headers, source: Headers, name: string) { function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
const value = source.get(name); const value = source.get(name);
if (value) { if (value) {
@@ -36,6 +32,8 @@ async function buildForwardHeaders(request?: Request): Promise<Headers> {
copyHeaderIfPresent(forwarded, source, "x-mnote-workspace-id"); copyHeaderIfPresent(forwarded, source, "x-mnote-workspace-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-channel"); copyHeaderIfPresent(forwarded, source, "x-mnote-source-channel");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-client"); copyHeaderIfPresent(forwarded, source, "x-mnote-source-client");
copyHeaderIfPresent(forwarded, source, "x-mnote-actor-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-actor-type");
copyHeaderIfPresent(forwarded, source, "user-agent"); copyHeaderIfPresent(forwarded, source, "user-agent");
if (!forwarded.has("authorization") && !isDevAuthEnabled()) { if (!forwarded.has("authorization") && !isDevAuthEnabled()) {
@@ -51,12 +49,25 @@ async function buildForwardHeaders(request?: Request): Promise<Headers> {
if (!forwarded.has("x-mnote-source-client")) { if (!forwarded.has("x-mnote-source-client")) {
forwarded.set("x-mnote-source-client", "wolai-frontend"); forwarded.set("x-mnote-source-client", "wolai-frontend");
} }
if (!forwarded.has("x-mnote-actor-id")) {
try {
const auth = await getAuthContext();
if (auth.userId?.trim()) {
forwarded.set("x-mnote-actor-id", auth.userId.trim());
forwarded.set("x-mnote-actor-type", "user");
}
} catch {
// 说明:未登录或当前上下文无法解析用户时,继续走已有 header / admin fallback。
}
}
return forwarded; return forwarded;
} }
export function getMnoteWebBaseUrl(): string | null { export function getMnoteWebBaseUrl(): string | null {
return MNOTE_WEB_BASE_URL || null; const runtime = getMnoteRuntimeConfig();
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
return baseUrl || null;
} }
export async function fetchSidebarDatasetFromMnoteWeb(input: { export async function fetchSidebarDatasetFromMnoteWeb(input: {
+7 -13
View File
@@ -9,10 +9,6 @@ import {
type SidebarDatasetListQueryResult, type SidebarDatasetListQueryResult,
} from "@/lib/sidebar-data"; } from "@/lib/sidebar-data";
import type { WorkspaceSummary } from "@/lib/workspaces"; import type { WorkspaceSummary } from "@/lib/workspaces";
import {
fetchSidebarDatasetFromMnoteWeb,
getMnoteWebBaseUrl,
} from "@/lib/server/mnote-web";
type LoadSidebarDataFromConvexInput = { type LoadSidebarDataFromConvexInput = {
client: ConvexHttpClient; client: ConvexHttpClient;
@@ -61,15 +57,13 @@ export async function loadSidebarDataFromConvex(
}; };
} }
const sidebarDataset = getMnoteWebBaseUrl() // 说明:SSR 首屏阶段必须优先保证“可进入页面”。
? ( // mnote-web compat sidebar 仍处于渐进接入期,若这里优先走远端/Rust 兼容接口,
await fetchSidebarDatasetFromMnoteWeb({ // 一旦 actor/header 权限链不一致,就会把整个 (app) layout 阻塞住,表现为“登录后进不去页面”。
workspaceId: targetWorkspaceId, // 因此服务端首屏一律先走稳定的 Convex 直查;mnote-web 仅允许用于客户端树壳渐进增强。
}) const sidebarDataset = (await input.client.query(sidebarDatasetListQuery, {
).dataset workspaceId: targetWorkspaceId,
: ((await input.client.query(sidebarDatasetListQuery, { })) as SidebarDatasetListQueryResult;
workspaceId: targetWorkspaceId,
})) as SidebarDatasetListQueryResult);
const normalizedDocuments = (sidebarDataset.documents ?? []) as DocumentRecord[]; const normalizedDocuments = (sidebarDataset.documents ?? []) as DocumentRecord[];
return { return {
+19 -3
View File
@@ -51,7 +51,8 @@ export type SidebarDatasetListQueryResult = {
active_workspace_id: string; active_workspace_id: string;
workspaces: WorkspaceSummary[]; workspaces: WorkspaceSummary[];
documents: DocumentRecord[]; documents: DocumentRecord[];
kernel_sidebar_projection: KernelSidebarProjection; kernel_sidebar_projection?: KernelSidebarProjection;
kernelSidebarProjection?: KernelSidebarProjection;
trashed_documents: SidebarInitialData["trashedDocuments"]; trashed_documents: SidebarInitialData["trashedDocuments"];
media_assets: MediaAsset[]; media_assets: MediaAsset[];
trashed_media_assets: MediaAsset[]; trashed_media_assets: MediaAsset[];
@@ -241,14 +242,29 @@ export function buildSidebarDatasetListQueryResult(
export function mapSidebarDatasetListQueryResultToInitialData( export function mapSidebarDatasetListQueryResultToInitialData(
result: SidebarDatasetListQueryResult, result: SidebarDatasetListQueryResult,
): SidebarInitialData { ): SidebarInitialData {
// 说明:兼容旧的 Convex `sidebar.dataset.list` 返回体。
// 若上游暂未附带 `kernel_sidebar_projection`,这里按 documents 现算一份,
// 避免 SSR 因契约未完全切齐而直接崩掉。
const candidateProjection =
result.kernel_sidebar_projection ??
result.kernelSidebarProjection ??
null;
const kernelSidebarProjection =
candidateProjection &&
typeof candidateProjection === "object" &&
Array.isArray(candidateProjection.items) &&
Array.isArray(candidateProjection.edges)
? candidateProjection
: buildKernelSidebarProjection(result.documents ?? []);
return { return {
activeWorkspaceId: result.active_workspace_id, activeWorkspaceId: result.active_workspace_id,
workspaces: [...result.workspaces], workspaces: [...result.workspaces],
documents: [...result.documents], documents: [...result.documents],
kernelSidebarProjection: result.kernel_sidebar_projection, kernelSidebarProjection,
kernelSidebarTree: buildSidebarTreeFromKernelProjection({ kernelSidebarTree: buildSidebarTreeFromKernelProjection({
records: result.documents, records: result.documents,
projection: result.kernel_sidebar_projection, projection: kernelSidebarProjection,
}), }),
trashedDocuments: [...result.trashed_documents], trashedDocuments: [...result.trashed_documents],
trashedMediaAssets: [...result.trashed_media_assets], trashedMediaAssets: [...result.trashed_media_assets],
+10 -4
View File
@@ -39,7 +39,13 @@ interface EditorBridgeState {
registerBridge: (bridge: EditorReferenceBridge | null) => void; registerBridge: (bridge: EditorReferenceBridge | null) => void;
} }
export const useEditorBridgeStore = create<EditorBridgeState>((set) => ({ export const useEditorBridgeStore = create<EditorBridgeState>((set) => ({
bridge: null, bridge: null,
registerBridge: (bridge) => set({ bridge }), registerBridge: (bridge) =>
})); set((state) => {
if (state.bridge === bridge) {
return state;
}
return { bridge };
}),
}));