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:
+28
-458
@@ -1,458 +1,28 @@
|
||||
[2026-04-14T05:21:15Z] [SESSION-0] INIT Harness initialized for project /mnt/Data1T/mnote
|
||||
[[2026-04-14T05:26:16Z] [SESSION-0] INIT Added [task-001] via harness add
|
||||
[2026-04-14T05:27:30Z] [SESSION-0] WARN Previous log line has malformed prefix from add command; corrected entry appended
|
||||
[2026-04-14T05:27:30Z] [SESSION-0] INIT Added [task-001] via harness add
|
||||
[2026-04-14T05:34:11Z] [SESSION-1] LOCK acquired (pid=2142754)
|
||||
[2026-04-14T05:34:11Z] [SESSION-1] INIT Environment health check: PASS
|
||||
[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-14T05:40:04Z] [SESSION-1] CHECKPOINT [task-001] step=1/4 "完成 Rust 协议层对 documents.meta/content/save 的建模与映射,cargo test/check 已通过"
|
||||
[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-14T05:40:04Z] [SESSION-1] Starting [task-002] 补齐 Rust core-protocol/storage-convex-bridge 对 documents.meta/content/save 的协议与映射 (base=fc26ed21)
|
||||
[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-14T05:40:04Z] [SESSION-1] Completed [task-002] (commit skipped by repo rule; base=fc26ed21)
|
||||
[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-14T05:40:04Z] [SESSION-1] LOCK released
|
||||
[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-14T05:42:53Z] [SESSION-2] LOCK acquired (pid=2188805)
|
||||
[2026-04-14T05:42:53Z] [SESSION-2] INIT Environment health check: PASS
|
||||
[2026-04-14T05:42:53Z] [SESSION-2] Starting [task-003] 为 blocks.patch 与正文保存补最小 Rust command 协议和 storage-convex-bridge 映射 (base=fc26ed21)
|
||||
[2026-04-14T05:43:41Z] [SESSION-2] CHECKPOINT [task-003] step=1/2 "已把 blocks.patch 映射纳入 storage-convex-bridge,目标仍对齐当前 documents:updateContent 写链"
|
||||
[2026-04-14T05:43:54Z] [SESSION-2] CHECKPOINT [task-003] step=2/2 "storage-convex-bridge 单测与 workspace cargo check 已通过"
|
||||
[2026-04-14T05:43:54Z] [SESSION-2] Completed [task-003] (commit skipped by repo rule; base=fc26ed21)
|
||||
[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-14T05:43:54Z] [SESSION-2] LOCK released
|
||||
[2026-04-14T06:09:58Z] [SESSION-3] LOCK acquired (pid=manual-sync)
|
||||
[2026-04-14T06:09:58Z] [SESSION-3] INIT Environment health check: PASS
|
||||
[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-14T06:10:01Z] [SESSION-3] Starting [task-004] 为 sidebar.dataset.list 固定 Rust query 契约并对齐 SidebarInitialData 聚合边界 (base=fc26ed21)
|
||||
[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-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 error,vitest 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 指定的定向 eslint(0 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.1,api.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 transport;cargo 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 command,Next 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 adapter;Convex functions 刷新、cargo test、bridge vitest 与定向 eslint(0 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 "已补定向 vitest;eslint 仅剩仓库既有 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 adapter,4 个 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 指定 eslint;tsc 失败仍为仓库既有 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/历史 alias,bridge-runtime 与 CLI 定向 cargo test 全部通过"
|
||||
[2026-04-15T07:21:09Z] [SESSION-17] CHECKPOINT [task-032] step=2/3 "已把 legacy mindmap route 收口到 Rust query/command plan,AI 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 smoke;legacy 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.overview:Convex 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 adapter,route 仅保留 transport (base=de35d423)
|
||||
[2026-04-15T11:03:14Z] [SESSION-21] CHECKPOINT [task-036] step=1/2 "已把 create-child/embed/template 三条页面旧路由收口到统一 page adapter,route 仅保留 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 fallback,media/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 为假 cutover,embed 仍保留 TS 内容编排。"
|
||||
[2026-04-15T13:57:26Z] [SESSION-24] CHECKPOINT [task-046] step=2/3 "已将 create-child 切到 documents.create,template/empty-trash/purge 切到统一 Rust runtime + transport,embed 改走 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.ts,src/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_ops,outline-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] 保持 pending:docs_search/docs_read 仍缺少 Rust runtime,AI 工具链运行态证据未闭合
|
||||
[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.sh;CLI 主链验收骨架闭合。"
|
||||
[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_read,docsServerTools 仅保留 fallback。"
|
||||
[2026-04-16T02:29:43Z] [SESSION-26] CHECKPOINT [task-050] step=2/3 "已将 AI 工具矩阵回写为 docs_search/docs_read=RUST_OWNER,asset_extract_outline/asset_to_mindmap/oo_*=TS_TRANSPORT_KEEP,builtins 主链不再保留 TS_COMPAT_PENDING。"
|
||||
[2026-04-16T02:29:44Z] [SESSION-26] CHECKPOINT [task-050] step=3/3 "已通过 bridge-runtime 测试、runAgent.test.ts 与 task-050 关联 eslint;AI 工具矩阵收口完成。"
|
||||
[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 bridge,route 仅保留鉴权、上下文规范化、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 island,Hermes 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 文档回写为 PARTIAL;cargo 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 优先转发真实 Authorization;Sidebar 服务端首包与 /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
|
||||
|
||||
[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-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-17T08:02:25Z] [SESSION-1] LOCK acquired (pid=640651)
|
||||
[2026-04-17T08:02:25Z] [SESSION-1] INIT Environment health check: PASS
|
||||
[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-17T08:03:15Z] [SESSION-1] CHECKPOINT [task-001] step=1/1 "已验证 /api/sidebar 不再默认走 mnote-web compat,tree shell 默认改为显式关闭,主链边界符合长期架构文档。"
|
||||
[2026-04-17T08:03:15Z] [SESSION-1] Completed [task-001] (commit skipped by repo rule; base=4538f85d)
|
||||
[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-17T08:06:06Z] [SESSION-1] CHECKPOINT [task-002] step=1/1 "已让 move/embed picker 在 tree shell 默认关闭时直接走稳定 projection fallback,不再额外请求 viewer/tree-shell 依赖。"
|
||||
[2026-04-17T08:06:06Z] [SESSION-1] Completed [task-002] (commit skipped by repo rule; base=4538f85d)
|
||||
[2026-04-17T08:06:06Z] [SESSION-1] Starting [task-003] 执行 Stage A-3:新增并跑通 3000 首页入口 smoke,验证在 3104 不可用时首页主链仍可进入 /auth 或已登录文档页,不因实验壳阻塞 (base=4538f85d)
|
||||
[2026-04-17T08:12:27Z] [SESSION-1] CHECKPOINT [task-003] step=1/1 "已新增 task097 首页入口 smoke,并验证 3104 不可达时 3000 仍可进入 /auth;问题落在 dev 进程健康度,而非首页主链再次依赖实验壳。"
|
||||
[2026-04-17T08:12:27Z] [SESSION-1] Completed [task-003] (commit skipped by repo rule; base=4538f85d)
|
||||
[2026-04-17T08:12:27Z] [SESSION-1] Starting [task-004] 执行 Stage B-1:继续收缩前端树域真相泄漏,确认主路径 consumer 只消费 kernel projection,兼容 helper 不再回到运行时主链 (base=4538f85d)
|
||||
[2026-04-17T08:11:55Z] [SESSION-1] CHECKPOINT [task-004] step=1/1 "已确认 sidebar / picker / host 主路径不再回退到 buildDocumentTree;树域主消费面继续停留在 kernel projection 上。"
|
||||
[2026-04-17T08:11:55Z] [SESSION-1] Completed [task-004] (commit skipped by repo rule; base=4538f85d)
|
||||
[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-17T08:28:00Z] [SESSION-2] LOCK acquired (pid=resume)
|
||||
[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-17T08:40:10Z] [SESSION-2] CHECKPOINT [task-005] step=1/2 "已完成 tree command 入口与 Rust envelope 接缝审计,并把 create/rename/move 的前端直连入口收成共享 tree command client。"
|
||||
[2026-04-17T08:40:10Z] [SESSION-2] Completed [task-005] (commit skipped by repo rule; base=4538f85d)
|
||||
[2026-04-17T08:42:00Z] [SESSION-2] CHECKPOINT [task-006] step=1/1 "已产出 Rust Web 正式实时树事件流方案文档,明确 Convex substrate 与 Rust semantic owner 的职责边界。"
|
||||
[2026-04-17T08:42:00Z] [SESSION-2] Completed [task-006] (commit skipped by repo rule; base=4538f85d)
|
||||
[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-17T08:56:10Z] [SESSION-2] CHECKPOINT [task-007] step=2/2 "已修复 dev server 的 Convex HTTP 代理并完成回归验证:/convex/version=200, /auth=200, 首页 smoke 通过。"
|
||||
[2026-04-17T08:56:30Z] [SESSION-2] Completed [task-007] (commit skipped by repo rule; base=4538f85d)
|
||||
[2026-04-17T08:56:30Z] [SESSION-2] STATS tasks_total=7 completed=7 failed=0 pending=0 blocked=0 attempts_total=7 checkpoints=8
|
||||
[2026-04-17T08:56:30Z] [SESSION-2] LOCK released
|
||||
|
||||
+61
-3136
File diff suppressed because it is too large
Load Diff
@@ -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")) {
|
||||
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
|
||||
.get("content")
|
||||
@@ -5712,7 +5718,9 @@ mod tests {
|
||||
assert_eq!(result["projection"], json!("sidebar_tree"));
|
||||
assert_eq!(result["rootNodeId"], 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]["position"], json!(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::middleware::request_context::inject_request_context;
|
||||
use crate::routes::build_router;
|
||||
use axum::Router;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
@@ -15,6 +16,8 @@ pub struct AppConfig {
|
||||
pub convex_url: Option<String>,
|
||||
pub convex_admin_key: Option<String>,
|
||||
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_name: String,
|
||||
pub dev_user_email: String,
|
||||
@@ -34,23 +37,74 @@ impl AppConfig {
|
||||
convex_url: env::var("CONVEX_SELF_HOSTED_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())
|
||||
.filter(|value| !value.is_empty()),
|
||||
convex_admin_key: env::var("CONVEX_SELF_HOSTED_ADMIN_KEY")
|
||||
.ok()
|
||||
.or_else(|| read_env_or_dotenv("CONVEX_SELF_HOSTED_ADMIN_KEY"))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
allow_dev_fixtures: env::var("MNOTE_WEB_ALLOW_DEV_FIXTURES")
|
||||
.ok()
|
||||
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
||||
.unwrap_or(false),
|
||||
dev_user_id: env::var("DEV_USER_ID").unwrap_or_else(|_| "dev-user".into()),
|
||||
dev_user_name: env::var("DEV_USER_NAME").unwrap_or_else(|_| "开发用户".into()),
|
||||
dev_user_email: env::var("DEV_USER_EMAIL").unwrap_or_else(|_| "dev@mnote.local".into()),
|
||||
query_fixtures_json: env::var("MNOTE_WEB_QUERY_FIXTURES_JSON")
|
||||
.ok()
|
||||
.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)]
|
||||
pub struct AppState {
|
||||
config: Arc<AppConfig>,
|
||||
|
||||
@@ -73,13 +73,13 @@ fn workspace_query_payload(
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_bridge_query(
|
||||
async fn execute_bridge_query(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
) -> 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(
|
||||
@@ -95,7 +95,8 @@ pub async fn workspace(
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
workspace_query_payload(&effective_workspace_id, &query),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
@@ -119,7 +120,8 @@ pub async fn request(
|
||||
"commandId": query.command_id,
|
||||
}),
|
||||
},
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
@@ -143,7 +145,8 @@ pub async fn trace(
|
||||
"commandId": query.command_id,
|
||||
}),
|
||||
},
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
@@ -155,10 +158,6 @@ mod tests {
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
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 {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
@@ -168,6 +167,8 @@ mod tests {
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
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_name: "开发用户".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)
|
||||
}
|
||||
@@ -69,7 +69,8 @@ pub async fn next_sidebar(
|
||||
"workspaceId": effective_workspace_id,
|
||||
}),
|
||||
},
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let projection = execute_runtime_query_against_data(
|
||||
&context,
|
||||
Some(&effective_workspace_id),
|
||||
@@ -115,10 +116,6 @@ mod tests {
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
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 {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
@@ -128,6 +125,8 @@ mod tests {
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
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_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
|
||||
@@ -52,7 +52,7 @@ fn sidebar_dataset_query(workspace_id: &str) -> RuntimeQueryEnvelopeWire {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_sidebar_dataset(
|
||||
async fn load_sidebar_dataset(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
@@ -63,6 +63,7 @@ fn load_sidebar_dataset(
|
||||
Some(workspace_id),
|
||||
sidebar_dataset_query(workspace_id),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn execute_kernel_query(
|
||||
@@ -94,7 +95,7 @@ pub async fn project_sidebar(
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
|
||||
.expect("workspace_required 已确保存在");
|
||||
let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id)?;
|
||||
let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?;
|
||||
let result = execute_kernel_query(
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
@@ -124,7 +125,7 @@ pub async fn subtree(
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
|
||||
.expect("workspace_required 已确保存在");
|
||||
let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id)?;
|
||||
let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?;
|
||||
let result = execute_kernel_query(
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
@@ -151,7 +152,7 @@ pub async fn edges(
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
|
||||
.expect("workspace_required 已确保存在");
|
||||
let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id)?;
|
||||
let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?;
|
||||
let result = execute_kernel_query(
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
@@ -176,7 +177,7 @@ pub async fn graph(
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
|
||||
.expect("workspace_required 已确保存在");
|
||||
let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id)?;
|
||||
let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?;
|
||||
let result = execute_kernel_query(
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
@@ -202,10 +203,6 @@ mod tests {
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
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 {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
@@ -215,6 +212,8 @@ mod tests {
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
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_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
mod bridge;
|
||||
mod command_support;
|
||||
mod compat;
|
||||
mod health;
|
||||
mod hermes;
|
||||
mod kernel;
|
||||
mod query_support;
|
||||
mod sse;
|
||||
mod tree;
|
||||
mod ws;
|
||||
|
||||
use crate::app::AppState;
|
||||
@@ -17,6 +19,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
|
||||
Router::new()
|
||||
.route("/health", get(health::health))
|
||||
.route("/tree", get(tree::tree_shell))
|
||||
.route(
|
||||
"/api/kernel/projections/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/edges", get(kernel::edges))
|
||||
.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/request", get(bridge::request))
|
||||
.route("/api/bridge/trace", get(bridge::trace))
|
||||
|
||||
@@ -96,14 +96,14 @@ pub fn build_runtime_query_plan(
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
pub fn fetch_query_data_via_convex(
|
||||
pub async fn fetch_query_data_via_convex(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
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(
|
||||
@@ -120,12 +120,12 @@ pub fn execute_runtime_query_against_data(
|
||||
.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,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
) -> 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)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use bridge_runtime::RuntimeQueryExecutionPlan;
|
||||
use bridge_runtime::{RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
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_SOURCE_CHANNEL: &str = "x-mnote-source-channel";
|
||||
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> {
|
||||
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")
|
||||
})?;
|
||||
|
||||
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!({
|
||||
"subject": config.dev_user_id,
|
||||
"subject": effective_actor_id,
|
||||
"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,
|
||||
"email": config.dev_user_email,
|
||||
});
|
||||
@@ -113,7 +122,7 @@ fn load_query_fixture(
|
||||
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);
|
||||
};
|
||||
let trimmed = raw.trim();
|
||||
@@ -133,7 +142,36 @@ fn load_query_fixture(
|
||||
.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,
|
||||
context: &RequestContext,
|
||||
plan: &RuntimeQueryExecutionPlan,
|
||||
@@ -157,7 +195,7 @@ pub fn execute_convex_query_plan(
|
||||
"args": plan.args_json,
|
||||
});
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
@@ -186,7 +224,7 @@ pub fn execute_convex_query_plan(
|
||||
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() {
|
||||
WebError::gateway_timeout_code("convex_timeout", format!("Convex query 超时: {error}"))
|
||||
} else {
|
||||
@@ -201,7 +239,7 @@ pub fn execute_convex_query_plan(
|
||||
})?;
|
||||
|
||||
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(
|
||||
"convex_bad_response",
|
||||
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,
|
||||
context: &RequestContext,
|
||||
plan: &RuntimeQueryExecutionPlan,
|
||||
) -> 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())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -4,6 +4,8 @@
|
||||
"supabaseInternalUrl": "http://127.0.0.1:18000",
|
||||
"supabaseAnonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc2NDExNTA0OSwiZXhwIjoyMDc5NDc1MDQ5fQ.18ohcQZXVkoR1TIF56QWJxHyoVnA9aarH-XfTyBJn1Y",
|
||||
"backendUrl": "https://frp-dry.com:44399",
|
||||
"mnoteWebBaseUrl": "http://127.0.0.1:3104",
|
||||
"mnoteWebTreeShellEnabled": false,
|
||||
"onlyofficeBaseUrlWeb": "/onlyoffice-server",
|
||||
"onlyofficeBaseUrlDesktop": "http://localhost:8082",
|
||||
"onlyofficeProxyOrigin": "http://host.docker.internal:3000",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* - node scripts/dev-server.js -p 3000
|
||||
*
|
||||
* 依赖环境变量:
|
||||
* - ONLYOFFICE_INTERNAL_URL:可显式指定;未指定或失效时会自动探测 8081/8082
|
||||
* - ONLYOFFICE_INTERNAL_URL:可显式指定;未指定或失效时优先探测 8082,再回退 8081
|
||||
*/
|
||||
|
||||
const http = require("http");
|
||||
@@ -20,7 +20,7 @@ const { parse: parseUrl } = require("url");
|
||||
|
||||
const ONLYOFFICE_PREFIX = "/onlyoffice-server";
|
||||
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_RESOLVE_CACHE_TTL_MS = 30_000;
|
||||
|
||||
@@ -92,9 +92,9 @@ function listOnlyOfficeInternalUrlCandidates() {
|
||||
}
|
||||
|
||||
push(DEFAULT_ONLYOFFICE_INTERNAL_URL);
|
||||
push("http://127.0.0.1:8082");
|
||||
push("http://localhost:8081");
|
||||
push("http://127.0.0.1:8081");
|
||||
push("http://localhost:8082");
|
||||
push("http://localhost:8081");
|
||||
|
||||
return candidates.length > 0 ? candidates : [DEFAULT_ONLYOFFICE_INTERNAL_URL];
|
||||
}
|
||||
@@ -370,6 +370,17 @@ function proxyConvexHttp(req, res) {
|
||||
} catch {}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* - pnpm start (默认会执行本脚本)
|
||||
*
|
||||
* 依赖环境变量:
|
||||
* - ONLYOFFICE_INTERNAL_URL:可显式指定;未指定或失效时会自动探测 8081/8082
|
||||
* - ONLYOFFICE_INTERNAL_URL:可显式指定;未指定或失效时优先探测 8082,再回退 8081
|
||||
* - CONVEX_INTERNAL_URL:默认 http://127.0.0.1:3210
|
||||
*/
|
||||
|
||||
@@ -21,7 +21,7 @@ const { parse: parseUrl } = require("url");
|
||||
|
||||
const ONLYOFFICE_PREFIX = "/onlyoffice-server";
|
||||
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_RESOLVE_CACHE_TTL_MS = 30_000;
|
||||
|
||||
@@ -93,9 +93,9 @@ function listOnlyOfficeInternalUrlCandidates() {
|
||||
}
|
||||
|
||||
push(DEFAULT_ONLYOFFICE_INTERNAL_URL);
|
||||
push("http://127.0.0.1:8082");
|
||||
push("http://localhost:8081");
|
||||
push("http://127.0.0.1:8081");
|
||||
push("http://localhost:8082");
|
||||
push("http://localhost:8081");
|
||||
|
||||
return candidates.length > 0 ? candidates : [DEFAULT_ONLYOFFICE_INTERNAL_URL];
|
||||
}
|
||||
|
||||
@@ -31,14 +31,22 @@ export default function AuthPage() {
|
||||
|
||||
const currentUser = useQuery(api.users.currentUser, isAuthenticated ? {} : "skip");
|
||||
|
||||
const navigateToHome = useCallback(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.assign("/");
|
||||
return;
|
||||
}
|
||||
router.replace("/");
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!isAuthenticated) return;
|
||||
if (currentUser === undefined) return;
|
||||
if (currentUser && currentUser.name) {
|
||||
router.replace("/");
|
||||
navigateToHome();
|
||||
}
|
||||
}, [currentUser, isAuthenticated, isLoading, router]);
|
||||
}, [currentUser, isAuthenticated, isLoading, navigateToHome]);
|
||||
|
||||
const [flow, setFlow] = useState<AuthStep>("signIn");
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -70,7 +78,7 @@ export default function AuthPage() {
|
||||
if (cancelled) return;
|
||||
setPendingUsernameToSave(null);
|
||||
setMessage({ type: "success", text: "注册成功!" });
|
||||
router.replace("/");
|
||||
navigateToHome();
|
||||
} catch (err: unknown) {
|
||||
if (cancelled) return;
|
||||
setPendingUsernameToSave(null);
|
||||
@@ -83,7 +91,7 @@ export default function AuthPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentUser, isAuthenticated, pendingUsernameToSave, router, setMyUsername]);
|
||||
}, [currentUser, isAuthenticated, navigateToHome, pendingUsernameToSave, setMyUsername]);
|
||||
|
||||
// 执行登录的核心逻辑
|
||||
const performSignIn = useCallback(async (email: string, password: string, flow: AuthStep) => {
|
||||
@@ -110,8 +118,10 @@ export default function AuthPage() {
|
||||
setPendingUsernameToSave(username.trim());
|
||||
return;
|
||||
}
|
||||
setMessage({ type: "success", text: flow === "signIn" ? "登录成功!" : "注册成功!" });
|
||||
setTimeout(() => router.push("/"), 500);
|
||||
// 说明:登录成功后不要立刻强推 `/`。
|
||||
// Convex Auth cookie 与 currentUser 查询存在短暂同步窗口,过早跳转会把页面推进到根路由空白态。
|
||||
// 这里仅提示“正在同步”,真正跳转交给上面的 useEffect 在登录态稳定后执行。
|
||||
setMessage({ type: "info", text: "登录成功,正在同步登录状态..." });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -187,7 +197,7 @@ export default function AuthPage() {
|
||||
setIsSavingUsername(true);
|
||||
await setMyUsername({ username: username.trim() });
|
||||
setMessage({ type: "success", text: "用户名已保存!" });
|
||||
router.replace("/");
|
||||
navigateToHome();
|
||||
} catch (error: any) {
|
||||
setMessage({ type: "error", text: getUserFacingErrorMessage(error, "保存失败,请重试") });
|
||||
} finally {
|
||||
|
||||
@@ -13,14 +13,9 @@ import {
|
||||
resolveRustBridgeQueryPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
import {
|
||||
buildSidebarDatasetListQueryPayload,
|
||||
mapSidebarDatasetListQueryResultToInitialData,
|
||||
type SidebarDatasetListQueryResult,
|
||||
} from "@/lib/sidebar-data";
|
||||
import {
|
||||
fetchSidebarDatasetFromMnoteWeb,
|
||||
getMnoteWebBaseUrl,
|
||||
} from "@/lib/server/mnote-web";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -41,31 +36,15 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
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({
|
||||
request,
|
||||
workspaceId: targetWorkspaceId,
|
||||
});
|
||||
const envelope = buildDocumentQueryEnvelope({
|
||||
name: "sidebar.dataset.list",
|
||||
payload: buildSidebarDatasetListQueryPayload(targetWorkspaceId),
|
||||
payload: {
|
||||
workspaceId: targetWorkspaceId,
|
||||
},
|
||||
});
|
||||
const plan = await resolveRustBridgeQueryPlan({
|
||||
context: bridgeContext,
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Search } from "lucide-react";
|
||||
import { MnoteWebTreeShell } from "@/components/sidebar/MnoteWebTreeShell";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { buildPageTreeProjectionItems, buildPickerTreeItems } from "@/lib/tree-projection";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDocumentSearch } from "@/hooks/use-document-search";
|
||||
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
|
||||
@@ -27,6 +29,10 @@ type PickerItem =
|
||||
| { kind: "root"; id: null; title: string; subtitle?: string }
|
||||
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number; raw?: DocumentSearchResult };
|
||||
|
||||
type ViewerIdentity = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
async function fetchSidebarData(workspaceId: string): Promise<SidebarInitialData> {
|
||||
const response = await fetch(`/api/sidebar?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
method: "GET",
|
||||
@@ -42,6 +48,18 @@ async function fetchSidebarData(workspaceId: string): Promise<SidebarInitialData
|
||||
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 {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -129,6 +147,10 @@ function MoveEmbedPickerDialogBody({
|
||||
|
||||
const trimmed = query.trim();
|
||||
const isEmptyQuery = trimmed.length === 0;
|
||||
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
|
||||
const treeShellEnabled = Boolean(
|
||||
(runtimeConfig.mnoteWebBaseUrl ?? "").trim() && runtimeConfig.mnoteWebTreeShellEnabled === true,
|
||||
);
|
||||
|
||||
const sidebarQuery = useQuery({
|
||||
queryKey: ["move-embed-picker-sidebar", workspaceId],
|
||||
@@ -143,6 +165,14 @@ function MoveEmbedPickerDialogBody({
|
||||
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 items = useMemo<PickerItem[]>(() => {
|
||||
@@ -190,6 +220,39 @@ function MoveEmbedPickerDialogBody({
|
||||
}, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.kernelSidebarTree]);
|
||||
|
||||
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(
|
||||
async (targetId: string | null) => {
|
||||
@@ -232,6 +295,9 @@ function MoveEmbedPickerDialogBody({
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
onKeyDown={(event) => {
|
||||
if (isEmptyQuery) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setHighlighted((prev) => Math.min(items.length - 1, prev + 1));
|
||||
@@ -248,10 +314,34 @@ function MoveEmbedPickerDialogBody({
|
||||
>
|
||||
{!workspaceId ? (
|
||||
<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>
|
||||
) : (isEmptyQuery ? sidebarQuery.error : error) ? (
|
||||
<div className="p-4 text-sm text-red-600">{String(isEmptyQuery ? sidebarQuery.error : error)}</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-sm text-red-600">{String(error)}</div>
|
||||
) : items.length === 0 ? (
|
||||
<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 { CustomSideMenu } from "./menus/CustomSideMenu";
|
||||
import { CustomSlashMenu } from "./menus/CustomSlashMenu";
|
||||
import { MoveEmbedPickerHost } from "@/components/documents/move-embed-picker-host";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
@@ -36,6 +35,7 @@ import { useCommentsUiStore } from "@/store/comments-ui";
|
||||
import { useConvexAuth, useQuery } from "convex/react";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
import type { EditorReferenceBridge } from "@/store/editor-bridge";
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
documentId: string;
|
||||
@@ -197,6 +197,10 @@ export function BlockNoteEditor({
|
||||
const isFullScreenTableOpen = fullScreenTableId !== null;
|
||||
const revisionRef = useRef<number | null>(initialRevision);
|
||||
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 registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
|
||||
@@ -226,6 +230,16 @@ export function BlockNoteEditor({
|
||||
[initialContent],
|
||||
);
|
||||
|
||||
if (
|
||||
!stableInitialContentRef.current ||
|
||||
stableInitialContentRef.current.documentId !== documentId
|
||||
) {
|
||||
stableInitialContentRef.current = {
|
||||
documentId,
|
||||
content: normalizedInitialContent,
|
||||
};
|
||||
}
|
||||
|
||||
const collaboration = useMemo(() => {
|
||||
const url = process.env.NEXT_PUBLIC_HOCUSPOCUS_URL;
|
||||
if (!url) return null;
|
||||
@@ -240,7 +254,7 @@ export function BlockNoteEditor({
|
||||
|
||||
const editor = useCreateBlockNote(
|
||||
{
|
||||
initialContent: normalizedInitialContent as never,
|
||||
initialContent: stableInitialContentRef.current?.content as never,
|
||||
schema: customBlockSchema,
|
||||
placeholders: {
|
||||
default: "输入'/'选择,按 空格 打开AI...",
|
||||
@@ -257,7 +271,7 @@ export function BlockNoteEditor({
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
[documentId, normalizedInitialContent],
|
||||
[documentId],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
@@ -268,6 +282,20 @@ export function BlockNoteEditor({
|
||||
[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(() => {
|
||||
revisionRef.current = initialRevision;
|
||||
}, [initialRevision]);
|
||||
@@ -276,6 +304,18 @@ export function BlockNoteEditor({
|
||||
conflictDetectionKeyRef.current = initialConflictDetectionKey;
|
||||
}, [initialConflictDetectionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
onStatsChangeRef.current = onStatsChange;
|
||||
}, [onStatsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onSnapshotRef.current = onSnapshot;
|
||||
}, [onSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
onPersistedMetaChangeRef.current = onPersistedMetaChange;
|
||||
}, [onPersistedMetaChange]);
|
||||
|
||||
const saveContent = useCallback(
|
||||
async (content: Json) => {
|
||||
setIsSaving(true);
|
||||
@@ -324,7 +364,7 @@ export function BlockNoteEditor({
|
||||
: conflictDetectionKeyRef.current;
|
||||
revisionRef.current = nextRevision ?? null;
|
||||
conflictDetectionKeyRef.current = nextConflictDetectionKey ?? null;
|
||||
onPersistedMetaChange?.({
|
||||
onPersistedMetaChangeRef.current?.({
|
||||
revision: revisionRef.current,
|
||||
conflictDetectionKey: conflictDetectionKeyRef.current,
|
||||
});
|
||||
@@ -337,7 +377,7 @@ export function BlockNoteEditor({
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[documentId, onPersistedMetaChange, workspaceId],
|
||||
[documentId, workspaceId],
|
||||
);
|
||||
|
||||
const debouncedSave = useDebouncedCallback(saveContent, 800);
|
||||
@@ -705,8 +745,8 @@ export function BlockNoteEditor({
|
||||
setTocEntries(buildHeadingToc(typedBlocks));
|
||||
syncProgressMeters(editor);
|
||||
const stats = computeDocumentStats(typedBlocks);
|
||||
onStatsChange?.(stats);
|
||||
onSnapshot?.({ blocks: blocks as Json, stats });
|
||||
onStatsChangeRef.current?.(stats);
|
||||
onSnapshotRef.current?.({ blocks: blocks as Json, stats });
|
||||
|
||||
// 检测块删除 -> 同步删除附件/思维导图并刷新侧边栏
|
||||
const { assetIds, mindmapBlockIds, onlineTableIds } = collectAssets(typedBlocks);
|
||||
@@ -745,7 +785,7 @@ export function BlockNoteEditor({
|
||||
disposed = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, deleteOnlineTables, editor, onSnapshot, onStatsChange, restoreOnlineTableIfNeeded]);
|
||||
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, deleteOnlineTables, editor, restoreOnlineTableIfNeeded]);
|
||||
|
||||
const jumpToHeading = useCallback((headingId: string) => {
|
||||
const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
|
||||
@@ -1003,6 +1043,18 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
[documentId, editor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
insertMediaAssetRef.current = insertMediaAssetBlock;
|
||||
}, [insertMediaAssetBlock]);
|
||||
|
||||
useEffect(() => {
|
||||
insertMindmapRef.current = insertMindmapBlock;
|
||||
}, [insertMindmapBlock]);
|
||||
|
||||
useEffect(() => {
|
||||
insertOnlineTableRef.current = insertOnlineTableBlock;
|
||||
}, [insertOnlineTableBlock]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return undefined;
|
||||
|
||||
@@ -1090,46 +1142,48 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
[documentId, workspaceId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
registerEditorBridge(null);
|
||||
return;
|
||||
}
|
||||
const bridge = {
|
||||
const stableEditorBridge = useMemo<EditorReferenceBridge>(
|
||||
() => ({
|
||||
undo: () => {
|
||||
try {
|
||||
editor.focus();
|
||||
editor.undo();
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return;
|
||||
currentEditor.focus();
|
||||
currentEditor.undo();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
redo: () => {
|
||||
try {
|
||||
editor.focus();
|
||||
editor.redo();
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return;
|
||||
currentEditor.focus();
|
||||
currentEditor.redo();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
openTableFullScreen: (tableId: string) => {
|
||||
setFullScreenTableId(tableId);
|
||||
setFullScreenTableIdRef.current(tableId);
|
||||
},
|
||||
insertMediaAsset: (asset: MediaAsset) => {
|
||||
insertMediaAssetBlock(asset);
|
||||
insertMediaAssetRef.current(asset);
|
||||
},
|
||||
insertMindmapAsset: (args: { documentId: string; mindmapId: string }) => {
|
||||
insertMindmapBlock(args);
|
||||
insertMindmapRef.current(args);
|
||||
},
|
||||
insertOnlineTableAsset: (args: { documentId: string; tableId: string }) => {
|
||||
insertOnlineTableBlock(args);
|
||||
insertOnlineTableRef.current(args);
|
||||
},
|
||||
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
|
||||
editor.focus();
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return { blockId: null };
|
||||
currentEditor.focus();
|
||||
const cursor = currentEditor.getTextCursorPosition();
|
||||
const blockId = cursor?.block?.id ?? null;
|
||||
const text = aliasText || target.title || "无标题";
|
||||
editor.insertInlineContent([
|
||||
currentEditor.insertInlineContent([
|
||||
{
|
||||
type: "link",
|
||||
href: buildDocumentPath(target.id),
|
||||
@@ -1140,12 +1194,14 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
return { blockId };
|
||||
},
|
||||
insertEmbedReference: (target: ReferenceTarget) => {
|
||||
editor.focus();
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return { blockId: null };
|
||||
currentEditor.focus();
|
||||
const cursor = currentEditor.getTextCursorPosition();
|
||||
const referenceBlock =
|
||||
cursor?.block ?? editor.topLevelBlocks[editor.topLevelBlocks.length - 1];
|
||||
cursor?.block ?? currentEditor.topLevelBlocks[currentEditor.topLevelBlocks.length - 1];
|
||||
const blockId = generateBlockId();
|
||||
editor.insertBlocks(
|
||||
currentEditor.insertBlocks(
|
||||
[
|
||||
{
|
||||
id: blockId,
|
||||
@@ -1162,7 +1218,9 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
return { blockId };
|
||||
},
|
||||
replaceWithSnapshot: (payload: Json) => {
|
||||
editor.focus();
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return;
|
||||
currentEditor.focus();
|
||||
const nextBlocks = Array.isArray(payload)
|
||||
? payload
|
||||
: Array.isArray((payload as { blocks?: Json }).blocks)
|
||||
@@ -1171,20 +1229,29 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
if (!Array.isArray(nextBlocks)) {
|
||||
return;
|
||||
}
|
||||
editor.replaceBlocks(editor.topLevelBlocks, nextBlocks as never);
|
||||
currentEditor.replaceBlocks(currentEditor.topLevelBlocks, nextBlocks as never);
|
||||
},
|
||||
getCursorBlockId: () => {
|
||||
try {
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return null;
|
||||
const cursor = currentEditor.getTextCursorPosition();
|
||||
return cursor?.block?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
registerEditorBridge(bridge);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
registerEditorBridge(editor ? stableEditorBridge : null);
|
||||
}, [editor, registerEditorBridge, stableEditorBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => registerEditorBridge(null);
|
||||
}, [editor, insertMediaAssetBlock, insertMindmapBlock, insertOnlineTableBlock, registerEditorBridge]);
|
||||
}, [registerEditorBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
@@ -1336,9 +1403,6 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
</div>
|
||||
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} onClose={onCloseToc} />
|
||||
</div>
|
||||
|
||||
<MoveEmbedPickerHost />
|
||||
|
||||
{/* 全屏表格编辑器 Modal */}
|
||||
{fullScreenTableId && (
|
||||
<FullScreenTableEditor
|
||||
|
||||
@@ -3,14 +3,10 @@
|
||||
import dynamic from "next/dynamic";
|
||||
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 { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
|
||||
import { PageBacklinksPanel } from "@/components/editor/page-backlinks-panel";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import { usePageLayoutStore } from "@/store/page-layout";
|
||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||
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 { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
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 { DocumentReadView } from "@/components/editor/document-read-view";
|
||||
import { buildPageSubtreeProjection, extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||
import { moveDocumentCommand, renameDocumentCommand } from "@/lib/documents/tree-command-client";
|
||||
|
||||
const BlockNoteEditor = dynamic(
|
||||
() => 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 {
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
@@ -358,11 +394,11 @@ export function DocumentContent({
|
||||
async (nextTitle: string) => {
|
||||
if (readOnly) return;
|
||||
const payload = nextTitle.trim() || "无标题";
|
||||
await fetch("/api/documents/title", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId, workspaceId, title: payload }),
|
||||
});
|
||||
try {
|
||||
await renameDocumentCommand({ documentId, workspaceId, title: payload });
|
||||
} catch (error) {
|
||||
console.error("更新页面标题失败", error);
|
||||
}
|
||||
},
|
||||
[documentId, readOnly, workspaceId],
|
||||
);
|
||||
@@ -590,14 +626,15 @@ export function DocumentContent({
|
||||
excludeIds: [documentId],
|
||||
onPick: async (mode, targetId) => {
|
||||
if (mode === "move") {
|
||||
const resp = await fetch("/api/documents/move", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId, parentId: targetId ?? null, position: 999999 }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "移动失败");
|
||||
try {
|
||||
await moveDocumentCommand({
|
||||
documentId,
|
||||
parentId: targetId ?? null,
|
||||
position: 999999,
|
||||
workspaceId,
|
||||
});
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "移动失败");
|
||||
return;
|
||||
}
|
||||
window.alert("移动成功");
|
||||
@@ -640,7 +677,18 @@ export function DocumentContent({
|
||||
|
||||
const formattedUpdatedAt = useMemo(() => {
|
||||
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]);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
@@ -924,6 +972,7 @@ export function DocumentContent({
|
||||
history={history}
|
||||
onRestore={handleRestoreSnapshot}
|
||||
/>
|
||||
<MoveEmbedPickerHost />
|
||||
<DocumentCommentsDrawer />
|
||||
<DocumentAiAgentPanel
|
||||
documentId={documentId}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { deleteOnlineTable } from "@/lib/online-table";
|
||||
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
|
||||
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
|
||||
import { useCommentsUiStore } from "@/store/comments-ui";
|
||||
import { createChildDocumentCommand } from "@/lib/documents/tree-command-client";
|
||||
|
||||
type InlineNode = { text?: unknown };
|
||||
type TableMenuBlock = Parameters<
|
||||
@@ -196,32 +197,26 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
|
||||
}, [block, currentDocumentId, editor, removePageReference]);
|
||||
|
||||
const turnToPage = useCallback(async () => {
|
||||
const response = await fetch("/api/documents/create-child", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
try {
|
||||
const payload = await createChildDocumentCommand({
|
||||
parentId: currentDocumentId,
|
||||
title: extractText(block),
|
||||
blocks: [block],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
editor.replaceBlocks(
|
||||
[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]);
|
||||
|
||||
const handleMoveEmbedPick = useCallback(
|
||||
|
||||
@@ -39,15 +39,26 @@ const BacklinkItem = ({ record }: { record: BacklinkRecord }) => (
|
||||
);
|
||||
|
||||
export function PageBacklinksPanel({ workspaceId, documentId, className, defaultCollapsed }: PageBacklinksPanelProps) {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const { data, isLoading, error, refetch, isFetching } = useBacklinks({
|
||||
workspaceId,
|
||||
documentId,
|
||||
enabled: isReady,
|
||||
});
|
||||
|
||||
const records = useMemo(() => data ?? [], [data]);
|
||||
// 说明:collapsed 需要可交互;这里用一个轻量的局部状态,但默认值来自 props(用于“自定义页面:折叠引用列表”)。
|
||||
const [isCollapsed, setIsCollapsed] = useState(Boolean(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)) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { useConvex } from "convex/react";
|
||||
import { useConvexAuth, useQuery } from "convex/react";
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
ArrowUpRight,
|
||||
@@ -36,7 +37,7 @@ import { cn } from "@/lib/utils";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import { useSidebarStore } from "@/store/sidebar";
|
||||
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 { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree";
|
||||
import {
|
||||
@@ -47,6 +48,7 @@ import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||
import { FileTree } from "@/components/sidebar/file-tree";
|
||||
import { MnoteWebTreeShell } from "@/components/sidebar/MnoteWebTreeShell";
|
||||
import { buildVisibleRows } from "@/lib/file-tree/rows";
|
||||
import type { FileTreeSelectionState } 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 { api } from "@/lib/convex/api";
|
||||
import { GroupManagerDialog } from "@/components/groups/group-manager-dialog";
|
||||
import {
|
||||
createDocumentCommand,
|
||||
moveDocumentCommand,
|
||||
renameDocumentCommand,
|
||||
} from "@/lib/documents/tree-command-client";
|
||||
|
||||
const TOP_BUTTONS = [
|
||||
{ id: "search", icon: SearchIcon, label: "搜索" },
|
||||
@@ -148,20 +155,22 @@ function SidebarConvex({ initialData }: SidebarProps) {
|
||||
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
|
||||
interface SidebarContentProps {
|
||||
initialData: SidebarInitialData;
|
||||
sidebarQuery: {
|
||||
data: SidebarInitialData | null | undefined;
|
||||
isLoading: boolean;
|
||||
refetch: () => Promise<unknown>;
|
||||
};
|
||||
sidebarQuery: SidebarDataResult;
|
||||
}
|
||||
|
||||
function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const convex = useConvex();
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const currentUser = useQuery(api.users.currentUser, isAuthenticated ? {} : "skip");
|
||||
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } =
|
||||
useSidebarStore();
|
||||
const viewMode = useSidebarStore((state) => state.viewMode);
|
||||
const setViewMode = useSidebarStore((state) => state.setViewMode);
|
||||
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
|
||||
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
|
||||
const treeShellAvailable = Boolean(
|
||||
(runtimeConfig.mnoteWebBaseUrl ?? "").trim() && runtimeConfig.mnoteWebTreeShellEnabled === true,
|
||||
);
|
||||
|
||||
// 处理数据
|
||||
const sidebarData = useMemo(() => {
|
||||
@@ -234,6 +243,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [treeShellReloadToken, setTreeShellReloadToken] = useState(0);
|
||||
const [fileTreeSelection, setFileTreeSelection] = useState<FileTreeSelectionState>(() => ({
|
||||
selectedRowIds: new Set(),
|
||||
anchorRowId: null,
|
||||
@@ -304,6 +314,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
await sidebarQuery.refetch();
|
||||
setTreeShellReloadToken((prev) => prev + 1);
|
||||
}, [sidebarQuery]);
|
||||
|
||||
const refreshShareSummary = useCallback(async () => {
|
||||
@@ -585,6 +596,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
return map;
|
||||
}, [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 fileTreeRows = useMemo(
|
||||
@@ -652,6 +671,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
[router, setOpen],
|
||||
);
|
||||
|
||||
const handleNavigateFromTreeShell = useCallback(
|
||||
(documentId: string) => {
|
||||
if (!documentId) return;
|
||||
handleOpenDocument(documentId, "main");
|
||||
},
|
||||
[handleOpenDocument],
|
||||
);
|
||||
|
||||
const handleCopyLink = useCallback(async (node: SidebarTreeNode, includeTitle = false) => {
|
||||
const url = buildDocumentUrl(node.id);
|
||||
const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url;
|
||||
@@ -1264,9 +1291,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
setAssetMenu(null);
|
||||
mindmapIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, undefined, false, 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 () => {
|
||||
@@ -1389,60 +1416,50 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
}
|
||||
creatingDocumentUnderParentRef.current.add(creatingKey);
|
||||
try {
|
||||
const response = await fetch("/api/documents/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ parentId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "新建页面失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as SidebarTreeNode;
|
||||
const nextNode: SidebarTreeNode = {
|
||||
...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;
|
||||
const payload = (await createDocumentCommand(parentId)) as SidebarTreeNode;
|
||||
const nextNode: SidebarTreeNode = {
|
||||
...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,
|
||||
},
|
||||
};
|
||||
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}`);
|
||||
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();
|
||||
router.push(`/documents/${nextNode.id}`);
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "新建页面失败,请稍后再试");
|
||||
} finally {
|
||||
creatingDocumentUnderParentRef.current.delete(creatingKey);
|
||||
}
|
||||
@@ -1456,18 +1473,19 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
if (!title.trim()) {
|
||||
return;
|
||||
}
|
||||
await fetch("/api/documents/title", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
try {
|
||||
await renameDocumentCommand({
|
||||
documentId,
|
||||
workspaceId: sidebarData.activeWorkspaceId ?? null,
|
||||
title: title.trim(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "重命名失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
await refreshTree();
|
||||
},
|
||||
[refreshTree],
|
||||
[refreshTree, sidebarData.activeWorkspaceId],
|
||||
);
|
||||
|
||||
const moveLocalNode = useCallback((currentTree: SidebarTreeNode[], nodeId: string, parentId: string | null, index: number) => {
|
||||
@@ -1486,15 +1504,17 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
if (parentId) {
|
||||
setExpanded((prev) => new Set(prev).add(parentId));
|
||||
}
|
||||
await fetch("/api/documents/move", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
try {
|
||||
await moveDocumentCommand({
|
||||
documentId,
|
||||
parentId,
|
||||
position: index,
|
||||
}),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "移动失败,请稍后再试");
|
||||
await refreshTree();
|
||||
return;
|
||||
}
|
||||
await refreshTree();
|
||||
},
|
||||
[moveLocalNode, refreshTree, setExpanded],
|
||||
@@ -1710,14 +1730,10 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
setExpanded((prev) => new Set(prev).add(targetDocId));
|
||||
|
||||
for (let i = 0; i < topLevelDocIds.length; i += 1) {
|
||||
await fetch("/api/documents/move", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
documentId: topLevelDocIds[i],
|
||||
parentId: targetDocId,
|
||||
position: baseIndex + i,
|
||||
}),
|
||||
await moveDocumentCommand({
|
||||
documentId: topLevelDocIds[i],
|
||||
parentId: targetDocId,
|
||||
position: baseIndex + i,
|
||||
});
|
||||
}
|
||||
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) => {
|
||||
setShareTarget({
|
||||
id: node.id,
|
||||
@@ -2519,17 +2597,42 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
</button>
|
||||
{!collapsedSections.private ? (
|
||||
<div className="flex-1 px-1 pb-2">
|
||||
<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}
|
||||
{treeShellAvailable ? (
|
||||
<MnoteWebTreeShell
|
||||
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
||||
activeDocumentId={activeId}
|
||||
actorId={typeof currentUser?._id === "string" ? currentUser._id : null}
|
||||
reloadToken={treeShellReloadToken}
|
||||
onNavigate={handleNavigateFromTreeShell}
|
||||
onOpenContextMenu={openContextMenuFromTreeShell}
|
||||
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 className="px-4 pb-2 text-xs text-gray-400">已折叠</div>
|
||||
@@ -2542,23 +2645,64 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
<div
|
||||
ref={fileTreeContainerRef}
|
||||
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
|
||||
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}
|
||||
/>
|
||||
{treeShellAvailable ? (
|
||||
<MnoteWebTreeShell
|
||||
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
||||
activeDocumentId={activeId}
|
||||
actorId={typeof currentUser?._id === "string" ? currentUser._id : null}
|
||||
mode="filetree"
|
||||
reloadToken={treeShellReloadToken}
|
||||
onNavigate={handleNavigateFromTreeShell}
|
||||
onOpenAsset={(assetId) => {
|
||||
const asset = assetById.get(assetId);
|
||||
if (asset) {
|
||||
handleOpenAsset(asset);
|
||||
}
|
||||
}}
|
||||
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>
|
||||
|
||||
@@ -2,31 +2,69 @@ import { useEffect, useState } from "react";
|
||||
|
||||
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() {
|
||||
const [status, setStatus] = useState<Status>(() => {
|
||||
return "idle";
|
||||
});
|
||||
const [status, setStatus] = useState<Status>(() => cachedStatus);
|
||||
|
||||
useEffect(() => {
|
||||
let destroyed = false;
|
||||
const controller = new AbortController();
|
||||
const check = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/backend/health", { signal: controller.signal });
|
||||
const payload = (await response.json().catch(() => null)) as { status?: Status } | null;
|
||||
if (!destroyed) {
|
||||
setStatus(payload?.status ?? "error");
|
||||
}
|
||||
} catch {
|
||||
if (!destroyed) {
|
||||
setStatus("error");
|
||||
}
|
||||
const listener = (nextStatus: Status) => {
|
||||
if (!destroyed) {
|
||||
setStatus(nextStatus);
|
||||
}
|
||||
};
|
||||
check();
|
||||
listeners.add(listener);
|
||||
void ensureBackendHealthFresh().then((nextStatus) => {
|
||||
if (!destroyed) {
|
||||
setStatus(nextStatus);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
destroyed = true;
|
||||
controller.abort();
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { BacklinkRecord } from "@/types/references";
|
||||
interface UseBacklinksOptions {
|
||||
workspaceId: string | null;
|
||||
documentId: string | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface RawBacklink {
|
||||
@@ -46,7 +47,7 @@ const fetchBacklinks = async (workspaceId: string, documentId: string): Promise<
|
||||
return (payload.backlinks ?? []).map(mapBacklink);
|
||||
};
|
||||
|
||||
export function useBacklinks({ workspaceId, documentId }: UseBacklinksOptions) {
|
||||
export function useBacklinks({ workspaceId, documentId, enabled = true }: UseBacklinksOptions) {
|
||||
return useQuery({
|
||||
queryKey: ["page-backlinks", workspaceId, documentId],
|
||||
queryFn: () => {
|
||||
@@ -55,7 +56,7 @@ export function useBacklinks({ workspaceId, documentId }: UseBacklinksOptions) {
|
||||
}
|
||||
return fetchBacklinks(workspaceId, documentId);
|
||||
},
|
||||
enabled: Boolean(workspaceId && documentId),
|
||||
enabled: Boolean(enabled && workspaceId && documentId),
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,17 @@ const sidebarDatasetListQuery = ((api as unknown as Record<string, unknown>).sid
|
||||
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 的 useQuery 实现实时订阅,无需手动 refetch
|
||||
@@ -26,13 +37,8 @@ const sidebarDatasetListQuery = ((api as unknown as Record<string, unknown>).sid
|
||||
* 注意:此 hook 仅应在 Convex 模式下使用
|
||||
* 后端从 ctx.auth.getUserIdentity() 获取用户身份,前端无需传入 userId
|
||||
*/
|
||||
export function useConvexSidebarData(workspaceId: string): {
|
||||
data: SidebarInitialData | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
export function useConvexSidebarData(workspaceId: string): ConvexSidebarDataResult {
|
||||
const { isAuthenticated, isLoading: isAuthLoading } = useConvexAuth();
|
||||
|
||||
// 显式转为 boolean,确保类型正确
|
||||
const shouldFetch = Boolean(isAuthenticated && workspaceId);
|
||||
@@ -81,13 +87,22 @@ export function useConvexSidebarData(workspaceId: string): {
|
||||
// 使用 === undefined 判断,因为 skip 时返回 undefined
|
||||
const isLoading = shouldFetch && sidebarDataset === undefined;
|
||||
const error = null;
|
||||
const hasLiveSubscription = shouldFetch;
|
||||
const canUseHttpFallback = Boolean(workspaceId) && !isAuthLoading && !isAuthenticated;
|
||||
|
||||
// Convex 模式下数据自动实时同步,refetch 是空操作
|
||||
// Convex 的 useQuery 会自动实时同步,无需手动 refetch
|
||||
// 这个方法只是为了保持与 useSidebarData 的接口兼容
|
||||
// Convex 模式下数据自动实时同步,refetch 保持空操作以兼容旧接口。
|
||||
const refetch = async () => {
|
||||
// 空操作 - Convex 会自动同步数据
|
||||
};
|
||||
|
||||
return { data, isLoading, error, refetch };
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
isAuthLoading,
|
||||
isAuthenticated,
|
||||
hasLiveSubscription,
|
||||
canUseHttpFallback,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 { 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> {
|
||||
const response = await fetch(`/api/sidebar?workspaceId=${workspaceId}`, {
|
||||
@@ -16,13 +26,55 @@ async function requestSidebarData(workspaceId: string): Promise<SidebarInitialDa
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function useSidebarData(initialData: SidebarInitialData): UseQueryResult<SidebarInitialData> {
|
||||
export function useSidebarData(initialData: SidebarInitialData): SidebarDataResult {
|
||||
const workspaceId = initialData.activeWorkspaceId;
|
||||
const convexSidebar = useConvexSidebarData(workspaceId);
|
||||
const shouldUseHttpFallback =
|
||||
!convexSidebar.hasLiveSubscription && convexSidebar.canUseHttpFallback;
|
||||
|
||||
return useQuery({
|
||||
const httpQuery = useQuery({
|
||||
queryKey: ["sidebar", workspaceId],
|
||||
queryFn: () => requestSidebarData(workspaceId),
|
||||
initialData,
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -15,63 +15,6 @@ export interface DocumentNode extends DocumentRecord {
|
||||
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[] {
|
||||
const map = new Map<string, DocumentRecord>();
|
||||
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,
|
||||
},
|
||||
"移动失败,请稍后再试",
|
||||
);
|
||||
}
|
||||
@@ -136,11 +136,14 @@ export function buildSidebarTreeFromKernelProjection(input: {
|
||||
records: DocumentRecord[];
|
||||
projection: KernelSidebarProjection;
|
||||
}): SidebarTreeNode[] {
|
||||
const projectionItems = Array.isArray(input.projection?.items)
|
||||
? input.projection.items
|
||||
: [];
|
||||
const recordById = new Map(input.records.map((record) => [record.id, record]));
|
||||
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);
|
||||
nodeMap.set(item.nodeId, {
|
||||
access_scope: record?.access_scope ?? "private",
|
||||
@@ -165,7 +168,7 @@ export function buildSidebarTreeFromKernelProjection(input: {
|
||||
}
|
||||
|
||||
const roots: SidebarTreeNode[] = [];
|
||||
for (const item of input.projection.items) {
|
||||
for (const item of projectionItems) {
|
||||
const node = nodeMap.get(item.nodeId);
|
||||
if (!node) continue;
|
||||
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 RESOLVE_CACHE_TTL_MS = 30_000;
|
||||
|
||||
const DEFAULT_ONLYOFFICE_INTERNAL_URL_CANDIDATES = [
|
||||
DEFAULT_ONLYOFFICE_INTERNAL_URL,
|
||||
"http://127.0.0.1:8082",
|
||||
"http://localhost:8081",
|
||||
"http://127.0.0.1:8081",
|
||||
"http://localhost:8082",
|
||||
"http://localhost:8081",
|
||||
];
|
||||
|
||||
let cachedOnlyOfficeInternalUrl = "";
|
||||
@@ -90,4 +90,3 @@ export const resolveOnlyOfficeInternalUrl = async () => {
|
||||
pendingOnlyOfficeInternalUrl = null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,16 @@ export type MnoteRuntimeConfig = {
|
||||
onlyofficeProxyOriginWeb?: string;
|
||||
onlyofficeCallbackOriginWeb?: string;
|
||||
onlyofficeCallbackOriginDesktop?: string;
|
||||
/**
|
||||
* Rust Web 主入口,仅用于客户端渐进增强能力(例如独立 tree shell)。
|
||||
* 说明:禁止把它作为主页面 SSR 首屏依赖。
|
||||
*/
|
||||
mnoteWebBaseUrl?: string;
|
||||
/**
|
||||
* 是否启用 Rust Web tree shell 客户端增强。
|
||||
* 说明:实验壳必须显式开启,禁止仅因配置了 mnoteWebBaseUrl 就自动进入主界面链路。
|
||||
*/
|
||||
mnoteWebTreeShellEnabled?: boolean;
|
||||
/**
|
||||
* 是否为桌面端(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 => ({
|
||||
useConvex: process.env.USE_CONVEX === "1" || process.env.NEXT_PUBLIC_USE_CONVEX === "1",
|
||||
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
|
||||
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
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,
|
||||
onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE,
|
||||
onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN,
|
||||
@@ -63,9 +111,12 @@ const readFromPublicJson = (): Partial<MnoteRuntimeConfig> => {
|
||||
// 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。
|
||||
// 网页端运行时 process.cwd() 通常为 wolai-frontend。
|
||||
|
||||
const fs = require("fs") as typeof import("fs");
|
||||
|
||||
const path = require("path") as typeof import("path");
|
||||
// 说明:这里不能直接写 `require("fs")` / `require("path")`,
|
||||
// 否则客户端 bundle 在解析该模块时会把它们也当成浏览器依赖,触发持续重编译或空白页。
|
||||
// 仅在服务端运行时通过惰性 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)`,
|
||||
// 导致 process.cwd() 变成 `.next/standalone`,此时 public/mnote-env.json 位于上层目录。
|
||||
@@ -152,9 +203,15 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
|
||||
cfg.onlyofficeCallbackOriginDesktop ||
|
||||
"";
|
||||
|
||||
const mnoteWebBaseUrl = (cfg.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
|
||||
const mnoteWebTreeShellEnabled =
|
||||
parseRuntimeBoolean(cfg.mnoteWebTreeShellEnabled) ?? false;
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
isDesktop,
|
||||
mnoteWebBaseUrl,
|
||||
mnoteWebTreeShellEnabled,
|
||||
onlyofficeBaseUrl,
|
||||
onlyofficeStorageHostOverride,
|
||||
onlyofficeProxyOrigin,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { headers } from "next/headers";
|
||||
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
|
||||
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";
|
||||
|
||||
type MnoteWebSidebarCompatResponse = {
|
||||
@@ -11,12 +13,6 @@ type MnoteWebSidebarCompatResponse = {
|
||||
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) {
|
||||
const value = source.get(name);
|
||||
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-source-channel");
|
||||
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");
|
||||
|
||||
if (!forwarded.has("authorization") && !isDevAuthEnabled()) {
|
||||
@@ -51,12 +49,25 @@ async function buildForwardHeaders(request?: Request): Promise<Headers> {
|
||||
if (!forwarded.has("x-mnote-source-client")) {
|
||||
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;
|
||||
}
|
||||
|
||||
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: {
|
||||
|
||||
@@ -9,10 +9,6 @@ import {
|
||||
type SidebarDatasetListQueryResult,
|
||||
} from "@/lib/sidebar-data";
|
||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||
import {
|
||||
fetchSidebarDatasetFromMnoteWeb,
|
||||
getMnoteWebBaseUrl,
|
||||
} from "@/lib/server/mnote-web";
|
||||
|
||||
type LoadSidebarDataFromConvexInput = {
|
||||
client: ConvexHttpClient;
|
||||
@@ -61,15 +57,13 @@ export async function loadSidebarDataFromConvex(
|
||||
};
|
||||
}
|
||||
|
||||
const sidebarDataset = getMnoteWebBaseUrl()
|
||||
? (
|
||||
await fetchSidebarDatasetFromMnoteWeb({
|
||||
workspaceId: targetWorkspaceId,
|
||||
})
|
||||
).dataset
|
||||
: ((await input.client.query(sidebarDatasetListQuery, {
|
||||
workspaceId: targetWorkspaceId,
|
||||
})) as SidebarDatasetListQueryResult);
|
||||
// 说明:SSR 首屏阶段必须优先保证“可进入页面”。
|
||||
// mnote-web compat sidebar 仍处于渐进接入期,若这里优先走远端/Rust 兼容接口,
|
||||
// 一旦 actor/header 权限链不一致,就会把整个 (app) layout 阻塞住,表现为“登录后进不去页面”。
|
||||
// 因此服务端首屏一律先走稳定的 Convex 直查;mnote-web 仅允许用于客户端树壳渐进增强。
|
||||
const sidebarDataset = (await input.client.query(sidebarDatasetListQuery, {
|
||||
workspaceId: targetWorkspaceId,
|
||||
})) as SidebarDatasetListQueryResult;
|
||||
const normalizedDocuments = (sidebarDataset.documents ?? []) as DocumentRecord[];
|
||||
|
||||
return {
|
||||
|
||||
@@ -51,7 +51,8 @@ export type SidebarDatasetListQueryResult = {
|
||||
active_workspace_id: string;
|
||||
workspaces: WorkspaceSummary[];
|
||||
documents: DocumentRecord[];
|
||||
kernel_sidebar_projection: KernelSidebarProjection;
|
||||
kernel_sidebar_projection?: KernelSidebarProjection;
|
||||
kernelSidebarProjection?: KernelSidebarProjection;
|
||||
trashed_documents: SidebarInitialData["trashedDocuments"];
|
||||
media_assets: MediaAsset[];
|
||||
trashed_media_assets: MediaAsset[];
|
||||
@@ -241,14 +242,29 @@ export function buildSidebarDatasetListQueryResult(
|
||||
export function mapSidebarDatasetListQueryResultToInitialData(
|
||||
result: SidebarDatasetListQueryResult,
|
||||
): SidebarInitialData {
|
||||
// 说明:兼容旧的 Convex `sidebar.dataset.list` 返回体。
|
||||
// 若上游暂未附带 `kernel_sidebar_projection`,这里按 documents 现算一份,
|
||||
// 避免 SSR 因契约未完全切齐而直接崩掉。
|
||||
const candidateProjection =
|
||||
result.kernel_sidebar_projection ??
|
||||
result.kernelSidebarProjection ??
|
||||
null;
|
||||
const kernelSidebarProjection =
|
||||
candidateProjection &&
|
||||
typeof candidateProjection === "object" &&
|
||||
Array.isArray(candidateProjection.items) &&
|
||||
Array.isArray(candidateProjection.edges)
|
||||
? candidateProjection
|
||||
: buildKernelSidebarProjection(result.documents ?? []);
|
||||
|
||||
return {
|
||||
activeWorkspaceId: result.active_workspace_id,
|
||||
workspaces: [...result.workspaces],
|
||||
documents: [...result.documents],
|
||||
kernelSidebarProjection: result.kernel_sidebar_projection,
|
||||
kernelSidebarProjection,
|
||||
kernelSidebarTree: buildSidebarTreeFromKernelProjection({
|
||||
records: result.documents,
|
||||
projection: result.kernel_sidebar_projection,
|
||||
projection: kernelSidebarProjection,
|
||||
}),
|
||||
trashedDocuments: [...result.trashed_documents],
|
||||
trashedMediaAssets: [...result.trashed_media_assets],
|
||||
|
||||
@@ -41,5 +41,11 @@ interface EditorBridgeState {
|
||||
|
||||
export const useEditorBridgeStore = create<EditorBridgeState>((set) => ({
|
||||
bridge: null,
|
||||
registerBridge: (bridge) => set({ bridge }),
|
||||
registerBridge: (bridge) =>
|
||||
set((state) => {
|
||||
if (state.bridge === bridge) {
|
||||
return state;
|
||||
}
|
||||
return { bridge };
|
||||
}),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user