feat: 完成 rust cutover phase 8 收口
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
active
|
|
||||||
+140
-1
@@ -112,4 +112,143 @@
|
|||||||
[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] 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] 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: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-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
|
||||||
|
|||||||
+791
-13
@@ -10,7 +10,7 @@
|
|||||||
{
|
{
|
||||||
"id": "task-001",
|
"id": "task-001",
|
||||||
"title": "请参考/mnt/Data1T/mnote/design/rust-kernel-backport-plan.md完成单仓收口 Rust 内核回迁计划,这是详细checklist/mnt/Data1T/mnote/design/rust-kernel-backport-phase-checklist.md,里面已经完成了部分内容,你需要继续完成",
|
"title": "请参考/mnt/Data1T/mnote/design/rust-kernel-backport-plan.md完成单仓收口 Rust 内核回迁计划,这是详细checklist/mnt/Data1T/mnote/design/rust-kernel-backport-phase-checklist.md,里面已经完成了部分内容,你需要继续完成",
|
||||||
"status": "failed",
|
"status": "completed",
|
||||||
"priority": "P1",
|
"priority": "P1",
|
||||||
"depends_on": [],
|
"depends_on": [],
|
||||||
"attempts": 1,
|
"attempts": 1,
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"cleanup": null
|
"cleanup": null
|
||||||
},
|
},
|
||||||
"error_log": [
|
"error_log": [
|
||||||
"[CONFIG] Missing validation.command for umbrella task; execution decomposed into concrete subtasks"
|
"umbrella 任务已由 task-002..task-006 取代,相关子任务均已完成;原 failed 原因仅为缺少 validation.command。"
|
||||||
],
|
],
|
||||||
"checkpoints": [
|
"checkpoints": [
|
||||||
{
|
{
|
||||||
@@ -32,9 +32,15 @@
|
|||||||
"total": 4,
|
"total": 4,
|
||||||
"description": "完成 Rust 协议层对 documents.meta/content/save 的建模与映射,cargo test/check 已通过",
|
"description": "完成 Rust 协议层对 documents.meta/content/save 的建模与映射,cargo test/check 已通过",
|
||||||
"timestamp": "2026-04-14T05:40:04Z"
|
"timestamp": "2026-04-14T05:40:04Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-04-15T11:53:44Z",
|
||||||
|
"step": 2,
|
||||||
|
"total": 2,
|
||||||
|
"description": "umbrella 任务已由 task-002..task-006 取代,相关子任务均已完成;原 failed 原因仅为缺少 validation.command。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"completed_at": null
|
"completed_at": "2026-04-15T11:53:44Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "task-002",
|
"id": "task-002",
|
||||||
@@ -200,7 +206,7 @@
|
|||||||
{
|
{
|
||||||
"id": "task-007",
|
"id": "task-007",
|
||||||
"title": "请完成该checklist /mnt/Data1T/mnote/design/rust-kernel-backport-phase-checklist.md",
|
"title": "请完成该checklist /mnt/Data1T/mnote/design/rust-kernel-backport-phase-checklist.md",
|
||||||
"status": "failed",
|
"status": "completed",
|
||||||
"priority": "P1",
|
"priority": "P1",
|
||||||
"depends_on": [],
|
"depends_on": [],
|
||||||
"attempts": 1,
|
"attempts": 1,
|
||||||
@@ -214,7 +220,7 @@
|
|||||||
"cleanup": null
|
"cleanup": null
|
||||||
},
|
},
|
||||||
"error_log": [
|
"error_log": [
|
||||||
"[CONFIG] Missing validation.command for umbrella task; execution decomposed into task-008..task-015"
|
"umbrella 任务已由 task-008..task-015 取代,相关子任务均已完成;原 failed 原因仅为缺少 validation.command。"
|
||||||
],
|
],
|
||||||
"checkpoints": [
|
"checkpoints": [
|
||||||
{
|
{
|
||||||
@@ -222,9 +228,15 @@
|
|||||||
"total": 2,
|
"total": 2,
|
||||||
"description": "确认该 checklist 为 umbrella 任务,拆分为单仓口径、Phase D 边界、Sidebar Rust query 目标、发布前源码审计等可验证子任务",
|
"description": "确认该 checklist 为 umbrella 任务,拆分为单仓口径、Phase D 边界、Sidebar Rust query 目标、发布前源码审计等可验证子任务",
|
||||||
"timestamp": "2026-04-14T07:25:50Z"
|
"timestamp": "2026-04-14T07:25:50Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-04-15T11:53:44Z",
|
||||||
|
"step": 2,
|
||||||
|
"total": 2,
|
||||||
|
"description": "umbrella 任务已由 task-008..task-015 取代,相关子任务均已完成;原 failed 原因仅为缺少 validation.command。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"completed_at": null
|
"completed_at": "2026-04-15T11:53:44Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "task-008",
|
"id": "task-008",
|
||||||
@@ -669,7 +681,7 @@
|
|||||||
{
|
{
|
||||||
"id": "task-022",
|
"id": "task-022",
|
||||||
"title": "补 OnlyOffice 真实页面打开与回调链回归,确认签名、代理、callback、forcesave 与静态资源在浏览器层可用,并把发布前 checklist 对应项收口",
|
"title": "补 OnlyOffice 真实页面打开与回调链回归,确认签名、代理、callback、forcesave 与静态资源在浏览器层可用,并把发布前 checklist 对应项收口",
|
||||||
"status": "failed",
|
"status": "completed",
|
||||||
"priority": "P1",
|
"priority": "P1",
|
||||||
"depends_on": [
|
"depends_on": [
|
||||||
"task-017"
|
"task-017"
|
||||||
@@ -685,7 +697,7 @@
|
|||||||
"cleanup": null
|
"cleanup": null
|
||||||
},
|
},
|
||||||
"error_log": [
|
"error_log": [
|
||||||
"[CONFIG] 用户改为先升级 ONLYOFFICE,再执行重连与真实回归;原 task-022 已拆分为 task-023/task-024"
|
"OnlyOffice 回归已由 task-023/task-024 取代并完成;原 failed 原因仅为用户调整顺序,先升级再重连。"
|
||||||
],
|
],
|
||||||
"checkpoints": [
|
"checkpoints": [
|
||||||
{
|
{
|
||||||
@@ -693,9 +705,15 @@
|
|||||||
"total": 2,
|
"total": 2,
|
||||||
"description": "已确认当前 9.2.1 文档服务可打开,但自定义插件桥与真实回归仍未稳定;按用户要求改为先升级 ONLYOFFICE。",
|
"description": "已确认当前 9.2.1 文档服务可打开,但自定义插件桥与真实回归仍未稳定;按用户要求改为先升级 ONLYOFFICE。",
|
||||||
"timestamp": "2026-04-14T18:08:23Z"
|
"timestamp": "2026-04-14T18:08:23Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-04-15T11:53:44Z",
|
||||||
|
"step": 2,
|
||||||
|
"total": 2,
|
||||||
|
"description": "OnlyOffice 回归已由 task-023/task-024 取代并完成;原 failed 原因仅为用户调整顺序,先升级再重连。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"completed_at": null
|
"completed_at": "2026-04-15T11:53:44Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "task-023",
|
"id": "task-023",
|
||||||
@@ -770,7 +788,7 @@
|
|||||||
{
|
{
|
||||||
"id": "task-025",
|
"id": "task-025",
|
||||||
"title": "把 OnlyOffice callback 附件写回链接到统一 bridge/Rust 协议边界:补最小 media asset writeback command、storage-convex-bridge 映射与 Next route 执行器,替换 callback 里的直连 Convex mutation",
|
"title": "把 OnlyOffice callback 附件写回链接到统一 bridge/Rust 协议边界:补最小 media asset writeback command、storage-convex-bridge 映射与 Next route 执行器,替换 callback 里的直连 Convex mutation",
|
||||||
"status": "in_progress",
|
"status": "completed",
|
||||||
"priority": "P0",
|
"priority": "P0",
|
||||||
"depends_on": [
|
"depends_on": [
|
||||||
"task-024"
|
"task-024"
|
||||||
@@ -792,11 +810,771 @@
|
|||||||
"total": 3,
|
"total": 3,
|
||||||
"description": "已在升级后的 9.3.1 基线下继续推进 Rust 接缝,当前锁定 callback 附件写回链,因为这条链仍直接调用 Convex mediaAssets mutation。",
|
"description": "已在升级后的 9.3.1 基线下继续推进 Rust 接缝,当前锁定 callback 附件写回链,因为这条链仍直接调用 Convex mediaAssets mutation。",
|
||||||
"timestamp": "2026-04-14T18:43:06Z"
|
"timestamp": "2026-04-14T18:43:06Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"total": 3,
|
||||||
|
"description": "已补 media.assets.replace_storage 最小命令、ReplaceMediaAssetStorage 协议对象、storage-convex-bridge 映射,以及 OnlyOffice callback 的显式 actor bridge context 与写回执行器。",
|
||||||
|
"timestamp": "2026-04-14T20:07:16Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 3,
|
||||||
|
"total": 3,
|
||||||
|
"description": "已通过 Rust 单测、bridge/callback route 定向测试、定向 eslint 与真实 scripts/task022-onlyoffice-ui-regression.js;期间通过重新注册 Convex functions 修复了 auth:signIn 缺失导致的登录页卡死。",
|
||||||
|
"timestamp": "2026-04-14T20:07:16Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"completed_at": null
|
"completed_at": "2026-04-14T20:07:16Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-026",
|
||||||
|
"title": "基于 design/rust-kernel-replacement-roadmap-v1.md 建立真实 Rust bridge/runtime 目录与最小执行样板,让至少一条读链和两条写链从 Web route 真实进入 Rust 执行器",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P0",
|
||||||
|
"depends_on": [
|
||||||
|
"task-025"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "de35d423",
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml && cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/api/documents/content/route.ts src/app/api/documents/title/route.ts src/app/api/documents/save/route.ts src/lib/documents/bridge.ts",
|
||||||
|
"timeout_seconds": 2400
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 3,
|
||||||
|
"description": "初始化 rust/bridge/ 目录与最小 runtime executor,不再只在 TypeScript 中模拟 Rust request",
|
||||||
|
"timestamp": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"total": 3,
|
||||||
|
"description": "优先打通 documents.content、documents.title、documents.save 的 Web -> Rust -> Convex 样板链",
|
||||||
|
"timestamp": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 3,
|
||||||
|
"total": 3,
|
||||||
|
"description": "补对应 smoke/单测,并在路线图与 checklist 中记录真实 Rust 执行入口已建立",
|
||||||
|
"timestamp": null
|
||||||
|
},
|
||||||
|
"已确定 task-026 最小样板边界:新增 Rust bridge runtime 二进制,先接管 documents.content.get、documents.title.update、documents.save 的执行计划与参数映射,TS 仅保留 Convex transport。",
|
||||||
|
"已完成 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 烟测均通过。"
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-14T23:32:53Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-027",
|
||||||
|
"title": "把 mnote-cli 并入主仓 Rust workspace,先冻结 page/block/search/sidebar/tool 的最小命令面与 --json 输出协议",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P0",
|
||||||
|
"depends_on": [
|
||||||
|
"task-026"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "de35d423",
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo check --manifest-path rust/Cargo.toml && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml -p mnote-cli",
|
||||||
|
"timeout_seconds": 2400
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 3,
|
||||||
|
"description": "新增 rust/crates/mnote-cli 并接入 workspace members",
|
||||||
|
"timestamp": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"total": 3,
|
||||||
|
"description": "冻结 page/block/search/sidebar/tool 五类最小命令面与 --json 输出协议",
|
||||||
|
"timestamp": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 3,
|
||||||
|
"total": 3,
|
||||||
|
"description": "补 CLI smoke,确保 CLI 与 Web 共享同一 Rust 执行面而不是第二套实现",
|
||||||
|
"timestamp": null
|
||||||
|
},
|
||||||
|
"已并入 mnote-cli:新增主仓 workspace crate,冻结 page/block/search/sidebar/tool 五类最小命令面与 --json 输出协议,cargo check/test -p mnote-cli 通过,workspace 全量 cargo test 通过,CLI page get --json 烟测输出稳定。"
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-14T23:49:31Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-028",
|
||||||
|
"title": "完成页面系统 Rust 化第一批:把 documents.create/move/delete/restore/duplicate/copy-tree 收口到 Rust command,Next route 仅保留 transport 包装",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P0",
|
||||||
|
"depends_on": [
|
||||||
|
"task-026",
|
||||||
|
"task-027"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "de35d423",
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml && cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/api/documents/create/route.ts src/app/api/documents/move/route.ts src/app/api/documents/delete/route.ts src/app/api/documents/restore/route.ts src/app/api/documents/duplicate/route.ts src/app/api/documents/copy-tree/route.ts",
|
||||||
|
"timeout_seconds": 3000
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 3,
|
||||||
|
"description": "为页面生命周期操作补齐 Rust domain model、command、storage-convex-bridge 映射",
|
||||||
|
"timestamp": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"total": 3,
|
||||||
|
"description": "让 create/move/delete/restore/duplicate/copy-tree 进入统一 Rust command 执行链",
|
||||||
|
"timestamp": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 3,
|
||||||
|
"total": 3,
|
||||||
|
"description": "补页面树与回收站回归,并标注可退役的旧 TS 业务逻辑",
|
||||||
|
"timestamp": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 4,
|
||||||
|
"total": 4,
|
||||||
|
"description": "已把 documents.create/move/delete/restore/duplicate/copy-tree 收口到 Rust command + page adapter;Convex functions 刷新、cargo test、bridge vitest 与定向 eslint(0 error)已通过",
|
||||||
|
"timestamp": "2026-04-15T01:36:12Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"session_count": 9,
|
"completed_at": "2026-04-15T01:36:12Z"
|
||||||
"last_session": "2026-04-14T18:08:23Z"
|
},
|
||||||
|
{
|
||||||
|
"id": "task-029",
|
||||||
|
"title": "完成块系统 Rust 化第一批:把 blocks.get/patch/move/embed 与 AI 文档写入工具统一到 Rust block ops 协议",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P0",
|
||||||
|
"depends_on": [
|
||||||
|
"task-026",
|
||||||
|
"task-027"
|
||||||
|
],
|
||||||
|
"attempts": 0,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "de35d423",
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml && cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/api/blocks/get/route.ts src/app/api/blocks/patch/route.ts src/app/api/blocks/move/route.ts src/app/api/blocks/embed/route.ts src/components/editor/blocknote-editor.tsx src/components/editor/DocumentAiAgentPanel.tsx",
|
||||||
|
"timeout_seconds": 3000
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 3,
|
||||||
|
"description": "已补 blocks.get/patch/move/embed 的 Rust query/command 契约,并让前端 blocks route 统一经 block adapter + Rust plan 进入保存链",
|
||||||
|
"timestamp": "2026-04-15T02:51:38Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"total": 3,
|
||||||
|
"description": "AI 文档写入工具已改为共享块快照 adapter;doc_insert_blocks/doc_replace_range 不再直连旧 saveBlocks 路径",
|
||||||
|
"timestamp": "2026-04-15T02:51:38Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 3,
|
||||||
|
"total": 3,
|
||||||
|
"description": "已通过全量 cargo test 与 task-029 指定 eslint 验证;BlockNote/AI 面仅保留交互和快照替换职责",
|
||||||
|
"timestamp": "2026-04-15T02:51:38Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"total": 3,
|
||||||
|
"description": "AI 文档写入工具改为复用纯 block ops 适配层,不再依赖长期的 loadBlocks/saveBlocks 直连路径",
|
||||||
|
"timestamp": "2026-04-15T02:17:43Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-15T02:51:38Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-030",
|
||||||
|
"title": "把 search.documents/search.recent/sidebar.dataset.list 收口到 Rust query/index-fts 主链,停止在 TS route 中持有召回、排序和 snippet 逻辑",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P1",
|
||||||
|
"depends_on": [
|
||||||
|
"task-026",
|
||||||
|
"task-027"
|
||||||
|
],
|
||||||
|
"attempts": 0,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "de35d423",
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml -p index-fts -p storage-convex-bridge && cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/api/search/documents/route.ts src/app/api/search/recent/route.ts src/app/api/sidebar/route.ts src/lib/server/sidebar-data.ts",
|
||||||
|
"timeout_seconds": 3000
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 3,
|
||||||
|
"description": "已让 search.documents、search.recent、sidebar.dataset.list 进入 Rust query/runtime 主链,route 不再持有核心 ranking/snippet 逻辑",
|
||||||
|
"timestamp": "2026-04-15T03:55:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"total": 3,
|
||||||
|
"description": "已把 index-fts 接入产品搜索主路径,新增 evaluate_search_documents 负责召回合并、高亮 snippet 与 OCR 待补队列决策",
|
||||||
|
"timestamp": "2026-04-15T03:55:30Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 3,
|
||||||
|
"total": 3,
|
||||||
|
"description": "已通过 cargo test -p index-fts -p storage-convex-bridge -p bridge-runtime 与 task-030 指定 eslint;tsc 失败仍为仓库既有 Convex/类型问题,非本次新增",
|
||||||
|
"timestamp": "2026-04-15T03:56:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-15T03:56:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-031",
|
||||||
|
"title": "为 Rust 内核建立统一 Tool 协议与注册表,让 CLI 与 AI 共享 doc_get/doc_find/doc_insert_blocks/doc_replace_range 等一组同源工具",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P0",
|
||||||
|
"depends_on": [
|
||||||
|
"task-027",
|
||||||
|
"task-029"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "de35d423",
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml && cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/api/ai-agent/run/route.ts src/components/editor/DocumentAiAgentPanel.tsx",
|
||||||
|
"timeout_seconds": 3000
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 3,
|
||||||
|
"description": "在 Rust 侧建立 Tool protocol、tool registry 与统一错误码/审计模型",
|
||||||
|
"timestamp": "2026-04-15T05:58:48Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"total": 3,
|
||||||
|
"description": "让 AI 与 CLI 共用同一批文档工具,不再依赖前端私有工具注册表作为写入真入口",
|
||||||
|
"timestamp": "2026-04-15T05:58:48Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 3,
|
||||||
|
"total": 3,
|
||||||
|
"description": "补 dry-run、validate-only、explain-plan 等安全模式并验证 trace/event 可回查",
|
||||||
|
"timestamp": "2026-04-15T05:58:48Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-15T06:01:57Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-032",
|
||||||
|
"title": "并入 adapter-mindmap,形成可被 Web/CLI/AI 共用的 Rust mindmap ops 协议与执行层",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P1",
|
||||||
|
"depends_on": [
|
||||||
|
"task-029",
|
||||||
|
"task-031"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "de35d423",
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml && cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/api/mindmap/[docId]/route.ts src/app/api/mindmap/[docId]/[mindmapId]/route.ts src/lib/mindmap/mindmapOps.ts src/components/editor/blocks/MindmapBlock.tsx",
|
||||||
|
"timeout_seconds": 3000
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 3,
|
||||||
|
"description": "把 adapter-mindmap 并入主仓并冻结节点树、节点引用、节点操作日志协议",
|
||||||
|
"timestamp": "2026-04-15T07:15:09Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"total": 3,
|
||||||
|
"description": "让 Mindmap 的 get/put/op 可由 CLI 与 AI 直接调用",
|
||||||
|
"timestamp": "2026-04-15T07:21:09Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 3,
|
||||||
|
"total": 3,
|
||||||
|
"description": "补 Mindmap 浏览器回归和 CLI smoke,确认前端形态不变而执行层已切换",
|
||||||
|
"timestamp": "$TS"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "$TS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-033",
|
||||||
|
"title": "并入 adapter-onlyoffice,把 sign/proxy/callback/forcesave/session 收口到 Rust 对象适配层,OnlyOffice 前端页只保留 transport 与展示职责",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P1",
|
||||||
|
"depends_on": [
|
||||||
|
"task-025",
|
||||||
|
"task-031"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "de35d423",
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml && cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/api/onlyoffice/sign/route.ts src/app/api/onlyoffice/proxy/route.ts src/app/api/onlyoffice/callback/route.ts src/app/api/onlyoffice/forcesave/route.ts src/app/onlyoffice/OnlyOfficeClientPage.tsx && node /mnt/Data1T/mnote/scripts/task022-onlyoffice-ui-regression.js",
|
||||||
|
"timeout_seconds": 3600
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 3,
|
||||||
|
"description": "已并入 adapter-onlyoffice,补齐 asset/session/sign/proxy/callback/forcesave 的 Rust 对象边界与 tool registry",
|
||||||
|
"timestamp": "2026-04-15T08:02:28Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"total": 3,
|
||||||
|
"description": "OnlyOffice sign/proxy/callback/forcesave route 已收口为共享 adapter + Rust tool 包装,前端页只保留展示与 transport 组装",
|
||||||
|
"timestamp": "2026-04-15T08:02:28Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 3,
|
||||||
|
"total": 3,
|
||||||
|
"description": "已通过 Rust workspace 测试、OnlyOffice 定向 vitest/eslint 与真实 task022 浏览器回归;并修正 mnote-env.json 与回归脚本默认端口到 3000",
|
||||||
|
"timestamp": "2026-04-15T08:02:28Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-15T08:02:28Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-034",
|
||||||
|
"title": "建立 Rust 内核统一观测面:补全 command/event/request/trace 的全域落账、幂等、冲突、回放和索引重建命令,作为 CLI 与 AI 稳定运行前置条件",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P0",
|
||||||
|
"depends_on": [
|
||||||
|
"task-026",
|
||||||
|
"task-027",
|
||||||
|
"task-031"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "de35d423",
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml && cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/api/bridge/request/route.ts src/app/api/bridge/trace/route.ts src/lib/documents/bridge-log.ts",
|
||||||
|
"timeout_seconds": 3000
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 3,
|
||||||
|
"description": "已统一 bridge request/trace/command 的 Rust query/tool 协议,并让 /api/bridge/request 与 /api/bridge/trace 切到 Rust runtime transport 主链",
|
||||||
|
"timestamp": "2026-04-15T08:40:21Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"total": 3,
|
||||||
|
"description": "已补 event_replay 与 index_rebuild 恢复命令,bridge-runtime 可直接输出回放/重建摘要,cargo test 与 bridge 定向 eslint 已通过",
|
||||||
|
"timestamp": "2026-04-15T08:40:21Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 3,
|
||||||
|
"total": 3,
|
||||||
|
"description": "已同步路线图、missing-targets、phase checklist 与 core 设计文档,把统一观测面写成 Rust cutover 的硬前置条件",
|
||||||
|
"timestamp": "2026-04-15T08:40:21Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-15T08:40:21Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-035",
|
||||||
|
"title": "制定并执行旧 TS 执行面退役清单:按路线图阶段标记哪些 route 仅剩 transport,哪些业务逻辑允许删除,并形成 Rust 成为唯一业务执行平面的最终 cutover 文档",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P0",
|
||||||
|
"depends_on": [
|
||||||
|
"task-028",
|
||||||
|
"task-029",
|
||||||
|
"task-030",
|
||||||
|
"task-031",
|
||||||
|
"task-032",
|
||||||
|
"task-033",
|
||||||
|
"task-034"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "de35d423",
|
||||||
|
"validation": {
|
||||||
|
"command": "python3 - <<'PY'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote')\nroadmap = (root/'design/rust-kernel-replacement-roadmap-v1.md').read_text(encoding='utf-8')\nmissing = (root/'design/rust-kernel-missing-targets.md').read_text(encoding='utf-8')\nassert 'Phase 8:旧内核退役与总割接' in roadmap\nassert '仍缺一条“从旧内核切换到新内核”的明确割接路线' in missing\nprint('task-035-planned')\nPY",
|
||||||
|
"timeout_seconds": 120
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 3,
|
||||||
|
"description": "已按能力域盘点主仓 route,明确页面、块、查询、AI、Mindmap、OnlyOffice 分别属于 Rust owner、transport keep 或旧 TS 兼容面",
|
||||||
|
"timestamp": "2026-04-15T09:01:04Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"total": 3,
|
||||||
|
"description": "已补最终 cutover 文档,写清 Phase 8 四组 gate、允许删除与必须保留的 Web 壳,以及旧 TS 执行面退役顺序",
|
||||||
|
"timestamp": "2026-04-15T09:01:04Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 3,
|
||||||
|
"total": 3,
|
||||||
|
"description": "已同步 roadmap、missing-targets、phase checklist 与 release audit,并通过 task-035 验证脚本",
|
||||||
|
"timestamp": "2026-04-15T09:01:04Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-15T09:01:04Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-036",
|
||||||
|
"title": "页面兼容旧面第一批 Rust 化:把 documents/create-child、documents/embed、documents/template 收口到统一 page command/query adapter,route 仅保留 transport",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P0",
|
||||||
|
"depends_on": [
|
||||||
|
"task-035"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "de35d423",
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml -p bridge-runtime -p storage-convex-bridge && cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/api/documents/create-child/route.ts src/app/api/documents/embed/route.ts src/app/api/documents/template/route.ts src/lib/documents/page-command-adapter.ts src/lib/documents/page-lifecycle-command-adapter.ts",
|
||||||
|
"timeout_seconds": 3000
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 2,
|
||||||
|
"description": "已把 documents/create-child、documents/embed、documents/template 收口到统一 page command adapter,route 仅保留 transport 转发。",
|
||||||
|
"timestamp": "2026-04-15T11:03:14Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"total": 2,
|
||||||
|
"description": "已通过 route-adapters/bridge 定向测试与 task-036 指定 cargo+eslint 验证;当前业务规则已集中到 page-command-adapter。",
|
||||||
|
"timestamp": "2026-04-15T11:03:14Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-15T11:03:14Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-037",
|
||||||
|
"title": "页面兼容旧面第二批 Rust 化:把 documents/empty-trash、documents/purge 收口到 Rust lifecycle command,并切断 route 内直连 Convex 业务逻辑",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P0",
|
||||||
|
"depends_on": [
|
||||||
|
"task-036"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "de35d423",
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml -p bridge-runtime -p storage-convex-bridge && cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/api/documents/empty-trash/route.ts src/app/api/documents/purge/route.ts src/lib/documents/page-lifecycle-command-adapter.ts src/lib/documents/bridge-log.ts",
|
||||||
|
"timeout_seconds": 3000
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 2,
|
||||||
|
"description": "已把 documents/empty-trash、documents/purge 收口到统一 page command adapter,并补统一失败落账入口。",
|
||||||
|
"timestamp": "2026-04-15T11:03:14Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"total": 2,
|
||||||
|
"description": "已通过 task-037 指定 cargo+eslint 验证;页面旧面第二批已降级为 transport route + adapter 执行。",
|
||||||
|
"timestamp": "2026-04-15T11:03:14Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-15T11:03:14Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-038",
|
||||||
|
"title": "Mindmap 剩余旧面收口:把 mindmap-trash/empty 与 mindmap-ai/* 迁到 Rust mindmap adapter/tool,明确 route 只做产品编排而不是对象规则真入口",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P0",
|
||||||
|
"depends_on": [
|
||||||
|
"task-035"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "de35d423",
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml -p core-protocol -p bridge-runtime -p storage-convex-bridge && cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/api/mindmap-trash/empty/route.ts src/app/api/mindmap-ai/agent/route.ts src/app/api/mindmap-ai/assets/route.ts src/app/api/mindmap-ai/expand-node/route.ts src/app/api/mindmap-ai/outline-to-mindmap/route.ts src/lib/mindmap/mindmapOps.ts",
|
||||||
|
"timeout_seconds": 3000
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 2,
|
||||||
|
"description": "已确认 mindmap-trash/empty 与 mindmap-ai 主路径接入 Rust tool/runtime:mindmap_empty_trash、mindmap_apply_ops 等工具已被 route 调用。",
|
||||||
|
"timestamp": "2026-04-15T11:06:05Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"total": 2,
|
||||||
|
"description": "已通过 task-038 指定 cargo test 与 eslint;保留仓库既有 unused/no-explicit-any warnings,但无新 error。",
|
||||||
|
"timestamp": "2026-04-15T11:06:05Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-15T11:06:05Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-039",
|
||||||
|
"title": "建立 AI 工具 cutover 矩阵:盘点 builtins/**、ai-agent/run 余下工具与 Rust Tool registry 的一一对应,并冻结迁移顺序与删除清单",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P0",
|
||||||
|
"depends_on": [
|
||||||
|
"task-035"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "de35d423",
|
||||||
|
"validation": {
|
||||||
|
"command": "python3 - <<'PY'\nfrom pathlib import Path\ntext = Path('/mnt/Data1T/mnote/design/ai-tool-cutover-matrix.md').read_text(encoding='utf-8')\nfor needle in ['docsServerTools', 'mediaServerTools', 'mindmapServerTools', 'onlyofficeServerTools', 'registryBuiltins', 'ai-agent/run']:\n assert needle in text, needle\nprint('task-039-ok')\nPY",
|
||||||
|
"timeout_seconds": 120
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 2,
|
||||||
|
"description": "已新增 ai-tool-cutover-matrix,覆盖 docsServerTools、mediaServerTools、mindmapServerTools、onlyofficeServerTools、registryBuiltins 与 ai-agent/run 的一一对应矩阵。",
|
||||||
|
"timestamp": "2026-04-15T11:03:14Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"total": 2,
|
||||||
|
"description": "已补 search_web/image_read/slash_run 的 Rust tool registry 对照,并通过 task-039 文档校验。",
|
||||||
|
"timestamp": "2026-04-15T11:03:14Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-15T11:03:14Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-040",
|
||||||
|
"title": "迁移 AI 非 doc_* 核心工具到 Rust Tool runtime,收缩 ai-agent/run 与 builtins/** 私有真入口,只保留 client-tool-result 这类 transport 壳",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P0",
|
||||||
|
"depends_on": [
|
||||||
|
"task-039"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": null,
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml -p bridge-runtime -p mnote-cli && cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/api/ai-agent/run/route.ts src/app/api/ai-agent/client-tool-result/route.ts src/lib/ai-agent/tools/builtins/registryBuiltins.ts src/lib/ai-agent/tools/builtins/media/mediaServerTools.ts src/lib/ai-agent/tools/builtins/searchWeb.ts src/lib/ai-agent/tools/builtins/slash/slashServerTools.ts src/lib/ai-agent/tools/builtins/onlyoffice/onlyofficeServerTools.ts",
|
||||||
|
"timeout_seconds": 3600
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"at": "2026-04-15T11:32:44Z",
|
||||||
|
"description": "已补 bridge-runtime 对 search_web/image_read/slash_run 的 tool plan 与 slash 结果解析,ai-agent/run 不再保留 search_web 的 TS fallback,media/slash builtins 只剩 transport helper。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "2026-04-15T11:32:44Z",
|
||||||
|
"description": "已通过 cargo test -p bridge-runtime -p mnote-cli 与 task-040 指定 eslint;仅剩仓库既有 any/unused warnings。"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-15T11:32:44Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-041",
|
||||||
|
"title": "补齐统一观测面 workspace 总览、分页与按对象/状态过滤,让 bridge 查询成为 cutover 阶段的正式运营与回查入口",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P1",
|
||||||
|
"depends_on": [
|
||||||
|
"task-035"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "de35d423",
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml -p bridge-runtime -p mnote-cli && cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/api/bridge/request/route.ts src/app/api/bridge/trace/route.ts src/lib/documents/bridge-log.ts",
|
||||||
|
"timeout_seconds": 3000
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 2,
|
||||||
|
"description": "已补 bridge.workspace.overview 观测链路:Convex bridgeLogs 新增 workspace 总览、分页与按状态/对象过滤,Rust runtime 与 Next route 已连通。",
|
||||||
|
"timestamp": "2026-04-15T10:50:15Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"total": 2,
|
||||||
|
"description": "已通过 cargo test -p bridge-runtime -p storage-convex-bridge、bridge.test.ts 与定向 eslint;仅保留仓库既有 no-explicit-any warnings。",
|
||||||
|
"timestamp": "2026-04-15T10:50:15Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-15T10:50:15Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-042",
|
||||||
|
"title": "把失败态、冲突态与补偿态统一落账到 bridge-log/runtime,覆盖页面主写链与 AI、Mindmap 写链,完成 cutover 所需状态语义闭环",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P0",
|
||||||
|
"depends_on": [
|
||||||
|
"task-037",
|
||||||
|
"task-038",
|
||||||
|
"task-040",
|
||||||
|
"task-041"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": null,
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml -p bridge-runtime -p storage-convex-bridge && cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/lib/documents/bridge.test.ts && pnpm exec eslint src/lib/documents/bridge-log.ts src/lib/documents/page-command-adapter.ts src/lib/documents/page-lifecycle-command-adapter.ts src/app/api/ai-agent/run/route.ts src/lib/mindmap/mindmapOps.ts",
|
||||||
|
"timeout_seconds": 3600
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"at": "2026-04-15T11:32:44Z",
|
||||||
|
"description": "已补 bridge-log 失败/补偿状态语义、metadata command 失败落账,以及 AI/Mindmap 写链的统一失败态记录;mindmap 本地 ops 冲突不再伪装成功。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "2026-04-15T11:32:44Z",
|
||||||
|
"description": "已通过 cargo test -p bridge-runtime -p storage-convex-bridge、src/lib/documents/bridge.test.ts 与 task-042 指定 eslint;仅剩仓库既有 any/unused warnings。"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-15T11:32:44Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-043",
|
||||||
|
"title": "按 cutover 文档执行第一批 TS_LEGACY_DELETE:先停写并切调用方,再删除已被 Rust 替代的 builtins 服务端真入口与过渡 helper",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P1",
|
||||||
|
"depends_on": [
|
||||||
|
"task-037",
|
||||||
|
"task-038",
|
||||||
|
"task-040"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": null,
|
||||||
|
"validation": {
|
||||||
|
"command": "python3 - <<'PY'\nfrom pathlib import Path\ntext = Path('/mnt/Data1T/mnote/design/rust-kernel-legacy-delete-list.md').read_text(encoding='utf-8')\nfor needle in ['documents/create-child', 'documents/embed', 'documents/empty-trash', 'documents/purge', 'documents/template', 'mindmap-trash/empty', 'builtins/**']:\n assert needle in text, needle\nprint('task-043-ok')\nPY && cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/api/ai-agent/run/route.ts src/app/api/mindmap-ai/agent/route.ts src/app/api/documents/create-child/route.ts src/app/api/documents/purge/route.ts",
|
||||||
|
"timeout_seconds": 1200
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"at": "2026-04-15T11:32:44Z",
|
||||||
|
"description": "已新增 rust-kernel-legacy-delete-list.md,冻结第一批 TS_LEGACY_DELETE 清单:documents/create-child/embed/empty-trash/purge/template、mindmap-trash/empty、builtins/**。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "2026-04-15T11:32:44Z",
|
||||||
|
"description": "已通过 task-043 文档校验与指定 eslint;仅剩仓库既有 any/unused warnings。"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-15T11:32:44Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-044",
|
||||||
|
"title": "完成 Phase 8 cutover 最终验证与文档回写,确认剩余 Web route 全部降级为 TS_TRANSPORT_KEEP 或 Rust owner,并形成可对外宣布的统一口径",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P0",
|
||||||
|
"depends_on": [
|
||||||
|
"task-037",
|
||||||
|
"task-038",
|
||||||
|
"task-040",
|
||||||
|
"task-041",
|
||||||
|
"task-042",
|
||||||
|
"task-043"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": null,
|
||||||
|
"validation": {
|
||||||
|
"command": "python3 - <<'PY'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote')\ncutover = (root/'design/rust-kernel-cutover-v1.md').read_text(encoding='utf-8')\naudit = (root/'design/release-readiness-source-audit.md').read_text(encoding='utf-8')\nchecklist = (root/'design/rust-kernel-backport-phase-checklist.md').read_text(encoding='utf-8')\nassert 'Rust 已成为 mnote 的唯一业务执行平面' in cutover\nassert 'TS_TRANSPORT_KEEP' in cutover\nassert 'Phase 8' in checklist\nassert 'release readiness' in audit.lower() or 'source audit' in audit.lower()\nprint('task-044-ok')\nPY",
|
||||||
|
"timeout_seconds": 120
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"at": "2026-04-15T11:32:44Z",
|
||||||
|
"description": "已回写 cutover/release-audit/phase-checklist/missing-targets,补齐 Phase 8 最终口径、TS_TRANSPORT_KEEP/TS_LEGACY_DELETE 边界与可对外宣布文案。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "2026-04-15T11:32:44Z",
|
||||||
|
"description": "已通过 task-044 文档校验脚本。"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-15T11:32:44Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"session_count": 21,
|
||||||
|
"last_session": "2026-04-15T11:53:44Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
name: mnote-convex
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
# 说明:官方自托管镜像。需要时可将 :latest 固定为特定版本。
|
# 说明:官方自托管镜像。需要时可将 :latest 固定为特定版本。
|
||||||
|
|||||||
Generated
+1662
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,11 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
|
"crates/adapter-onlyoffice",
|
||||||
|
"crates/bridge-runtime",
|
||||||
"crates/core-domain",
|
"crates/core-domain",
|
||||||
"crates/core-protocol",
|
"crates/core-protocol",
|
||||||
"crates/event-log",
|
"crates/event-log",
|
||||||
|
"crates/mnote-cli",
|
||||||
"crates/storage-convex-bridge",
|
"crates/storage-convex-bridge",
|
||||||
"crates/index-fts",
|
"crates/index-fts",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Rust Bridge Runtime
|
||||||
|
|
||||||
|
当前目录用于承载 Phase 1 的真实 Rust bridge/runtime 说明。
|
||||||
|
|
||||||
|
当前最小样板已经落到 workspace crate `rust/crates/bridge-runtime/`,职责是:
|
||||||
|
|
||||||
|
- 接收 Web 侧传入的 bridge context 与 command/query envelope
|
||||||
|
- 在 Rust 内完成协议校验、命令/查询映射与 Convex transport args 生成
|
||||||
|
- 把执行计划返回给 Next route,由 TS 仅负责 transport 调用 Convex
|
||||||
|
|
||||||
|
当前已接管的最小链路:
|
||||||
|
|
||||||
|
- `documents.content.get`
|
||||||
|
- `documents.title.update`
|
||||||
|
- `documents.save`
|
||||||
|
|
||||||
|
当前刻意保留在 TypeScript 的内容:
|
||||||
|
|
||||||
|
- Convex HTTP client 调用
|
||||||
|
- Next route 的认证、HTTP transport 与错误转译
|
||||||
|
- 现有 bridge log 写回
|
||||||
|
|
||||||
|
这样做的目的不是长期保留双层执行,而是先让 Web 真实进入 Rust 执行器,再在后续阶段继续把 transport 之外的剩余业务域收口到 Rust。
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "adapter-onlyoffice"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
base64 = "0.22"
|
||||||
|
hmac = "0.12"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
url = "2"
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use sha2::Sha256;
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OnlyOfficeAssetLocator {
|
||||||
|
pub asset_id: String,
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
pub document_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OnlyOfficeSessionRef {
|
||||||
|
pub session_id: String,
|
||||||
|
pub asset_id: String,
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
pub document_id: Option<String>,
|
||||||
|
pub user_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OnlyOfficeSignTokens {
|
||||||
|
pub token: Option<String>,
|
||||||
|
pub document_token: Option<String>,
|
||||||
|
pub editor_config_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OnlyOfficeHeader {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OnlyOfficeProxyPreparationInput {
|
||||||
|
pub encoded_url: String,
|
||||||
|
pub method: String,
|
||||||
|
pub range: Option<String>,
|
||||||
|
pub supabase_url: Option<String>,
|
||||||
|
pub supabase_internal_url: Option<String>,
|
||||||
|
pub onlyoffice_storage_host_override: Option<String>,
|
||||||
|
pub convex_origin: Option<String>,
|
||||||
|
pub supabase_anon_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OnlyOfficeProxyPreparation {
|
||||||
|
pub target_url: String,
|
||||||
|
pub forward_headers: Vec<OnlyOfficeHeader>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OnlyOfficeCallbackPreparationInput {
|
||||||
|
pub asset_id: String,
|
||||||
|
pub document_id: Option<String>,
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
pub user_id: Option<String>,
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
pub status: i64,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub key: Option<String>,
|
||||||
|
pub onlyoffice_internal_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OnlyOfficeCallbackPreparation {
|
||||||
|
pub should_write: bool,
|
||||||
|
pub download_url: Option<String>,
|
||||||
|
pub idempotency_key: Option<String>,
|
||||||
|
pub locator: OnlyOfficeAssetLocator,
|
||||||
|
pub session: OnlyOfficeSessionRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OnlyOfficeForcesavePreparationInput {
|
||||||
|
pub asset_id: String,
|
||||||
|
pub key: String,
|
||||||
|
pub onlyoffice_internal_url: String,
|
||||||
|
pub secret: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OnlyOfficeOutboundRequest {
|
||||||
|
pub via: String,
|
||||||
|
pub url: String,
|
||||||
|
pub method: String,
|
||||||
|
pub headers: Vec<OnlyOfficeHeader>,
|
||||||
|
pub body_json: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OnlyOfficeSessionResolveInput {
|
||||||
|
pub asset_id: String,
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
pub document_id: Option<String>,
|
||||||
|
pub user_id: Option<String>,
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OnlyOfficeSessionResolveResult {
|
||||||
|
pub locator: OnlyOfficeAssetLocator,
|
||||||
|
pub session: OnlyOfficeSessionRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn adapter_boundary_statement() -> &'static str {
|
||||||
|
"adapter-onlyoffice 只定义资产定位、会话定位、签名/代理/callback/forcesave 边界,不把 OnlyOffice 变成主事实层。"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_secret(raw: &str) -> String {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
if ((trimmed.starts_with('"') && trimmed.ends_with('"'))
|
||||||
|
|| (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
|
||||||
|
&& trimmed.len() >= 2
|
||||||
|
{
|
||||||
|
return trimmed[1..trimmed.len() - 1].trim().to_string();
|
||||||
|
}
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sign_config(config: &Value, secret: &str) -> Result<OnlyOfficeSignTokens, String> {
|
||||||
|
let normalized_secret = normalize_secret(secret);
|
||||||
|
if normalized_secret.is_empty() {
|
||||||
|
return Ok(OnlyOfficeSignTokens {
|
||||||
|
token: None,
|
||||||
|
document_token: None,
|
||||||
|
editor_config_token: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = sign_hs256(config, &normalized_secret)?;
|
||||||
|
let document_token = config
|
||||||
|
.get("document")
|
||||||
|
.map(|value| sign_hs256(value, &normalized_secret))
|
||||||
|
.transpose()?;
|
||||||
|
let editor_config_token = config
|
||||||
|
.get("editorConfig")
|
||||||
|
.map(|value| sign_hs256(value, &normalized_secret))
|
||||||
|
.transpose()?;
|
||||||
|
|
||||||
|
Ok(OnlyOfficeSignTokens {
|
||||||
|
token: Some(token),
|
||||||
|
document_token,
|
||||||
|
editor_config_token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_session(input: OnlyOfficeSessionResolveInput) -> OnlyOfficeSessionResolveResult {
|
||||||
|
let session_id = input
|
||||||
|
.session_id
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| format!("onlyoffice:{}", input.asset_id.trim()));
|
||||||
|
let locator = OnlyOfficeAssetLocator {
|
||||||
|
asset_id: input.asset_id.trim().to_string(),
|
||||||
|
workspace_id: input.workspace_id.filter(|value| !value.trim().is_empty()),
|
||||||
|
document_id: input.document_id.filter(|value| !value.trim().is_empty()),
|
||||||
|
};
|
||||||
|
let session = OnlyOfficeSessionRef {
|
||||||
|
session_id,
|
||||||
|
asset_id: locator.asset_id.clone(),
|
||||||
|
workspace_id: locator.workspace_id.clone(),
|
||||||
|
document_id: locator.document_id.clone(),
|
||||||
|
user_id: input.user_id.filter(|value| !value.trim().is_empty()),
|
||||||
|
};
|
||||||
|
OnlyOfficeSessionResolveResult { locator, session }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prepare_proxy_request(
|
||||||
|
input: OnlyOfficeProxyPreparationInput,
|
||||||
|
) -> Result<OnlyOfficeProxyPreparation, String> {
|
||||||
|
let decoded = decode_base64url_to_utf8(&input.encoded_url)?;
|
||||||
|
let mut target = Url::parse(&decoded).map_err(|_| "u 不是有效的 URL".to_string())?;
|
||||||
|
|
||||||
|
if target.scheme() != "http" && target.scheme() != "https" {
|
||||||
|
return Err("仅支持 http/https URL".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let supa = try_parse_origin_host(input.supabase_url.as_deref());
|
||||||
|
let supa_internal_origin = try_parse_origin_url(input.supabase_internal_url.as_deref());
|
||||||
|
let storage_override = try_parse_origin_host(input.onlyoffice_storage_host_override.as_deref());
|
||||||
|
let convex_origin = try_parse_origin_url(input.convex_origin.as_deref());
|
||||||
|
|
||||||
|
let is_supabase_path = target.path().starts_with("/storage/v1/")
|
||||||
|
|| target.path().starts_with("/auth/v1/")
|
||||||
|
|| target.path().starts_with("/rest/v1/")
|
||||||
|
|| target.path().starts_with("/functions/v1/")
|
||||||
|
|| target.path().starts_with("/realtime/v1/");
|
||||||
|
|
||||||
|
if is_supabase_path {
|
||||||
|
if let Some(origin) = supa_internal_origin.as_ref() {
|
||||||
|
target.set_scheme(origin.scheme()).ok();
|
||||||
|
target.set_host(origin.host_str()).ok();
|
||||||
|
target.set_port(origin.port()).ok();
|
||||||
|
} else if let Some(origin) = try_parse_origin_url(input.supabase_url.as_deref()) {
|
||||||
|
target.set_scheme(origin.scheme()).ok();
|
||||||
|
target.set_host(origin.host_str()).ok();
|
||||||
|
target.set_port(origin.port()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut allowed_hostnames = BTreeSet::new();
|
||||||
|
let mut allowed_ports_by_hostname = BTreeMap::<String, BTreeSet<String>>::new();
|
||||||
|
let mut add_allowed = |hostname: &str, port: &str| {
|
||||||
|
if hostname.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
allowed_hostnames.insert(hostname.to_string());
|
||||||
|
if !port.trim().is_empty() {
|
||||||
|
allowed_ports_by_hostname
|
||||||
|
.entry(hostname.to_string())
|
||||||
|
.or_default()
|
||||||
|
.insert(port.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((hostname, port)) = supa.as_ref() {
|
||||||
|
add_allowed(hostname, port);
|
||||||
|
if is_local_hostname(hostname) {
|
||||||
|
add_allowed("127.0.0.1", port);
|
||||||
|
add_allowed("localhost", port);
|
||||||
|
add_allowed("host.docker.internal", port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(origin) = supa_internal_origin.as_ref() {
|
||||||
|
let port = origin.port().map(|value| value.to_string()).unwrap_or_default();
|
||||||
|
if let Some(hostname) = origin.host_str() {
|
||||||
|
add_allowed(hostname, &port);
|
||||||
|
if is_local_hostname(hostname) {
|
||||||
|
add_allowed("127.0.0.1", &port);
|
||||||
|
add_allowed("localhost", &port);
|
||||||
|
add_allowed("host.docker.internal", &port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((hostname, port)) = storage_override.as_ref() {
|
||||||
|
add_allowed(hostname, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(origin) = convex_origin.as_ref() {
|
||||||
|
let port = origin.port().map(|value| value.to_string()).unwrap_or_default();
|
||||||
|
if let Some(hostname) = origin.host_str() {
|
||||||
|
add_allowed(hostname, &port);
|
||||||
|
if is_local_hostname(hostname) {
|
||||||
|
add_allowed("127.0.0.1", &port);
|
||||||
|
add_allowed("localhost", &port);
|
||||||
|
add_allowed("host.docker.internal", &port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hostname = target
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| "目标 URL 缺少 hostname".to_string())?;
|
||||||
|
|
||||||
|
if is_private_ipv4(hostname) && !is_local_hostname(hostname) && !allowed_hostnames.contains(hostname) {
|
||||||
|
return Err("禁止访问内网/私有地址".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allowed_hostnames.is_empty() && !allowed_hostnames.contains(hostname) {
|
||||||
|
return Err("禁止代理到非允许的主机".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(allowed_ports) = allowed_ports_by_hostname.get(hostname) {
|
||||||
|
if !allowed_ports.is_empty() {
|
||||||
|
let port = target
|
||||||
|
.port_or_known_default()
|
||||||
|
.ok_or_else(|| "目标 URL 缺少端口信息".to_string())?
|
||||||
|
.to_string();
|
||||||
|
if !allowed_ports.contains(&port) {
|
||||||
|
return Err("禁止代理到该端口".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut forward_headers = Vec::new();
|
||||||
|
if let Some(range) = input.range.filter(|value| !value.trim().is_empty()) {
|
||||||
|
forward_headers.push(OnlyOfficeHeader {
|
||||||
|
name: "range".into(),
|
||||||
|
value: range,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(anon_key) = input.supabase_anon_key.filter(|value| !value.trim().is_empty()) {
|
||||||
|
let matches_public = supa.as_ref().map(|(value, _)| value == hostname).unwrap_or(false);
|
||||||
|
let matches_internal = supa_internal_origin
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.host_str())
|
||||||
|
.map(|value| value == hostname)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if matches_public || matches_internal {
|
||||||
|
forward_headers.push(OnlyOfficeHeader {
|
||||||
|
name: "apikey".into(),
|
||||||
|
value: anon_key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(OnlyOfficeProxyPreparation {
|
||||||
|
target_url: target.to_string(),
|
||||||
|
forward_headers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prepare_callback(
|
||||||
|
input: OnlyOfficeCallbackPreparationInput,
|
||||||
|
) -> Result<OnlyOfficeCallbackPreparation, String> {
|
||||||
|
let session_result = resolve_session(OnlyOfficeSessionResolveInput {
|
||||||
|
asset_id: input.asset_id.clone(),
|
||||||
|
workspace_id: input.workspace_id.clone(),
|
||||||
|
document_id: input.document_id.clone(),
|
||||||
|
user_id: input.user_id.clone(),
|
||||||
|
session_id: input.session_id.clone().or_else(|| Some("onlyoffice-callback".into())),
|
||||||
|
});
|
||||||
|
|
||||||
|
if input.status != 2 && input.status != 6 {
|
||||||
|
return Ok(OnlyOfficeCallbackPreparation {
|
||||||
|
should_write: false,
|
||||||
|
download_url: None,
|
||||||
|
idempotency_key: input.key.filter(|value| !value.trim().is_empty()),
|
||||||
|
locator: session_result.locator,
|
||||||
|
session: session_result.session,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_url = input
|
||||||
|
.url
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.ok_or_else(|| "OnlyOffice callback 缺少下载地址".to_string())?;
|
||||||
|
|
||||||
|
Ok(OnlyOfficeCallbackPreparation {
|
||||||
|
should_write: true,
|
||||||
|
download_url: Some(rewrite_onlyoffice_download_url(
|
||||||
|
&raw_url,
|
||||||
|
&input.onlyoffice_internal_url,
|
||||||
|
)),
|
||||||
|
idempotency_key: input.key.filter(|value| !value.trim().is_empty()),
|
||||||
|
locator: session_result.locator,
|
||||||
|
session: session_result.session,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prepare_forcesave(
|
||||||
|
input: OnlyOfficeForcesavePreparationInput,
|
||||||
|
) -> Result<Vec<OnlyOfficeOutboundRequest>, String> {
|
||||||
|
let base = input.onlyoffice_internal_url.trim().trim_end_matches('/');
|
||||||
|
if base.is_empty() {
|
||||||
|
return Err("缺少 onlyofficeInternalUrl".into());
|
||||||
|
}
|
||||||
|
if input.asset_id.trim().is_empty() {
|
||||||
|
return Err("缺少 assetId".into());
|
||||||
|
}
|
||||||
|
if input.key.trim().is_empty() {
|
||||||
|
return Err("缺少 key".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"c": "forcesave",
|
||||||
|
"key": input.key,
|
||||||
|
"userdata": format!("asset:{}", input.asset_id.trim()),
|
||||||
|
});
|
||||||
|
let raw_body_json = serde_json::to_string(&payload).map_err(|error| error.to_string())?;
|
||||||
|
|
||||||
|
let normalized_secret = normalize_secret(input.secret.as_deref().unwrap_or(""));
|
||||||
|
let mut requests = Vec::new();
|
||||||
|
|
||||||
|
if !normalized_secret.is_empty() {
|
||||||
|
let token = sign_hs256(&payload, &normalized_secret)?;
|
||||||
|
requests.push(OnlyOfficeOutboundRequest {
|
||||||
|
via: "command".into(),
|
||||||
|
url: format!("{base}/command"),
|
||||||
|
method: "POST".into(),
|
||||||
|
headers: vec![OnlyOfficeHeader {
|
||||||
|
name: "content-type".into(),
|
||||||
|
value: "application/json".into(),
|
||||||
|
}],
|
||||||
|
body_json: serde_json::to_string(&json!({ "token": token }))
|
||||||
|
.map_err(|error| error.to_string())?,
|
||||||
|
});
|
||||||
|
requests.push(OnlyOfficeOutboundRequest {
|
||||||
|
via: "forcesave".into(),
|
||||||
|
url: format!("{base}/forcesave"),
|
||||||
|
method: "POST".into(),
|
||||||
|
headers: vec![OnlyOfficeHeader {
|
||||||
|
name: "content-type".into(),
|
||||||
|
value: "application/json".into(),
|
||||||
|
}],
|
||||||
|
body_json: raw_body_json,
|
||||||
|
});
|
||||||
|
return Ok(requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
requests.push(OnlyOfficeOutboundRequest {
|
||||||
|
via: "forcesave".into(),
|
||||||
|
url: format!("{base}/forcesave"),
|
||||||
|
method: "POST".into(),
|
||||||
|
headers: vec![OnlyOfficeHeader {
|
||||||
|
name: "content-type".into(),
|
||||||
|
value: "application/json".into(),
|
||||||
|
}],
|
||||||
|
body_json: raw_body_json.clone(),
|
||||||
|
});
|
||||||
|
requests.push(OnlyOfficeOutboundRequest {
|
||||||
|
via: "command_no_token".into(),
|
||||||
|
url: format!("{base}/command"),
|
||||||
|
method: "POST".into(),
|
||||||
|
headers: vec![OnlyOfficeHeader {
|
||||||
|
name: "content-type".into(),
|
||||||
|
value: "application/json".into(),
|
||||||
|
}],
|
||||||
|
body_json: raw_body_json,
|
||||||
|
});
|
||||||
|
Ok(requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rewrite_onlyoffice_download_url(raw: &str, onlyoffice_internal_url: &str) -> String {
|
||||||
|
let base = onlyoffice_internal_url.trim().trim_end_matches('/');
|
||||||
|
if base.is_empty() {
|
||||||
|
return raw.to_string();
|
||||||
|
}
|
||||||
|
let Ok(url) = Url::parse(raw) else {
|
||||||
|
return raw.to_string();
|
||||||
|
};
|
||||||
|
let prefix = "/onlyoffice-server";
|
||||||
|
if !url.path().starts_with(prefix) {
|
||||||
|
return raw.to_string();
|
||||||
|
}
|
||||||
|
let next_path = url.path()[prefix.len()..].trim_start_matches('/');
|
||||||
|
if next_path.is_empty() {
|
||||||
|
return raw.to_string();
|
||||||
|
}
|
||||||
|
let mut rewritten = format!("{base}/{next_path}");
|
||||||
|
if let Some(query) = url.query() {
|
||||||
|
rewritten.push('?');
|
||||||
|
rewritten.push_str(query);
|
||||||
|
}
|
||||||
|
rewritten
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_hs256(payload: &Value, secret: &str) -> Result<String, String> {
|
||||||
|
let header = json!({ "alg": "HS256", "typ": "JWT" });
|
||||||
|
let header_part = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes());
|
||||||
|
let payload_part = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes());
|
||||||
|
let signing_input = format!("{header_part}.{payload_part}");
|
||||||
|
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|error| error.to_string())?;
|
||||||
|
mac.update(signing_input.as_bytes());
|
||||||
|
let signature = mac.finalize().into_bytes();
|
||||||
|
let signature_part = URL_SAFE_NO_PAD.encode(signature);
|
||||||
|
Ok(format!("{signing_input}.{signature_part}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_base64url_to_utf8(input: &str) -> Result<String, String> {
|
||||||
|
let bytes = URL_SAFE_NO_PAD
|
||||||
|
.decode(input.trim())
|
||||||
|
.map_err(|_| "u 不是有效的 base64url URL".to_string())?;
|
||||||
|
String::from_utf8(bytes).map_err(|_| "u 不是有效的 base64url URL".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_local_hostname(hostname: &str) -> bool {
|
||||||
|
matches!(hostname, "127.0.0.1" | "localhost" | "host.docker.internal")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_private_ipv4(hostname: &str) -> bool {
|
||||||
|
let parts = hostname
|
||||||
|
.split('.')
|
||||||
|
.map(str::parse::<u16>)
|
||||||
|
.collect::<Result<Vec<_>, _>>();
|
||||||
|
let Ok(parts) = parts else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if parts.len() != 4 || parts.iter().any(|value| *value > 255) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let a = parts[0];
|
||||||
|
let b = parts[1];
|
||||||
|
a == 10
|
||||||
|
|| a == 127
|
||||||
|
|| (a == 169 && b == 254)
|
||||||
|
|| (a == 172 && (16..=31).contains(&b))
|
||||||
|
|| (a == 192 && b == 168)
|
||||||
|
|| a == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_parse_origin_host(raw: Option<&str>) -> Option<(String, String)> {
|
||||||
|
let value = raw?.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if value.starts_with("http://") || value.starts_with("https://") {
|
||||||
|
let url = Url::parse(value).ok()?;
|
||||||
|
return Some((
|
||||||
|
url.host_str()?.to_string(),
|
||||||
|
url.port().map(|value| value.to_string()).unwrap_or_default(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some((value.to_string(), String::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_parse_origin_url(raw: Option<&str>) -> Option<Url> {
|
||||||
|
let value = raw?.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Url::parse(value)
|
||||||
|
.or_else(|_| Url::parse(&format!("http://{value}")))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boundary_statement_mentions_non_source_of_truth() {
|
||||||
|
assert!(adapter_boundary_statement().contains("不把 OnlyOffice 变成主事实层"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_secret_strips_quotes() {
|
||||||
|
assert_eq!(normalize_secret(" \"abc\" "), "abc");
|
||||||
|
assert_eq!(normalize_secret("'xyz'"), "xyz");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sign_config_returns_document_and_editor_tokens() {
|
||||||
|
let tokens = sign_config(
|
||||||
|
&json!({
|
||||||
|
"document": {"title": "A"},
|
||||||
|
"editorConfig": {"mode": "edit"},
|
||||||
|
}),
|
||||||
|
"secret",
|
||||||
|
)
|
||||||
|
.expect("sign should succeed");
|
||||||
|
assert!(tokens.token.is_some());
|
||||||
|
assert!(tokens.document_token.is_some());
|
||||||
|
assert!(tokens.editor_config_token.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proxy_request_rewrites_supabase_path_to_internal_origin() {
|
||||||
|
let encoded_url = URL_SAFE_NO_PAD.encode(
|
||||||
|
b"https://public.example.com/storage/v1/object/sign/documents/a.docx?token=abc",
|
||||||
|
);
|
||||||
|
let result = prepare_proxy_request(OnlyOfficeProxyPreparationInput {
|
||||||
|
encoded_url,
|
||||||
|
method: "GET".into(),
|
||||||
|
range: Some("bytes=0-99".into()),
|
||||||
|
supabase_url: Some("https://public.example.com".into()),
|
||||||
|
supabase_internal_url: Some("http://127.0.0.1:18000".into()),
|
||||||
|
onlyoffice_storage_host_override: None,
|
||||||
|
convex_origin: Some("http://127.0.0.1:3210".into()),
|
||||||
|
supabase_anon_key: Some("anon".into()),
|
||||||
|
})
|
||||||
|
.expect("proxy should prepare");
|
||||||
|
assert!(result.target_url.starts_with("http://127.0.0.1:18000/storage/v1/"));
|
||||||
|
assert_eq!(result.forward_headers.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn callback_preparation_rewrites_onlyoffice_server_path() {
|
||||||
|
let result = prepare_callback(OnlyOfficeCallbackPreparationInput {
|
||||||
|
asset_id: "asset_1".into(),
|
||||||
|
document_id: Some("doc_1".into()),
|
||||||
|
workspace_id: Some("ws_1".into()),
|
||||||
|
user_id: Some("user_1".into()),
|
||||||
|
session_id: None,
|
||||||
|
status: 6,
|
||||||
|
url: Some("http://app.example.com/onlyoffice-server/cache/files/out.docx?token=1".into()),
|
||||||
|
key: Some("doc_key_1".into()),
|
||||||
|
onlyoffice_internal_url: "http://127.0.0.1:8082".into(),
|
||||||
|
})
|
||||||
|
.expect("callback should prepare");
|
||||||
|
assert!(result.should_write);
|
||||||
|
assert_eq!(
|
||||||
|
result.download_url.as_deref(),
|
||||||
|
Some("http://127.0.0.1:8082/cache/files/out.docx?token=1")
|
||||||
|
);
|
||||||
|
assert_eq!(result.session.session_id, "onlyoffice-callback");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forcesave_preparation_prefers_command_when_secret_present() {
|
||||||
|
let result = prepare_forcesave(OnlyOfficeForcesavePreparationInput {
|
||||||
|
asset_id: "asset_1".into(),
|
||||||
|
key: "doc_key_1".into(),
|
||||||
|
onlyoffice_internal_url: "http://127.0.0.1:8082".into(),
|
||||||
|
secret: Some("secret".into()),
|
||||||
|
})
|
||||||
|
.expect("forcesave should prepare");
|
||||||
|
assert_eq!(result[0].via, "command");
|
||||||
|
assert_eq!(result[1].via, "forcesave");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "bridge-runtime"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
adapter-onlyoffice = { path = "../adapter-onlyoffice" }
|
||||||
|
core-domain = { path = "../core-domain" }
|
||||||
|
core-protocol = { path = "../core-protocol" }
|
||||||
|
event-log = { path = "../event-log" }
|
||||||
|
index-fts = { path = "../index-fts" }
|
||||||
|
storage-convex-bridge = { path = "../storage-convex-bridge" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
|
urlencoding = "2"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
|||||||
|
use std::io::{self, Read};
|
||||||
|
|
||||||
|
use bridge_runtime::{
|
||||||
|
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
|
||||||
|
runtime_input_requests_result, RuntimeFailure, RuntimeInput,
|
||||||
|
};
|
||||||
|
use storage_convex_bridge::{BridgeError, BridgeErrorKind};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let input = match read_stdin() {
|
||||||
|
Ok(input) => input,
|
||||||
|
Err(error) => {
|
||||||
|
emit_failure(BridgeError {
|
||||||
|
kind: BridgeErrorKind::Transport,
|
||||||
|
message: format!("读取 bridge runtime stdin 失败: {error}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let runtime_input = match serde_json::from_str::<RuntimeInput>(&input) {
|
||||||
|
Ok(runtime_input) => runtime_input,
|
||||||
|
Err(error) => {
|
||||||
|
emit_failure(BridgeError::validation(format!(
|
||||||
|
"bridge runtime 输入 JSON 非法: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if runtime_input_requests_result(&runtime_input) {
|
||||||
|
match execute_runtime_query(runtime_input) {
|
||||||
|
Ok(result) => {
|
||||||
|
let payload = serde_json::to_string(&serde_json::json!({
|
||||||
|
"ok": true,
|
||||||
|
"result": result,
|
||||||
|
}))
|
||||||
|
.expect("bridge runtime query result 必须可序列化");
|
||||||
|
println!("{payload}");
|
||||||
|
}
|
||||||
|
Err(error) => emit_failure(error),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match execute_runtime_input(runtime_input) {
|
||||||
|
Ok(plan) => {
|
||||||
|
let payload = serde_json::to_string(&build_success_response(plan))
|
||||||
|
.expect("bridge runtime 成功响应必须可序列化");
|
||||||
|
println!("{payload}");
|
||||||
|
}
|
||||||
|
Err(error) => emit_failure(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_stdin() -> io::Result<String> {
|
||||||
|
let mut buffer = String::new();
|
||||||
|
io::stdin().read_to_string(&mut buffer)?;
|
||||||
|
Ok(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_failure(error: BridgeError) -> ! {
|
||||||
|
let payload =
|
||||||
|
serde_json::to_string(&build_failure_response(error)).unwrap_or_else(|serialize_error| {
|
||||||
|
serde_json::to_string(&RuntimeFailure {
|
||||||
|
ok: false,
|
||||||
|
error: bridge_runtime::RuntimeErrorPayload {
|
||||||
|
kind: "transport".into(),
|
||||||
|
message: format!("bridge runtime 错误序列化失败: {serialize_error}"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect("bridge runtime 兜底错误响应必须可序列化")
|
||||||
|
});
|
||||||
|
println!("{payload}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
@@ -7,3 +7,5 @@ authors.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
core-domain = { path = "../core-domain" }
|
core-domain = { path = "../core-domain" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|||||||
@@ -44,6 +44,44 @@ pub struct UpdatePageTitle {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CreateDocumentPage {
|
||||||
|
pub page_id: String,
|
||||||
|
pub parent_page_id: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub workspace_seed_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct MoveDocumentPage {
|
||||||
|
pub page_id: String,
|
||||||
|
pub parent_page_id: Option<String>,
|
||||||
|
pub sort_order: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DeleteDocumentPage {
|
||||||
|
pub page_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RestoreDocumentPage {
|
||||||
|
pub page_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DuplicateDocumentPage {
|
||||||
|
pub source_page_id: String,
|
||||||
|
pub new_page_id: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CopyTreeDocumentPages {
|
||||||
|
pub items_json: String,
|
||||||
|
pub target_parent_page_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct UpdatePageStats {
|
pub struct UpdatePageStats {
|
||||||
pub page_id: String,
|
pub page_id: String,
|
||||||
@@ -87,6 +125,16 @@ pub struct SavePageContent {
|
|||||||
pub conflict_detection_key: Option<String>,
|
pub conflict_detection_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PatchBlock {
|
||||||
|
pub page_id: String,
|
||||||
|
pub block_id: String,
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
pub revision: Option<u64>,
|
||||||
|
pub block_snapshot_json: String,
|
||||||
|
pub conflict_detection_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct PatchPageBlock {
|
pub struct PatchPageBlock {
|
||||||
pub page_id: String,
|
pub page_id: String,
|
||||||
@@ -97,6 +145,14 @@ pub struct PatchPageBlock {
|
|||||||
pub conflict_detection_key: Option<String>,
|
pub conflict_detection_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PutMindmap {
|
||||||
|
pub document_id: String,
|
||||||
|
pub mindmap_id: String,
|
||||||
|
pub data_json: String,
|
||||||
|
pub create_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct InsertBlock {
|
pub struct InsertBlock {
|
||||||
pub page_id: String,
|
pub page_id: String,
|
||||||
@@ -120,6 +176,14 @@ pub struct MoveBlock {
|
|||||||
pub prev_block_id: Option<String>,
|
pub prev_block_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct EmbedBlock {
|
||||||
|
pub source_document_id: String,
|
||||||
|
pub source_block_id: String,
|
||||||
|
pub target_document_id: String,
|
||||||
|
pub target_block_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct DeleteBlock {
|
pub struct DeleteBlock {
|
||||||
pub block_id: String,
|
pub block_id: String,
|
||||||
|
|||||||
@@ -1,23 +1,43 @@
|
|||||||
pub mod command;
|
pub mod command;
|
||||||
pub mod common;
|
pub mod common;
|
||||||
pub mod governance;
|
pub mod governance;
|
||||||
|
pub mod mindmap;
|
||||||
pub mod query;
|
pub mod query;
|
||||||
pub mod tool;
|
pub mod tool;
|
||||||
|
|
||||||
pub use command::{
|
pub use command::{
|
||||||
CommandEnvelope, CreatePage, CreateWorkspace, DeleteBlock, InsertBlock, MoveBlock,
|
CommandEnvelope, CopyTreeDocumentPages, CreateDocumentPage, CreatePage, CreateWorkspace,
|
||||||
PatchPageBlock, ReplaceMediaAssetStorage, SavePageContent, UpdateBlock, UpdatePageOptions,
|
DeleteBlock, DeleteDocumentPage, DuplicateDocumentPage, EmbedBlock, InsertBlock, MoveBlock,
|
||||||
UpdatePageStats, UpdatePageTitle,
|
MoveDocumentPage, PatchBlock, PatchPageBlock, PutMindmap, ReplaceMediaAssetStorage,
|
||||||
|
RestoreDocumentPage, SavePageContent, UpdateBlock, UpdatePageOptions, UpdatePageStats,
|
||||||
|
UpdatePageTitle,
|
||||||
};
|
};
|
||||||
pub use common::{
|
pub use common::{
|
||||||
ActorPayload, AffectedObject, ErrorDetail, ErrorPayload, OkPayload, RequestMeta, ResponseMeta,
|
ActorPayload, AffectedObject, ErrorDetail, ErrorPayload, OkPayload, RequestMeta, ResponseMeta,
|
||||||
SourcePayload, TargetRef,
|
SourcePayload, TargetRef,
|
||||||
};
|
};
|
||||||
|
pub use mindmap::{
|
||||||
|
MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode,
|
||||||
|
};
|
||||||
pub use query::{
|
pub use query::{
|
||||||
GetPage, GetPageContent, GetPageMeta, ListPageBlocks, ListSidebarDataset, QueryEnvelope,
|
GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, GetPage,
|
||||||
SearchBlocks, SearchPages,
|
GetPageContent, GetPageMeta, ListBridgeWorkspaceOverview, ListPageBlocks,
|
||||||
|
ListSidebarDataset, QueryEnvelope, SearchBlocks, SearchDocuments, SearchPages, SearchRecent,
|
||||||
|
};
|
||||||
|
pub use tool::{
|
||||||
|
default_tool_registry, invocation_kind_label, tool_effect_label, tool_mode_label,
|
||||||
|
InvocationKind, ToolEffect, ToolExecutionMode, ToolInvocation, ToolRegistry, ToolSetSpec,
|
||||||
|
ToolSpec, BRIDGE_TOOL_COMMAND_GET, BRIDGE_TOOL_REQUEST_GET, BRIDGE_TOOL_TRACE_GET,
|
||||||
|
DOC_TOOL_FIND, DOC_TOOL_GET, DOC_TOOL_INSERT_BLOCKS, DOC_TOOL_REPLACE_RANGE,
|
||||||
|
DOC_TOOLSET_READ, DOC_TOOLSET_WRITE, INDEX_TOOL_REBUILD, MINDMAP_TOOL_APPLY_OPS,
|
||||||
|
MINDMAP_TOOL_EMPTY_TRASH, MINDMAP_TOOL_EXPAND_NODE, MINDMAP_TOOL_GET,
|
||||||
|
MINDMAP_TOOL_GET_SUBTREE,
|
||||||
|
MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT, MINDMAP_TOOLSET_READ,
|
||||||
|
MINDMAP_TOOLSET_WRITE, OBSERVE_TOOLSET_READ, ONLYOFFICE_TOOL_PREPARE_CALLBACK,
|
||||||
|
ONLYOFFICE_TOOL_PREPARE_FORCESAVE, ONLYOFFICE_TOOL_PREPARE_PROXY,
|
||||||
|
ONLYOFFICE_TOOL_SESSION_RESOLVE, ONLYOFFICE_TOOL_SIGN, ONLYOFFICE_TOOLSET_SERVICE,
|
||||||
|
RECOVERY_TOOLSET_JOB, REPLAY_TOOL_EVENTS,
|
||||||
};
|
};
|
||||||
pub use tool::{InvocationKind, ToolInvocation};
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -51,4 +71,124 @@ mod tests {
|
|||||||
|
|
||||||
assert!(envelope.validate_only);
|
assert!(envelope.validate_only);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_tool_registry_exposes_doc_tools() {
|
||||||
|
let registry = default_tool_registry();
|
||||||
|
assert_eq!(
|
||||||
|
registry.tool_names(),
|
||||||
|
vec![
|
||||||
|
"search_web",
|
||||||
|
"doc_get",
|
||||||
|
"doc_find",
|
||||||
|
"image_read",
|
||||||
|
"doc_insert_blocks",
|
||||||
|
"doc_replace_range",
|
||||||
|
"slash_run",
|
||||||
|
"mindmap_get",
|
||||||
|
"mindmap_get_subtree",
|
||||||
|
"mindmap_put",
|
||||||
|
"mindmap_apply_ops",
|
||||||
|
"mindmap_expand_node",
|
||||||
|
"mindmap_empty_trash",
|
||||||
|
"mindmap_outline_to_mindmap",
|
||||||
|
"bridge_request_get",
|
||||||
|
"bridge_trace_get",
|
||||||
|
"bridge_command_get",
|
||||||
|
"event_replay",
|
||||||
|
"index_rebuild",
|
||||||
|
"onlyoffice_session_resolve",
|
||||||
|
"onlyoffice_sign",
|
||||||
|
"onlyoffice_prepare_proxy",
|
||||||
|
"onlyoffice_prepare_callback",
|
||||||
|
"onlyoffice_prepare_forcesave",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
registry
|
||||||
|
.tool("search_web")
|
||||||
|
.expect("search_web should exist")
|
||||||
|
.toolset_id,
|
||||||
|
"toolset.readonly"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
registry
|
||||||
|
.tool("image_read")
|
||||||
|
.expect("image_read should exist")
|
||||||
|
.toolset_id,
|
||||||
|
"toolset.media_read"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
registry
|
||||||
|
.tool("slash_run")
|
||||||
|
.expect("slash_run should exist")
|
||||||
|
.toolset_id,
|
||||||
|
"toolset.slash_write"
|
||||||
|
);
|
||||||
|
let readonly = registry
|
||||||
|
.toolset("toolset.readonly")
|
||||||
|
.expect("readonly toolset should exist");
|
||||||
|
assert!(!readonly.write_toolset);
|
||||||
|
assert_eq!(readonly.tool_names, &["search_web"]);
|
||||||
|
let media = registry
|
||||||
|
.toolset("toolset.media_read")
|
||||||
|
.expect("media toolset should exist");
|
||||||
|
assert!(!media.write_toolset);
|
||||||
|
assert_eq!(media.tool_names, &["image_read"]);
|
||||||
|
let slash = registry
|
||||||
|
.toolset("toolset.slash_write")
|
||||||
|
.expect("slash toolset should exist");
|
||||||
|
assert!(slash.write_toolset);
|
||||||
|
assert_eq!(slash.tool_names, &["slash_run"]);
|
||||||
|
let doc_write = registry
|
||||||
|
.toolset("toolset.doc_write")
|
||||||
|
.expect("doc_write toolset should exist");
|
||||||
|
assert!(doc_write.write_toolset);
|
||||||
|
assert_eq!(doc_write.tool_names, &["doc_insert_blocks", "doc_replace_range"]);
|
||||||
|
assert_eq!(
|
||||||
|
registry.tool("doc_get").expect("doc_get should exist").toolset_id,
|
||||||
|
"toolset.doc_read"
|
||||||
|
);
|
||||||
|
let mindmap_write = registry
|
||||||
|
.toolset("toolset.mindmap_write")
|
||||||
|
.expect("mindmap_write toolset should exist");
|
||||||
|
assert!(mindmap_write.write_toolset);
|
||||||
|
assert_eq!(
|
||||||
|
mindmap_write.tool_names,
|
||||||
|
&[
|
||||||
|
"mindmap_put",
|
||||||
|
"mindmap_apply_ops",
|
||||||
|
"mindmap_expand_node",
|
||||||
|
"mindmap_empty_trash",
|
||||||
|
"mindmap_outline_to_mindmap",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let onlyoffice_service = registry
|
||||||
|
.toolset("toolset.onlyoffice_service")
|
||||||
|
.expect("onlyoffice service toolset should exist");
|
||||||
|
assert!(onlyoffice_service.write_toolset);
|
||||||
|
assert_eq!(
|
||||||
|
onlyoffice_service.tool_names,
|
||||||
|
&[
|
||||||
|
"onlyoffice_session_resolve",
|
||||||
|
"onlyoffice_sign",
|
||||||
|
"onlyoffice_prepare_proxy",
|
||||||
|
"onlyoffice_prepare_callback",
|
||||||
|
"onlyoffice_prepare_forcesave",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let observe = registry
|
||||||
|
.toolset("toolset.observe_read")
|
||||||
|
.expect("observe toolset should exist");
|
||||||
|
assert!(!observe.write_toolset);
|
||||||
|
assert_eq!(
|
||||||
|
observe.tool_names,
|
||||||
|
&["bridge_request_get", "bridge_trace_get", "bridge_command_get"]
|
||||||
|
);
|
||||||
|
let recovery = registry
|
||||||
|
.toolset("toolset.recovery_job")
|
||||||
|
.expect("recovery toolset should exist");
|
||||||
|
assert!(recovery.write_toolset);
|
||||||
|
assert_eq!(recovery.tool_names, &["event_replay", "index_rebuild"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MindmapNodeRef {
|
||||||
|
pub kind: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub asset_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub file_url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub page: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub slide: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub snippet: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MindmapNodeData {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub uid: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub text: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub hyperlink: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub note: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub refs: Option<Vec<MindmapNodeRef>>,
|
||||||
|
#[serde(flatten, default)]
|
||||||
|
pub extra: BTreeMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct MindmapTreeNode {
|
||||||
|
pub data: MindmapNodeData,
|
||||||
|
#[serde(default)]
|
||||||
|
pub children: Vec<MindmapTreeNode>,
|
||||||
|
#[serde(flatten, default)]
|
||||||
|
pub extra: BTreeMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MindmapNodeInput {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub uid: Option<String>,
|
||||||
|
pub text: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub hyperlink: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub note: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub refs: Option<Vec<MindmapNodeRef>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(tag = "op", rename_all = "camelCase")]
|
||||||
|
pub enum MindmapOp {
|
||||||
|
#[serde(alias = "add_child")]
|
||||||
|
AddChild {
|
||||||
|
#[serde(rename = "parentUid")]
|
||||||
|
#[serde(alias = "parent_uid")]
|
||||||
|
parent_uid: String,
|
||||||
|
node: MindmapNodeInput,
|
||||||
|
},
|
||||||
|
#[serde(alias = "add_sibling_after")]
|
||||||
|
AddSiblingAfter {
|
||||||
|
#[serde(rename = "targetUid")]
|
||||||
|
#[serde(alias = "target_uid")]
|
||||||
|
target_uid: String,
|
||||||
|
node: MindmapNodeInput,
|
||||||
|
},
|
||||||
|
#[serde(alias = "update_node", alias = "updateNode")]
|
||||||
|
UpdateText {
|
||||||
|
uid: String,
|
||||||
|
#[serde(alias = "value")]
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
#[serde(alias = "set_link", alias = "set_hyperlink")]
|
||||||
|
SetHyperlink {
|
||||||
|
#[serde(alias = "id")]
|
||||||
|
uid: String,
|
||||||
|
#[serde(alias = "url")]
|
||||||
|
hyperlink: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(alias = "set_refs")]
|
||||||
|
SetRefs {
|
||||||
|
#[serde(alias = "id")]
|
||||||
|
uid: String,
|
||||||
|
refs: Vec<MindmapNodeRef>,
|
||||||
|
},
|
||||||
|
#[serde(alias = "append_note")]
|
||||||
|
AppendNote {
|
||||||
|
#[serde(alias = "id")]
|
||||||
|
uid: String,
|
||||||
|
#[serde(alias = "note")]
|
||||||
|
markdown: String,
|
||||||
|
},
|
||||||
|
#[serde(alias = "delete_node")]
|
||||||
|
DeleteNode {
|
||||||
|
#[serde(alias = "id")]
|
||||||
|
uid: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -27,6 +27,18 @@ pub struct GetPageContent {
|
|||||||
pub workspace_id: Option<String>,
|
pub workspace_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct GetBlock {
|
||||||
|
pub block_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct GetMindmap {
|
||||||
|
pub document_id: String,
|
||||||
|
pub mindmap_id: String,
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ListSidebarDataset {
|
pub struct ListSidebarDataset {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
@@ -45,9 +57,62 @@ pub struct SearchPages {
|
|||||||
pub pagination: Pagination,
|
pub pagination: Pagination,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SearchDocuments {
|
||||||
|
pub query: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub page_id: Option<String>,
|
||||||
|
pub pagination: Pagination,
|
||||||
|
pub title_only: bool,
|
||||||
|
pub exact: bool,
|
||||||
|
pub include_ocr: bool,
|
||||||
|
pub time_range: String,
|
||||||
|
pub time_field: String,
|
||||||
|
pub custom_range_from: Option<String>,
|
||||||
|
pub custom_range_to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SearchRecent {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub pagination: Pagination,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct SearchBlocks {
|
pub struct SearchBlocks {
|
||||||
pub query: String,
|
pub query: String,
|
||||||
pub page_id: Option<String>,
|
pub page_id: Option<String>,
|
||||||
pub pagination: Pagination,
|
pub pagination: Pagination,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct GetBridgeRequest {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub request_id: String,
|
||||||
|
pub command_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct GetBridgeTrace {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub trace_id: String,
|
||||||
|
pub command_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct GetBridgeCommand {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub command_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ListBridgeWorkspaceOverview {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub pagination: Pagination,
|
||||||
|
pub command_status: Option<String>,
|
||||||
|
pub event_status: Option<String>,
|
||||||
|
pub target_page_id: Option<String>,
|
||||||
|
pub target_block_id: Option<String>,
|
||||||
|
pub aggregate_type: Option<String>,
|
||||||
|
pub aggregate_id: Option<String>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,534 @@
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum InvocationKind {
|
pub enum InvocationKind {
|
||||||
Command,
|
Command,
|
||||||
Query,
|
Query,
|
||||||
Job,
|
Job,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ToolExecutionMode {
|
||||||
|
Plan,
|
||||||
|
Result,
|
||||||
|
ExplainPlan,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ToolInvocation {
|
pub struct ToolInvocation {
|
||||||
pub tool: String,
|
pub tool: String,
|
||||||
pub kind: InvocationKind,
|
pub kind: InvocationKind,
|
||||||
|
pub mode: ToolExecutionMode,
|
||||||
pub args_json: String,
|
pub args_json: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ToolEffect {
|
||||||
|
Read,
|
||||||
|
Write,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ToolSpec {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub display_name: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
pub toolset_id: &'static str,
|
||||||
|
pub invocation_kind: InvocationKind,
|
||||||
|
pub effect: ToolEffect,
|
||||||
|
pub requires_confirmation: bool,
|
||||||
|
pub input_schema_json: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ToolSetSpec {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub display_name: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
pub write_toolset: bool,
|
||||||
|
pub tool_names: &'static [&'static str],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ToolRegistry {
|
||||||
|
pub toolsets: &'static [ToolSetSpec],
|
||||||
|
pub tools: &'static [ToolSpec],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolRegistry {
|
||||||
|
pub const fn new(toolsets: &'static [ToolSetSpec], tools: &'static [ToolSpec]) -> Self {
|
||||||
|
Self { toolsets, tools }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tool(&self, name: &str) -> Option<&'static ToolSpec> {
|
||||||
|
self.tools.iter().find(|spec| spec.name == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toolset(&self, id: &str) -> Option<&'static ToolSetSpec> {
|
||||||
|
self.toolsets.iter().find(|spec| spec.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tool_names(&self) -> Vec<&'static str> {
|
||||||
|
self.tools.iter().map(|spec| spec.name).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tool_names_in_set(&self, id: &str) -> Option<Vec<&'static str>> {
|
||||||
|
self.toolset(id)
|
||||||
|
.map(|toolset| toolset.tool_names.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tools_in_set(&self, id: &str) -> Option<Vec<&'static ToolSpec>> {
|
||||||
|
self.toolset(id).map(|toolset| {
|
||||||
|
toolset
|
||||||
|
.tool_names
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tool_name| self.tool(tool_name))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const DOC_TOOL_GET: ToolSpec = ToolSpec {
|
||||||
|
name: "doc_get",
|
||||||
|
display_name: "文档读取",
|
||||||
|
description: "读取当前文档快照,用于查看页面内容和结构。",
|
||||||
|
toolset_id: "toolset.doc_read",
|
||||||
|
invocation_kind: InvocationKind::Query,
|
||||||
|
effect: ToolEffect::Read,
|
||||||
|
requires_confirmation: false,
|
||||||
|
input_schema_json: r#"{"type":"object","properties":{"maxBlocks":{"type":"integer","minimum":10,"maximum":240}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const SEARCH_WEB_TOOL: ToolSpec = ToolSpec {
|
||||||
|
name: "search_web",
|
||||||
|
display_name: "联网检索",
|
||||||
|
description: "使用 SearxNG 进行联网检索,返回标题、URL 与摘要。",
|
||||||
|
toolset_id: "toolset.readonly",
|
||||||
|
invocation_kind: InvocationKind::Query,
|
||||||
|
effect: ToolEffect::Read,
|
||||||
|
requires_confirmation: false,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"count":{"type":"integer","minimum":1,"maximum":10}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const DOC_TOOL_FIND: ToolSpec = ToolSpec {
|
||||||
|
name: "doc_find",
|
||||||
|
display_name: "文档查找",
|
||||||
|
description: "在当前文档范围内查找片段、标题或结构信息。",
|
||||||
|
toolset_id: "toolset.doc_read",
|
||||||
|
invocation_kind: InvocationKind::Query,
|
||||||
|
effect: ToolEffect::Read,
|
||||||
|
requires_confirmation: false,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"maxResults":{"type":"integer","minimum":1,"maximum":30}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec {
|
||||||
|
name: "image_read",
|
||||||
|
display_name: "读取图片",
|
||||||
|
description: "读取图片/附件 OCR 信息并返回归一化结果。",
|
||||||
|
toolset_id: "toolset.media_read",
|
||||||
|
invocation_kind: InvocationKind::Query,
|
||||||
|
effect: ToolEffect::Read,
|
||||||
|
requires_confirmation: false,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","properties":{"assetId":{"type":"string"},"fileUrl":{"type":"string"},"attachmentRef":{"type":"string"}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const DOC_TOOL_INSERT_BLOCKS: ToolSpec = ToolSpec {
|
||||||
|
name: "doc_insert_blocks",
|
||||||
|
display_name: "插入块",
|
||||||
|
description: "在文档中插入一个或多个块,是写入类操作。",
|
||||||
|
toolset_id: "toolset.doc_write",
|
||||||
|
invocation_kind: InvocationKind::Command,
|
||||||
|
effect: ToolEffect::Write,
|
||||||
|
requires_confirmation: true,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["blocks"],"properties":{"afterBlockId":{"type":"string"},"beforeBlockId":{"type":"string"},"blocks":{"type":"array","minItems":1,"maxItems":20}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const DOC_TOOL_REPLACE_RANGE: ToolSpec = ToolSpec {
|
||||||
|
name: "doc_replace_range",
|
||||||
|
display_name: "替换范围",
|
||||||
|
description: "替换文档中的块范围,是写入类操作。",
|
||||||
|
toolset_id: "toolset.doc_write",
|
||||||
|
invocation_kind: InvocationKind::Command,
|
||||||
|
effect: ToolEffect::Write,
|
||||||
|
requires_confirmation: true,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["blockId","text"],"properties":{"blockId":{"type":"string"},"text":{"type":"string"},"mode":{"enum":["replace","append","prepend"]}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const SLASH_RUN_TOOL: ToolSpec = ToolSpec {
|
||||||
|
name: "slash_run",
|
||||||
|
display_name: "斜杠命令",
|
||||||
|
description: "解析并规范化斜杠命令,用于创建或重命名页面。",
|
||||||
|
toolset_id: "toolset.slash_write",
|
||||||
|
invocation_kind: InvocationKind::Command,
|
||||||
|
effect: ToolEffect::Write,
|
||||||
|
requires_confirmation: true,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","properties":{"text":{"type":"string"},"command":{"enum":["new_doc","rename_doc"]},"params":{"type":"object"},"reason":{"type":"string"}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MINDMAP_TOOL_GET: ToolSpec = ToolSpec {
|
||||||
|
name: "mindmap_get",
|
||||||
|
display_name: "导图读取",
|
||||||
|
description: "读取当前思维导图的节点摘要,用于定位节点和规划改动。",
|
||||||
|
toolset_id: "toolset.mindmap_read",
|
||||||
|
invocation_kind: InvocationKind::Query,
|
||||||
|
effect: ToolEffect::Read,
|
||||||
|
requires_confirmation: false,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","properties":{"maxNodes":{"type":"integer","minimum":10,"maximum":300}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MINDMAP_TOOL_GET_SUBTREE: ToolSpec = ToolSpec {
|
||||||
|
name: "mindmap_get_subtree",
|
||||||
|
display_name: "导图子树读取",
|
||||||
|
description: "读取指定节点 uid 的子树摘要,供 AI 精确查看局部结构。",
|
||||||
|
toolset_id: "toolset.mindmap_read",
|
||||||
|
invocation_kind: InvocationKind::Query,
|
||||||
|
effect: ToolEffect::Read,
|
||||||
|
requires_confirmation: false,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["uid"],"properties":{"uid":{"type":"string"},"depth":{"type":"integer","minimum":0,"maximum":6},"maxNodes":{"type":"integer","minimum":5,"maximum":200}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MINDMAP_TOOL_PUT: ToolSpec = ToolSpec {
|
||||||
|
name: "mindmap_put",
|
||||||
|
display_name: "导图覆盖写入",
|
||||||
|
description: "使用完整思维导图树覆盖当前导图,适合 CLI 或 AI 在拿到完整快照后直接写回。",
|
||||||
|
toolset_id: "toolset.mindmap_write",
|
||||||
|
invocation_kind: InvocationKind::Command,
|
||||||
|
effect: ToolEffect::Write,
|
||||||
|
requires_confirmation: true,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["data"],"properties":{"data":{"type":"object"},"createOnly":{"type":"boolean"}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MINDMAP_TOOL_APPLY_OPS: ToolSpec = ToolSpec {
|
||||||
|
name: "mindmap_apply_ops",
|
||||||
|
display_name: "导图增量写入",
|
||||||
|
description: "对思维导图应用增量 ops,并返回新的整棵树快照。",
|
||||||
|
toolset_id: "toolset.mindmap_write",
|
||||||
|
invocation_kind: InvocationKind::Command,
|
||||||
|
effect: ToolEffect::Write,
|
||||||
|
requires_confirmation: true,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["ops"],"properties":{"ops":{"type":"array","minItems":1,"maxItems":80},"reason":{"type":"string"},"targetUid":{"type":"string"},"searchResults":{"type":"array"}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MINDMAP_TOOL_EXPAND_NODE: ToolSpec = ToolSpec {
|
||||||
|
name: "mindmap_expand_node",
|
||||||
|
display_name: "补完思维导图节点",
|
||||||
|
description: "对 AI 生成的补完候选做统一归一化,再应用到当前思维导图并返回新树。",
|
||||||
|
toolset_id: "toolset.mindmap_write",
|
||||||
|
invocation_kind: InvocationKind::Command,
|
||||||
|
effect: ToolEffect::Write,
|
||||||
|
requires_confirmation: true,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["targetUid"],"properties":{"targetUid":{"type":"string"},"instruction":{"type":"string"},"ops":{"type":"array","maxItems":80},"searchResults":{"type":"array"},"reason":{"type":"string"}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MINDMAP_TOOL_EMPTY_TRASH: ToolSpec = ToolSpec {
|
||||||
|
name: "mindmap_empty_trash",
|
||||||
|
display_name: "清空导图回收站",
|
||||||
|
description: "校验并标准化清空导图回收站所需的工作区对象,供 route 继续执行实际写入。",
|
||||||
|
toolset_id: "toolset.mindmap_write",
|
||||||
|
invocation_kind: InvocationKind::Command,
|
||||||
|
effect: ToolEffect::Write,
|
||||||
|
requires_confirmation: true,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MINDMAP_TOOL_OUTLINE_TO_MINDMAP: ToolSpec = ToolSpec {
|
||||||
|
name: "mindmap_outline_to_mindmap",
|
||||||
|
display_name: "大纲转导图",
|
||||||
|
description: "把结构化大纲转换为标准思维导图树,统一 uid、refs 与 page 链接生成规则。",
|
||||||
|
toolset_id: "toolset.mindmap_write",
|
||||||
|
invocation_kind: InvocationKind::Command,
|
||||||
|
effect: ToolEffect::Write,
|
||||||
|
requires_confirmation: true,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["rootTitle","pageLinkPattern","outline"],"properties":{"rootTitle":{"type":"string"},"pageLinkPattern":{"type":"string"},"outline":{"type":"array","items":{"type":"object","required":["title","level","page"],"properties":{"title":{"type":"string"},"level":{"type":"integer","minimum":1,"maximum":6},"page":{"type":"integer","minimum":1}}}}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const BRIDGE_TOOL_REQUEST_GET: ToolSpec = ToolSpec {
|
||||||
|
name: "bridge_request_get",
|
||||||
|
display_name: "请求回查",
|
||||||
|
description: "按 request_id 查询统一 command log 与 domain event 视图。",
|
||||||
|
toolset_id: "toolset.observe_read",
|
||||||
|
invocation_kind: InvocationKind::Query,
|
||||||
|
effect: ToolEffect::Read,
|
||||||
|
requires_confirmation: false,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["workspaceId","requestId"],"properties":{"workspaceId":{"type":"string"},"requestId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const BRIDGE_TOOL_TRACE_GET: ToolSpec = ToolSpec {
|
||||||
|
name: "bridge_trace_get",
|
||||||
|
display_name: "链路回查",
|
||||||
|
description: "按 trace_id 查询统一 command log 与 domain event 视图。",
|
||||||
|
toolset_id: "toolset.observe_read",
|
||||||
|
invocation_kind: InvocationKind::Query,
|
||||||
|
effect: ToolEffect::Read,
|
||||||
|
requires_confirmation: false,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["workspaceId","traceId"],"properties":{"workspaceId":{"type":"string"},"traceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const BRIDGE_TOOL_COMMAND_GET: ToolSpec = ToolSpec {
|
||||||
|
name: "bridge_command_get",
|
||||||
|
display_name: "命令回查",
|
||||||
|
description: "按 command_id 查询统一 command log 与 domain event 视图。",
|
||||||
|
toolset_id: "toolset.observe_read",
|
||||||
|
invocation_kind: InvocationKind::Query,
|
||||||
|
effect: ToolEffect::Read,
|
||||||
|
requires_confirmation: false,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["workspaceId","commandId"],"properties":{"workspaceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const REPLAY_TOOL_EVENTS: ToolSpec = ToolSpec {
|
||||||
|
name: "event_replay",
|
||||||
|
display_name: "事件回放",
|
||||||
|
description: "基于统一事件流做回放预演,输出将被追平的 event 与派生批次摘要。",
|
||||||
|
toolset_id: "toolset.recovery_job",
|
||||||
|
invocation_kind: InvocationKind::Job,
|
||||||
|
effect: ToolEffect::Write,
|
||||||
|
requires_confirmation: true,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const INDEX_TOOL_REBUILD: ToolSpec = ToolSpec {
|
||||||
|
name: "index_rebuild",
|
||||||
|
display_name: "索引重建",
|
||||||
|
description: "根据事件流重建全文索引游标与派生批次,作为回放和恢复前置命令。",
|
||||||
|
toolset_id: "toolset.recovery_job",
|
||||||
|
invocation_kind: InvocationKind::Job,
|
||||||
|
effect: ToolEffect::Write,
|
||||||
|
requires_confirmation: true,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const ONLYOFFICE_TOOL_SESSION_RESOLVE: ToolSpec = ToolSpec {
|
||||||
|
name: "onlyoffice_session_resolve",
|
||||||
|
display_name: "OnlyOffice 会话解析",
|
||||||
|
description: "解析 OnlyOffice 的附件/页面/用户上下文,返回统一 session 与 asset 边界。",
|
||||||
|
toolset_id: "toolset.onlyoffice_service",
|
||||||
|
invocation_kind: InvocationKind::Job,
|
||||||
|
effect: ToolEffect::Read,
|
||||||
|
requires_confirmation: false,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["assetId"],"properties":{"assetId":{"type":"string"},"workspaceId":{"type":"string"},"documentId":{"type":"string"},"userId":{"type":"string"},"sessionId":{"type":"string"}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const ONLYOFFICE_TOOL_SIGN: ToolSpec = ToolSpec {
|
||||||
|
name: "onlyoffice_sign",
|
||||||
|
display_name: "OnlyOffice 签名",
|
||||||
|
description: "为 OnlyOffice config/document/editorConfig 生成 JWT 签名。",
|
||||||
|
toolset_id: "toolset.onlyoffice_service",
|
||||||
|
invocation_kind: InvocationKind::Job,
|
||||||
|
effect: ToolEffect::Read,
|
||||||
|
requires_confirmation: false,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","properties":{"config":{"type":"object"},"secret":{"type":"string"}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const ONLYOFFICE_TOOL_PREPARE_PROXY: ToolSpec = ToolSpec {
|
||||||
|
name: "onlyoffice_prepare_proxy",
|
||||||
|
display_name: "OnlyOffice 代理预处理",
|
||||||
|
description: "校验 proxy 回源目标并返回同源回源所需的上游 URL 与请求头。",
|
||||||
|
toolset_id: "toolset.onlyoffice_service",
|
||||||
|
invocation_kind: InvocationKind::Job,
|
||||||
|
effect: ToolEffect::Read,
|
||||||
|
requires_confirmation: false,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["encodedUrl"],"properties":{"encodedUrl":{"type":"string"},"method":{"enum":["GET","HEAD"]},"range":{"type":"string"}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const ONLYOFFICE_TOOL_PREPARE_CALLBACK: ToolSpec = ToolSpec {
|
||||||
|
name: "onlyoffice_prepare_callback",
|
||||||
|
display_name: "OnlyOffice callback 预处理",
|
||||||
|
description: "解析 callback 状态、下载地址和 session 边界,供写回链继续执行。",
|
||||||
|
toolset_id: "toolset.onlyoffice_service",
|
||||||
|
invocation_kind: InvocationKind::Job,
|
||||||
|
effect: ToolEffect::Write,
|
||||||
|
requires_confirmation: false,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["assetId","status","onlyofficeInternalUrl"],"properties":{"assetId":{"type":"string"},"documentId":{"type":"string"},"workspaceId":{"type":"string"},"userId":{"type":"string"},"sessionId":{"type":"string"},"status":{"type":"integer"},"url":{"type":"string"},"key":{"type":"string"},"onlyofficeInternalUrl":{"type":"string"}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const ONLYOFFICE_TOOL_PREPARE_FORCESAVE: ToolSpec = ToolSpec {
|
||||||
|
name: "onlyoffice_prepare_forcesave",
|
||||||
|
display_name: "OnlyOffice forcesave 预处理",
|
||||||
|
description: "生成触发 forcesave 所需的请求序列与 JWT 负载。",
|
||||||
|
toolset_id: "toolset.onlyoffice_service",
|
||||||
|
invocation_kind: InvocationKind::Job,
|
||||||
|
effect: ToolEffect::Write,
|
||||||
|
requires_confirmation: false,
|
||||||
|
input_schema_json:
|
||||||
|
r#"{"type":"object","required":["assetId","key","onlyofficeInternalUrl"],"properties":{"assetId":{"type":"string"},"key":{"type":"string"},"onlyofficeInternalUrl":{"type":"string"},"secret":{"type":"string"}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const DOC_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||||
|
id: "toolset.doc_read",
|
||||||
|
display_name: "文档读取",
|
||||||
|
description: "只读文档工具集合。",
|
||||||
|
write_toolset: false,
|
||||||
|
tool_names: &["doc_get", "doc_find"],
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const READONLY_TOOLSET: ToolSetSpec = ToolSetSpec {
|
||||||
|
id: "toolset.readonly",
|
||||||
|
display_name: "只读工具",
|
||||||
|
description: "Rust 只读工具集合。",
|
||||||
|
write_toolset: false,
|
||||||
|
tool_names: &["search_web"],
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MEDIA_TOOLSET: ToolSetSpec = ToolSetSpec {
|
||||||
|
id: "toolset.media_read",
|
||||||
|
display_name: "媒体读取",
|
||||||
|
description: "Rust 媒体读取工具集合。",
|
||||||
|
write_toolset: false,
|
||||||
|
tool_names: &["image_read"],
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const DOC_TOOLSET_WRITE: ToolSetSpec = ToolSetSpec {
|
||||||
|
id: "toolset.doc_write",
|
||||||
|
display_name: "文档写入",
|
||||||
|
description: "写入文档工具集合。",
|
||||||
|
write_toolset: true,
|
||||||
|
tool_names: &["doc_insert_blocks", "doc_replace_range"],
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MINDMAP_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||||
|
id: "toolset.mindmap_read",
|
||||||
|
display_name: "导图读取",
|
||||||
|
description: "只读思维导图工具集合。",
|
||||||
|
write_toolset: false,
|
||||||
|
tool_names: &["mindmap_get", "mindmap_get_subtree"],
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MINDMAP_TOOLSET_WRITE: ToolSetSpec = ToolSetSpec {
|
||||||
|
id: "toolset.mindmap_write",
|
||||||
|
display_name: "导图写入",
|
||||||
|
description: "写入思维导图的工具集合。",
|
||||||
|
write_toolset: true,
|
||||||
|
tool_names: &[
|
||||||
|
"mindmap_put",
|
||||||
|
"mindmap_apply_ops",
|
||||||
|
"mindmap_expand_node",
|
||||||
|
"mindmap_empty_trash",
|
||||||
|
"mindmap_outline_to_mindmap",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const OBSERVE_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||||
|
id: "toolset.observe_read",
|
||||||
|
display_name: "统一观测",
|
||||||
|
description: "按 request、trace、command 回查统一日志与事件视图。",
|
||||||
|
write_toolset: false,
|
||||||
|
tool_names: &["bridge_request_get", "bridge_trace_get", "bridge_command_get"],
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const RECOVERY_TOOLSET_JOB: ToolSetSpec = ToolSetSpec {
|
||||||
|
id: "toolset.recovery_job",
|
||||||
|
display_name: "恢复与重建",
|
||||||
|
description: "统一事件回放、索引重建与恢复类任务集合。",
|
||||||
|
write_toolset: true,
|
||||||
|
tool_names: &["event_replay", "index_rebuild"],
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const ONLYOFFICE_TOOLSET_SERVICE: ToolSetSpec = ToolSetSpec {
|
||||||
|
id: "toolset.onlyoffice_service",
|
||||||
|
display_name: "OnlyOffice 服务边界",
|
||||||
|
description: "供 Web route / CLI / AI 共享的 OnlyOffice 对象适配工具集合。",
|
||||||
|
write_toolset: true,
|
||||||
|
tool_names: &[
|
||||||
|
"onlyoffice_session_resolve",
|
||||||
|
"onlyoffice_sign",
|
||||||
|
"onlyoffice_prepare_proxy",
|
||||||
|
"onlyoffice_prepare_callback",
|
||||||
|
"onlyoffice_prepare_forcesave",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const SLASH_TOOLSET_WRITE: ToolSetSpec = ToolSetSpec {
|
||||||
|
id: "toolset.slash_write",
|
||||||
|
display_name: "斜杠命令",
|
||||||
|
description: "Rust 斜杠命令工具集合。",
|
||||||
|
write_toolset: true,
|
||||||
|
tool_names: &["slash_run"],
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const DOC_TOOL_REGISTRY: ToolRegistry = ToolRegistry::new(
|
||||||
|
&[
|
||||||
|
READONLY_TOOLSET,
|
||||||
|
MEDIA_TOOLSET,
|
||||||
|
SLASH_TOOLSET_WRITE,
|
||||||
|
DOC_TOOLSET_READ,
|
||||||
|
DOC_TOOLSET_WRITE,
|
||||||
|
MINDMAP_TOOLSET_READ,
|
||||||
|
MINDMAP_TOOLSET_WRITE,
|
||||||
|
OBSERVE_TOOLSET_READ,
|
||||||
|
RECOVERY_TOOLSET_JOB,
|
||||||
|
ONLYOFFICE_TOOLSET_SERVICE,
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
SEARCH_WEB_TOOL,
|
||||||
|
DOC_TOOL_GET,
|
||||||
|
DOC_TOOL_FIND,
|
||||||
|
IMAGE_READ_TOOL,
|
||||||
|
DOC_TOOL_INSERT_BLOCKS,
|
||||||
|
DOC_TOOL_REPLACE_RANGE,
|
||||||
|
SLASH_RUN_TOOL,
|
||||||
|
MINDMAP_TOOL_GET,
|
||||||
|
MINDMAP_TOOL_GET_SUBTREE,
|
||||||
|
MINDMAP_TOOL_PUT,
|
||||||
|
MINDMAP_TOOL_APPLY_OPS,
|
||||||
|
MINDMAP_TOOL_EXPAND_NODE,
|
||||||
|
MINDMAP_TOOL_EMPTY_TRASH,
|
||||||
|
MINDMAP_TOOL_OUTLINE_TO_MINDMAP,
|
||||||
|
BRIDGE_TOOL_REQUEST_GET,
|
||||||
|
BRIDGE_TOOL_TRACE_GET,
|
||||||
|
BRIDGE_TOOL_COMMAND_GET,
|
||||||
|
REPLAY_TOOL_EVENTS,
|
||||||
|
INDEX_TOOL_REBUILD,
|
||||||
|
ONLYOFFICE_TOOL_SESSION_RESOLVE,
|
||||||
|
ONLYOFFICE_TOOL_SIGN,
|
||||||
|
ONLYOFFICE_TOOL_PREPARE_PROXY,
|
||||||
|
ONLYOFFICE_TOOL_PREPARE_CALLBACK,
|
||||||
|
ONLYOFFICE_TOOL_PREPARE_FORCESAVE,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
pub fn default_tool_registry() -> &'static ToolRegistry {
|
||||||
|
&DOC_TOOL_REGISTRY
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tool_mode_label(mode: &ToolExecutionMode) -> &'static str {
|
||||||
|
match mode {
|
||||||
|
ToolExecutionMode::Plan => "plan",
|
||||||
|
ToolExecutionMode::Result => "result",
|
||||||
|
ToolExecutionMode::ExplainPlan => "explain-plan",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invocation_kind_label(kind: &InvocationKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
InvocationKind::Command => "command",
|
||||||
|
InvocationKind::Query => "query",
|
||||||
|
InvocationKind::Job => "job",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tool_effect_label(effect: &ToolEffect) -> &'static str {
|
||||||
|
match effect {
|
||||||
|
ToolEffect::Read => "read",
|
||||||
|
ToolEffect::Write => "write",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ authors.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
core-domain = { path = "../core-domain" }
|
core-domain = { path = "../core-domain" }
|
||||||
event-log = { path = "../event-log" }
|
event-log = { path = "../event-log" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
regex = "1"
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use event_log::DomainEventRecord;
|
use event_log::DomainEventRecord;
|
||||||
|
use regex::RegexBuilder;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum IndexedEntityKind {
|
pub enum IndexedEntityKind {
|
||||||
@@ -57,6 +62,107 @@ pub struct ProjectionResult {
|
|||||||
pub batches: Vec<ProjectedDocumentBatch>,
|
pub batches: Vec<ProjectedDocumentBatch>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SearchDocumentRecord {
|
||||||
|
pub id: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub raw_text: Option<String>,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SearchMindmapRecord {
|
||||||
|
pub document_id: String,
|
||||||
|
pub data: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SearchTableRecord {
|
||||||
|
pub id: String,
|
||||||
|
pub document_id: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SearchTableRowRecord {
|
||||||
|
pub table_id: String,
|
||||||
|
pub document_id: String,
|
||||||
|
pub row_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SearchAssetRecord {
|
||||||
|
pub id: String,
|
||||||
|
pub document_id: String,
|
||||||
|
pub asset_type: Option<String>,
|
||||||
|
pub file_name: Option<String>,
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
pub ocr_text: Option<String>,
|
||||||
|
pub ocr_status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SearchDocumentsDataset {
|
||||||
|
pub documents: Vec<SearchDocumentRecord>,
|
||||||
|
pub mindmaps: Vec<SearchMindmapRecord>,
|
||||||
|
pub tables: Vec<SearchTableRecord>,
|
||||||
|
pub table_rows: Vec<SearchTableRowRecord>,
|
||||||
|
pub assets: Vec<SearchAssetRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SearchDocumentsRequest {
|
||||||
|
pub query: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub page_id: Option<String>,
|
||||||
|
pub limit: usize,
|
||||||
|
pub title_only: bool,
|
||||||
|
pub exact: bool,
|
||||||
|
pub include_ocr: bool,
|
||||||
|
pub time_range: String,
|
||||||
|
pub time_field: String,
|
||||||
|
pub custom_range_from: Option<String>,
|
||||||
|
pub custom_range_to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum SearchMatchField {
|
||||||
|
Title,
|
||||||
|
Content,
|
||||||
|
Recent,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RankedSearchDocument {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub snippet: String,
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
pub match_field: SearchMatchField,
|
||||||
|
pub has_ocr: bool,
|
||||||
|
pub public_path: String,
|
||||||
|
pub score: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SearchDocumentsEvaluation {
|
||||||
|
pub enqueue_asset_ids: Vec<String>,
|
||||||
|
pub results: Vec<RankedSearchDocument>,
|
||||||
|
}
|
||||||
|
|
||||||
pub trait DomainEventProjector {
|
pub trait DomainEventProjector {
|
||||||
fn project(&self, event: &DomainEventRecord) -> Vec<IndexedDocument>;
|
fn project(&self, event: &DomainEventRecord) -> Vec<IndexedDocument>;
|
||||||
}
|
}
|
||||||
@@ -150,10 +256,22 @@ pub fn search_pages(
|
|||||||
let query = query.trim().to_lowercase();
|
let query = query.trim().to_lowercase();
|
||||||
let hits = documents
|
let hits = documents
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|document| matches!(document.entity_kind, IndexedEntityKind::PageTitle | IndexedEntityKind::PageSummary))
|
.filter(|document| {
|
||||||
|
matches!(
|
||||||
|
document.entity_kind,
|
||||||
|
IndexedEntityKind::PageTitle | IndexedEntityKind::PageSummary
|
||||||
|
)
|
||||||
|
})
|
||||||
.filter(|document| workspace_id.map_or(true, |workspace| document.workspace_id == workspace))
|
.filter(|document| workspace_id.map_or(true, |workspace| document.workspace_id == workspace))
|
||||||
.filter(|document| document.title.as_deref().unwrap_or("").to_lowercase().contains(&query)
|
.filter(|document| {
|
||||||
|| document.content.to_lowercase().contains(&query))
|
document
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase()
|
||||||
|
.contains(&query)
|
||||||
|
|| document.content.to_lowercase().contains(&query)
|
||||||
|
})
|
||||||
.take(limit)
|
.take(limit)
|
||||||
.map(|document| SearchHit {
|
.map(|document| SearchHit {
|
||||||
workspace_id: document.workspace_id.clone(),
|
workspace_id: document.workspace_id.clone(),
|
||||||
@@ -180,8 +298,17 @@ pub fn search_blocks(
|
|||||||
let query = query.trim().to_lowercase();
|
let query = query.trim().to_lowercase();
|
||||||
let hits = documents
|
let hits = documents
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|document| matches!(document.entity_kind, IndexedEntityKind::BlockContent | IndexedEntityKind::BlockPath))
|
.filter(|document| {
|
||||||
.filter(|document| page_id.map_or(true, |page| document.parent_id.as_deref() == Some(page) || document.entity_id == page))
|
matches!(
|
||||||
|
document.entity_kind,
|
||||||
|
IndexedEntityKind::BlockContent | IndexedEntityKind::BlockPath
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.filter(|document| {
|
||||||
|
page_id.map_or(true, |page| {
|
||||||
|
document.parent_id.as_deref() == Some(page) || document.entity_id == page
|
||||||
|
})
|
||||||
|
})
|
||||||
.filter(|document| document.content.to_lowercase().contains(&query))
|
.filter(|document| document.content.to_lowercase().contains(&query))
|
||||||
.take(limit)
|
.take(limit)
|
||||||
.map(|document| SearchHit {
|
.map(|document| SearchHit {
|
||||||
@@ -200,6 +327,281 @@ pub fn search_blocks(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn evaluate_search_documents(
|
||||||
|
request: &SearchDocumentsRequest,
|
||||||
|
dataset: &SearchDocumentsDataset,
|
||||||
|
) -> SearchDocumentsEvaluation {
|
||||||
|
let normalized_query = request.query.trim();
|
||||||
|
if normalized_query.is_empty() {
|
||||||
|
return SearchDocumentsEvaluation {
|
||||||
|
enqueue_asset_ids: Vec::new(),
|
||||||
|
results: Vec::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized_lower = normalized_query.to_lowercase();
|
||||||
|
let boundary_iso = match request.time_range.as_str() {
|
||||||
|
"7d" => iso_days_ago(7),
|
||||||
|
"30d" => iso_days_ago(30),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let eligible_docs: Vec<&SearchDocumentRecord> = dataset
|
||||||
|
.documents
|
||||||
|
.iter()
|
||||||
|
.filter(|document| document.workspace_id == request.workspace_id)
|
||||||
|
.filter(|document| {
|
||||||
|
if let Some(page_id) = request.page_id.as_ref() {
|
||||||
|
document.id == *page_id
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(|document| match request.time_field.as_str() {
|
||||||
|
"created" => within_range(
|
||||||
|
document.created_at.as_deref(),
|
||||||
|
boundary_iso.as_deref(),
|
||||||
|
request.custom_range_from.as_deref(),
|
||||||
|
request.custom_range_to.as_deref(),
|
||||||
|
),
|
||||||
|
_ => within_range(
|
||||||
|
document.updated_at
|
||||||
|
.as_deref()
|
||||||
|
.or(document.created_at.as_deref()),
|
||||||
|
boundary_iso.as_deref(),
|
||||||
|
request.custom_range_from.as_deref(),
|
||||||
|
request.custom_range_to.as_deref(),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let eligible_doc_ids: HashSet<&str> = eligible_docs.iter().map(|document| document.id.as_str()).collect();
|
||||||
|
let doc_map: HashMap<&str, &SearchDocumentRecord> = eligible_docs
|
||||||
|
.iter()
|
||||||
|
.map(|document| (document.id.as_str(), *document))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut matches = HashMap::<String, SearchMatchInfo>::new();
|
||||||
|
for document in &eligible_docs {
|
||||||
|
let title = normalize_title(document.title.as_deref());
|
||||||
|
let title_lower = title.to_lowercase();
|
||||||
|
let hit_title = if request.exact {
|
||||||
|
title_lower == normalized_lower
|
||||||
|
} else {
|
||||||
|
title_lower.contains(&normalized_lower)
|
||||||
|
};
|
||||||
|
|
||||||
|
if hit_title {
|
||||||
|
upsert_match(
|
||||||
|
&mut matches,
|
||||||
|
&document.id,
|
||||||
|
SearchMatchInfo {
|
||||||
|
score: 3.0,
|
||||||
|
match_field: SearchMatchField::Title,
|
||||||
|
snippet: build_snippet(&title, normalized_query),
|
||||||
|
has_ocr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.title_only {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_text = document.raw_text.as_deref().unwrap_or("").trim();
|
||||||
|
if !raw_text.is_empty() && raw_text.to_lowercase().contains(&normalized_lower) {
|
||||||
|
upsert_match(
|
||||||
|
&mut matches,
|
||||||
|
&document.id,
|
||||||
|
SearchMatchInfo {
|
||||||
|
score: 2.0,
|
||||||
|
match_field: SearchMatchField::Content,
|
||||||
|
snippet: build_snippet(raw_text, normalized_query),
|
||||||
|
has_ocr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut table_title_by_id = HashMap::<String, String>::new();
|
||||||
|
if !request.title_only {
|
||||||
|
for mindmap in &dataset.mindmaps {
|
||||||
|
if !eligible_doc_ids.contains(mindmap.document_id.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let text = extract_text_from_mindmap_data(&mindmap.data, 60_000);
|
||||||
|
if text.is_empty() || !text.to_lowercase().contains(&normalized_lower) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
upsert_match(
|
||||||
|
&mut matches,
|
||||||
|
&mindmap.document_id,
|
||||||
|
SearchMatchInfo {
|
||||||
|
score: 1.6,
|
||||||
|
match_field: SearchMatchField::Content,
|
||||||
|
snippet: build_snippet(&format!("思维导图:{text}"), normalized_query),
|
||||||
|
has_ocr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for table in &dataset.tables {
|
||||||
|
if !eligible_doc_ids.contains(table.document_id.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let title = normalize_title(table.title.as_deref());
|
||||||
|
table_title_by_id.insert(table.id.clone(), title.clone());
|
||||||
|
if title.to_lowercase().contains(&normalized_lower) {
|
||||||
|
upsert_match(
|
||||||
|
&mut matches,
|
||||||
|
&table.document_id,
|
||||||
|
SearchMatchInfo {
|
||||||
|
score: 1.5,
|
||||||
|
match_field: SearchMatchField::Content,
|
||||||
|
snippet: build_snippet(&format!("表格:{title}"), normalized_query),
|
||||||
|
has_ocr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in &dataset.table_rows {
|
||||||
|
if !eligible_doc_ids.contains(row.document_id.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let row_hash = row.row_hash.as_deref().unwrap_or("").trim();
|
||||||
|
if row_hash.is_empty() || !row_hash.to_lowercase().contains(&normalized_lower) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let table_title = table_title_by_id
|
||||||
|
.get(&row.table_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "未命名表格".into());
|
||||||
|
upsert_match(
|
||||||
|
&mut matches,
|
||||||
|
&row.document_id,
|
||||||
|
SearchMatchInfo {
|
||||||
|
score: 1.4,
|
||||||
|
match_field: SearchMatchField::Content,
|
||||||
|
snippet: build_snippet(
|
||||||
|
&format!("表格:{table_title}\n{row_hash}"),
|
||||||
|
normalized_query,
|
||||||
|
),
|
||||||
|
has_ocr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut enqueue_asset_ids = Vec::new();
|
||||||
|
for asset in &dataset.assets {
|
||||||
|
if !eligible_doc_ids.contains(asset.document_id.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_name = asset.file_name.as_deref().unwrap_or("").trim();
|
||||||
|
if !request.title_only && !file_name.is_empty() && file_name.to_lowercase().contains(&normalized_lower) {
|
||||||
|
upsert_match(
|
||||||
|
&mut matches,
|
||||||
|
&asset.document_id,
|
||||||
|
SearchMatchInfo {
|
||||||
|
score: 1.2,
|
||||||
|
match_field: SearchMatchField::Content,
|
||||||
|
snippet: build_snippet(&format!("附件:{file_name}"), normalized_query),
|
||||||
|
has_ocr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !request.include_ocr {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ocr_text = asset.ocr_text.as_deref().unwrap_or("").trim();
|
||||||
|
if ocr_text.is_empty() {
|
||||||
|
let ocr_status = asset.ocr_status.as_deref().unwrap_or("").trim();
|
||||||
|
let busy = ocr_status == "queued" || ocr_status == "running";
|
||||||
|
if !busy
|
||||||
|
&& should_extract_attachment_text(
|
||||||
|
asset.asset_type.as_deref(),
|
||||||
|
asset.mime_type.as_deref(),
|
||||||
|
asset.file_name.as_deref(),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
enqueue_asset_ids.push(asset.id.clone());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ocr_text.to_lowercase().contains(&normalized_lower) {
|
||||||
|
upsert_match(
|
||||||
|
&mut matches,
|
||||||
|
&asset.document_id,
|
||||||
|
SearchMatchInfo {
|
||||||
|
score: 1.7,
|
||||||
|
match_field: SearchMatchField::Content,
|
||||||
|
snippet: build_snippet(
|
||||||
|
&format!("附件:{}\n{ocr_text}", if file_name.is_empty() { asset.id.as_str() } else { file_name }),
|
||||||
|
normalized_query,
|
||||||
|
),
|
||||||
|
has_ocr: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut results = matches
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(document_id, matched)| {
|
||||||
|
let document = doc_map.get(document_id.as_str())?;
|
||||||
|
Some(RankedSearchDocument {
|
||||||
|
id: document.id.clone(),
|
||||||
|
title: normalize_title(document.title.as_deref()),
|
||||||
|
snippet: matched.snippet,
|
||||||
|
updated_at: document.updated_at.clone(),
|
||||||
|
created_at: document.created_at.clone(),
|
||||||
|
match_field: matched.match_field,
|
||||||
|
has_ocr: matched.has_ocr,
|
||||||
|
public_path: format!("/documents/{}", document.id),
|
||||||
|
score: matched.score,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
results.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.score
|
||||||
|
.partial_cmp(&left.score)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
.then_with(|| {
|
||||||
|
let left_time = left
|
||||||
|
.updated_at
|
||||||
|
.as_deref()
|
||||||
|
.or(left.created_at.as_deref())
|
||||||
|
.unwrap_or("");
|
||||||
|
let right_time = right
|
||||||
|
.updated_at
|
||||||
|
.as_deref()
|
||||||
|
.or(right.created_at.as_deref())
|
||||||
|
.unwrap_or("");
|
||||||
|
right_time.cmp(left_time)
|
||||||
|
})
|
||||||
|
.then_with(|| left.title.cmp(&right.title))
|
||||||
|
});
|
||||||
|
results.truncate(request.limit);
|
||||||
|
|
||||||
|
enqueue_asset_ids.sort();
|
||||||
|
enqueue_asset_ids.dedup();
|
||||||
|
if enqueue_asset_ids.len() > 3 {
|
||||||
|
enqueue_asset_ids.truncate(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchDocumentsEvaluation {
|
||||||
|
enqueue_asset_ids,
|
||||||
|
results,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct MinimalWorkspaceProjector;
|
pub struct MinimalWorkspaceProjector;
|
||||||
|
|
||||||
impl DomainEventProjector for MinimalWorkspaceProjector {
|
impl DomainEventProjector for MinimalWorkspaceProjector {
|
||||||
@@ -233,11 +635,258 @@ impl DomainEventProjector for MinimalWorkspaceProjector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct SearchMatchInfo {
|
||||||
|
score: f64,
|
||||||
|
match_field: SearchMatchField,
|
||||||
|
snippet: String,
|
||||||
|
has_ocr: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_title(title: Option<&str>) -> String {
|
||||||
|
let normalized = title.unwrap_or("").trim();
|
||||||
|
if normalized.is_empty() {
|
||||||
|
"无标题".into()
|
||||||
|
} else {
|
||||||
|
normalized.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn within_range(
|
||||||
|
timestamp: Option<&str>,
|
||||||
|
boundary_iso: Option<&str>,
|
||||||
|
custom_range_from: Option<&str>,
|
||||||
|
custom_range_to: Option<&str>,
|
||||||
|
) -> bool {
|
||||||
|
let Some(timestamp) = timestamp else {
|
||||||
|
return boundary_iso.is_none() && custom_range_from.is_none() && custom_range_to.is_none();
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(boundary_iso) = boundary_iso {
|
||||||
|
if timestamp < boundary_iso {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(custom_range_from) = custom_range_from {
|
||||||
|
if timestamp < custom_range_from {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(custom_range_to) = custom_range_to {
|
||||||
|
if timestamp > custom_range_to {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iso_days_ago(days: i64) -> Option<String> {
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
let seconds = days.checked_mul(24 * 60 * 60)?;
|
||||||
|
let duration = Duration::from_secs(seconds as u64);
|
||||||
|
let cutoff = SystemTime::now().checked_sub(duration)?;
|
||||||
|
let datetime = chrono_like::system_time_to_iso(cutoff)?;
|
||||||
|
Some(datetime)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_match(
|
||||||
|
matches: &mut HashMap<String, SearchMatchInfo>,
|
||||||
|
document_id: &str,
|
||||||
|
patch: SearchMatchInfo,
|
||||||
|
) {
|
||||||
|
let Some(previous) = matches.get(document_id).cloned() else {
|
||||||
|
matches.insert(document_id.into(), patch);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut next = SearchMatchInfo {
|
||||||
|
score: previous.score.max(patch.score),
|
||||||
|
match_field: patch.match_field.clone(),
|
||||||
|
snippet: patch.snippet.clone(),
|
||||||
|
has_ocr: previous.has_ocr || patch.has_ocr,
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches!(previous.match_field, SearchMatchField::Title)
|
||||||
|
&& !matches!(patch.match_field, SearchMatchField::Title)
|
||||||
|
{
|
||||||
|
next.match_field = SearchMatchField::Title;
|
||||||
|
next.snippet = previous.snippet;
|
||||||
|
} else if patch.score <= previous.score {
|
||||||
|
next.match_field = previous.match_field;
|
||||||
|
next.snippet = previous.snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.insert(document_id.into(), next);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String {
|
||||||
|
fn walk(node: &Value, output: &mut Vec<String>, max_chars: usize) {
|
||||||
|
if output.join("\n").len() >= max_chars {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match node {
|
||||||
|
Value::Object(map) => {
|
||||||
|
if let Some(Value::Object(data)) = map.get("data") {
|
||||||
|
if let Some(Value::String(text)) = data.get("text") {
|
||||||
|
let normalized = normalize_text(text);
|
||||||
|
if !normalized.is_empty() {
|
||||||
|
output.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(Value::Array(children)) = map.get("children") {
|
||||||
|
for child in children {
|
||||||
|
walk(child, output, max_chars);
|
||||||
|
if output.join("\n").len() >= max_chars {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(root) = map.get("root") {
|
||||||
|
walk(root, output, max_chars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(items) => {
|
||||||
|
for item in items {
|
||||||
|
walk(item, output, max_chars);
|
||||||
|
if output.join("\n").len() >= max_chars {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = Vec::new();
|
||||||
|
walk(value, &mut output, max_chars);
|
||||||
|
let joined = output.join("\n").trim().to_string();
|
||||||
|
if joined.len() > max_chars {
|
||||||
|
format!("{}…", &joined[..max_chars])
|
||||||
|
} else {
|
||||||
|
joined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_text(value: &str) -> String {
|
||||||
|
value.split_whitespace().collect::<Vec<_>>().join(" ").trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_html(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_snippet(text: &str, keyword: &str) -> String {
|
||||||
|
let source = text.trim();
|
||||||
|
if source.is_empty() {
|
||||||
|
return "暂无正文内容".into();
|
||||||
|
}
|
||||||
|
let escaped = escape_html(source);
|
||||||
|
if keyword.trim().is_empty() {
|
||||||
|
return truncate_snippet(&escaped);
|
||||||
|
}
|
||||||
|
|
||||||
|
let regex = match RegexBuilder::new(®ex::escape(keyword))
|
||||||
|
.case_insensitive(true)
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(regex) => regex,
|
||||||
|
Err(_) => return truncate_snippet(&escaped),
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(found) = regex.find(&escaped) else {
|
||||||
|
return truncate_snippet(&escaped);
|
||||||
|
};
|
||||||
|
|
||||||
|
let start = found.start().saturating_sub(20);
|
||||||
|
let end = (found.end() + 80).min(escaped.len());
|
||||||
|
let segment = escaped[start..end].to_string();
|
||||||
|
regex
|
||||||
|
.replace_all(&segment, |captures: ®ex::Captures<'_>| {
|
||||||
|
format!("<mark>{}</mark>", &captures[0])
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_snippet(value: &str) -> String {
|
||||||
|
let mut chars = value.chars();
|
||||||
|
let truncated = chars.by_ref().take(120).collect::<String>();
|
||||||
|
if chars.next().is_some() {
|
||||||
|
format!("{truncated}…")
|
||||||
|
} else {
|
||||||
|
truncated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_extract_attachment_text(
|
||||||
|
asset_type: Option<&str>,
|
||||||
|
mime_type: Option<&str>,
|
||||||
|
file_name: Option<&str>,
|
||||||
|
) -> bool {
|
||||||
|
if asset_type.unwrap_or("").trim() != "file" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mime_type = mime_type.unwrap_or("").trim().to_lowercase();
|
||||||
|
if matches!(
|
||||||
|
mime_type.as_str(),
|
||||||
|
"application/pdf"
|
||||||
|
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
| "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||||
|
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_name = file_name.unwrap_or("").trim().to_lowercase();
|
||||||
|
[".pdf", ".docx", ".pptx", ".xlsx"]
|
||||||
|
.iter()
|
||||||
|
.any(|suffix| file_name.ends_with(suffix))
|
||||||
|
}
|
||||||
|
|
||||||
|
mod chrono_like {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
pub fn system_time_to_iso(time: SystemTime) -> Option<String> {
|
||||||
|
let duration = time.duration_since(UNIX_EPOCH).ok()?;
|
||||||
|
let seconds = duration.as_secs() as i64;
|
||||||
|
let days = seconds.div_euclid(86_400);
|
||||||
|
let secs_of_day = seconds.rem_euclid(86_400);
|
||||||
|
|
||||||
|
let (year, month, day) = civil_from_days(days)?;
|
||||||
|
let hour = secs_of_day / 3_600;
|
||||||
|
let minute = (secs_of_day % 3_600) / 60;
|
||||||
|
let second = secs_of_day % 60;
|
||||||
|
Some(format!(
|
||||||
|
"{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn civil_from_days(days: i64) -> Option<(i64, i64, i64)> {
|
||||||
|
let z = days.checked_add(719_468)?;
|
||||||
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||||
|
let doe = z - era * 146_097;
|
||||||
|
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||||
|
let y = yoe + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||||
|
let m = mp + if mp < 10 { 3 } else { -9 };
|
||||||
|
let year = y + if m <= 2 { 1 } else { 0 };
|
||||||
|
Some((year, m, d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use core_domain::Timestamp;
|
use core_domain::Timestamp;
|
||||||
use event_log::EventStatus;
|
use event_log::EventStatus;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
fn cursor() -> IndexCursor {
|
fn cursor() -> IndexCursor {
|
||||||
IndexCursor {
|
IndexCursor {
|
||||||
@@ -352,4 +1001,68 @@ mod tests {
|
|||||||
assert_eq!(blocks.hits.len(), 1);
|
assert_eq!(blocks.hits.len(), 1);
|
||||||
assert_eq!(blocks.hits[0].entity_id, "block_1");
|
assert_eq!(blocks.hits[0].entity_id, "block_1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evaluate_search_documents_uses_rust_scoring_and_snippet() {
|
||||||
|
let result = evaluate_search_documents(
|
||||||
|
&SearchDocumentsRequest {
|
||||||
|
query: "rust".into(),
|
||||||
|
workspace_id: "ws_1".into(),
|
||||||
|
page_id: None,
|
||||||
|
limit: 10,
|
||||||
|
title_only: false,
|
||||||
|
exact: false,
|
||||||
|
include_ocr: true,
|
||||||
|
time_range: "any".into(),
|
||||||
|
time_field: "updated".into(),
|
||||||
|
custom_range_from: None,
|
||||||
|
custom_range_to: None,
|
||||||
|
},
|
||||||
|
&SearchDocumentsDataset {
|
||||||
|
documents: vec![
|
||||||
|
SearchDocumentRecord {
|
||||||
|
id: "page_1".into(),
|
||||||
|
workspace_id: "ws_1".into(),
|
||||||
|
title: Some("Rust Notes".into()),
|
||||||
|
raw_text: Some("正文包含 rust 搜索".into()),
|
||||||
|
created_at: Some("2026-04-11T00:00:01Z".into()),
|
||||||
|
updated_at: Some("2026-04-11T00:00:02Z".into()),
|
||||||
|
},
|
||||||
|
SearchDocumentRecord {
|
||||||
|
id: "page_2".into(),
|
||||||
|
workspace_id: "ws_1".into(),
|
||||||
|
title: Some("附件页".into()),
|
||||||
|
raw_text: Some("".into()),
|
||||||
|
created_at: Some("2026-04-11T00:00:01Z".into()),
|
||||||
|
updated_at: Some("2026-04-11T00:00:03Z".into()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mindmaps: vec![SearchMindmapRecord {
|
||||||
|
document_id: "page_1".into(),
|
||||||
|
data: json!({
|
||||||
|
"root": {
|
||||||
|
"data": { "text": "Rust 脑图节点" },
|
||||||
|
"children": [],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
tables: vec![],
|
||||||
|
table_rows: vec![],
|
||||||
|
assets: vec![SearchAssetRecord {
|
||||||
|
id: "asset_1".into(),
|
||||||
|
document_id: "page_2".into(),
|
||||||
|
asset_type: Some("file".into()),
|
||||||
|
file_name: Some("demo.pdf".into()),
|
||||||
|
mime_type: Some("application/pdf".into()),
|
||||||
|
ocr_text: None,
|
||||||
|
ocr_status: Some("idle".into()),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(result.results.len(), 1);
|
||||||
|
assert_eq!(result.results[0].id, "page_1");
|
||||||
|
assert!(result.results[0].snippet.contains("<mark>Rust</mark>") || result.results[0].snippet.contains("<mark>rust</mark>"));
|
||||||
|
assert_eq!(result.enqueue_asset_ids, vec!["asset_1".to_string()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "mnote-cli"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.5.38", features = ["derive"] }
|
||||||
|
core-protocol = { path = "../core-protocol" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
storage-convex-bridge = { path = "../storage-convex-bridge" }
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# mnote-cli
|
||||||
|
|
||||||
|
当前 crate 是 Phase 2 的最小 CLI 协议入口。
|
||||||
|
|
||||||
|
当前目标不是直接替代全部执行链,而是先冻结这五类命令面的命名和 `--json` 输出协议:
|
||||||
|
|
||||||
|
- `page`
|
||||||
|
- `block`
|
||||||
|
- `search`
|
||||||
|
- `sidebar`
|
||||||
|
- `tool`
|
||||||
|
|
||||||
|
当前最小子命令面:
|
||||||
|
|
||||||
|
- `page get`
|
||||||
|
- `page title`
|
||||||
|
- `page save`
|
||||||
|
- `block insert`
|
||||||
|
- `block patch`
|
||||||
|
- `search documents`
|
||||||
|
- `search blocks`
|
||||||
|
- `sidebar dataset`
|
||||||
|
- `tool run`
|
||||||
|
|
||||||
|
最小示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json page get --page-id page_demo --workspace-id ws_demo
|
||||||
|
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json page title --page-id page_demo --workspace-id ws_demo --title "新标题"
|
||||||
|
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json page save --page-id page_demo --workspace-id ws_demo --content-json '[{\"id\":\"block_1\"}]'
|
||||||
|
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json sidebar dataset --workspace-id ws_demo
|
||||||
|
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json tool run --tool-name doc_get --kind query --mode explain-plan --args-json '{"pageId":"page_demo"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
当前输出的重点字段:
|
||||||
|
|
||||||
|
- `domain` / `action`
|
||||||
|
- `context.requestId` / `context.traceId`
|
||||||
|
- `operation.kind`
|
||||||
|
- `operation.name` 或 `operation.toolName`
|
||||||
|
- `operation.executionMode` / `operation.toolsetId` / `operation.effect`
|
||||||
|
- `operation.transport.functionName`
|
||||||
|
- `operation.transport.payloadJson`
|
||||||
|
- `operation.transport.argsJson`
|
||||||
|
|
||||||
|
`tool run` 当前会先校验 Rust 内置 tool registry,并统一暴露 `validateOnly` / `dryRun` / `explain-plan` 等安全模式字段。后续阶段会继续把这些 CLI 面接到真实执行器,但命令命名和 JSON 契约应尽量保持稳定。
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,477 @@
|
|||||||
|
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||||
|
use core_protocol::{InvocationKind, ToolExecutionMode};
|
||||||
|
use mnote_cli::{
|
||||||
|
plan_block_insert, plan_block_patch, plan_mindmap_get, plan_mindmap_op, plan_mindmap_put,
|
||||||
|
plan_page_get, plan_page_save, plan_page_title, plan_search_blocks,
|
||||||
|
plan_search_documents, plan_sidebar_dataset, plan_tool_run, render_plain_output, CliContext,
|
||||||
|
CliError, CliJsonOutput,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "mnote-cli")]
|
||||||
|
#[command(about = "Phase 2 最小 CLI 协议冻结器", long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(flatten)]
|
||||||
|
global: GlobalArgs,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug, Clone)]
|
||||||
|
struct GlobalArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
|
||||||
|
#[arg(long, default_value = "cli_user")]
|
||||||
|
actor_id: String,
|
||||||
|
|
||||||
|
#[arg(long, default_value = "human")]
|
||||||
|
actor_type: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
reason: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
idempotency_key: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
validate_only: bool,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
dry_run: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum Commands {
|
||||||
|
Page(PageCommand),
|
||||||
|
Block(BlockCommand),
|
||||||
|
Mindmap(MindmapCommand),
|
||||||
|
Search(SearchCommand),
|
||||||
|
Sidebar(SidebarCommand),
|
||||||
|
Tool(ToolCommand),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct PageCommand {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: PageAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum PageAction {
|
||||||
|
Get(PageGetArgs),
|
||||||
|
Title(PageTitleArgs),
|
||||||
|
Save(PageSaveArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct PageGetArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
page_id: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct PageTitleArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
page_id: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct PageSaveArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
page_id: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
revision: Option<u64>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
content_json: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
conflict_detection_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct BlockCommand {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: BlockAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum BlockAction {
|
||||||
|
Insert(BlockInsertArgs),
|
||||||
|
Patch(BlockPatchArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct BlockInsertArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
workspace_id: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
page_id: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
content: String,
|
||||||
|
|
||||||
|
#[arg(long, default_value = "paragraph")]
|
||||||
|
block_type: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
parent_block_id: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
prev_block_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct BlockPatchArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
page_id: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
block_id: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
revision: Option<u64>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
snapshot_json: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
conflict_detection_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct MindmapCommand {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: MindmapAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum MindmapAction {
|
||||||
|
Get(MindmapGetArgs),
|
||||||
|
Put(MindmapPutArgs),
|
||||||
|
Op(MindmapOpArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct MindmapGetArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
document_id: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
mindmap_id: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct MindmapPutArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
document_id: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
mindmap_id: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
data_json: String,
|
||||||
|
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
create_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct MindmapOpArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
document_id: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
mindmap_id: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
ops_json: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct SearchCommand {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: SearchAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum SearchAction {
|
||||||
|
Documents(SearchDocumentsArgs),
|
||||||
|
Blocks(SearchBlocksArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct SearchDocumentsArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
query: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long, default_value_t = 20)]
|
||||||
|
limit: u32,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct SearchBlocksArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
query: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
page_id: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long, default_value_t = 20)]
|
||||||
|
limit: u32,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct SidebarCommand {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: SidebarAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum SidebarAction {
|
||||||
|
Dataset(SidebarDatasetArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct SidebarDatasetArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
workspace_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct ToolCommand {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: ToolAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum ToolAction {
|
||||||
|
Run(ToolRunArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct ToolRunArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
tool_name: String,
|
||||||
|
|
||||||
|
#[arg(long, value_enum, default_value_t = ToolKindArg::Command)]
|
||||||
|
kind: ToolKindArg,
|
||||||
|
|
||||||
|
#[arg(long, value_enum, default_value_t = ToolModeArg::Plan)]
|
||||||
|
mode: ToolModeArg,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
args_json: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
||||||
|
enum ToolKindArg {
|
||||||
|
Command,
|
||||||
|
Query,
|
||||||
|
Job,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
||||||
|
enum ToolModeArg {
|
||||||
|
Plan,
|
||||||
|
Result,
|
||||||
|
ExplainPlan,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let context = CliContext {
|
||||||
|
actor_id: cli.global.actor_id,
|
||||||
|
actor_type: cli.global.actor_type,
|
||||||
|
session_id: cli.global.session_id,
|
||||||
|
reason: cli.global.reason,
|
||||||
|
idempotency_key: cli.global.idempotency_key,
|
||||||
|
validate_only: cli.global.validate_only,
|
||||||
|
dry_run: cli.global.dry_run,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = match cli.command {
|
||||||
|
Commands::Page(command) => match command.action {
|
||||||
|
PageAction::Get(args) => {
|
||||||
|
plan_page_get(&context, &args.page_id, args.workspace_id.as_deref())
|
||||||
|
}
|
||||||
|
PageAction::Title(args) => plan_page_title(
|
||||||
|
&context,
|
||||||
|
&args.page_id,
|
||||||
|
args.workspace_id.as_deref(),
|
||||||
|
&args.title,
|
||||||
|
),
|
||||||
|
PageAction::Save(args) => plan_page_save(
|
||||||
|
&context,
|
||||||
|
&args.page_id,
|
||||||
|
args.workspace_id.as_deref(),
|
||||||
|
args.revision,
|
||||||
|
&args.content_json,
|
||||||
|
args.conflict_detection_key.as_deref(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Commands::Block(command) => match command.action {
|
||||||
|
BlockAction::Insert(args) => plan_block_insert(
|
||||||
|
&context,
|
||||||
|
&args.workspace_id,
|
||||||
|
&args.page_id,
|
||||||
|
&args.content,
|
||||||
|
&args.block_type,
|
||||||
|
args.parent_block_id.as_deref(),
|
||||||
|
args.prev_block_id.as_deref(),
|
||||||
|
),
|
||||||
|
BlockAction::Patch(args) => plan_block_patch(
|
||||||
|
&context,
|
||||||
|
args.workspace_id.as_deref(),
|
||||||
|
&args.page_id,
|
||||||
|
&args.block_id,
|
||||||
|
args.revision,
|
||||||
|
&args.snapshot_json,
|
||||||
|
args.conflict_detection_key.as_deref(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Commands::Mindmap(command) => match command.action {
|
||||||
|
MindmapAction::Get(args) => plan_mindmap_get(
|
||||||
|
&context,
|
||||||
|
args.workspace_id.as_deref(),
|
||||||
|
&args.document_id,
|
||||||
|
&args.mindmap_id,
|
||||||
|
),
|
||||||
|
MindmapAction::Put(args) => plan_mindmap_put(
|
||||||
|
&context,
|
||||||
|
args.workspace_id.as_deref(),
|
||||||
|
&args.document_id,
|
||||||
|
&args.mindmap_id,
|
||||||
|
&args.data_json,
|
||||||
|
args.create_only,
|
||||||
|
),
|
||||||
|
MindmapAction::Op(args) => plan_mindmap_op(
|
||||||
|
&context,
|
||||||
|
args.workspace_id.as_deref(),
|
||||||
|
&args.document_id,
|
||||||
|
&args.mindmap_id,
|
||||||
|
&args.ops_json,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Commands::Search(command) => match command.action {
|
||||||
|
SearchAction::Documents(args) => plan_search_documents(
|
||||||
|
&context,
|
||||||
|
&args.query,
|
||||||
|
args.workspace_id.as_deref(),
|
||||||
|
args.limit,
|
||||||
|
args.cursor.as_deref(),
|
||||||
|
),
|
||||||
|
SearchAction::Blocks(args) => plan_search_blocks(
|
||||||
|
&context,
|
||||||
|
&args.query,
|
||||||
|
args.page_id.as_deref(),
|
||||||
|
args.limit,
|
||||||
|
args.cursor.as_deref(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Commands::Sidebar(command) => match command.action {
|
||||||
|
SidebarAction::Dataset(args) => plan_sidebar_dataset(&context, &args.workspace_id),
|
||||||
|
},
|
||||||
|
Commands::Tool(command) => match command.action {
|
||||||
|
ToolAction::Run(args) => plan_tool_run(
|
||||||
|
&context,
|
||||||
|
&args.tool_name,
|
||||||
|
to_invocation_kind(args.kind),
|
||||||
|
to_execution_mode(args.mode),
|
||||||
|
&args.args_json,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(output) => emit_success(&output, cli.global.json),
|
||||||
|
Err(error) => emit_error(&error, cli.global.json),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_success(output: &CliJsonOutput, json_mode: bool) {
|
||||||
|
if json_mode {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(output).expect("CLI JSON 输出必须可序列化")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("{}", render_plain_output(output));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_error(error: &CliError, json_mode: bool) -> ! {
|
||||||
|
if json_mode {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&serde_json::json!({
|
||||||
|
"ok": false,
|
||||||
|
"entrypoint": "mnote-cli",
|
||||||
|
"error": {
|
||||||
|
"code": error.code,
|
||||||
|
"message": error.message,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.expect("CLI 错误 JSON 输出必须可序列化")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
eprintln!("{}: {}", error.code, error.message);
|
||||||
|
}
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_invocation_kind(kind: ToolKindArg) -> InvocationKind {
|
||||||
|
match kind {
|
||||||
|
ToolKindArg::Command => InvocationKind::Command,
|
||||||
|
ToolKindArg::Query => InvocationKind::Query,
|
||||||
|
ToolKindArg::Job => InvocationKind::Job,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_execution_mode(mode: ToolModeArg) -> ToolExecutionMode {
|
||||||
|
match mode {
|
||||||
|
ToolModeArg::Plan => ToolExecutionMode::Plan,
|
||||||
|
ToolModeArg::Result => ToolExecutionMode::Result,
|
||||||
|
ToolModeArg::ExplainPlan => ToolExecutionMode::ExplainPlan,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,10 +22,16 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use core_protocol::{
|
use core_protocol::{
|
||||||
command::{
|
command::{
|
||||||
CreatePage, CreateWorkspace, PatchPageBlock, ReplaceMediaAssetStorage,
|
CopyTreeDocumentPages, CreateDocumentPage, CreatePage, CreateWorkspace,
|
||||||
SavePageContent, UpdatePageOptions, UpdatePageStats, UpdatePageTitle,
|
DeleteDocumentPage, DuplicateDocumentPage, MoveDocumentPage, PatchPageBlock,
|
||||||
|
PutMindmap, ReplaceMediaAssetStorage, RestoreDocumentPage, SavePageContent,
|
||||||
|
UpdatePageOptions, UpdatePageStats, UpdatePageTitle,
|
||||||
|
},
|
||||||
|
query::{
|
||||||
|
GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, GetPage,
|
||||||
|
GetPageContent, GetPageMeta, ListBridgeWorkspaceOverview, ListSidebarDataset,
|
||||||
|
SearchDocuments, SearchRecent,
|
||||||
},
|
},
|
||||||
query::{GetPage, GetPageContent, GetPageMeta, ListSidebarDataset},
|
|
||||||
ActorPayload, CommandEnvelope, QueryEnvelope, SourcePayload, TargetRef,
|
ActorPayload, CommandEnvelope, QueryEnvelope, SourcePayload, TargetRef,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,7 +86,8 @@ mod tests {
|
|||||||
validate_only: false,
|
validate_only: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = build_write_request(&demo_context(), &command).expect("write request should build");
|
let request =
|
||||||
|
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||||
assert_eq!(request.function_name, "pages:create");
|
assert_eq!(request.function_name, "pages:create");
|
||||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
assert_eq!(request.deployment_id.as_deref(), Some("dep_1"));
|
assert_eq!(request.deployment_id.as_deref(), Some("dep_1"));
|
||||||
@@ -99,7 +106,8 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = build_query_request(&demo_context(), &query).expect("query request should build");
|
let request =
|
||||||
|
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||||
assert_eq!(request.function_name, "pages:get");
|
assert_eq!(request.function_name, "pages:get");
|
||||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
assert_eq!(request.request_id, "req_1");
|
assert_eq!(request.request_id, "req_1");
|
||||||
@@ -134,12 +142,19 @@ mod tests {
|
|||||||
validate_only: false,
|
validate_only: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let pipeline = build_write_pipeline(&demo_context(), &command).expect("pipeline should build");
|
let pipeline =
|
||||||
|
build_write_pipeline(&demo_context(), &command).expect("pipeline should build");
|
||||||
assert_eq!(pipeline.command_log.command_name, "create_workspace");
|
assert_eq!(pipeline.command_log.command_name, "create_workspace");
|
||||||
assert!(pipeline.command_log.payload_summary.contains("request_id=req_1"));
|
assert!(pipeline
|
||||||
|
.command_log
|
||||||
|
.payload_summary
|
||||||
|
.contains("request_id=req_1"));
|
||||||
assert_eq!(pipeline.command_log.trace_id, "trace_1");
|
assert_eq!(pipeline.command_log.trace_id, "trace_1");
|
||||||
assert_eq!(pipeline.command_log.request_id, "req_1");
|
assert_eq!(pipeline.command_log.request_id, "req_1");
|
||||||
assert!(pipeline.domain_event.payload_json.contains("\"trace_id\":\"trace_1\""));
|
assert!(pipeline
|
||||||
|
.domain_event
|
||||||
|
.payload_json
|
||||||
|
.contains("\"trace_id\":\"trace_1\""));
|
||||||
assert_eq!(pipeline.result.event_ids, vec!["evt_cmd_2".to_string()]);
|
assert_eq!(pipeline.result.event_ids, vec!["evt_cmd_2".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,10 +188,13 @@ mod tests {
|
|||||||
validate_only: false,
|
validate_only: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = build_write_request(&demo_context(), &command).expect("write request should build");
|
let request =
|
||||||
|
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||||
assert_eq!(request.function_name, "documents:updateTitle");
|
assert_eq!(request.function_name, "documents:updateTitle");
|
||||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
assert!(request.payload_json.contains("\"name\":\"documents.title.update\""));
|
assert!(request
|
||||||
|
.payload_json
|
||||||
|
.contains("\"name\":\"documents.title.update\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -212,12 +230,190 @@ mod tests {
|
|||||||
validate_only: false,
|
validate_only: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = build_write_request(&demo_context(), &command).expect("write request should build");
|
let request =
|
||||||
|
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||||
assert_eq!(request.function_name, "documents:updateContent");
|
assert_eq!(request.function_name, "documents:updateContent");
|
||||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
assert!(request.payload_json.contains("\"name\":\"documents.save\""));
|
assert!(request.payload_json.contains("\"name\":\"documents.save\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_lifecycle_commands_map_to_document_mutations() {
|
||||||
|
let base_actor = ActorPayload {
|
||||||
|
actor_type: "human".into(),
|
||||||
|
actor_id: "user_1".into(),
|
||||||
|
session_id: Some("session_1".into()),
|
||||||
|
};
|
||||||
|
let base_source = SourcePayload {
|
||||||
|
channel: "next-route".into(),
|
||||||
|
client: "wolai-frontend".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let create_request = build_write_request(
|
||||||
|
&demo_context(),
|
||||||
|
&CommandEnvelope {
|
||||||
|
name: "documents.create".into(),
|
||||||
|
command_id: "cmd_create_1".into(),
|
||||||
|
idempotency_key: Some("idem_create".into()),
|
||||||
|
actor: base_actor.clone(),
|
||||||
|
source: base_source.clone(),
|
||||||
|
target: Some(TargetRef {
|
||||||
|
workspace_id: None,
|
||||||
|
page_id: Some("page_1".into()),
|
||||||
|
block_id: None,
|
||||||
|
}),
|
||||||
|
payload: CreateDocumentPage {
|
||||||
|
page_id: "page_1".into(),
|
||||||
|
parent_page_id: Some("parent_1".into()),
|
||||||
|
title: "无标题".into(),
|
||||||
|
workspace_seed_id: "ws_seed_1".into(),
|
||||||
|
},
|
||||||
|
reason: Some("创建页面".into()),
|
||||||
|
refs: vec![],
|
||||||
|
dry_run: false,
|
||||||
|
validate_only: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("create request should build");
|
||||||
|
assert_eq!(
|
||||||
|
create_request.function_name,
|
||||||
|
"documents:createWithParentReference"
|
||||||
|
);
|
||||||
|
|
||||||
|
let move_request = build_write_request(
|
||||||
|
&demo_context(),
|
||||||
|
&CommandEnvelope {
|
||||||
|
name: "documents.move".into(),
|
||||||
|
command_id: "cmd_move_1".into(),
|
||||||
|
idempotency_key: Some("idem_move".into()),
|
||||||
|
actor: base_actor.clone(),
|
||||||
|
source: base_source.clone(),
|
||||||
|
target: Some(TargetRef {
|
||||||
|
workspace_id: None,
|
||||||
|
page_id: Some("page_1".into()),
|
||||||
|
block_id: None,
|
||||||
|
}),
|
||||||
|
payload: MoveDocumentPage {
|
||||||
|
page_id: "page_1".into(),
|
||||||
|
parent_page_id: Some("parent_2".into()),
|
||||||
|
sort_order: 3,
|
||||||
|
},
|
||||||
|
reason: Some("移动页面".into()),
|
||||||
|
refs: vec![],
|
||||||
|
dry_run: false,
|
||||||
|
validate_only: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("move request should build");
|
||||||
|
assert_eq!(move_request.function_name, "documents:move");
|
||||||
|
|
||||||
|
let delete_request = build_write_request(
|
||||||
|
&demo_context(),
|
||||||
|
&CommandEnvelope {
|
||||||
|
name: "documents.delete".into(),
|
||||||
|
command_id: "cmd_delete_1".into(),
|
||||||
|
idempotency_key: Some("idem_delete".into()),
|
||||||
|
actor: base_actor.clone(),
|
||||||
|
source: base_source.clone(),
|
||||||
|
target: Some(TargetRef {
|
||||||
|
workspace_id: None,
|
||||||
|
page_id: Some("page_1".into()),
|
||||||
|
block_id: None,
|
||||||
|
}),
|
||||||
|
payload: DeleteDocumentPage {
|
||||||
|
page_id: "page_1".into(),
|
||||||
|
},
|
||||||
|
reason: Some("删除页面".into()),
|
||||||
|
refs: vec![],
|
||||||
|
dry_run: false,
|
||||||
|
validate_only: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("delete request should build");
|
||||||
|
assert_eq!(delete_request.function_name, "documents:softDelete");
|
||||||
|
|
||||||
|
let restore_request = build_write_request(
|
||||||
|
&demo_context(),
|
||||||
|
&CommandEnvelope {
|
||||||
|
name: "documents.restore".into(),
|
||||||
|
command_id: "cmd_restore_1".into(),
|
||||||
|
idempotency_key: Some("idem_restore".into()),
|
||||||
|
actor: base_actor.clone(),
|
||||||
|
source: base_source.clone(),
|
||||||
|
target: Some(TargetRef {
|
||||||
|
workspace_id: None,
|
||||||
|
page_id: Some("page_1".into()),
|
||||||
|
block_id: None,
|
||||||
|
}),
|
||||||
|
payload: RestoreDocumentPage {
|
||||||
|
page_id: "page_1".into(),
|
||||||
|
},
|
||||||
|
reason: Some("恢复页面".into()),
|
||||||
|
refs: vec![],
|
||||||
|
dry_run: false,
|
||||||
|
validate_only: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("restore request should build");
|
||||||
|
assert_eq!(restore_request.function_name, "documents:restore");
|
||||||
|
|
||||||
|
let duplicate_request = build_write_request(
|
||||||
|
&demo_context(),
|
||||||
|
&CommandEnvelope {
|
||||||
|
name: "documents.duplicate".into(),
|
||||||
|
command_id: "cmd_duplicate_1".into(),
|
||||||
|
idempotency_key: Some("idem_duplicate".into()),
|
||||||
|
actor: base_actor.clone(),
|
||||||
|
source: base_source.clone(),
|
||||||
|
target: Some(TargetRef {
|
||||||
|
workspace_id: None,
|
||||||
|
page_id: Some("page_copy_1".into()),
|
||||||
|
block_id: None,
|
||||||
|
}),
|
||||||
|
payload: DuplicateDocumentPage {
|
||||||
|
source_page_id: "page_1".into(),
|
||||||
|
new_page_id: "page_copy_1".into(),
|
||||||
|
title: Some("无标题 副本".into()),
|
||||||
|
},
|
||||||
|
reason: Some("复制页面".into()),
|
||||||
|
refs: vec![],
|
||||||
|
dry_run: false,
|
||||||
|
validate_only: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("duplicate request should build");
|
||||||
|
assert_eq!(
|
||||||
|
duplicate_request.function_name,
|
||||||
|
"documents:duplicateWithMindmaps"
|
||||||
|
);
|
||||||
|
|
||||||
|
let copy_tree_request = build_write_request(
|
||||||
|
&demo_context(),
|
||||||
|
&CommandEnvelope {
|
||||||
|
name: "documents.copy_tree".into(),
|
||||||
|
command_id: "cmd_copy_tree_1".into(),
|
||||||
|
idempotency_key: Some("idem_copy_tree".into()),
|
||||||
|
actor: base_actor,
|
||||||
|
source: base_source,
|
||||||
|
target: Some(TargetRef {
|
||||||
|
workspace_id: None,
|
||||||
|
page_id: Some("target_parent_1".into()),
|
||||||
|
block_id: None,
|
||||||
|
}),
|
||||||
|
payload: CopyTreeDocumentPages {
|
||||||
|
items_json: "[{\"documentId\":\"page_1\",\"recursive\":true}]".into(),
|
||||||
|
target_parent_page_id: Some("target_parent_1".into()),
|
||||||
|
},
|
||||||
|
reason: Some("复制页面树".into()),
|
||||||
|
refs: vec![],
|
||||||
|
dry_run: false,
|
||||||
|
validate_only: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("copy tree request should build");
|
||||||
|
assert_eq!(copy_tree_request.function_name, "documents:copyTree");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn block_patch_command_maps_to_documents_update_content() {
|
fn block_patch_command_maps_to_documents_update_content() {
|
||||||
let command = CommandEnvelope {
|
let command = CommandEnvelope {
|
||||||
@@ -252,12 +448,29 @@ mod tests {
|
|||||||
validate_only: false,
|
validate_only: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = build_write_request(&demo_context(), &command).expect("write request should build");
|
let request =
|
||||||
|
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||||
assert_eq!(request.function_name, "documents:updateContent");
|
assert_eq!(request.function_name, "documents:updateContent");
|
||||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
assert!(request.payload_json.contains("\"name\":\"blocks.patch\""));
|
assert!(request.payload_json.contains("\"name\":\"blocks.patch\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocks_get_query_maps_to_blocks_get_by_id() {
|
||||||
|
let query = QueryEnvelope {
|
||||||
|
name: "blocks.get".into(),
|
||||||
|
payload: GetBlock {
|
||||||
|
block_id: "block_1".into(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let request =
|
||||||
|
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||||
|
assert_eq!(request.function_name, "blocks:getById");
|
||||||
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
|
assert!(request.payload_json.contains("\"name\":\"blocks.get\""));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn document_stats_command_maps_to_documents_update_stats() {
|
fn document_stats_command_maps_to_documents_update_stats() {
|
||||||
let command = CommandEnvelope {
|
let command = CommandEnvelope {
|
||||||
@@ -292,10 +505,13 @@ mod tests {
|
|||||||
validate_only: false,
|
validate_only: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = build_write_request(&demo_context(), &command).expect("write request should build");
|
let request =
|
||||||
|
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||||
assert_eq!(request.function_name, "documents:updateStats");
|
assert_eq!(request.function_name, "documents:updateStats");
|
||||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
assert!(request.payload_json.contains("\"name\":\"documents.stats.update\""));
|
assert!(request
|
||||||
|
.payload_json
|
||||||
|
.contains("\"name\":\"documents.stats.update\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -340,10 +556,13 @@ mod tests {
|
|||||||
validate_only: false,
|
validate_only: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = build_write_request(&demo_context(), &command).expect("write request should build");
|
let request =
|
||||||
|
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||||
assert_eq!(request.function_name, "documents:updateOptions");
|
assert_eq!(request.function_name, "documents:updateOptions");
|
||||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
assert!(request.payload_json.contains("\"name\":\"documents.options.update\""));
|
assert!(request
|
||||||
|
.payload_json
|
||||||
|
.contains("\"name\":\"documents.options.update\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -376,10 +595,16 @@ mod tests {
|
|||||||
validate_only: false,
|
validate_only: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = build_write_request(&demo_context(), &command).expect("write request should build");
|
let request =
|
||||||
assert_eq!(request.function_name, "mediaAssets:replaceStorageFromUpload");
|
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||||
|
assert_eq!(
|
||||||
|
request.function_name,
|
||||||
|
"mediaAssets:replaceStorageFromUpload"
|
||||||
|
);
|
||||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
assert!(request.payload_json.contains("\"name\":\"media.assets.replace_storage\""));
|
assert!(request
|
||||||
|
.payload_json
|
||||||
|
.contains("\"name\":\"media.assets.replace_storage\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -392,10 +617,13 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = build_query_request(&demo_context(), &query).expect("query request should build");
|
let request =
|
||||||
|
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||||
assert_eq!(request.function_name, "documents:getMeta");
|
assert_eq!(request.function_name, "documents:getMeta");
|
||||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
assert!(request.payload_json.contains("\"name\":\"documents.meta.get\""));
|
assert!(request
|
||||||
|
.payload_json
|
||||||
|
.contains("\"name\":\"documents.meta.get\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -408,10 +636,31 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = build_query_request(&demo_context(), &query).expect("query request should build");
|
let request =
|
||||||
|
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||||
assert_eq!(request.function_name, "documents:getContent");
|
assert_eq!(request.function_name, "documents:getContent");
|
||||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
assert!(request.payload_json.contains("\"name\":\"documents.content.get\""));
|
assert!(request
|
||||||
|
.payload_json
|
||||||
|
.contains("\"name\":\"documents.content.get\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mindmap_get_query_maps_to_mindmaps_get() {
|
||||||
|
let query = QueryEnvelope {
|
||||||
|
name: "mindmaps.get".into(),
|
||||||
|
payload: GetMindmap {
|
||||||
|
document_id: "page_1".into(),
|
||||||
|
mindmap_id: "mind_1".into(),
|
||||||
|
workspace_id: Some("ws_1".into()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let request =
|
||||||
|
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||||
|
assert_eq!(request.function_name, "mindmaps:get");
|
||||||
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
|
assert!(request.payload_json.contains("\"name\":\"mindmaps.get\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -423,9 +672,191 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = build_query_request(&demo_context(), &query).expect("query request should build");
|
let request =
|
||||||
|
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||||
assert_eq!(request.function_name, "sidebar:datasetList");
|
assert_eq!(request.function_name, "sidebar:datasetList");
|
||||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
assert!(request.payload_json.contains("\"name\":\"sidebar.dataset.list\""));
|
assert!(request
|
||||||
|
.payload_json
|
||||||
|
.contains("\"name\":\"sidebar.dataset.list\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_documents_query_maps_to_search_documents() {
|
||||||
|
let query = QueryEnvelope {
|
||||||
|
name: "search.documents".into(),
|
||||||
|
payload: SearchDocuments {
|
||||||
|
query: "rust".into(),
|
||||||
|
workspace_id: "ws_1".into(),
|
||||||
|
page_id: Some("page_1".into()),
|
||||||
|
pagination: core_protocol::query::Pagination {
|
||||||
|
limit: 20,
|
||||||
|
cursor: None,
|
||||||
|
},
|
||||||
|
title_only: false,
|
||||||
|
exact: false,
|
||||||
|
include_ocr: true,
|
||||||
|
time_range: "any".into(),
|
||||||
|
time_field: "updated".into(),
|
||||||
|
custom_range_from: None,
|
||||||
|
custom_range_to: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let request =
|
||||||
|
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||||
|
assert_eq!(request.function_name, "search:documents");
|
||||||
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
|
assert!(request
|
||||||
|
.payload_json
|
||||||
|
.contains("\"name\":\"search.documents\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_recent_query_maps_to_search_recent() {
|
||||||
|
let query = QueryEnvelope {
|
||||||
|
name: "search.recent".into(),
|
||||||
|
payload: SearchRecent {
|
||||||
|
workspace_id: "ws_1".into(),
|
||||||
|
pagination: core_protocol::query::Pagination {
|
||||||
|
limit: 10,
|
||||||
|
cursor: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let request =
|
||||||
|
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||||
|
assert_eq!(request.function_name, "search:recent");
|
||||||
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
|
assert!(request
|
||||||
|
.payload_json
|
||||||
|
.contains("\"name\":\"search.recent\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridge_request_query_maps_to_bridge_logs_request() {
|
||||||
|
let query = QueryEnvelope {
|
||||||
|
name: "bridge.request.get".into(),
|
||||||
|
payload: GetBridgeRequest {
|
||||||
|
workspace_id: "ws_1".into(),
|
||||||
|
request_id: "req_1".into(),
|
||||||
|
command_id: Some("cmd_1".into()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let request =
|
||||||
|
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||||
|
assert_eq!(request.function_name, "bridgeLogs:listByRequest");
|
||||||
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
|
assert!(request
|
||||||
|
.payload_json
|
||||||
|
.contains("\"name\":\"bridge.request.get\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridge_trace_query_maps_to_bridge_logs_trace() {
|
||||||
|
let query = QueryEnvelope {
|
||||||
|
name: "bridge.trace.get".into(),
|
||||||
|
payload: GetBridgeTrace {
|
||||||
|
workspace_id: "ws_1".into(),
|
||||||
|
trace_id: "trace_1".into(),
|
||||||
|
command_id: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let request =
|
||||||
|
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||||
|
assert_eq!(request.function_name, "bridgeLogs:listByTrace");
|
||||||
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
|
assert!(request
|
||||||
|
.payload_json
|
||||||
|
.contains("\"name\":\"bridge.trace.get\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridge_command_query_maps_to_bridge_logs_command() {
|
||||||
|
let query = QueryEnvelope {
|
||||||
|
name: "bridge.command.get".into(),
|
||||||
|
payload: GetBridgeCommand {
|
||||||
|
workspace_id: "ws_1".into(),
|
||||||
|
command_id: "cmd_1".into(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let request =
|
||||||
|
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||||
|
assert_eq!(request.function_name, "bridgeLogs:listByCommand");
|
||||||
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
|
assert!(request
|
||||||
|
.payload_json
|
||||||
|
.contains("\"name\":\"bridge.command.get\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridge_workspace_overview_query_maps_to_bridge_logs_workspace_overview() {
|
||||||
|
let query = QueryEnvelope {
|
||||||
|
name: "bridge.workspace.overview".into(),
|
||||||
|
payload: ListBridgeWorkspaceOverview {
|
||||||
|
workspace_id: "ws_1".into(),
|
||||||
|
pagination: core_protocol::query::Pagination {
|
||||||
|
limit: 20,
|
||||||
|
cursor: Some("cursor_1".into()),
|
||||||
|
},
|
||||||
|
command_status: Some("failed".into()),
|
||||||
|
event_status: Some("failed".into()),
|
||||||
|
target_page_id: Some("page_1".into()),
|
||||||
|
target_block_id: None,
|
||||||
|
aggregate_type: Some("page".into()),
|
||||||
|
aggregate_id: Some("page_1".into()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let request =
|
||||||
|
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||||
|
assert_eq!(request.function_name, "bridgeLogs:listWorkspaceOverview");
|
||||||
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
|
assert!(request
|
||||||
|
.payload_json
|
||||||
|
.contains("\"name\":\"bridge.workspace.overview\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mindmap_put_command_maps_to_mindmaps_put() {
|
||||||
|
let command = CommandEnvelope {
|
||||||
|
name: "mindmaps.put".into(),
|
||||||
|
command_id: "cmd_mindmap_put_1".into(),
|
||||||
|
idempotency_key: Some("idem_mindmap_put".into()),
|
||||||
|
actor: ActorPayload {
|
||||||
|
actor_type: "human".into(),
|
||||||
|
actor_id: "user_1".into(),
|
||||||
|
session_id: Some("session_1".into()),
|
||||||
|
},
|
||||||
|
source: SourcePayload {
|
||||||
|
channel: "cli".into(),
|
||||||
|
client: "mnote-cli".into(),
|
||||||
|
},
|
||||||
|
target: Some(TargetRef {
|
||||||
|
workspace_id: Some("ws_1".into()),
|
||||||
|
page_id: Some("page_1".into()),
|
||||||
|
block_id: Some("mind_1".into()),
|
||||||
|
}),
|
||||||
|
payload: PutMindmap {
|
||||||
|
document_id: "page_1".into(),
|
||||||
|
mindmap_id: "mind_1".into(),
|
||||||
|
data_json: r#"{"data":{"text":"中心主题"},"children":[]}"#.into(),
|
||||||
|
create_only: false,
|
||||||
|
},
|
||||||
|
reason: Some("保存导图".into()),
|
||||||
|
refs: vec!["task-032".into()],
|
||||||
|
dry_run: false,
|
||||||
|
validate_only: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let request =
|
||||||
|
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||||
|
assert_eq!(request.function_name, "mindmaps:put");
|
||||||
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
|
assert!(request.payload_json.contains("\"name\":\"mindmaps.put\""));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,15 +29,24 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
|
|||||||
match command_name {
|
match command_name {
|
||||||
"create_workspace" => "workspaces:create",
|
"create_workspace" => "workspaces:create",
|
||||||
"create_page" => "pages:create",
|
"create_page" => "pages:create",
|
||||||
|
"documents.create" => "documents:createWithParentReference",
|
||||||
|
"documents.move" => "documents:move",
|
||||||
|
"documents.delete" => "documents:softDelete",
|
||||||
|
"documents.restore" => "documents:restore",
|
||||||
|
"documents.duplicate" => "documents:duplicateWithMindmaps",
|
||||||
|
"documents.copy_tree" => "documents:copyTree",
|
||||||
"blocks.patch" => "documents:updateContent",
|
"blocks.patch" => "documents:updateContent",
|
||||||
"documents.save" => "documents:updateContent",
|
"documents.save" => "documents:updateContent",
|
||||||
"documents.title.update" => "documents:updateTitle",
|
"documents.title.update" => "documents:updateTitle",
|
||||||
"documents.stats.update" => "documents:updateStats",
|
"documents.stats.update" => "documents:updateStats",
|
||||||
"documents.options.update" => "documents:updateOptions",
|
"documents.options.update" => "documents:updateOptions",
|
||||||
|
"mindmaps.put" => "mindmaps:put",
|
||||||
"insert_block" => "blocks:insert",
|
"insert_block" => "blocks:insert",
|
||||||
"update_block" => "blocks:update",
|
"update_block" => "blocks:update",
|
||||||
"move_block" => "blocks:move",
|
"move_block" => "blocks:move",
|
||||||
"delete_block" => "blocks:delete",
|
"delete_block" => "blocks:delete",
|
||||||
|
"blocks.move" => "blocks:move",
|
||||||
|
"blocks.embed" => "blocks:insert",
|
||||||
"media.assets.replace_storage" => "mediaAssets:replaceStorageFromUpload",
|
"media.assets.replace_storage" => "mediaAssets:replaceStorageFromUpload",
|
||||||
_ => "commands:unknown",
|
_ => "commands:unknown",
|
||||||
}
|
}
|
||||||
@@ -45,9 +54,17 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
|
|||||||
|
|
||||||
pub fn map_query_name_to_convex(query_name: &str) -> &'static str {
|
pub fn map_query_name_to_convex(query_name: &str) -> &'static str {
|
||||||
match query_name {
|
match query_name {
|
||||||
|
"bridge.request.get" => "bridgeLogs:listByRequest",
|
||||||
|
"bridge.trace.get" => "bridgeLogs:listByTrace",
|
||||||
|
"bridge.command.get" => "bridgeLogs:listByCommand",
|
||||||
|
"bridge.workspace.overview" => "bridgeLogs:listWorkspaceOverview",
|
||||||
"documents.meta.get" => "documents:getMeta",
|
"documents.meta.get" => "documents:getMeta",
|
||||||
"documents.content.get" => "documents:getContent",
|
"documents.content.get" => "documents:getContent",
|
||||||
|
"mindmaps.get" => "mindmaps:get",
|
||||||
|
"blocks.get" => "blocks:getById",
|
||||||
"sidebar.dataset.list" => "sidebar:datasetList",
|
"sidebar.dataset.list" => "sidebar:datasetList",
|
||||||
|
"search.documents" => "search:documents",
|
||||||
|
"search.recent" => "search:recent",
|
||||||
"get_page" => "pages:get",
|
"get_page" => "pages:get",
|
||||||
"list_page_blocks" => "blocks:list_by_page",
|
"list_page_blocks" => "blocks:list_by_page",
|
||||||
"search_pages" => "search:pages",
|
"search_pages" => "search:pages",
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ impl BridgeError {
|
|||||||
message: message.into(),
|
message: message.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn transport(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: BridgeErrorKind::Transport,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type BridgeResult<T> = Result<T, BridgeError>;
|
pub type BridgeResult<T> = Result<T, BridgeError>;
|
||||||
|
|||||||
@@ -21,15 +21,6 @@ pub fn validate_command_envelope<T>(
|
|||||||
if context.actor_id.trim().is_empty() {
|
if context.actor_id.trim().is_empty() {
|
||||||
return Err(BridgeError::validation("actor_id 不能为空"));
|
return Err(BridgeError::validation("actor_id 不能为空"));
|
||||||
}
|
}
|
||||||
if context
|
|
||||||
.workspace_id
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim()
|
|
||||||
.is_empty()
|
|
||||||
{
|
|
||||||
return Err(BridgeError::validation("workspace_id 不能为空"));
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
const { chromium } = require("playwright");
|
const { chromium } = require("playwright");
|
||||||
|
|
||||||
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001";
|
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||||
const REQUEST_TIMEOUT_MS = 20_000;
|
const REQUEST_TIMEOUT_MS = 20_000;
|
||||||
const UI_TIMEOUT_MS = 30_000;
|
const UI_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
const { chromium } = require("playwright");
|
const { chromium } = require("playwright");
|
||||||
|
|
||||||
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001";
|
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||||
const REQUEST_TIMEOUT_MS = 20_000;
|
const REQUEST_TIMEOUT_MS = 20_000;
|
||||||
const UI_TIMEOUT_MS = 30_000;
|
const UI_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const { chromium } = require("playwright");
|
const { chromium } = require("playwright");
|
||||||
|
|
||||||
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001";
|
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||||
const REQUEST_TIMEOUT_MS = 20_000;
|
const REQUEST_TIMEOUT_MS = 20_000;
|
||||||
const UI_TIMEOUT_MS = 120_000;
|
const UI_TIMEOUT_MS = 120_000;
|
||||||
const CALLBACK_TIMEOUT_MS = 90_000;
|
const CALLBACK_TIMEOUT_MS = 90_000;
|
||||||
|
|||||||
@@ -14,6 +14,61 @@ async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: str
|
|||||||
return member;
|
return member;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortByNewest<T extends Record<string, any>>(rows: T[]) {
|
||||||
|
return [...rows].sort((left, right) => {
|
||||||
|
const leftTime = String(left.created_at ?? left.finished_at ?? "");
|
||||||
|
const rightTime = String(right.created_at ?? right.finished_at ?? "");
|
||||||
|
return rightTime.localeCompare(leftTime);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCursor(raw: string | null | undefined) {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const decoded = JSON.parse(raw) as {
|
||||||
|
createdAt?: string | null;
|
||||||
|
id?: string | null;
|
||||||
|
};
|
||||||
|
const createdAt = typeof decoded.createdAt === "string" ? decoded.createdAt : "";
|
||||||
|
const id = typeof decoded.id === "string" ? decoded.id : "";
|
||||||
|
if (!createdAt || !id) return null;
|
||||||
|
return { createdAt, id };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeCursor(row: { created_at?: string | null; id?: string | null } | null) {
|
||||||
|
if (!row?.created_at || !row?.id) return null;
|
||||||
|
return JSON.stringify({
|
||||||
|
createdAt: row.created_at,
|
||||||
|
id: row.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesCursor<T extends Record<string, any>>(
|
||||||
|
row: T,
|
||||||
|
cursor: { createdAt: string; id: string } | null,
|
||||||
|
) {
|
||||||
|
if (!cursor) return true;
|
||||||
|
const createdAt = String(row.created_at ?? row.finished_at ?? "");
|
||||||
|
const id = String(row.id ?? "");
|
||||||
|
if (!createdAt || !id) return false;
|
||||||
|
if (createdAt < cursor.createdAt) return true;
|
||||||
|
if (createdAt > cursor.createdAt) return false;
|
||||||
|
return id < cursor.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatusFilter(raw: string | null | undefined) {
|
||||||
|
const normalized = String(raw ?? "").trim();
|
||||||
|
return normalized.length > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeObjectFilter(raw: string | null | undefined) {
|
||||||
|
const normalized = String(raw ?? "").trim();
|
||||||
|
return normalized.length > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
export const recordCommandLog = mutation({
|
export const recordCommandLog = mutation({
|
||||||
args: {
|
args: {
|
||||||
workspaceId: v.string(),
|
workspaceId: v.string(),
|
||||||
@@ -171,3 +226,107 @@ export const listByRequest = query({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const listByCommand = query({
|
||||||
|
args: { workspaceId: v.string(), commandId: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
await requireWorkspaceMember(ctx, args.workspaceId, userId);
|
||||||
|
|
||||||
|
const commandLogs = await ctx.db
|
||||||
|
.query("command_logs")
|
||||||
|
.withIndex("by_workspace_command", (q) =>
|
||||||
|
q.eq("workspace_id", args.workspaceId).eq("command_id", args.commandId),
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
const domainEvents = await ctx.db
|
||||||
|
.query("domain_events")
|
||||||
|
.withIndex("by_workspace_command", (q) =>
|
||||||
|
q.eq("workspace_id", args.workspaceId).eq("command_id", args.commandId),
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
command_id: args.commandId,
|
||||||
|
command_logs: commandLogs,
|
||||||
|
domain_events: domainEvents,
|
||||||
|
generated_at: nowIso(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listWorkspaceOverview = query({
|
||||||
|
args: {
|
||||||
|
workspaceId: v.string(),
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
cursor: v.optional(v.union(v.string(), v.null())),
|
||||||
|
commandStatus: v.optional(v.union(v.string(), v.null())),
|
||||||
|
eventStatus: v.optional(v.union(v.string(), v.null())),
|
||||||
|
targetPageId: v.optional(v.union(v.string(), v.null())),
|
||||||
|
targetBlockId: v.optional(v.union(v.string(), v.null())),
|
||||||
|
aggregateType: v.optional(v.union(v.string(), v.null())),
|
||||||
|
aggregateId: v.optional(v.union(v.string(), v.null())),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
await requireWorkspaceMember(ctx, args.workspaceId, userId);
|
||||||
|
|
||||||
|
const limit = Math.max(1, Math.min(100, Math.floor(args.limit ?? 50)));
|
||||||
|
const cursor = decodeCursor(args.cursor ?? null);
|
||||||
|
const commandStatus = normalizeStatusFilter(args.commandStatus);
|
||||||
|
const eventStatus = normalizeStatusFilter(args.eventStatus);
|
||||||
|
const targetPageId = normalizeObjectFilter(args.targetPageId);
|
||||||
|
const targetBlockId = normalizeObjectFilter(args.targetBlockId);
|
||||||
|
const aggregateType = normalizeObjectFilter(args.aggregateType);
|
||||||
|
const aggregateId = normalizeObjectFilter(args.aggregateId);
|
||||||
|
|
||||||
|
const allCommandLogs = await ctx.db
|
||||||
|
.query("command_logs")
|
||||||
|
.withIndex("by_workspace_request", (q) => q.eq("workspace_id", args.workspaceId))
|
||||||
|
.collect();
|
||||||
|
const allDomainEvents = await ctx.db
|
||||||
|
.query("domain_events")
|
||||||
|
.withIndex("by_workspace_request", (q) => q.eq("workspace_id", args.workspaceId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const filteredCommandLogs = sortByNewest(
|
||||||
|
allCommandLogs.filter((row: any) => {
|
||||||
|
if (commandStatus && row.status !== commandStatus) return false;
|
||||||
|
if (targetPageId && String(row.target_page_id ?? "") !== targetPageId) return false;
|
||||||
|
if (targetBlockId && String(row.target_block_id ?? "") !== targetBlockId) return false;
|
||||||
|
return matchesCursor(row, cursor);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pageCommandLogs = filteredCommandLogs.slice(0, limit);
|
||||||
|
const nextCursor = encodeCursor(pageCommandLogs[pageCommandLogs.length - 1] ?? null);
|
||||||
|
const commandIds = new Set(pageCommandLogs.map((row: any) => String(row.command_id)));
|
||||||
|
|
||||||
|
const domainEvents = sortByNewest(
|
||||||
|
allDomainEvents.filter((row: any) => {
|
||||||
|
if (commandIds.size > 0 && !commandIds.has(String(row.command_id ?? ""))) return false;
|
||||||
|
if (eventStatus && row.status !== eventStatus) return false;
|
||||||
|
if (aggregateType && String(row.aggregate_type ?? "") !== aggregateType) return false;
|
||||||
|
if (aggregateId && String(row.aggregate_id ?? "") !== aggregateId) return false;
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspace_id: args.workspaceId,
|
||||||
|
command_logs: pageCommandLogs,
|
||||||
|
domain_events: domainEvents,
|
||||||
|
next_cursor: nextCursor,
|
||||||
|
has_more: filteredCommandLogs.length > pageCommandLogs.length,
|
||||||
|
filters: {
|
||||||
|
command_status: commandStatus,
|
||||||
|
event_status: eventStatus,
|
||||||
|
target_page_id: targetPageId,
|
||||||
|
target_block_id: targetBlockId,
|
||||||
|
aggregate_type: aggregateType,
|
||||||
|
aggregate_id: aggregateId,
|
||||||
|
},
|
||||||
|
generated_at: nowIso(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { internalQuery, mutation, query } from "./_generated/server";
|
import { internalQuery, mutation, query } from "./_generated/server";
|
||||||
|
import { api } from "./_generated/api";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { requireUserId } from "./_utils/auth";
|
import { requireUserId } from "./_utils/auth";
|
||||||
import { nowIso } from "./_utils/time";
|
import { nowIso } from "./_utils/time";
|
||||||
@@ -13,6 +14,352 @@ type SharePermission = "read" | "edit";
|
|||||||
|
|
||||||
type SharePolicy = { permission: SharePermission; disableDownload: boolean; disableCopy: boolean };
|
type SharePolicy = { permission: SharePermission; disableDownload: boolean; disableCopy: boolean };
|
||||||
|
|
||||||
|
type CopyTreeDocument = {
|
||||||
|
_id: unknown;
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
parent_id: string | null;
|
||||||
|
workspace_id: string;
|
||||||
|
access_scope: "private" | "shared" | "public" | null;
|
||||||
|
sort_order: number | null;
|
||||||
|
created_at: string | null;
|
||||||
|
content: unknown;
|
||||||
|
user_id: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeTitle(title: string | null | undefined): string {
|
||||||
|
const safe = String(title ?? "").trim();
|
||||||
|
return safe.length > 0 ? safe : "无标题";
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUniqueTitle(baseTitle: string, existing: Set<string>): string {
|
||||||
|
const base = normalizeTitle(baseTitle);
|
||||||
|
if (!existing.has(base)) {
|
||||||
|
existing.add(base);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = `${base} 副本`;
|
||||||
|
if (!existing.has(first)) {
|
||||||
|
existing.add(first);
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 2; i < 1000; i += 1) {
|
||||||
|
const candidate = `${base} 副本 ${i}`;
|
||||||
|
if (!existing.has(candidate)) {
|
||||||
|
existing.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = `${base} 副本 ${Date.now()}`;
|
||||||
|
existing.add(fallback);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBlocksFromContent(content: unknown): unknown[] {
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content as unknown[];
|
||||||
|
}
|
||||||
|
if (content && typeof content === "object" && Array.isArray((content as { blocks?: unknown[] }).blocks)) {
|
||||||
|
return ((content as { blocks?: unknown[] }).blocks ?? []) as unknown[];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeContentWithBlocks(content: unknown, blocks: unknown[]): unknown {
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return blocks as unknown[];
|
||||||
|
}
|
||||||
|
if (content && typeof content === "object") {
|
||||||
|
return {
|
||||||
|
...(content as Record<string, unknown>),
|
||||||
|
blocks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { blocks };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPageReferenceBlock(content: unknown, pageId: string, title: string): unknown {
|
||||||
|
const blocks = extractBlocksFromContent(content);
|
||||||
|
const pageReferenceBlock = {
|
||||||
|
id: typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`,
|
||||||
|
type: "pageReference",
|
||||||
|
props: {
|
||||||
|
pageId,
|
||||||
|
title: normalizeTitle(title),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return composeContentWithBlocks(content, [...blocks, pageReferenceBlock]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChildrenSorted(childrenByParent: Map<string | null, CopyTreeDocument[]>, parentId: string | null): CopyTreeDocument[] {
|
||||||
|
const list = childrenByParent.get(parentId) ?? [];
|
||||||
|
return [...list].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;
|
||||||
|
const timeA = new Date(a.created_at ?? 0).getTime();
|
||||||
|
const timeB = new Date(b.created_at ?? 0).getTime();
|
||||||
|
return timeA - timeB;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyMindmapsForDocument(ctx: any, sourceDocId: string, targetDocId: string) {
|
||||||
|
return await ctx.runMutation(api.mindmaps.copyByDocument, {
|
||||||
|
sourceDocId,
|
||||||
|
targetDocId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertDocumentVisibleToUser(ctx: any, doc: any, userId: string, errorMessage = "无权限") {
|
||||||
|
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
|
||||||
|
|
||||||
|
if (doc.user_id === userId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doc.access_scope === "public") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
|
||||||
|
if (!policy) {
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDocumentRecord(
|
||||||
|
ctx: any,
|
||||||
|
args: {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
accessScope: "private" | "shared" | "public";
|
||||||
|
content?: unknown;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
const existing = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
|
||||||
|
if (existing && existing.deleted_at == null) {
|
||||||
|
throw new Error("页面已存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblings = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_workspace_parent", (q: any) => q.eq("workspace_id", args.workspaceId).eq("parent_id", args.parentId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const sortOrder = siblings.filter((d: any) => d.deleted_at == null).length;
|
||||||
|
const ts = nowIso();
|
||||||
|
const title = (args.title ?? "无标题") || "无标题";
|
||||||
|
const content = typeof args.content === "undefined" ? [] : args.content;
|
||||||
|
const rawText = extractTextFromDocumentContent(content);
|
||||||
|
|
||||||
|
await ctx.db.insert("documents", {
|
||||||
|
id: args.id,
|
||||||
|
user_id: userId,
|
||||||
|
workspace_id: args.workspaceId,
|
||||||
|
parent_id: args.parentId,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
content_revision: 0,
|
||||||
|
content_conflict_key: `${args.id}:0`,
|
||||||
|
raw_text: rawText,
|
||||||
|
access_scope: args.accessScope,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
is_starred: false,
|
||||||
|
is_template: false,
|
||||||
|
|
||||||
|
wide_layout: false,
|
||||||
|
use_small_text: false,
|
||||||
|
show_heading_numbers: true,
|
||||||
|
show_toc: false,
|
||||||
|
show_structure: false,
|
||||||
|
protect_editing: false,
|
||||||
|
show_word_count: true,
|
||||||
|
collapse_backlinks: false,
|
||||||
|
page_font: "default",
|
||||||
|
layout_density: "normal",
|
||||||
|
hide_child_pages: false,
|
||||||
|
show_block_ref_count: false,
|
||||||
|
embed_default_block_id: null,
|
||||||
|
word_count: 0,
|
||||||
|
character_count: 0,
|
||||||
|
block_count: 0,
|
||||||
|
todo_total_count: 0,
|
||||||
|
todo_done_count: 0,
|
||||||
|
created_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
deleted_at: null,
|
||||||
|
deleted_by: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: args.id,
|
||||||
|
title,
|
||||||
|
parent_id: args.parentId,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
is_starred: false,
|
||||||
|
created_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
workspace_id: args.workspaceId,
|
||||||
|
access_scope: args.accessScope,
|
||||||
|
is_template: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateDocumentContentRecord(
|
||||||
|
ctx: any,
|
||||||
|
args: {
|
||||||
|
id: string;
|
||||||
|
content: unknown;
|
||||||
|
expectedRevision?: number | null;
|
||||||
|
conflictDetectionKey?: string | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
|
||||||
|
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
|
||||||
|
if (!doc) throw new Error("页面不存在");
|
||||||
|
if (doc.deleted_at != null) throw new Error("页面不存在");
|
||||||
|
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
|
||||||
|
|
||||||
|
if (doc.user_id !== userId) {
|
||||||
|
if (doc.access_scope === "public") throw new Error("无权限");
|
||||||
|
const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
|
||||||
|
if (perm !== "edit") throw new Error("无权限");
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRevision = doc.content_revision ?? 0;
|
||||||
|
const currentConflictDetectionKey =
|
||||||
|
doc.content_conflict_key ??
|
||||||
|
`${args.id}:${currentRevision}`;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof args.expectedRevision === "number" &&
|
||||||
|
Number.isInteger(args.expectedRevision) &&
|
||||||
|
args.expectedRevision >= 0 &&
|
||||||
|
args.expectedRevision !== currentRevision
|
||||||
|
) {
|
||||||
|
throw new Error("正文内容已变更,请刷新后重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof args.conflictDetectionKey === "string" &&
|
||||||
|
args.conflictDetectionKey.trim() &&
|
||||||
|
args.conflictDetectionKey.trim() !== currentConflictDetectionKey
|
||||||
|
) {
|
||||||
|
throw new Error("正文冲突检测失败,请刷新后重试");
|
||||||
|
}
|
||||||
|
const ts = nowIso();
|
||||||
|
const rawText = extractTextFromDocumentContent(args.content);
|
||||||
|
const nextRevision = currentRevision + 1;
|
||||||
|
const nextConflictDetectionKey = `${args.id}:${nextRevision}`;
|
||||||
|
await ctx.db.patch(doc._id, {
|
||||||
|
content: args.content,
|
||||||
|
content_revision: nextRevision,
|
||||||
|
content_conflict_key: nextConflictDetectionKey,
|
||||||
|
raw_text: rawText,
|
||||||
|
updated_at: ts,
|
||||||
|
});
|
||||||
|
|
||||||
|
await enqueueIngestDocumentJob(ctx, { userId: doc.user_id, documentId: args.id, debounceMs: 1500 });
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
updated_at: ts,
|
||||||
|
revision: nextRevision,
|
||||||
|
conflict_detection_key: nextConflictDetectionKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function duplicateDocumentRecord(
|
||||||
|
ctx: any,
|
||||||
|
args: {
|
||||||
|
sourceId: string;
|
||||||
|
newId: string;
|
||||||
|
title?: string | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
|
||||||
|
const source = await getCanonicalDocumentByBusinessId<any>(ctx, args.sourceId);
|
||||||
|
if (!source) throw new Error("页面不存在或无权限访问");
|
||||||
|
if (source.user_id !== userId) throw new Error("页面不存在或无权限访问");
|
||||||
|
|
||||||
|
const existingTarget = await getCanonicalDocumentByBusinessId(ctx, args.newId);
|
||||||
|
if (existingTarget && existingTarget.deleted_at == null) {
|
||||||
|
throw new Error("目标页面已存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblings = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_workspace_parent", (q: any) =>
|
||||||
|
q.eq("workspace_id", source.workspace_id).eq("parent_id", source.parent_id),
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const sortOrder = siblings.filter((d: any) => d.deleted_at == null).length;
|
||||||
|
const ts = nowIso();
|
||||||
|
const baseTitle = (source.title ?? "无标题").trim() ? (source.title ?? "无标题").trim() : "无标题";
|
||||||
|
const title = (args.title ?? `${baseTitle} 副本`) || `${baseTitle} 副本`;
|
||||||
|
const rawText = extractTextFromDocumentContent(source.content ?? []);
|
||||||
|
|
||||||
|
await ctx.db.insert("documents", {
|
||||||
|
id: args.newId,
|
||||||
|
user_id: userId,
|
||||||
|
workspace_id: source.workspace_id,
|
||||||
|
parent_id: source.parent_id,
|
||||||
|
title,
|
||||||
|
content: source.content ?? [],
|
||||||
|
content_revision: 0,
|
||||||
|
content_conflict_key: `${args.newId}:0`,
|
||||||
|
raw_text: rawText,
|
||||||
|
access_scope: source.access_scope,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
is_starred: false,
|
||||||
|
is_template: false,
|
||||||
|
|
||||||
|
wide_layout: source.wide_layout ?? false,
|
||||||
|
use_small_text: source.use_small_text ?? false,
|
||||||
|
show_heading_numbers: source.show_heading_numbers ?? true,
|
||||||
|
show_toc: source.show_toc ?? false,
|
||||||
|
show_structure: source.show_structure ?? false,
|
||||||
|
protect_editing: source.protect_editing ?? false,
|
||||||
|
show_word_count: source.show_word_count ?? true,
|
||||||
|
collapse_backlinks: (source as any).collapse_backlinks ?? false,
|
||||||
|
page_font: (source as any).page_font ?? "default",
|
||||||
|
layout_density: (source as any).layout_density ?? "normal",
|
||||||
|
hide_child_pages: (source as any).hide_child_pages ?? false,
|
||||||
|
show_block_ref_count: (source as any).show_block_ref_count ?? false,
|
||||||
|
embed_default_block_id: (source as any).embed_default_block_id ?? null,
|
||||||
|
word_count: source.word_count ?? 0,
|
||||||
|
character_count: source.character_count ?? 0,
|
||||||
|
block_count: source.block_count ?? 0,
|
||||||
|
todo_total_count: (source as any).todo_total_count ?? 0,
|
||||||
|
todo_done_count: (source as any).todo_done_count ?? 0,
|
||||||
|
|
||||||
|
created_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
deleted_at: null,
|
||||||
|
deleted_by: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: args.newId,
|
||||||
|
title,
|
||||||
|
parent_id: source.parent_id ?? null,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
workspace_id: source.workspace_id,
|
||||||
|
access_scope: source.access_scope,
|
||||||
|
created_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) {
|
async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) {
|
||||||
const member = await ctx.db
|
const member = await ctx.db
|
||||||
.query("workspace_members")
|
.query("workspace_members")
|
||||||
@@ -862,6 +1209,55 @@ export const create = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const createWithParentReference = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.string(),
|
||||||
|
workspaceId: v.string(),
|
||||||
|
parentId: v.union(v.string(), v.null()),
|
||||||
|
title: v.optional(v.union(v.string(), v.null())),
|
||||||
|
accessScope,
|
||||||
|
content: v.optional(v.any()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
if (!args.parentId) {
|
||||||
|
return await createDocumentRecord(ctx, {
|
||||||
|
id: args.id,
|
||||||
|
workspaceId: args.workspaceId,
|
||||||
|
parentId: args.parentId,
|
||||||
|
title: args.title ?? null,
|
||||||
|
accessScope: args.accessScope,
|
||||||
|
content: args.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
const parentDoc = await getCanonicalDocumentByBusinessId<any>(ctx, args.parentId);
|
||||||
|
if (!parentDoc) {
|
||||||
|
throw new Error("父页面不存在或无权限");
|
||||||
|
}
|
||||||
|
if (parentDoc.deleted_at != null) {
|
||||||
|
throw new Error("父页面不存在或无权限");
|
||||||
|
}
|
||||||
|
await assertDocumentVisibleToUser(ctx, parentDoc, userId, "父页面不存在或无权限");
|
||||||
|
|
||||||
|
const created = await createDocumentRecord(ctx, {
|
||||||
|
...args,
|
||||||
|
workspaceId: parentDoc.workspace_id,
|
||||||
|
parentId: args.parentId,
|
||||||
|
accessScope: (parentDoc.access_scope ?? "private") as "private" | "shared" | "public",
|
||||||
|
content: args.content ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedContent = appendPageReferenceBlock(parentDoc.content ?? null, created.id, created.title ?? "无标题");
|
||||||
|
await updateDocumentContentRecord(ctx, {
|
||||||
|
id: args.parentId,
|
||||||
|
content: updatedContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return created;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const updateContent = mutation({
|
export const updateContent = mutation({
|
||||||
args: {
|
args: {
|
||||||
id: v.string(),
|
id: v.string(),
|
||||||
@@ -1325,6 +1721,8 @@ export const duplicate = mutation({
|
|||||||
parent_id: source.parent_id,
|
parent_id: source.parent_id,
|
||||||
title,
|
title,
|
||||||
content: source.content ?? [],
|
content: source.content ?? [],
|
||||||
|
content_revision: 0,
|
||||||
|
content_conflict_key: `${args.newId}:0`,
|
||||||
raw_text: rawText,
|
raw_text: rawText,
|
||||||
access_scope: source.access_scope,
|
access_scope: source.access_scope,
|
||||||
sort_order: sortOrder,
|
sort_order: sortOrder,
|
||||||
@@ -1369,6 +1767,151 @@ export const duplicate = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const duplicateWithMindmaps = mutation({
|
||||||
|
args: {
|
||||||
|
sourceId: v.string(),
|
||||||
|
newId: v.string(),
|
||||||
|
title: v.optional(v.union(v.string(), v.null())),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const duplicated = await duplicateDocumentRecord(ctx, args);
|
||||||
|
await copyMindmapsForDocument(ctx, args.sourceId, duplicated.id);
|
||||||
|
return duplicated;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const copyTree = mutation({
|
||||||
|
args: {
|
||||||
|
items: v.array(
|
||||||
|
v.object({
|
||||||
|
documentId: v.string(),
|
||||||
|
recursive: v.boolean(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
targetParentId: v.union(v.string(), v.null()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
const normalizedItems = args.items.filter((item) => Boolean(item?.documentId));
|
||||||
|
if (normalizedItems.length === 0) {
|
||||||
|
throw new Error("items 为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
let workspaceId: string | null = null;
|
||||||
|
if (args.targetParentId) {
|
||||||
|
const targetDoc = await getCanonicalDocumentByBusinessId<any>(ctx, args.targetParentId);
|
||||||
|
if (!targetDoc) {
|
||||||
|
throw new Error("目标页面不存在或无权限");
|
||||||
|
}
|
||||||
|
if (targetDoc.deleted_at != null) {
|
||||||
|
throw new Error("目标页面不存在或无权限");
|
||||||
|
}
|
||||||
|
await assertDocumentVisibleToUser(ctx, targetDoc, userId, "目标页面不存在或无权限");
|
||||||
|
workspaceId = targetDoc.workspace_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId)));
|
||||||
|
const allDocs: CopyTreeDocument[] = [];
|
||||||
|
for (const sourceId of sourceIds) {
|
||||||
|
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, sourceId) as CopyTreeDocument | null;
|
||||||
|
if (!doc || doc.deleted_at != null || doc.user_id !== userId) {
|
||||||
|
throw new Error("源页面不存在或无权限");
|
||||||
|
}
|
||||||
|
allDocs.push(doc);
|
||||||
|
if (!workspaceId) {
|
||||||
|
workspaceId = doc.workspace_id;
|
||||||
|
} else if (workspaceId !== doc.workspace_id) {
|
||||||
|
throw new Error("源页面不在同一工作空间");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new Error("缺少目标工作空间");
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceDocs = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_workspace", (q) => q.eq("workspace_id", workspaceId))
|
||||||
|
.collect();
|
||||||
|
const ownedDocs = workspaceDocs.filter((d) => d.user_id === userId && d.deleted_at == null);
|
||||||
|
const childrenByParent = new Map<string | null, CopyTreeDocument[]>();
|
||||||
|
for (const doc of ownedDocs as CopyTreeDocument[]) {
|
||||||
|
const list = childrenByParent.get(doc.parent_id ?? null) ?? [];
|
||||||
|
list.push(doc);
|
||||||
|
childrenByParent.set(doc.parent_id ?? null, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingTitleSetByParent = new Map<string | null, Set<string>>();
|
||||||
|
const seedTitleSet = (parent: string | null) => {
|
||||||
|
if (existingTitleSetByParent.has(parent)) return;
|
||||||
|
const titles = new Set<string>();
|
||||||
|
(childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title)));
|
||||||
|
existingTitleSetByParent.set(parent, titles);
|
||||||
|
};
|
||||||
|
seedTitleSet(args.targetParentId);
|
||||||
|
|
||||||
|
const newIdByOldId = new Map<string, string>();
|
||||||
|
const copyQueue: Array<{ old: CopyTreeDocument; newParentId: string | null }> = [];
|
||||||
|
|
||||||
|
const enqueueTree = (root: CopyTreeDocument, newParent: string | null, recursive: boolean) => {
|
||||||
|
const visit = (node: CopyTreeDocument, parentNewId: string | null) => {
|
||||||
|
const newId =
|
||||||
|
typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
|
newIdByOldId.set(node.id, newId);
|
||||||
|
copyQueue.push({ old: node, newParentId: parentNewId });
|
||||||
|
if (!recursive) return;
|
||||||
|
const children = getChildrenSorted(childrenByParent, node.id);
|
||||||
|
children.forEach((child) => visit(child, newId));
|
||||||
|
};
|
||||||
|
visit(root, newParent);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of normalizedItems) {
|
||||||
|
const doc = allDocs.find((d) => d.id === item.documentId) ?? null;
|
||||||
|
if (doc) {
|
||||||
|
enqueueTree(doc, args.targetParentId, Boolean(item.recursive));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copyQueue.length === 0) {
|
||||||
|
throw new Error("没有可复制的页面");
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertedDocs: Array<{ oldId: string; newId: string; title: string }> = [];
|
||||||
|
|
||||||
|
for (const item of copyQueue) {
|
||||||
|
const newId = newIdByOldId.get(item.old.id)!;
|
||||||
|
const parentId = item.newParentId;
|
||||||
|
|
||||||
|
if (!existingTitleSetByParent.has(parentId)) {
|
||||||
|
seedTitleSet(parentId);
|
||||||
|
}
|
||||||
|
const titleSet = existingTitleSetByParent.get(parentId) ?? new Set<string>();
|
||||||
|
existingTitleSetByParent.set(parentId, titleSet);
|
||||||
|
|
||||||
|
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
|
||||||
|
|
||||||
|
await createDocumentRecord(ctx, {
|
||||||
|
id: newId,
|
||||||
|
workspaceId,
|
||||||
|
parentId,
|
||||||
|
title: newTitle,
|
||||||
|
accessScope: (item.old.access_scope ?? "private") as "private" | "shared" | "public",
|
||||||
|
content: item.old.content ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await copyMindmapsForDocument(ctx, item.old.id, newId);
|
||||||
|
insertedDocs.push({ oldId: item.old.id, newId, title: newTitle });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: insertedDocs,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const listAllForCopy = query({
|
export const listAllForCopy = query({
|
||||||
args: { workspaceId: v.string() },
|
args: { workspaceId: v.string() },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
"backendUrl": "https://frp-dry.com:44399",
|
"backendUrl": "https://frp-dry.com:44399",
|
||||||
"onlyofficeBaseUrlWeb": "/onlyoffice-server",
|
"onlyofficeBaseUrlWeb": "/onlyoffice-server",
|
||||||
"onlyofficeBaseUrlDesktop": "http://localhost:8082",
|
"onlyofficeBaseUrlDesktop": "http://localhost:8082",
|
||||||
"onlyofficeProxyOrigin": "http://host.docker.internal:3001",
|
"onlyofficeProxyOrigin": "http://host.docker.internal:3000",
|
||||||
"onlyofficeStorageHostOverrideWeb": "",
|
"onlyofficeStorageHostOverrideWeb": "",
|
||||||
"onlyofficeStorageHostOverrideDesktop": "",
|
"onlyofficeStorageHostOverrideDesktop": "",
|
||||||
"onlyofficeProxyOriginWeb": "http://host.docker.internal:3001",
|
"onlyofficeProxyOriginWeb": "http://host.docker.internal:3000",
|
||||||
"onlyofficeCallbackOriginWeb": "http://host.docker.internal:3001",
|
"onlyofficeCallbackOriginWeb": "http://host.docker.internal:3000",
|
||||||
"onlyofficeCallbackOriginDesktop": "http://host.docker.internal:3001"
|
"onlyofficeCallbackOriginDesktop": "http://host.docker.internal:3000"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { createToolRegistry, resolveAllowedToolIds } from "@/lib/ai-agent/tools/
|
|||||||
import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins";
|
import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins";
|
||||||
import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent";
|
import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { searchSearxng } from "@/lib/ai-agent/tools/builtins/searchWeb";
|
|
||||||
import { createMindmapServerTools, type MindmapSupabaseClient } from "@/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools";
|
import { createMindmapServerTools, type MindmapSupabaseClient } from "@/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools";
|
||||||
import { createDocServerTools, type DocSupabaseClient } from "@/lib/ai-agent/tools/builtins/doc/docServerTools";
|
import { createDocServerTools, type DocSupabaseClient } from "@/lib/ai-agent/tools/builtins/doc/docServerTools";
|
||||||
import { createRagServerTools } from "@/lib/ai-agent/tools/builtins/rag/lightragServerTools";
|
import { createRagServerTools } from "@/lib/ai-agent/tools/builtins/rag/lightragServerTools";
|
||||||
@@ -18,6 +17,13 @@ import { buildClientToolKey, registerClientToolCall } from "@/lib/ai-agent/runti
|
|||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import { api } from "@/lib/convex/api";
|
import { api } from "@/lib/convex/api";
|
||||||
import type { ConvexHttpClient } from "convex/browser";
|
import type { ConvexHttpClient } from "convex/browser";
|
||||||
|
import {
|
||||||
|
buildDocumentBridgeContextWithActor,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
DocumentBridgeError,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import { recordBridgeCommandFailureArtifacts } from "@/lib/documents/bridge-log";
|
||||||
|
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
|
||||||
import {
|
import {
|
||||||
DEFAULT_AGENT_MAX_STEPS,
|
DEFAULT_AGENT_MAX_STEPS,
|
||||||
MAX_AGENT_STEPS,
|
MAX_AGENT_STEPS,
|
||||||
@@ -106,6 +112,26 @@ const makeRunId = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isOnlyOfficeClientTool = (toolId: string) => toolId.startsWith("oo_");
|
const isOnlyOfficeClientTool = (toolId: string) => toolId.startsWith("oo_");
|
||||||
|
const isDocRustTool = (toolId: string): toolId is "doc_get" | "doc_find" | "doc_insert_blocks" | "doc_replace_range" =>
|
||||||
|
toolId === "doc_get" ||
|
||||||
|
toolId === "doc_find" ||
|
||||||
|
toolId === "doc_insert_blocks" ||
|
||||||
|
toolId === "doc_replace_range";
|
||||||
|
const isMindmapWriteTool = (toolId: string) =>
|
||||||
|
toolId === "mindmap_put" ||
|
||||||
|
toolId === "mindmap_apply_ops" ||
|
||||||
|
toolId === "mindmap_add_child" ||
|
||||||
|
toolId === "mindmap_add_sibling_after" ||
|
||||||
|
toolId === "mindmap_update_node_text" ||
|
||||||
|
toolId === "mindmap_set_hyperlink" ||
|
||||||
|
toolId === "mindmap_append_note" ||
|
||||||
|
toolId === "mindmap_set_refs" ||
|
||||||
|
toolId === "mindmap_delete_node" ||
|
||||||
|
toolId === "mindmap_add_attachment_ref" ||
|
||||||
|
toolId === "mindmap_add_attachment_child" ||
|
||||||
|
toolId === "mindmap_add_image_child" ||
|
||||||
|
toolId === "mindmap_append_image_note" ||
|
||||||
|
toolId === "mindmap_expand_node";
|
||||||
|
|
||||||
const sseHeaders = {
|
const sseHeaders = {
|
||||||
"Content-Type": "text/event-stream; charset=utf-8",
|
"Content-Type": "text/event-stream; charset=utf-8",
|
||||||
@@ -179,6 +205,20 @@ export async function POST(request: Request) {
|
|||||||
return errorResponses.unauthorized();
|
return errorResponses.unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const aiBridgeContext = buildDocumentBridgeContextWithActor({
|
||||||
|
request,
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: userId,
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
workspaceId: null,
|
||||||
|
source: {
|
||||||
|
channel: "ai-agent-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const provider = normalizeProvider(payload.options?.ai?.provider);
|
const provider = normalizeProvider(payload.options?.ai?.provider);
|
||||||
const modelOverride = String(payload.options?.ai?.model ?? "").trim() || null;
|
const modelOverride = String(payload.options?.ai?.model ?? "").trim() || null;
|
||||||
|
|
||||||
@@ -587,10 +627,6 @@ export async function POST(request: Request) {
|
|||||||
const blocks = normalizeBlocksForTools(res?.content ?? null);
|
const blocks = normalizeBlocksForTools(res?.content ?? null);
|
||||||
return { blocks, source: "convex" };
|
return { blocks, source: "convex" };
|
||||||
},
|
},
|
||||||
saveBlocks: async (blocks: unknown[]) => {
|
|
||||||
if (!convexClient) throw new Error("Convex 未初始化");
|
|
||||||
await convexClient.mutation(api.documents.updateContent, { id: documentId, content: blocks });
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
})
|
})
|
||||||
@@ -755,7 +791,20 @@ export async function POST(request: Request) {
|
|||||||
if (toolId === "search_web") {
|
if (toolId === "search_web") {
|
||||||
const query = String(toolArgs.query ?? "").trim();
|
const query = String(toolArgs.query ?? "").trim();
|
||||||
const count = Number(toolArgs.count ?? DEFAULT_SEARCH_COUNT);
|
const count = Number(toolArgs.count ?? DEFAULT_SEARCH_COUNT);
|
||||||
return await searchSearxng(query, Number.isFinite(count) ? count : DEFAULT_SEARCH_COUNT);
|
const rustCount = Number.isFinite(count) ? count : DEFAULT_SEARCH_COUNT;
|
||||||
|
const rustResult = await executeRustBridgeTool<unknown>({
|
||||||
|
context: aiBridgeContext,
|
||||||
|
toolName: "search_web",
|
||||||
|
invocationKind: "query",
|
||||||
|
args: {
|
||||||
|
query,
|
||||||
|
count: rustCount,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
source: "ai-agent-route",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return rustResult.result;
|
||||||
}
|
}
|
||||||
if (toolId.startsWith("rag_")) {
|
if (toolId.startsWith("rag_")) {
|
||||||
if (!ragTools) throw new Error(`工具未初始化:${toolId}`);
|
if (!ragTools) throw new Error(`工具未初始化:${toolId}`);
|
||||||
@@ -767,7 +816,22 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
if (toolId === "image_read") {
|
if (toolId === "image_read") {
|
||||||
if (!mediaTools) throw new Error(`工具未初始化:${toolId}`);
|
if (!mediaTools) throw new Error(`工具未初始化:${toolId}`);
|
||||||
return await mediaTools.run(toolId, toolArgs);
|
const transport = await mediaTools.resolveImageReadTransport(toolArgs);
|
||||||
|
const rustResult = await executeRustBridgeTool<typeof transport>({
|
||||||
|
context: aiBridgeContext,
|
||||||
|
toolName: "image_read",
|
||||||
|
invocationKind: "query",
|
||||||
|
args: {
|
||||||
|
assetId: typeof toolArgs.assetId === "string" ? toolArgs.assetId : undefined,
|
||||||
|
fileUrl: typeof toolArgs.fileUrl === "string" ? toolArgs.fileUrl : undefined,
|
||||||
|
attachmentRef: typeof toolArgs.attachmentRef === "string" ? toolArgs.attachmentRef : undefined,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
source: "ai-agent-route",
|
||||||
|
...transport,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return rustResult.result;
|
||||||
}
|
}
|
||||||
if (toolId.startsWith("asset_")) {
|
if (toolId.startsWith("asset_")) {
|
||||||
if (!onlyofficeTools) throw new Error(`工具未初始化:${toolId}`);
|
if (!onlyofficeTools) throw new Error(`工具未初始化:${toolId}`);
|
||||||
@@ -775,16 +839,138 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
if (toolId === "slash_run") {
|
if (toolId === "slash_run") {
|
||||||
if (!slashTools) throw new Error(`工具未初始化:${toolId}`);
|
if (!slashTools) throw new Error(`工具未初始化:${toolId}`);
|
||||||
return await slashTools.run(toolId, toolArgs);
|
const rustResult = await executeRustBridgeTool<{
|
||||||
|
ok: boolean;
|
||||||
|
source?: string;
|
||||||
|
parsed?: unknown;
|
||||||
|
}>({
|
||||||
|
context: aiBridgeContext,
|
||||||
|
toolName: "slash_run",
|
||||||
|
invocationKind: "command",
|
||||||
|
args: {
|
||||||
|
text: typeof toolArgs.text === "string" ? toolArgs.text : undefined,
|
||||||
|
command: typeof toolArgs.command === "string" ? toolArgs.command : undefined,
|
||||||
|
params:
|
||||||
|
isPlainObject(toolArgs.params) || Array.isArray(toolArgs.params)
|
||||||
|
? toolArgs.params
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
source: "ai-agent-route",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const parsed = rustResult.result?.parsed;
|
||||||
|
if (!isPlainObject(parsed) || parsed.ok !== true) {
|
||||||
|
throw new Error("Rust runtime 未返回有效的 slash_run 解析结果");
|
||||||
|
}
|
||||||
|
return await slashTools.executeSlashTransport(parsed as {
|
||||||
|
ok: true;
|
||||||
|
command: "new_doc" | "rename_doc";
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (toolId.startsWith("doc_")) {
|
if (toolId.startsWith("doc_")) {
|
||||||
if (!hasDocumentContext) throw new Error(`工具需要 document 上下文:${toolId}`);
|
if (!hasDocumentContext) throw new Error(`工具需要 document 上下文:${toolId}`);
|
||||||
|
if (convexOn && convexClient && isDocRustTool(toolId)) {
|
||||||
|
const base = normalizeBlocksForTools(documentBlocks);
|
||||||
|
const runtimeContext = buildDocumentBridgeContextWithActor({
|
||||||
|
request,
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: userId,
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
workspaceId: null,
|
||||||
|
source: {
|
||||||
|
channel: "ai-agent-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await executeRustBridgeTool({
|
||||||
|
context: runtimeContext,
|
||||||
|
toolName: toolId,
|
||||||
|
invocationKind:
|
||||||
|
toolId === "doc_get" || toolId === "doc_find" ? "query" : "command",
|
||||||
|
args: toolArgs,
|
||||||
|
data: {
|
||||||
|
source: base.length > 0 ? "client" : "convex",
|
||||||
|
blocks:
|
||||||
|
base.length > 0
|
||||||
|
? base
|
||||||
|
: normalizeBlocksForTools(
|
||||||
|
(await convexClient.query(api.documents.getContent, { id: documentId }))?.content ?? null,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
pageId: documentId,
|
||||||
|
workspaceId: null,
|
||||||
|
blockId:
|
||||||
|
typeof toolArgs.blockId === "string"
|
||||||
|
? toolArgs.blockId
|
||||||
|
: typeof toolArgs.afterBlockId === "string"
|
||||||
|
? toolArgs.afterBlockId
|
||||||
|
: typeof toolArgs.beforeBlockId === "string"
|
||||||
|
? toolArgs.beforeBlockId
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
reason:
|
||||||
|
typeof toolArgs.reason === "string" && toolArgs.reason.trim()
|
||||||
|
? toolArgs.reason.trim()
|
||||||
|
: `ai-agent:${toolId}`,
|
||||||
|
refs: ["task-031", "ai-agent"],
|
||||||
|
});
|
||||||
|
return result.result;
|
||||||
|
}
|
||||||
if (!docTools) throw new Error(`工具需要 document 上下文:${toolId}`);
|
if (!docTools) throw new Error(`工具需要 document 上下文:${toolId}`);
|
||||||
return await docTools.run(toolId, toolArgs);
|
return await docTools.run(toolId, toolArgs);
|
||||||
}
|
}
|
||||||
if (!hasMindmapContext) throw new Error(`工具需要 mindmap 上下文:${toolId}`);
|
if (!hasMindmapContext) throw new Error(`工具需要 mindmap 上下文:${toolId}`);
|
||||||
if (!mindmapTools) throw new Error(`工具需要 mindmap 上下文:${toolId}`);
|
if (!mindmapTools) throw new Error(`工具需要 mindmap 上下文:${toolId}`);
|
||||||
|
try {
|
||||||
return await mindmapTools.run(toolId, toolArgs);
|
return await mindmapTools.run(toolId, toolArgs);
|
||||||
|
} catch (error) {
|
||||||
|
if (isMindmapWriteTool(toolId) && hasMindmapContext) {
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.save",
|
||||||
|
payload: {
|
||||||
|
documentId,
|
||||||
|
workspaceId: null,
|
||||||
|
reason:
|
||||||
|
typeof toolArgs.reason === "string" && toolArgs.reason.trim()
|
||||||
|
? toolArgs.reason.trim()
|
||||||
|
: `ai-agent:${toolId}`,
|
||||||
|
toolId,
|
||||||
|
toolArgs,
|
||||||
|
},
|
||||||
|
context: aiBridgeContext,
|
||||||
|
target: {
|
||||||
|
workspaceId: null,
|
||||||
|
pageId: documentId,
|
||||||
|
blockId: mindmapId,
|
||||||
|
},
|
||||||
|
reason:
|
||||||
|
typeof toolArgs.reason === "string" && toolArgs.reason.trim()
|
||||||
|
? toolArgs.reason.trim()
|
||||||
|
: `ai-agent:${toolId}`,
|
||||||
|
refs: ["task-042", "ai-agent", toolId],
|
||||||
|
});
|
||||||
|
await recordBridgeCommandFailureArtifacts({
|
||||||
|
context: aiBridgeContext,
|
||||||
|
envelope,
|
||||||
|
client: convexClient ?? undefined,
|
||||||
|
error:
|
||||||
|
error instanceof DocumentBridgeError
|
||||||
|
? error
|
||||||
|
: new DocumentBridgeError(
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
500,
|
||||||
|
"TRANSPORT_ERROR",
|
||||||
|
{ reason: "mindmap_failed", toolId },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const maxSteps = (() => {
|
const maxSteps = (() => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { randomUUID } from "crypto";
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import {
|
||||||
import { api } from "@/lib/convex/api";
|
documentBridgeErrorResponse,
|
||||||
import { getBlocksFromDocumentContent, findBlockInTree, withBlocksWrittenBack } from "@/lib/blocks";
|
executeBlockEmbedCommand,
|
||||||
|
} from "@/lib/blocks/block-command-adapter";
|
||||||
|
|
||||||
type EmbedBlockPayload = {
|
type EmbedBlockPayload = {
|
||||||
sourceDocumentId: string;
|
sourceDocumentId: string;
|
||||||
@@ -24,42 +24,26 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
const { client } = await getAuthedConvexClient();
|
try {
|
||||||
|
const result = await executeBlockEmbedCommand({
|
||||||
const source = await client.query(api.documents.getContent, { id: sourceDocumentId });
|
request,
|
||||||
if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
|
||||||
const sourceBlocks = getBlocksFromDocumentContent(source.content);
|
|
||||||
const hit = findBlockInTree(sourceBlocks, blockId);
|
|
||||||
if (!hit) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
|
|
||||||
|
|
||||||
const target = await client.query(api.documents.getContent, { id: targetDocumentId });
|
|
||||||
if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
|
||||||
const targetMeta = await client.query(api.documents.getMeta, { id: targetDocumentId });
|
|
||||||
|
|
||||||
const targetBlocks = getBlocksFromDocumentContent(target.content);
|
|
||||||
const anchorId = (targetMeta as any)?.embed_default_block_id as string | null | undefined;
|
|
||||||
const anchorIndex =
|
|
||||||
typeof anchorId === "string" && anchorId.trim()
|
|
||||||
? targetBlocks.findIndex((b) => String((b as any)?.id ?? "") === anchorId)
|
|
||||||
: -1;
|
|
||||||
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : targetBlocks.length;
|
|
||||||
const referenceBlock = {
|
|
||||||
id: randomUUID(),
|
|
||||||
type: "blockReference",
|
|
||||||
props: {
|
|
||||||
sourceDocumentId,
|
sourceDocumentId,
|
||||||
targetBlockId: blockId,
|
blockId,
|
||||||
display: "embed",
|
targetDocumentId,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
...(result.result && typeof result.result === "object" ? result.result : {}),
|
||||||
|
meta: {
|
||||||
|
requestId: result.requestId,
|
||||||
|
traceId: result.traceId,
|
||||||
|
commandId: result.commandId,
|
||||||
|
commandName: result.commandName,
|
||||||
},
|
},
|
||||||
content: [],
|
});
|
||||||
children: [],
|
} catch (error) {
|
||||||
};
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
const nextBlocks = [...targetBlocks.slice(0, insertIndex), referenceBlock, ...targetBlocks.slice(insertIndex)];
|
|
||||||
const payload = withBlocksWrittenBack(target.content, nextBlocks);
|
|
||||||
await client.mutation(api.documents.updateContent, { id: targetDocumentId, content: payload });
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
@@ -121,3 +105,5 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import {
|
||||||
import { api } from "@/lib/convex/api";
|
documentBridgeErrorResponse,
|
||||||
import { getBlocksFromDocumentContent, findBlockInTree } from "@/lib/blocks";
|
executeBlockGetQuery,
|
||||||
|
} from "@/lib/blocks/block-command-adapter";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -14,13 +15,16 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
const { client } = await getAuthedConvexClient();
|
try {
|
||||||
const doc = await client.query(api.documents.getContent, { id: sourceDocumentId });
|
const result = await executeBlockGetQuery({
|
||||||
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
request,
|
||||||
const blocks = getBlocksFromDocumentContent(doc.content);
|
sourceDocumentId,
|
||||||
const hit = findBlockInTree(blocks, blockId);
|
blockId,
|
||||||
if (!hit) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
|
});
|
||||||
return NextResponse.json({ ok: true, block: hit.block });
|
return NextResponse.json({ ok: true, block: result.block, meta: result.meta });
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
@@ -47,3 +51,5 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ ok: true, block: hit.block });
|
return NextResponse.json({ ok: true, block: hit.block });
|
||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import {
|
||||||
import { api } from "@/lib/convex/api";
|
documentBridgeErrorResponse,
|
||||||
import { getBlocksFromDocumentContent, removeBlockSubtree, withBlocksWrittenBack } from "@/lib/blocks";
|
executeBlockMoveCommand,
|
||||||
|
} from "@/lib/blocks/block-command-adapter";
|
||||||
|
|
||||||
type MoveBlockPayload = {
|
type MoveBlockPayload = {
|
||||||
sourceDocumentId: string;
|
sourceDocumentId: string;
|
||||||
@@ -24,25 +25,30 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
const { client } = await getAuthedConvexClient();
|
try {
|
||||||
|
const result = await executeBlockMoveCommand({
|
||||||
const source = await client.query(api.documents.getContent, { id: sourceDocumentId });
|
request,
|
||||||
if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
sourceDocumentId,
|
||||||
const sourceBlocks = getBlocksFromDocumentContent(source.content);
|
blockId,
|
||||||
const removedRes = removeBlockSubtree(sourceBlocks, blockId);
|
targetDocumentId,
|
||||||
if (!removedRes.removed) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
|
});
|
||||||
|
const payload =
|
||||||
const target = await client.query(api.documents.getContent, { id: targetDocumentId });
|
result.result && typeof result.result === "object"
|
||||||
if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
? (result.result as Record<string, unknown>)
|
||||||
const targetBlocks = getBlocksFromDocumentContent(target.content);
|
: {};
|
||||||
|
return NextResponse.json({
|
||||||
const nextSourcePayload = withBlocksWrittenBack(source.content, removedRes.nextBlocks);
|
...payload,
|
||||||
const nextTargetPayload = withBlocksWrittenBack(target.content, [...targetBlocks, removedRes.removed]);
|
ok: typeof payload.ok === "boolean" ? payload.ok : true,
|
||||||
|
meta: {
|
||||||
await client.mutation(api.documents.updateContent, { id: sourceDocumentId, content: nextSourcePayload });
|
requestId: result.requestId,
|
||||||
await client.mutation(api.documents.updateContent, { id: targetDocumentId, content: nextTargetPayload });
|
traceId: result.traceId,
|
||||||
|
commandId: result.commandId,
|
||||||
return NextResponse.json({ ok: true });
|
commandName: result.commandName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
@@ -98,3 +104,5 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
|
||||||
import { api } from "@/lib/convex/api";
|
|
||||||
import { getBlocksFromDocumentContent, replaceBlockInTree, withBlocksWrittenBack } from "@/lib/blocks";
|
|
||||||
import {
|
import {
|
||||||
assertBlockId,
|
|
||||||
assertDocumentId,
|
|
||||||
assertNextBlock,
|
|
||||||
buildDocumentBridgeContext,
|
|
||||||
buildDocumentCommandEnvelope,
|
|
||||||
documentBridgeErrorResponse,
|
documentBridgeErrorResponse,
|
||||||
} from "@/lib/documents/bridge";
|
executeBlockPatchCommand,
|
||||||
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
} from "@/lib/blocks/block-command-adapter";
|
||||||
|
|
||||||
type PatchPayload = {
|
type PatchPayload = {
|
||||||
sourceDocumentId: string;
|
sourceDocumentId: string;
|
||||||
@@ -24,78 +16,20 @@ export async function POST(request: Request) {
|
|||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
try {
|
try {
|
||||||
const { sourceDocumentId, workspaceId, blockId, nextBlock }: PatchPayload = await request.json();
|
const { sourceDocumentId, workspaceId, blockId, nextBlock }: PatchPayload = await request.json();
|
||||||
const normalizedDocumentId = assertDocumentId(sourceDocumentId);
|
const result = await executeBlockPatchCommand({
|
||||||
const normalizedWorkspaceId = workspaceId?.trim() || null;
|
|
||||||
const normalizedBlockId = assertBlockId(blockId);
|
|
||||||
assertNextBlock(nextBlock);
|
|
||||||
|
|
||||||
const bridgeContext = await buildDocumentBridgeContext({
|
|
||||||
request,
|
request,
|
||||||
workspaceId: normalizedWorkspaceId,
|
sourceDocumentId,
|
||||||
});
|
workspaceId,
|
||||||
const envelope = buildDocumentCommandEnvelope({
|
blockId,
|
||||||
name: "blocks.patch",
|
|
||||||
payload: {
|
|
||||||
documentId: normalizedDocumentId,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
blockId: normalizedBlockId,
|
|
||||||
nextBlock,
|
nextBlock,
|
||||||
},
|
|
||||||
context: bridgeContext,
|
|
||||||
target: {
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
pageId: normalizedDocumentId,
|
|
||||||
blockId: normalizedBlockId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { client } = await getAuthedConvexClient();
|
|
||||||
const doc = await client.query(api.documents.getContent, { id: normalizedDocumentId });
|
|
||||||
if (!doc) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: "页面不存在或无权限",
|
|
||||||
meta: {
|
|
||||||
requestId: bridgeContext.requestId,
|
|
||||||
traceId: bridgeContext.traceId,
|
|
||||||
commandId: envelope.commandId,
|
|
||||||
commandName: envelope.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 404 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blocks = getBlocksFromDocumentContent(doc.content);
|
|
||||||
const replaced = replaceBlockInTree(blocks, normalizedBlockId, nextBlock as any);
|
|
||||||
if (!replaced.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: "块不存在或无权限",
|
|
||||||
meta: {
|
|
||||||
requestId: bridgeContext.requestId,
|
|
||||||
traceId: bridgeContext.traceId,
|
|
||||||
commandId: envelope.commandId,
|
|
||||||
commandName: envelope.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 404 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = withBlocksWrittenBack(doc.content, replaced.nextBlocks);
|
|
||||||
await client.mutation(api.documents.updateContent, { id: normalizedDocumentId, content: payload });
|
|
||||||
await recordBridgeCommandArtifacts({
|
|
||||||
context: bridgeContext,
|
|
||||||
envelope,
|
|
||||||
});
|
});
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
meta: {
|
meta: {
|
||||||
requestId: bridgeContext.requestId,
|
requestId: result.requestId,
|
||||||
traceId: bridgeContext.traceId,
|
traceId: result.traceId,
|
||||||
commandId: envelope.commandId,
|
commandId: result.commandId,
|
||||||
commandName: envelope.name,
|
commandName: result.commandName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -135,3 +69,5 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|||||||
@@ -1,26 +1,89 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import { api } from "@/lib/convex/api";
|
import {
|
||||||
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentQueryEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
executeRustBridgeQueryTransport,
|
||||||
|
resolveRustBridgeQueryPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
const bridgeLogsApi = api as any;
|
function sortByNewest<T extends Record<string, unknown>>(rows: T[]) {
|
||||||
|
return [...rows].sort((left, right) => {
|
||||||
|
const leftTime =
|
||||||
|
(typeof left.created_at === "string" && left.created_at) ||
|
||||||
|
(typeof left.finished_at === "string" && left.finished_at) ||
|
||||||
|
"";
|
||||||
|
const rightTime =
|
||||||
|
(typeof right.created_at === "string" && right.created_at) ||
|
||||||
|
(typeof right.finished_at === "string" && right.finished_at) ||
|
||||||
|
"";
|
||||||
|
return rightTime.localeCompare(leftTime);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByCommandId<T extends Record<string, unknown>>(rows: T[], commandId: string | null) {
|
||||||
|
if (!commandId) return rows;
|
||||||
|
return rows.filter((row) => row.command_id === commandId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const workspaceId = url.searchParams.get("workspaceId")?.trim() ?? "";
|
const workspaceId = url.searchParams.get("workspaceId")?.trim() ?? "";
|
||||||
const requestId = url.searchParams.get("requestId")?.trim() ?? "";
|
const requestId = url.searchParams.get("requestId")?.trim() ?? "";
|
||||||
|
const commandId = url.searchParams.get("commandId")?.trim() ?? "";
|
||||||
if (!workspaceId || !requestId) {
|
if (!workspaceId || !requestId) {
|
||||||
return NextResponse.json({ error: "缺少 workspaceId 或 requestId" }, { status: 400 });
|
return NextResponse.json({ error: "缺少 workspaceId 或 requestId" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { client } = await getAuthedConvexClient();
|
const { client } = await getAuthedConvexClient();
|
||||||
const result = await client.query(bridgeLogsApi.bridgeLogs.listByRequest, {
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentQueryEnvelope({
|
||||||
|
name: "bridge.request.get",
|
||||||
|
payload: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
requestId,
|
requestId,
|
||||||
|
commandId: commandId || null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
const plan = await resolveRustBridgeQueryPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
const result = await executeRustBridgeQueryTransport<{
|
||||||
|
request_id: string;
|
||||||
|
command_logs: Array<Record<string, unknown>>;
|
||||||
|
domain_events: Array<Record<string, unknown>>;
|
||||||
|
generated_at: string;
|
||||||
|
}>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
const commandLogs = sortByNewest(filterByCommandId(result.command_logs ?? [], commandId || null));
|
||||||
|
const domainEvents = sortByNewest(filterByCommandId(result.domain_events ?? [], commandId || null));
|
||||||
|
|
||||||
return NextResponse.json(result);
|
return NextResponse.json({
|
||||||
|
request_id: result.request_id,
|
||||||
|
command_logs: commandLogs,
|
||||||
|
domain_events: domainEvents,
|
||||||
|
generated_at: result.generated_at,
|
||||||
|
counts: {
|
||||||
|
command_logs: commandLogs.length,
|
||||||
|
domain_events: domainEvents.length,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
queryName: envelope.name,
|
||||||
|
commandId: commandId || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return documentBridgeErrorResponse(error);
|
return documentBridgeErrorResponse(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,89 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import { api } from "@/lib/convex/api";
|
import {
|
||||||
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentQueryEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
executeRustBridgeQueryTransport,
|
||||||
|
resolveRustBridgeQueryPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
const bridgeLogsApi = api as any;
|
function sortByNewest<T extends Record<string, unknown>>(rows: T[]) {
|
||||||
|
return [...rows].sort((left, right) => {
|
||||||
|
const leftTime =
|
||||||
|
(typeof left.created_at === "string" && left.created_at) ||
|
||||||
|
(typeof left.finished_at === "string" && left.finished_at) ||
|
||||||
|
"";
|
||||||
|
const rightTime =
|
||||||
|
(typeof right.created_at === "string" && right.created_at) ||
|
||||||
|
(typeof right.finished_at === "string" && right.finished_at) ||
|
||||||
|
"";
|
||||||
|
return rightTime.localeCompare(leftTime);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByCommandId<T extends Record<string, unknown>>(rows: T[], commandId: string | null) {
|
||||||
|
if (!commandId) return rows;
|
||||||
|
return rows.filter((row) => row.command_id === commandId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const workspaceId = url.searchParams.get("workspaceId")?.trim() ?? "";
|
const workspaceId = url.searchParams.get("workspaceId")?.trim() ?? "";
|
||||||
const traceId = url.searchParams.get("traceId")?.trim() ?? "";
|
const traceId = url.searchParams.get("traceId")?.trim() ?? "";
|
||||||
|
const commandId = url.searchParams.get("commandId")?.trim() ?? "";
|
||||||
if (!workspaceId || !traceId) {
|
if (!workspaceId || !traceId) {
|
||||||
return NextResponse.json({ error: "缺少 workspaceId 或 traceId" }, { status: 400 });
|
return NextResponse.json({ error: "缺少 workspaceId 或 traceId" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { client } = await getAuthedConvexClient();
|
const { client } = await getAuthedConvexClient();
|
||||||
const result = await client.query(bridgeLogsApi.bridgeLogs.listByTrace, {
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentQueryEnvelope({
|
||||||
|
name: "bridge.trace.get",
|
||||||
|
payload: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
traceId,
|
traceId,
|
||||||
|
commandId: commandId || null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
const plan = await resolveRustBridgeQueryPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
const result = await executeRustBridgeQueryTransport<{
|
||||||
|
trace_id: string;
|
||||||
|
command_logs: Array<Record<string, unknown>>;
|
||||||
|
domain_events: Array<Record<string, unknown>>;
|
||||||
|
generated_at: string;
|
||||||
|
}>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
const commandLogs = sortByNewest(filterByCommandId(result.command_logs ?? [], commandId || null));
|
||||||
|
const domainEvents = sortByNewest(filterByCommandId(result.domain_events ?? [], commandId || null));
|
||||||
|
|
||||||
return NextResponse.json(result);
|
return NextResponse.json({
|
||||||
|
trace_id: result.trace_id,
|
||||||
|
command_logs: commandLogs,
|
||||||
|
domain_events: domainEvents,
|
||||||
|
generated_at: result.generated_at,
|
||||||
|
counts: {
|
||||||
|
command_logs: commandLogs.length,
|
||||||
|
domain_events: domainEvents.length,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
queryName: envelope.name,
|
||||||
|
commandId: commandId || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return documentBridgeErrorResponse(error);
|
return documentBridgeErrorResponse(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import {
|
||||||
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentQueryEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
executeRustBridgeQueryTransport,
|
||||||
|
resolveRustBridgeQueryPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const workspaceId = url.searchParams.get("workspaceId")?.trim() ?? "";
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Number(url.searchParams.get("limit") ?? "50");
|
||||||
|
const cursor = url.searchParams.get("cursor")?.trim() ?? "";
|
||||||
|
const commandStatus = url.searchParams.get("commandStatus")?.trim() ?? "";
|
||||||
|
const eventStatus = url.searchParams.get("eventStatus")?.trim() ?? "";
|
||||||
|
const targetPageId = url.searchParams.get("targetPageId")?.trim() ?? "";
|
||||||
|
const targetBlockId = url.searchParams.get("targetBlockId")?.trim() ?? "";
|
||||||
|
const aggregateType = url.searchParams.get("aggregateType")?.trim() ?? "";
|
||||||
|
const aggregateId = url.searchParams.get("aggregateId")?.trim() ?? "";
|
||||||
|
|
||||||
|
const { client } = await getAuthedConvexClient();
|
||||||
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentQueryEnvelope({
|
||||||
|
name: "bridge.workspace.overview",
|
||||||
|
payload: {
|
||||||
|
workspaceId,
|
||||||
|
limit: Number.isFinite(limit) ? limit : 50,
|
||||||
|
cursor: cursor || null,
|
||||||
|
commandStatus: commandStatus || null,
|
||||||
|
eventStatus: eventStatus || null,
|
||||||
|
targetPageId: targetPageId || null,
|
||||||
|
targetBlockId: targetBlockId || null,
|
||||||
|
aggregateType: aggregateType || null,
|
||||||
|
aggregateId: aggregateId || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeQueryPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
const result = await executeRustBridgeQueryTransport<{
|
||||||
|
workspace_id: string;
|
||||||
|
command_logs: Array<Record<string, unknown>>;
|
||||||
|
domain_events: Array<Record<string, unknown>>;
|
||||||
|
counts: {
|
||||||
|
command_logs: number;
|
||||||
|
domain_events: number;
|
||||||
|
};
|
||||||
|
filters: Record<string, unknown> | null;
|
||||||
|
next_cursor: string | null;
|
||||||
|
has_more: boolean;
|
||||||
|
generated_at: string;
|
||||||
|
}>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
workspace_id: result.workspace_id,
|
||||||
|
command_logs: result.command_logs ?? [],
|
||||||
|
domain_events: result.domain_events ?? [],
|
||||||
|
counts: result.counts ?? {
|
||||||
|
command_logs: Array.isArray(result.command_logs) ? result.command_logs.length : 0,
|
||||||
|
domain_events: Array.isArray(result.domain_events) ? result.domain_events.length : 0,
|
||||||
|
},
|
||||||
|
filters: result.filters ?? null,
|
||||||
|
next_cursor: result.next_cursor ?? null,
|
||||||
|
has_more: Boolean(result.has_more),
|
||||||
|
generated_at: result.generated_at,
|
||||||
|
meta: {
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
queryName: envelope.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import { api } from "@/lib/convex/api";
|
|
||||||
import {
|
import {
|
||||||
assertDocumentId,
|
assertDocumentId,
|
||||||
buildDocumentBridgeContext,
|
buildDocumentBridgeContext,
|
||||||
buildDocumentQueryEnvelope,
|
buildDocumentQueryEnvelope,
|
||||||
documentBridgeErrorResponse,
|
documentBridgeErrorResponse,
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
executeRustBridgeQueryTransport,
|
||||||
|
resolveRustBridgeQueryPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
@@ -23,9 +27,17 @@ export async function GET(request: Request) {
|
|||||||
name: "documents.content.get",
|
name: "documents.content.get",
|
||||||
payload: { documentId, workspaceId },
|
payload: { documentId, workspaceId },
|
||||||
});
|
});
|
||||||
|
const plan = await resolveRustBridgeQueryPlan({
|
||||||
const result = await client.query(api.documents.getContent, {
|
context: bridgeContext,
|
||||||
id: documentId,
|
envelope,
|
||||||
|
});
|
||||||
|
const result = await executeRustBridgeQueryTransport<{
|
||||||
|
content?: unknown;
|
||||||
|
revision?: number | null;
|
||||||
|
conflict_detection_key?: string | null;
|
||||||
|
} | null>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
|
||||||
import { api } from "@/lib/convex/api";
|
import { api } from "@/lib/convex/api";
|
||||||
import path from "path";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import { promises as fs } from "fs";
|
import {
|
||||||
import type { Json } from "@/types/supabase";
|
assertDocumentId,
|
||||||
import { makeUniqueTitle } from "@/lib/file-tree/naming";
|
buildDocumentBridgeContext,
|
||||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
buildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
executePageLifecycleBridgeCommand,
|
||||||
|
type DocumentCopyTreePayload,
|
||||||
|
} from "@/lib/documents/page-command-adapter";
|
||||||
|
import {
|
||||||
|
copyMindmapFilesIfExists,
|
||||||
|
ensureDocumentScaffold,
|
||||||
|
} from "@/lib/documents/page-lifecycle-side-effects";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -20,525 +29,98 @@ type CopyTreePayload = {
|
|||||||
targetParentId: string | null;
|
targetParentId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DocRow = {
|
|
||||||
id: string;
|
|
||||||
title: string | null;
|
|
||||||
parent_id: string | null;
|
|
||||||
workspace_id: string;
|
|
||||||
access_scope: "private" | "shared" | "public" | null;
|
|
||||||
sort_order: number | null;
|
|
||||||
created_at: string | null;
|
|
||||||
content: Json | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AssetRow = {
|
|
||||||
id: string;
|
|
||||||
workspace_id: string;
|
|
||||||
document_id: string;
|
|
||||||
asset_type: string | null;
|
|
||||||
file_name: string | null;
|
|
||||||
file_size: number | null;
|
|
||||||
mime_type: string | null;
|
|
||||||
file_url: string | null;
|
|
||||||
thumbnail_url: string | null;
|
|
||||||
bucket: string | null;
|
|
||||||
storage_path: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const documentsBaseDir = getDocumentsBaseDir();
|
|
||||||
const DEFAULT_DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
|
|
||||||
|
|
||||||
async function ensureDocumentScaffold(id: string, title: string | null) {
|
|
||||||
const folder = path.join(documentsBaseDir, id);
|
|
||||||
const indexFile = path.join(folder, "index.md");
|
|
||||||
await fs.mkdir(folder, { recursive: true });
|
|
||||||
try {
|
|
||||||
await fs.access(indexFile);
|
|
||||||
} catch {
|
|
||||||
const safeTitle = title && title.trim() ? title.trim() : "无标题";
|
|
||||||
const content = `# ${safeTitle}\n`;
|
|
||||||
await fs.writeFile(indexFile, content, "utf8");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyMindmapIfExists(sourceId: string, targetId: string) {
|
|
||||||
const srcDir = path.join(documentsBaseDir, sourceId);
|
|
||||||
const destDir = path.join(documentsBaseDir, targetId);
|
|
||||||
try {
|
|
||||||
const entries = await fs.readdir(srcDir);
|
|
||||||
const mindmapFiles = entries.filter((name) => name === "mindmap.json" || /^mindmap-.+\\.json$/i.test(name));
|
|
||||||
if (mindmapFiles.length === 0) return;
|
|
||||||
await fs.mkdir(destDir, { recursive: true });
|
|
||||||
await Promise.all(
|
|
||||||
mindmapFiles.map(async (name) => {
|
|
||||||
try {
|
|
||||||
const buf = await fs.readFile(path.join(srcDir, name));
|
|
||||||
await fs.writeFile(path.join(destDir, name), buf);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// 源不存在则忽略
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTitle(title: string | null): string {
|
|
||||||
const safe = title?.trim();
|
|
||||||
return safe && safe.length > 0 ? safe : "无标题";
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStoragePath(fileUrl: string): { bucket: string; path: string } | null {
|
|
||||||
try {
|
|
||||||
const url = new URL(fileUrl);
|
|
||||||
const segments = url.pathname.split("/").filter(Boolean);
|
|
||||||
const objectIdx = segments.findIndex((seg) => seg === "object");
|
|
||||||
if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null;
|
|
||||||
if (segments[objectIdx + 1] === "public") {
|
|
||||||
const bucket = segments[objectIdx + 2];
|
|
||||||
const p = segments.slice(objectIdx + 3).join("/");
|
|
||||||
return p ? { bucket, path: p } : null;
|
|
||||||
}
|
|
||||||
if (segments[objectIdx + 1] === "sign") {
|
|
||||||
const bucket = segments[objectIdx + 2];
|
|
||||||
const p = segments.slice(objectIdx + 3).join("/");
|
|
||||||
return p ? { bucket, path: p } : null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChildrenSorted(childrenByParent: Map<string | null, DocRow[]>, parentId: string | null): DocRow[] {
|
|
||||||
const list = childrenByParent.get(parentId) ?? [];
|
|
||||||
return [...list].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;
|
|
||||||
const timeA = new Date(a.created_at ?? 0).getTime();
|
|
||||||
const timeB = new Date(b.created_at ?? 0).getTime();
|
|
||||||
return timeA - timeB;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceAssetRefsInContent(content: Json | null, assetMap: Map<string, { id: string; signedUrl: string }>, newDocId: string): Json | null {
|
|
||||||
if (!content) return content;
|
|
||||||
|
|
||||||
const transform = (value: unknown): unknown => {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.map(transform);
|
|
||||||
}
|
|
||||||
if (!value || typeof value !== "object") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const obj = value as Record<string, unknown>;
|
|
||||||
const next: Record<string, unknown> = {};
|
|
||||||
Object.keys(obj).forEach((key) => {
|
|
||||||
next[key] = transform(obj[key]);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (next.type === "media" && next.props && typeof next.props === "object") {
|
|
||||||
const props = next.props as Record<string, unknown>;
|
|
||||||
const oldId = typeof props.assetId === "string" ? props.assetId : null;
|
|
||||||
if (oldId && assetMap.has(oldId)) {
|
|
||||||
const mapped = assetMap.get(oldId)!;
|
|
||||||
props.assetId = mapped.id;
|
|
||||||
props.fileUrl = mapped.signedUrl;
|
|
||||||
props.thumbnailUrl = mapped.signedUrl;
|
|
||||||
props.documentId = newDocId;
|
|
||||||
next.props = props;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
return transform(content) as Json;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
|
try {
|
||||||
const { client } = await getAuthedConvexClient();
|
const { client } = await getAuthedConvexClient();
|
||||||
|
|
||||||
const payload = (await request.json()) as CopyTreePayload;
|
const payload = (await request.json()) as CopyTreePayload;
|
||||||
if (!payload?.items?.length) {
|
const normalizedItems = (payload.items ?? [])
|
||||||
return NextResponse.json({ error: "缺少 items" }, { status: 400 });
|
.filter((item) => item?.documentId)
|
||||||
}
|
.map((item) => ({
|
||||||
|
documentId: assertDocumentId(item.documentId),
|
||||||
|
recursive: Boolean(item.recursive),
|
||||||
|
}));
|
||||||
|
|
||||||
const normalizedItems = payload.items.filter((it) => it?.documentId);
|
|
||||||
if (normalizedItems.length === 0) {
|
if (normalizedItems.length === 0) {
|
||||||
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetParentId = payload.targetParentId ?? null;
|
const normalizedTargetParentId = payload.targetParentId?.trim() || null;
|
||||||
let workspaceId: string | null = null;
|
let workspaceId: string | null = null;
|
||||||
|
|
||||||
if (targetParentId) {
|
if (normalizedTargetParentId) {
|
||||||
const targetDoc = await client.query(api.documents.getMeta, { id: targetParentId });
|
const targetDoc = await client.query(api.documents.getMeta, { id: normalizedTargetParentId });
|
||||||
if (!targetDoc) {
|
if (!targetDoc) {
|
||||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||||
}
|
}
|
||||||
workspaceId = targetDoc.workspace_id;
|
workspaceId = targetDoc.workspace_id?.trim() || null;
|
||||||
}
|
} else {
|
||||||
|
const firstDoc = await client.query(api.documents.getMeta, { id: normalizedItems[0].documentId });
|
||||||
const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId)));
|
if (!firstDoc) {
|
||||||
const firstMeta = await client.query(api.documents.getMeta, { id: sourceIds[0] });
|
|
||||||
if (!firstMeta) {
|
|
||||||
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
workspaceId = firstDoc.workspace_id?.trim() || null;
|
||||||
if (!workspaceId) {
|
|
||||||
workspaceId = firstMeta.workspace_id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const wid = workspaceId;
|
const bridgeContext = await buildDocumentBridgeContext({
|
||||||
|
request,
|
||||||
const allDocs = await client.query(api.documents.listAllForCopy, {
|
workspaceId,
|
||||||
workspaceId: wid,
|
});
|
||||||
|
const result = await executePageLifecycleBridgeCommand<
|
||||||
|
DocumentCopyTreePayload,
|
||||||
|
{
|
||||||
|
items: Array<{
|
||||||
|
oldId: string;
|
||||||
|
newId: string;
|
||||||
|
title?: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
context: bridgeContext,
|
||||||
|
envelope: buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.copy_tree",
|
||||||
|
payload: {
|
||||||
|
workspaceId,
|
||||||
|
targetParentId: normalizedTargetParentId,
|
||||||
|
items: normalizedItems,
|
||||||
|
},
|
||||||
|
context: bridgeContext,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
pageId: normalizedTargetParentId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const sourceById = new Map<string, DocRow>();
|
await Promise.all(
|
||||||
(allDocs as unknown as DocRow[]).forEach((d) => sourceById.set(d.id, d));
|
result.result.items.map(async (item) => {
|
||||||
|
await ensureDocumentScaffold(item.newId, item.title ?? "无标题");
|
||||||
const missing = sourceIds.find((id) => !sourceById.has(id));
|
await copyMindmapFilesIfExists(item.oldId, item.newId);
|
||||||
if (missing) {
|
}),
|
||||||
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const childrenByParent = new Map<string | null, DocRow[]>();
|
|
||||||
(allDocs as unknown as DocRow[]).forEach((doc) => {
|
|
||||||
const list = childrenByParent.get(doc.parent_id) ?? [];
|
|
||||||
list.push(doc);
|
|
||||||
childrenByParent.set(doc.parent_id, list);
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingTitleSetByParent = new Map<string | null, Set<string>>();
|
|
||||||
const seedTitleSet = (parent: string | null) => {
|
|
||||||
if (existingTitleSetByParent.has(parent)) return;
|
|
||||||
const titles = new Set<string>();
|
|
||||||
(childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title)));
|
|
||||||
existingTitleSetByParent.set(parent, titles);
|
|
||||||
};
|
|
||||||
seedTitleSet(targetParentId);
|
|
||||||
|
|
||||||
const newIdByOldId = new Map<string, string>();
|
|
||||||
const copyQueue: Array<{ old: DocRow; newParentId: string | null; parentKey: string | null }> = [];
|
|
||||||
|
|
||||||
const enqueueTree = (root: DocRow, newParent: string | null, recursive: boolean) => {
|
|
||||||
const visit = (node: DocRow, parentNewId: string | null, parentKey: string | null) => {
|
|
||||||
const newId =
|
|
||||||
typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
|
||||||
newIdByOldId.set(node.id, newId);
|
|
||||||
copyQueue.push({ old: node, newParentId: parentNewId, parentKey });
|
|
||||||
if (!recursive) return;
|
|
||||||
const children = getChildrenSorted(childrenByParent, node.id);
|
|
||||||
children.forEach((child) => visit(child, newId, newId));
|
|
||||||
};
|
|
||||||
visit(root, newParent, newParent);
|
|
||||||
};
|
|
||||||
|
|
||||||
normalizedItems.forEach((item) => {
|
|
||||||
const doc = sourceById.get(item.documentId) ?? null;
|
|
||||||
if (doc) {
|
|
||||||
enqueueTree(doc, targetParentId, Boolean(item.recursive));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (copyQueue.length === 0) {
|
|
||||||
return NextResponse.json({ error: "没有可复制的页面" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertedDocs: Array<{ oldId: string; newId: string }> = [];
|
|
||||||
|
|
||||||
for (const item of copyQueue) {
|
|
||||||
const newId = newIdByOldId.get(item.old.id)!;
|
|
||||||
const parentId = item.newParentId;
|
|
||||||
|
|
||||||
if (!existingTitleSetByParent.has(parentId)) {
|
|
||||||
seedTitleSet(parentId);
|
|
||||||
}
|
|
||||||
const titleSet = existingTitleSetByParent.get(parentId) ?? new Set<string>();
|
|
||||||
existingTitleSetByParent.set(parentId, titleSet);
|
|
||||||
|
|
||||||
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
|
|
||||||
titleSet.add(newTitle);
|
|
||||||
|
|
||||||
await client.mutation(api.documents.create, {
|
|
||||||
id: newId,
|
|
||||||
workspaceId: wid,
|
|
||||||
parentId,
|
|
||||||
accessScope: (item.old.access_scope ?? "private") as "private" | "shared" | "public",
|
|
||||||
title: newTitle,
|
|
||||||
content: item.old.content ?? [],
|
|
||||||
});
|
|
||||||
|
|
||||||
await ensureDocumentScaffold(newId, newTitle);
|
|
||||||
await client.mutation(api.mindmaps.copyByDocument, {
|
|
||||||
sourceDocId: item.old.id,
|
|
||||||
targetDocId: newId,
|
|
||||||
});
|
|
||||||
insertedDocs.push({ oldId: item.old.id, newId });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
items: insertedDocs.map((d) => ({ oldId: d.oldId, newId: d.newId })),
|
items: result.result.items.map((item) => ({
|
||||||
|
oldId: item.oldId,
|
||||||
|
newId: item.newId,
|
||||||
|
})),
|
||||||
|
meta: {
|
||||||
|
requestId: result.requestId,
|
||||||
|
traceId: result.traceId,
|
||||||
|
commandId: result.commandId,
|
||||||
|
commandName: result.commandName,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
|
||||||
/*
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
|
||||||
const {
|
|
||||||
data: { session },
|
|
||||||
} = await supabase.auth.getSession();
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = (await request.json()) as CopyTreePayload;
|
export const runtime = "nodejs";
|
||||||
if (!payload?.items?.length) {
|
|
||||||
return NextResponse.json({ error: "缺少 items" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedItems = payload.items.filter((it) => it?.documentId);
|
|
||||||
if (normalizedItems.length === 0) {
|
|
||||||
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetParentId = payload.targetParentId ?? null;
|
|
||||||
let workspaceId: string | null = null;
|
|
||||||
let targetAccessScope: "private" | "shared" | "public" = "private";
|
|
||||||
|
|
||||||
if (targetParentId) {
|
|
||||||
const { data: targetDoc, error } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.select("workspace_id,access_scope")
|
|
||||||
.eq("id", targetParentId)
|
|
||||||
.eq("user_id", session.user.id)
|
|
||||||
.single();
|
|
||||||
if (error || !targetDoc) {
|
|
||||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
|
||||||
}
|
|
||||||
workspaceId = targetDoc.workspace_id;
|
|
||||||
targetAccessScope = (targetDoc.access_scope ?? "private") as typeof targetAccessScope;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId)));
|
|
||||||
const { data: sourceDocs, error: sourceErr } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.select("id,title,parent_id,workspace_id,access_scope,sort_order,created_at,content")
|
|
||||||
.in("id", sourceIds)
|
|
||||||
.eq("user_id", session.user.id);
|
|
||||||
if (sourceErr || !sourceDocs?.length) {
|
|
||||||
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceById = new Map<string, DocRow>();
|
|
||||||
(sourceDocs as DocRow[]).forEach((d) => sourceById.set(d.id, d));
|
|
||||||
|
|
||||||
if (!workspaceId) {
|
|
||||||
workspaceId = sourceDocs[0].workspace_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: allDocs, error: allErr } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.select("id,title,parent_id,workspace_id,access_scope,sort_order,created_at,content")
|
|
||||||
.eq("workspace_id", workspaceId)
|
|
||||||
.eq("user_id", session.user.id)
|
|
||||||
.order("sort_order", { ascending: true, nullsFirst: false })
|
|
||||||
.order("created_at", { ascending: true });
|
|
||||||
if (allErr) {
|
|
||||||
return NextResponse.json({ error: allErr.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const childrenByParent = new Map<string | null, DocRow[]>();
|
|
||||||
(allDocs as DocRow[]).forEach((doc) => {
|
|
||||||
const list = childrenByParent.get(doc.parent_id) ?? [];
|
|
||||||
list.push(doc);
|
|
||||||
childrenByParent.set(doc.parent_id, list);
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingTitleSetByParent = new Map<string | null, Set<string>>();
|
|
||||||
const seedTitleSet = (parent: string | null) => {
|
|
||||||
if (existingTitleSetByParent.has(parent)) return;
|
|
||||||
const titles = new Set<string>();
|
|
||||||
(childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title)));
|
|
||||||
existingTitleSetByParent.set(parent, titles);
|
|
||||||
};
|
|
||||||
seedTitleSet(targetParentId);
|
|
||||||
|
|
||||||
const newIdByOldId = new Map<string, string>();
|
|
||||||
const copyQueue: Array<{ old: DocRow; newParentId: string | null; parentKey: string | null }> = [];
|
|
||||||
|
|
||||||
const enqueueTree = (root: DocRow, newParent: string | null, recursive: boolean) => {
|
|
||||||
const visit = (node: DocRow, parentNewId: string | null, parentKey: string | null) => {
|
|
||||||
const newId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
|
||||||
newIdByOldId.set(node.id, newId);
|
|
||||||
copyQueue.push({ old: node, newParentId: parentNewId, parentKey });
|
|
||||||
if (!recursive) return;
|
|
||||||
const children = getChildrenSorted(childrenByParent, node.id);
|
|
||||||
children.forEach((child) => visit(child, newId, newId));
|
|
||||||
};
|
|
||||||
visit(root, newParent, newParent);
|
|
||||||
};
|
|
||||||
|
|
||||||
normalizedItems.forEach((item) => {
|
|
||||||
const doc = sourceById.get(item.documentId) ?? (allDocs as DocRow[]).find((d) => d.id === item.documentId) ?? null;
|
|
||||||
if (doc) {
|
|
||||||
enqueueTree(doc, targetParentId, Boolean(item.recursive));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (copyQueue.length === 0) {
|
|
||||||
return NextResponse.json({ error: "没有可复制的页面" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算根级 sort_order 起点
|
|
||||||
const siblingQuery = supabase
|
|
||||||
.from("documents")
|
|
||||||
.select("id", { head: true, count: "exact" })
|
|
||||||
.eq("workspace_id", workspaceId);
|
|
||||||
if (targetParentId) {
|
|
||||||
siblingQuery.eq("parent_id", targetParentId);
|
|
||||||
} else {
|
|
||||||
siblingQuery.is("parent_id", null);
|
|
||||||
}
|
|
||||||
const { count: rawSiblingCount } = await siblingQuery;
|
|
||||||
const siblingCount = rawSiblingCount ?? 0;
|
|
||||||
const nextSortByParent = new Map<string | null, number>([[targetParentId, siblingCount]]);
|
|
||||||
|
|
||||||
const insertedDocs: Array<{ oldId: string; newId: string }> = [];
|
|
||||||
|
|
||||||
for (const item of copyQueue) {
|
|
||||||
const newId = newIdByOldId.get(item.old.id)!;
|
|
||||||
const parentId = item.newParentId;
|
|
||||||
|
|
||||||
if (!existingTitleSetByParent.has(parentId)) {
|
|
||||||
if (newIdByOldId.has(parentId ?? "")) {
|
|
||||||
existingTitleSetByParent.set(parentId, new Set());
|
|
||||||
} else {
|
|
||||||
seedTitleSet(parentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const titleSet = existingTitleSetByParent.get(parentId) ?? new Set<string>();
|
|
||||||
existingTitleSetByParent.set(parentId, titleSet);
|
|
||||||
|
|
||||||
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
|
|
||||||
|
|
||||||
const currentSort = nextSortByParent.get(parentId) ?? 0;
|
|
||||||
nextSortByParent.set(parentId, currentSort + 1);
|
|
||||||
|
|
||||||
const { error: insertError } = await supabase.from("documents").insert({
|
|
||||||
id: newId,
|
|
||||||
user_id: session.user.id,
|
|
||||||
workspace_id: workspaceId,
|
|
||||||
parent_id: parentId,
|
|
||||||
access_scope: (item.old.access_scope ?? targetAccessScope) as typeof targetAccessScope,
|
|
||||||
title: newTitle,
|
|
||||||
content: item.old.content ?? { blocks: [] },
|
|
||||||
sort_order: currentSort,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (insertError) {
|
|
||||||
return NextResponse.json({ error: insertError.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureDocumentScaffold(newId, newTitle);
|
|
||||||
await copyMindmapIfExists(item.old.id, newId);
|
|
||||||
insertedDocs.push({ oldId: item.old.id, newId });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 复制附件并回写 content 中的 assetId
|
|
||||||
const oldDocIds = insertedDocs.map((d) => d.oldId);
|
|
||||||
const { data: assets, error: assetErr } = await supabase
|
|
||||||
.from("media_assets")
|
|
||||||
.select("id,workspace_id,document_id,asset_type,file_name,file_size,mime_type,file_url,thumbnail_url,bucket,storage_path")
|
|
||||||
.in("document_id", oldDocIds)
|
|
||||||
.eq("workspace_id", workspaceId)
|
|
||||||
.is("deleted_at", null);
|
|
||||||
|
|
||||||
if (assetErr) {
|
|
||||||
return NextResponse.json({ error: assetErr.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetMapByOldDoc = new Map<string, Map<string, { id: string; signedUrl: string }>>();
|
|
||||||
for (const asset of (assets as AssetRow[] | null) ?? []) {
|
|
||||||
const mappedDocId = newIdByOldId.get(asset.document_id);
|
|
||||||
if (!mappedDocId) continue;
|
|
||||||
|
|
||||||
const sourceLocation =
|
|
||||||
asset.storage_path
|
|
||||||
? { bucket: asset.bucket ?? DEFAULT_DOC_BUCKET, path: asset.storage_path }
|
|
||||||
: asset.file_url
|
|
||||||
? parseStoragePath(asset.file_url)
|
|
||||||
: null;
|
|
||||||
if (!sourceLocation) continue;
|
|
||||||
|
|
||||||
const fileName = (asset.file_name ?? "附件").replace(/[\\/]/g, "_");
|
|
||||||
const newAssetId =
|
|
||||||
typeof crypto.randomUUID === "function"
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
|
||||||
const targetPath = `${workspaceId}/${mappedDocId}/${newAssetId}-${fileName}`;
|
|
||||||
const bucket = sourceLocation.bucket || DEFAULT_DOC_BUCKET;
|
|
||||||
|
|
||||||
const map = assetMapByOldDoc.get(asset.document_id) ?? new Map<string, { id: string; signedUrl: string }>();
|
|
||||||
assetMapByOldDoc.set(asset.document_id, map);
|
|
||||||
|
|
||||||
const copyRes = await supabase.storage.from(bucket).copy(sourceLocation.path, targetPath);
|
|
||||||
if (copyRes.error) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7);
|
|
||||||
const signedUrl = signed?.signedUrl ?? "";
|
|
||||||
|
|
||||||
const { error: insertAssetErr } = await supabase.from("media_assets").insert({
|
|
||||||
id: newAssetId,
|
|
||||||
workspace_id: workspaceId,
|
|
||||||
document_id: mappedDocId,
|
|
||||||
asset_type: asset.asset_type ?? "file",
|
|
||||||
file_name: asset.file_name ?? fileName,
|
|
||||||
file_size: asset.file_size ?? null,
|
|
||||||
mime_type: asset.mime_type ?? null,
|
|
||||||
bucket,
|
|
||||||
storage_path: targetPath,
|
|
||||||
file_url: signedUrl,
|
|
||||||
thumbnail_url: signedUrl,
|
|
||||||
created_by: session.user.id,
|
|
||||||
});
|
|
||||||
if (!insertAssetErr) {
|
|
||||||
map.set(asset.id, { id: newAssetId, signedUrl });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const pair of insertedDocs) {
|
|
||||||
const map = assetMapByOldDoc.get(pair.oldId);
|
|
||||||
if (!map || map.size === 0) continue;
|
|
||||||
const source = (allDocs as DocRow[]).find((d) => d.id === pair.oldId);
|
|
||||||
if (!source) continue;
|
|
||||||
const newContent = replaceAssetRefsInContent(source.content, map, pair.newId);
|
|
||||||
const { error: updateErr } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.update({ content: newContent })
|
|
||||||
.eq("id", pair.newId)
|
|
||||||
.eq("user_id", session.user.id);
|
|
||||||
if (updateErr) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
items: insertedDocs.map((d) => ({ oldId: d.oldId, newId: d.newId })),
|
|
||||||
});
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,160 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { executeDocumentCreateChildBridgeCommand } from "@/lib/documents/page-command-adapter";
|
||||||
import { api } from "@/lib/convex/api";
|
|
||||||
|
|
||||||
function makeId(): string {
|
|
||||||
return typeof crypto.randomUUID === "function"
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateChildPayload = {
|
|
||||||
parentId: string | null;
|
|
||||||
title?: string;
|
|
||||||
blocks?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
return executeDocumentCreateChildBridgeCommand(request);
|
||||||
|
|
||||||
const { parentId, title, blocks }: CreateChildPayload = await request.json();
|
|
||||||
|
|
||||||
if (typeof parentId === "undefined") {
|
|
||||||
return NextResponse.json({ error: "缺少 parentId" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
let workspaceId: string;
|
|
||||||
let accessScope: "private" | "shared" | "public" = "private";
|
|
||||||
|
|
||||||
if (parentId) {
|
|
||||||
const parentDoc = await client.query(api.documents.getMeta, { id: parentId });
|
|
||||||
if (!parentDoc) {
|
|
||||||
return NextResponse.json({ error: "父页面不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
workspaceId = parentDoc.workspace_id;
|
|
||||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
|
||||||
} else {
|
|
||||||
const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
|
||||||
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
|
||||||
workspaceIdIfCreate: makeId(),
|
|
||||||
});
|
|
||||||
workspaceId = ensured.activeWorkspaceId;
|
|
||||||
if (!workspaceId) {
|
|
||||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedTitle = title && title.trim().length > 0 ? title.trim() : "未命名页面";
|
|
||||||
const contentPayload = Array.isArray(blocks) ? blocks : [];
|
|
||||||
const pageId = makeId();
|
|
||||||
|
|
||||||
const created = await client.mutation(api.documents.create, {
|
|
||||||
id: pageId,
|
|
||||||
workspaceId,
|
|
||||||
parentId: parentId ?? null,
|
|
||||||
title: resolvedTitle,
|
|
||||||
accessScope,
|
|
||||||
content: contentPayload,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
pageId: created.id,
|
|
||||||
title: created.title ?? resolvedTitle,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
|
||||||
/*
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
|
||||||
const {
|
|
||||||
data: { session },
|
|
||||||
} = await supabase.auth.getSession();
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return NextResponse.json({ error: "未登录无法创建页面" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { parentId, title, blocks }: CreateChildPayload = await request.json();
|
|
||||||
|
|
||||||
if (typeof parentId === "undefined") {
|
|
||||||
return NextResponse.json({ error: "缺少 parentId" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
let workspaceId: string;
|
|
||||||
let accessScope: "private" | "shared" | "public" = "private";
|
|
||||||
|
|
||||||
if (parentId) {
|
|
||||||
const { data: parentDoc, error: parentError } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.select("workspace_id,access_scope")
|
|
||||||
.eq("id", parentId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (parentError || !parentDoc) {
|
|
||||||
return NextResponse.json({ error: "父页面不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
workspaceId = parentDoc.workspace_id;
|
|
||||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
|
||||||
} else {
|
|
||||||
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
|
|
||||||
workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
|
|
||||||
if (!workspaceId) {
|
|
||||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const siblingQuery = supabase
|
|
||||||
.from("documents")
|
|
||||||
.select("id", { head: true, count: "exact" })
|
|
||||||
.eq("workspace_id", workspaceId);
|
|
||||||
|
|
||||||
if (parentId) {
|
|
||||||
siblingQuery.eq("parent_id", parentId);
|
|
||||||
} else {
|
|
||||||
siblingQuery.is("parent_id", null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { count: rawSiblingCount, error: countError } = await siblingQuery;
|
|
||||||
const siblingCount = rawSiblingCount ?? 0;
|
|
||||||
|
|
||||||
if (countError) {
|
|
||||||
return NextResponse.json({ error: countError.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedTitle =
|
|
||||||
title && title.trim().length > 0 ? title.trim() : "未命名页面";
|
|
||||||
|
|
||||||
const contentPayload = Array.isArray(blocks) ? blocks : [];
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.insert({
|
|
||||||
user_id: session.user.id,
|
|
||||||
parent_id: parentId,
|
|
||||||
workspace_id: workspaceId,
|
|
||||||
access_scope: accessScope,
|
|
||||||
title: resolvedTitle,
|
|
||||||
content: contentPayload,
|
|
||||||
sort_order: siblingCount,
|
|
||||||
})
|
|
||||||
.select("id,title")
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error?.message ?? "创建子页面失败" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
pageId: data.id,
|
|
||||||
title: data.title ?? resolvedTitle,
|
|
||||||
});
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,25 @@
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { Json } from "@/types/supabase";
|
|
||||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
|
||||||
import path from "path";
|
|
||||||
import { promises as fs } from "fs";
|
|
||||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { api } from "@/lib/convex/api";
|
import { api } from "@/lib/convex/api";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import {
|
||||||
const documentsBaseDir = getDocumentsBaseDir();
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
async function ensureDocumentScaffold(id: string, title: string | null) {
|
documentBridgeErrorResponse,
|
||||||
const folder = path.join(documentsBaseDir, id);
|
} from "@/lib/documents/bridge";
|
||||||
const indexFile = path.join(folder, "index.md");
|
import {
|
||||||
await fs.mkdir(folder, { recursive: true });
|
executePageLifecycleBridgeCommand,
|
||||||
try {
|
type DocumentCreatePayload,
|
||||||
await fs.access(indexFile);
|
} from "@/lib/documents/page-command-adapter";
|
||||||
} catch {
|
import { ensureDocumentScaffold } from "@/lib/documents/page-lifecycle-side-effects";
|
||||||
const safeTitle = title && title.trim() ? title.trim() : "无标题";
|
|
||||||
const content = `# ${safeTitle}\n`;
|
|
||||||
await fs.writeFile(indexFile, content, "utf8");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
return await handleCreateRequestConvex(request);
|
return await handleCreateRequestConvex(request);
|
||||||
}
|
}
|
||||||
return await handleCreateRequest(request);
|
return await handleCreateRequest();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("创建页面失败", error);
|
console.error("创建页面失败", error);
|
||||||
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
|
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
|
||||||
@@ -39,7 +29,6 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
async function handleCreateRequestConvex(request: Request) {
|
async function handleCreateRequestConvex(request: Request) {
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
|
||||||
const { parentId }: { parentId?: string | null } = await request.json();
|
const { parentId }: { parentId?: string | null } = await request.json();
|
||||||
|
|
||||||
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||||
@@ -48,7 +37,6 @@ async function handleCreateRequestConvex(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let workspaceId: string | null = null;
|
let workspaceId: string | null = null;
|
||||||
let parentContent: Json | null = null;
|
|
||||||
let accessScope: "private" | "shared" | "public" = "private";
|
let accessScope: "private" | "shared" | "public" = "private";
|
||||||
|
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
@@ -62,8 +50,6 @@ async function handleCreateRequestConvex(request: Request) {
|
|||||||
|
|
||||||
workspaceId = parentDoc.workspace_id;
|
workspaceId = parentDoc.workspace_id;
|
||||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||||
const parentContentRes = await client.query(api.documents.getContent, { id: parentId });
|
|
||||||
parentContent = (parentContentRes?.content as Json | null) ?? null;
|
|
||||||
} else {
|
} else {
|
||||||
workspaceId = workspaceBootstrap.activeWorkspaceId || null;
|
workspaceId = workspaceBootstrap.activeWorkspaceId || null;
|
||||||
}
|
}
|
||||||
@@ -72,156 +58,62 @@ async function handleCreateRequestConvex(request: Request) {
|
|||||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const data = await client.mutation(api.documents.create, {
|
const normalizedWorkspaceId = workspaceId.trim();
|
||||||
id,
|
const bridgeContext = await buildDocumentBridgeContext({
|
||||||
workspaceId,
|
request,
|
||||||
parentId: parentId ?? null,
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
});
|
||||||
|
const result = await executePageLifecycleBridgeCommand<
|
||||||
|
DocumentCreatePayload,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
title?: string | null;
|
||||||
|
parent_id?: string | null;
|
||||||
|
sort_order?: number | null;
|
||||||
|
workspace_id?: string;
|
||||||
|
access_scope?: "private" | "shared" | "public";
|
||||||
|
is_template?: boolean;
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
context: bridgeContext,
|
||||||
|
envelope: buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.create",
|
||||||
|
payload: {
|
||||||
|
documentId: id,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
parentId: parentId?.trim() || null,
|
||||||
title: "无标题",
|
title: "无标题",
|
||||||
accessScope,
|
accessScope,
|
||||||
content: [],
|
content: [],
|
||||||
});
|
|
||||||
|
|
||||||
// 为文件树创建本地目录和 index.md
|
|
||||||
if (data?.id) {
|
|
||||||
await ensureDocumentScaffold(data.id, data.title ?? "无标题");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parentId && data) {
|
|
||||||
const existingBlocks = extractBlocksFromContent(parentContent);
|
|
||||||
const pageReferenceBlock = {
|
|
||||||
id: randomUUID(),
|
|
||||||
type: "pageReference",
|
|
||||||
props: {
|
|
||||||
pageId: data.id,
|
|
||||||
title: data.title ?? "无标题",
|
|
||||||
},
|
},
|
||||||
};
|
context: bridgeContext,
|
||||||
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
|
target: {
|
||||||
const payload = composeContentWithBlocks(parentContent, nextBlocks);
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
pageId: id,
|
||||||
await client.mutation(api.documents.updateContent, {
|
},
|
||||||
id: parentId,
|
}),
|
||||||
content: payload,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await ensureDocumentScaffold(result.result.id, result.result.title ?? "无标题");
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...result.result,
|
||||||
|
meta: {
|
||||||
|
requestId: result.requestId,
|
||||||
|
traceId: result.traceId,
|
||||||
|
commandId: result.commandId,
|
||||||
|
commandName: result.commandName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(data);
|
async function handleCreateRequest() {
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateRequest(request: Request) {
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
|
||||||
/*
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
|
||||||
const {
|
|
||||||
data: { session },
|
|
||||||
} = await supabase.auth.getSession();
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { parentId }: { parentId?: string | null } = await request.json();
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
|
|
||||||
|
|
||||||
let workspaceId: string | null = null;
|
|
||||||
let parentContent: Json | null = null;
|
|
||||||
let accessScope: "private" | "shared" | "public" = "private";
|
|
||||||
|
|
||||||
if (parentId) {
|
|
||||||
const { data: parentDoc, error: parentError } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.select("workspace_id,access_scope,content,user_id")
|
|
||||||
.eq("id", parentId)
|
|
||||||
.eq("user_id", session.user.id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (parentError || !parentDoc) {
|
|
||||||
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
|
|
||||||
}
|
|
||||||
workspaceId = parentDoc.workspace_id;
|
|
||||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
|
||||||
parentContent = parentDoc.content;
|
|
||||||
} else {
|
|
||||||
workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
|
|
||||||
if (!workspaceId) {
|
|
||||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!workspaceId) {
|
|
||||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const siblingQuery = supabase
|
|
||||||
.from("documents")
|
|
||||||
.select("id", { head: true, count: "exact" })
|
|
||||||
.eq("workspace_id", workspaceId);
|
|
||||||
|
|
||||||
if (parentId) {
|
|
||||||
siblingQuery.eq("parent_id", parentId);
|
|
||||||
} else {
|
|
||||||
siblingQuery.is("parent_id", null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { count: rawSiblingCount, error: countError } = await siblingQuery;
|
|
||||||
const siblingCount = rawSiblingCount ?? 0;
|
|
||||||
|
|
||||||
if (countError) {
|
|
||||||
return NextResponse.json({ error: countError.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.insert({
|
|
||||||
user_id: session.user.id,
|
|
||||||
parent_id: parentId ?? null,
|
|
||||||
workspace_id: workspaceId,
|
|
||||||
title: "无标题",
|
|
||||||
content: { blocks: [] },
|
|
||||||
access_scope: accessScope,
|
|
||||||
sort_order: siblingCount,
|
|
||||||
})
|
|
||||||
.select(
|
|
||||||
"id,title,parent_id,sort_order,is_starred,created_at,updated_at,workspace_id,access_scope,is_template",
|
|
||||||
)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为文件树创建本地目录和 index.md
|
|
||||||
if (data?.id) {
|
|
||||||
await ensureDocumentScaffold(data.id, data.title ?? "无标题");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parentId && data) {
|
|
||||||
const existingBlocks = extractBlocksFromContent(parentContent);
|
|
||||||
const pageReferenceBlock = {
|
|
||||||
id: randomUUID(),
|
|
||||||
type: "pageReference",
|
|
||||||
props: {
|
|
||||||
pageId: data.id,
|
|
||||||
title: data.title ?? "无标题",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
|
|
||||||
const payload = composeContentWithBlocks(parentContent, nextBlocks);
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const { error: parentUpdateError } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.update({ content: payload, updated_at: timestamp })
|
|
||||||
.eq("id", parentId)
|
|
||||||
.eq("user_id", session.user.id);
|
|
||||||
|
|
||||||
if (parentUpdateError) {
|
|
||||||
return NextResponse.json({ error: parentUpdateError.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,52 +1,59 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import {
|
||||||
import { api } from "@/lib/convex/api";
|
assertDocumentId,
|
||||||
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
executePageLifecycleBridgeCommand,
|
||||||
|
type DocumentDeletePayload,
|
||||||
|
} from "@/lib/documents/page-command-adapter";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
const { documentId } = await request.json();
|
try {
|
||||||
if (!documentId) {
|
const body = (await request.json()) as { documentId?: string; workspaceId?: string | null };
|
||||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
const normalizedDocumentId = assertDocumentId(body.documentId);
|
||||||
|
const normalizedWorkspaceId = body.workspaceId?.trim() || null;
|
||||||
|
const bridgeContext = await buildDocumentBridgeContext({
|
||||||
|
request,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
});
|
||||||
|
const result = await executePageLifecycleBridgeCommand<DocumentDeletePayload, { ok: boolean }>({
|
||||||
|
context: bridgeContext,
|
||||||
|
envelope: buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.delete",
|
||||||
|
payload: {
|
||||||
|
documentId: normalizedDocumentId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
},
|
||||||
|
context: bridgeContext,
|
||||||
|
target: {
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
pageId: normalizedDocumentId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
meta: {
|
||||||
|
requestId: result.requestId,
|
||||||
|
traceId: result.traceId,
|
||||||
|
commandId: result.commandId,
|
||||||
|
commandName: result.commandName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
}
|
}
|
||||||
const { client } = await getAuthedConvexClient();
|
|
||||||
await client.mutation(api.documents.softDelete, { id: documentId });
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
|
||||||
/*
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
|
||||||
const {
|
|
||||||
data: { session },
|
|
||||||
} = await supabase.auth.getSession();
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { documentId } = await request.json();
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
if (!documentId) {
|
|
||||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.update({
|
|
||||||
deleted_at: new Date().toISOString(),
|
|
||||||
deleted_by: session.user.id,
|
|
||||||
})
|
|
||||||
.eq("id", documentId)
|
|
||||||
.eq("user_id", session.user.id);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,58 +2,33 @@ import { NextResponse } from "next/server";
|
|||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import { api } from "@/lib/convex/api";
|
import { api } from "@/lib/convex/api";
|
||||||
import path from "path";
|
import {
|
||||||
import { promises as fs } from "fs";
|
assertDocumentId,
|
||||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
executePageLifecycleBridgeCommand,
|
||||||
|
type DocumentDuplicatePayload,
|
||||||
|
} from "@/lib/documents/page-command-adapter";
|
||||||
|
import {
|
||||||
|
copyMindmapFilesIfExists,
|
||||||
|
ensureDocumentScaffold,
|
||||||
|
} from "@/lib/documents/page-lifecycle-side-effects";
|
||||||
|
|
||||||
interface DuplicatePayload {
|
interface DuplicatePayload {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const documentsBaseDir = getDocumentsBaseDir();
|
|
||||||
|
|
||||||
async function ensureDocumentScaffold(id: string, title: string | null) {
|
|
||||||
const folder = path.join(documentsBaseDir, id);
|
|
||||||
const indexFile = path.join(folder, "index.md");
|
|
||||||
await fs.mkdir(folder, { recursive: true });
|
|
||||||
const safeTitle = title && title.trim() ? title.trim() : "无标题";
|
|
||||||
const content = `# ${safeTitle}\n`;
|
|
||||||
await fs.writeFile(indexFile, content, "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyMindmapIfExists(sourceId: string, targetId: string) {
|
|
||||||
try {
|
|
||||||
const srcDir = path.join(documentsBaseDir, sourceId);
|
|
||||||
const destDir = path.join(documentsBaseDir, targetId);
|
|
||||||
const entries = await fs.readdir(srcDir);
|
|
||||||
const mindmapFiles = entries.filter((name) => name === "mindmap.json" || /^mindmap-.+\\.json$/i.test(name));
|
|
||||||
if (mindmapFiles.length === 0) return;
|
|
||||||
await fs.mkdir(destDir, { recursive: true });
|
|
||||||
await Promise.all(
|
|
||||||
mindmapFiles.map(async (name) => {
|
|
||||||
try {
|
|
||||||
const buf = await fs.readFile(path.join(srcDir, name));
|
|
||||||
await fs.writeFile(path.join(destDir, name), buf);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// 如果源不存在则忽略
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
|
try {
|
||||||
const { client } = await getAuthedConvexClient();
|
const { client } = await getAuthedConvexClient();
|
||||||
|
|
||||||
const { documentId }: DuplicatePayload = await request.json();
|
const { documentId }: DuplicatePayload = await request.json();
|
||||||
if (!documentId) {
|
const normalizedDocumentId = assertDocumentId(documentId);
|
||||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
const sourceDoc = await client.query(api.documents.getMeta, { id: normalizedDocumentId });
|
||||||
if (!sourceDoc) {
|
if (!sourceDoc) {
|
||||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -61,106 +36,62 @@ export async function POST(request: Request) {
|
|||||||
const fallbackTitle =
|
const fallbackTitle =
|
||||||
sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0 ? sourceDoc.title.trim() : "无标题";
|
sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0 ? sourceDoc.title.trim() : "无标题";
|
||||||
const duplicatedTitle = `${fallbackTitle} 副本`;
|
const duplicatedTitle = `${fallbackTitle} 副本`;
|
||||||
|
const newId =
|
||||||
const newId = typeof crypto.randomUUID === "function"
|
typeof crypto.randomUUID === "function"
|
||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
|
const normalizedWorkspaceId = sourceDoc.workspace_id?.trim() || null;
|
||||||
const duplicated = await client.mutation(api.documents.duplicate, {
|
const bridgeContext = await buildDocumentBridgeContext({
|
||||||
sourceId: documentId,
|
request,
|
||||||
newId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
});
|
||||||
|
const result = await executePageLifecycleBridgeCommand<
|
||||||
|
DocumentDuplicatePayload,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
title?: string | null;
|
||||||
|
parent_id?: string | null;
|
||||||
|
sort_order?: number | null;
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
context: bridgeContext,
|
||||||
|
envelope: buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.duplicate",
|
||||||
|
payload: {
|
||||||
|
sourceDocumentId: normalizedDocumentId,
|
||||||
|
newDocumentId: newId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
title: duplicatedTitle,
|
title: duplicatedTitle,
|
||||||
|
},
|
||||||
|
context: bridgeContext,
|
||||||
|
target: {
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
pageId: newId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
|
await ensureDocumentScaffold(result.result.id, result.result.title ?? duplicatedTitle);
|
||||||
await client.mutation(api.mindmaps.copyByDocument, {
|
await copyMindmapFilesIfExists(normalizedDocumentId, result.result.id);
|
||||||
sourceDocId: documentId,
|
|
||||||
targetDocId: duplicated.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
id: duplicated.id,
|
id: result.result.id,
|
||||||
title: duplicated.title ?? duplicatedTitle,
|
title: result.result.title ?? duplicatedTitle,
|
||||||
parent_id: duplicated.parent_id ?? null,
|
parent_id: result.result.parent_id ?? null,
|
||||||
sort_order: duplicated.sort_order ?? null,
|
sort_order: result.result.sort_order ?? null,
|
||||||
|
meta: {
|
||||||
|
requestId: result.requestId,
|
||||||
|
traceId: result.traceId,
|
||||||
|
commandId: result.commandId,
|
||||||
|
commandName: result.commandName,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
|
||||||
/*
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
|
||||||
const {
|
|
||||||
data: { session },
|
|
||||||
} = await supabase.auth.getSession();
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { documentId }: DuplicatePayload = await request.json();
|
export const runtime = "nodejs";
|
||||||
if (!documentId) {
|
|
||||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: sourceDoc, error: sourceError } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.select("id,title,content,parent_id,workspace_id,access_scope")
|
|
||||||
.eq("id", documentId)
|
|
||||||
.eq("user_id", session.user.id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (sourceError || !sourceDoc) {
|
|
||||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const siblingQuery = supabase
|
|
||||||
.from("documents")
|
|
||||||
.select("id", { head: true, count: "exact" })
|
|
||||||
.eq("workspace_id", sourceDoc.workspace_id);
|
|
||||||
|
|
||||||
if (sourceDoc.parent_id) {
|
|
||||||
siblingQuery.eq("parent_id", sourceDoc.parent_id);
|
|
||||||
} else {
|
|
||||||
siblingQuery.is("parent_id", null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { count: rawSiblingCount, error: countError } = await siblingQuery;
|
|
||||||
const siblingCount = rawSiblingCount ?? 0;
|
|
||||||
if (countError) {
|
|
||||||
return NextResponse.json({ error: countError.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackTitle =
|
|
||||||
sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0
|
|
||||||
? sourceDoc.title.trim()
|
|
||||||
: "无标题";
|
|
||||||
const duplicatedTitle = `${fallbackTitle} 副本`;
|
|
||||||
|
|
||||||
const { data: duplicated, error: duplicateError } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.insert({
|
|
||||||
user_id: session.user.id,
|
|
||||||
workspace_id: sourceDoc.workspace_id,
|
|
||||||
parent_id: sourceDoc.parent_id,
|
|
||||||
access_scope: sourceDoc.access_scope ?? "private",
|
|
||||||
title: duplicatedTitle,
|
|
||||||
content: sourceDoc.content,
|
|
||||||
sort_order: siblingCount,
|
|
||||||
})
|
|
||||||
.select("id,title,parent_id,sort_order")
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (duplicateError || !duplicated) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: duplicateError?.message ?? "复制失败" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
|
|
||||||
await copyMindmapIfExists(sourceDoc.id, duplicated.id);
|
|
||||||
|
|
||||||
return NextResponse.json(duplicated);
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,133 +1,11 @@
|
|||||||
import { randomUUID } from "crypto";
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { Json } from "@/types/supabase";
|
|
||||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { executeDocumentEmbedBridgeCommand } from "@/lib/documents/page-command-adapter";
|
||||||
import { api } from "@/lib/convex/api";
|
|
||||||
|
|
||||||
interface EmbedPayload {
|
|
||||||
sourceId: string;
|
|
||||||
targetId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
return executeDocumentEmbedBridgeCommand(request);
|
||||||
|
|
||||||
const { sourceId, targetId }: EmbedPayload = await request.json();
|
|
||||||
|
|
||||||
if (!sourceId || !targetId) {
|
|
||||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceDoc = await client.query(api.documents.getMeta, { id: sourceId });
|
|
||||||
if (!sourceDoc) {
|
|
||||||
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetContent = await client.query(api.documents.getContent, { id: targetId });
|
|
||||||
if (!targetContent) {
|
|
||||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
|
||||||
}
|
|
||||||
const targetMeta = await client.query(api.documents.getMeta, { id: targetId });
|
|
||||||
|
|
||||||
const currentBlocks = extractBlocksFromContent(targetContent.content);
|
|
||||||
const anchorId = (targetMeta as any)?.embed_default_block_id as string | null | undefined;
|
|
||||||
const anchorIndex =
|
|
||||||
typeof anchorId === "string" && anchorId.trim()
|
|
||||||
? currentBlocks.findIndex((b) => typeof b === "object" && b !== null && (b as any).id === anchorId)
|
|
||||||
: -1;
|
|
||||||
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
|
|
||||||
|
|
||||||
const nextBlocks: Json[] = [
|
|
||||||
...currentBlocks.slice(0, insertIndex),
|
|
||||||
{
|
|
||||||
id: randomUUID(),
|
|
||||||
type: "pageReference",
|
|
||||||
props: {
|
|
||||||
pageId: sourceDoc.id,
|
|
||||||
title: sourceDoc.title ?? "无标题",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...currentBlocks.slice(insertIndex),
|
|
||||||
];
|
|
||||||
|
|
||||||
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
|
|
||||||
|
|
||||||
await client.mutation(api.documents.updateContent, {
|
|
||||||
id: targetId,
|
|
||||||
content: payload,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
|
||||||
/*
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
|
||||||
const {
|
|
||||||
data: { session },
|
|
||||||
} = await supabase.auth.getSession();
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { sourceId, targetId }: EmbedPayload = await request.json();
|
|
||||||
|
|
||||||
if (!sourceId || !targetId) {
|
|
||||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: sourceDoc, error: sourceError } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.select("id,title,user_id")
|
|
||||||
.eq("id", sourceId)
|
|
||||||
.eq("user_id", session.user.id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (sourceError || !sourceDoc) {
|
|
||||||
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: targetDoc, error: targetError } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.select("id,content,user_id")
|
|
||||||
.eq("id", targetId)
|
|
||||||
.eq("user_id", session.user.id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (targetError || !targetDoc) {
|
|
||||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentBlocks = extractBlocksFromContent(targetDoc.content);
|
|
||||||
const nextBlocks: Json[] = [
|
|
||||||
...currentBlocks,
|
|
||||||
{
|
|
||||||
id: randomUUID(),
|
|
||||||
type: "pageReference",
|
|
||||||
props: {
|
|
||||||
pageId: sourceDoc.id,
|
|
||||||
title: sourceDoc.title ?? "无标题",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const payload: Json = composeContentWithBlocks(targetDoc.content, nextBlocks);
|
|
||||||
|
|
||||||
const { error: updateError } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.update({ content: payload })
|
|
||||||
.eq("id", targetDoc.id)
|
|
||||||
.eq("user_id", session.user.id);
|
|
||||||
|
|
||||||
if (updateError) {
|
|
||||||
return NextResponse.json({ error: updateError.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { executeDocumentEmptyTrashBridgeCommand } from "@/lib/documents/page-command-adapter";
|
||||||
import { api } from "@/lib/convex/api";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
interface EmptyTrashPayload {
|
|
||||||
workspaceId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
const { workspaceId }: EmptyTrashPayload = await request.json();
|
return executeDocumentEmptyTrashBridgeCommand(request);
|
||||||
if (!workspaceId) {
|
|
||||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const { client } = await getAuthedConvexClient();
|
|
||||||
await client.mutation(api.documents.emptyTrashByWorkspace, { workspaceId });
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
|
||||||
/*
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
|
||||||
const {
|
|
||||||
data: { session },
|
|
||||||
} = await supabase.auth.getSession();
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { workspaceId }: EmptyTrashPayload = await request.json();
|
|
||||||
|
|
||||||
if (!workspaceId) {
|
|
||||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: membership, error: membershipError } = await supabase
|
|
||||||
.from("workspace_members")
|
|
||||||
.select("workspace_id")
|
|
||||||
.eq("workspace_id", workspaceId)
|
|
||||||
.eq("user_id", session.user.id)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (membershipError) {
|
|
||||||
return NextResponse.json({ error: membershipError.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!membership || membership.length === 0) {
|
|
||||||
return NextResponse.json({ error: "无权操作该工作空间" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.delete()
|
|
||||||
.eq("workspace_id", workspaceId)
|
|
||||||
.eq("user_id", session.user.id)
|
|
||||||
.not("deleted_at", "is", null);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,67 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import {
|
||||||
import { api } from "@/lib/convex/api";
|
assertDocumentId,
|
||||||
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
executePageLifecycleBridgeCommand,
|
||||||
|
type DocumentMovePayload,
|
||||||
|
} from "@/lib/documents/page-command-adapter";
|
||||||
|
|
||||||
interface MovePayload {
|
interface MovePayload {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
position: number;
|
position: number;
|
||||||
|
workspaceId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
const { documentId, parentId = null, position }: MovePayload = await request.json();
|
try {
|
||||||
|
const { documentId, parentId = null, position, workspaceId }: MovePayload = await request.json();
|
||||||
|
const normalizedDocumentId = assertDocumentId(documentId);
|
||||||
|
const normalizedWorkspaceId = workspaceId?.trim() || null;
|
||||||
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
|
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
|
||||||
const { client } = await getAuthedConvexClient();
|
const bridgeContext = await buildDocumentBridgeContext({
|
||||||
await client.mutation(api.documents.move, {
|
request,
|
||||||
id: documentId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
parentId,
|
|
||||||
sortOrder,
|
|
||||||
});
|
});
|
||||||
return NextResponse.json({ ok: true });
|
const result = await executePageLifecycleBridgeCommand<DocumentMovePayload, { ok: boolean }>({
|
||||||
|
context: bridgeContext,
|
||||||
|
envelope: buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.move",
|
||||||
|
payload: {
|
||||||
|
documentId: normalizedDocumentId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
parentId: parentId?.trim() || null,
|
||||||
|
sortOrder,
|
||||||
|
},
|
||||||
|
context: bridgeContext,
|
||||||
|
target: {
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
pageId: normalizedDocumentId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
meta: {
|
||||||
|
requestId: result.requestId,
|
||||||
|
traceId: result.traceId,
|
||||||
|
commandId: result.commandId,
|
||||||
|
commandName: result.commandName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
|
||||||
/*
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
|
||||||
const {
|
|
||||||
data: { session },
|
|
||||||
} = await supabase.auth.getSession();
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { documentId, parentId = null, position }: MovePayload = await request.json();
|
export const runtime = "nodejs";
|
||||||
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.update({
|
|
||||||
parent_id: parentId,
|
|
||||||
sort_order: sortOrder,
|
|
||||||
})
|
|
||||||
.eq("id", documentId)
|
|
||||||
.eq("user_id", session.user.id);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,49 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { executeDocumentPurgeBridgeCommand } from "@/lib/documents/page-command-adapter";
|
||||||
import { api } from "@/lib/convex/api";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
const { documentId } = await request.json();
|
return executeDocumentPurgeBridgeCommand(request);
|
||||||
if (!documentId) {
|
|
||||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const { client } = await getAuthedConvexClient();
|
|
||||||
await client.mutation(api.documents.purge, { id: documentId });
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
|
||||||
/*
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
|
||||||
const {
|
|
||||||
data: { session },
|
|
||||||
} = await supabase.auth.getSession();
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { documentId } = await request.json();
|
|
||||||
|
|
||||||
if (!documentId) {
|
|
||||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.delete()
|
|
||||||
.eq("id", documentId)
|
|
||||||
.eq("user_id", session.user.id);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,59 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import {
|
||||||
import { api } from "@/lib/convex/api";
|
assertDocumentId,
|
||||||
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
executePageLifecycleBridgeCommand,
|
||||||
|
type DocumentRestorePayload,
|
||||||
|
} from "@/lib/documents/page-command-adapter";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
const { documentId } = await request.json();
|
try {
|
||||||
if (!documentId) {
|
const body = (await request.json()) as { documentId?: string; workspaceId?: string | null };
|
||||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
const normalizedDocumentId = assertDocumentId(body.documentId);
|
||||||
|
const normalizedWorkspaceId = body.workspaceId?.trim() || null;
|
||||||
|
const bridgeContext = await buildDocumentBridgeContext({
|
||||||
|
request,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
});
|
||||||
|
const result = await executePageLifecycleBridgeCommand<DocumentRestorePayload, { ok: boolean }>({
|
||||||
|
context: bridgeContext,
|
||||||
|
envelope: buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.restore",
|
||||||
|
payload: {
|
||||||
|
documentId: normalizedDocumentId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
},
|
||||||
|
context: bridgeContext,
|
||||||
|
target: {
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
pageId: normalizedDocumentId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
meta: {
|
||||||
|
requestId: result.requestId,
|
||||||
|
traceId: result.traceId,
|
||||||
|
commandId: result.commandId,
|
||||||
|
commandName: result.commandName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
}
|
}
|
||||||
const { client } = await getAuthedConvexClient();
|
|
||||||
await client.mutation(api.documents.restore, { id: documentId });
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
|
||||||
/*
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
|
||||||
const {
|
|
||||||
data: { session },
|
|
||||||
} = await supabase.auth.getSession();
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { documentId } = await request.json();
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
if (!documentId) {
|
|
||||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.update({
|
|
||||||
deleted_at: null,
|
|
||||||
deleted_by: null,
|
|
||||||
parent_id: null,
|
|
||||||
access_scope: "private",
|
|
||||||
})
|
|
||||||
.eq("id", documentId)
|
|
||||||
.eq("user_id", session.user.id);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/enabled", () => ({
|
||||||
|
isConvexEnabled: vi.fn(() => true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/page-command-adapter", () => ({
|
||||||
|
executeDocumentCreateChildBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||||
|
executeDocumentEmbedBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||||
|
executeDocumentTemplateBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||||
|
executeDocumentEmptyTrashBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||||
|
executeDocumentPurgeBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { POST as postCreateChild } from "@/app/api/documents/create-child/route";
|
||||||
|
import { POST as postEmbed } from "@/app/api/documents/embed/route";
|
||||||
|
import { POST as postTemplate } from "@/app/api/documents/template/route";
|
||||||
|
import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/route";
|
||||||
|
import { POST as postPurge } from "@/app/api/documents/purge/route";
|
||||||
|
import {
|
||||||
|
executeDocumentCreateChildBridgeCommand,
|
||||||
|
executeDocumentEmbedBridgeCommand,
|
||||||
|
executeDocumentTemplateBridgeCommand,
|
||||||
|
executeDocumentEmptyTrashBridgeCommand,
|
||||||
|
executeDocumentPurgeBridgeCommand,
|
||||||
|
} from "@/lib/documents/page-command-adapter";
|
||||||
|
|
||||||
|
describe("documents route adapters", () => {
|
||||||
|
it("creates child route delegates to unified adapter", async () => {
|
||||||
|
await postCreateChild(new Request("http://localhost/api/documents/create-child", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ parentId: null }),
|
||||||
|
}));
|
||||||
|
expect(executeDocumentCreateChildBridgeCommand).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("embed route delegates to unified adapter", async () => {
|
||||||
|
await postEmbed(new Request("http://localhost/api/documents/embed", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ sourceId: "doc_1", targetId: "doc_2" }),
|
||||||
|
}));
|
||||||
|
expect(executeDocumentEmbedBridgeCommand).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("template route delegates to unified adapter", async () => {
|
||||||
|
await postTemplate(new Request("http://localhost/api/documents/template", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ documentId: "doc_1", isTemplate: true }),
|
||||||
|
}));
|
||||||
|
expect(executeDocumentTemplateBridgeCommand).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty trash route delegates to unified adapter", async () => {
|
||||||
|
await postEmptyTrash(new Request("http://localhost/api/documents/empty-trash", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ workspaceId: "ws_1" }),
|
||||||
|
}));
|
||||||
|
expect(executeDocumentEmptyTrashBridgeCommand).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("purge route delegates to unified adapter", async () => {
|
||||||
|
await postPurge(new Request("http://localhost/api/documents/purge", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ documentId: "doc_1" }),
|
||||||
|
}));
|
||||||
|
expect(executeDocumentPurgeBridgeCommand).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,3 +58,5 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|||||||
@@ -1,26 +1,11 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { executeDocumentTemplateBridgeCommand } from "@/lib/documents/page-command-adapter";
|
||||||
import { api } from "@/lib/convex/api";
|
|
||||||
|
|
||||||
type TemplatePayload = {
|
|
||||||
documentId: string;
|
|
||||||
isTemplate: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
const { documentId, isTemplate }: TemplatePayload = await request.json();
|
return executeDocumentTemplateBridgeCommand(request);
|
||||||
if (!documentId || typeof isTemplate !== "boolean") {
|
|
||||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { client } = await getAuthedConvexClient();
|
|
||||||
await client.mutation(api.documents.setTemplate, { id: documentId, isTemplate });
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,3 +60,5 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { NextResponse } from "next/server";
|
|||||||
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
|
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
|
||||||
import { loadLocalAiConfig } from "@/lib/ai/localAiConfig";
|
import { loadLocalAiConfig } from "@/lib/ai/localAiConfig";
|
||||||
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
||||||
import { applyMindmapOps, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
|
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
|
||||||
|
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
|
||||||
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
|
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
|
||||||
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
||||||
import { applyMindmapOps, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
|
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
|
||||||
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
|
||||||
|
import { readMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
||||||
|
|
||||||
type RequestPayload = {
|
type RequestPayload = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -81,9 +82,9 @@ const searchSearxng = async (q: string, count = 5): Promise<SearxResult[]> => {
|
|||||||
return mapped;
|
return mapped;
|
||||||
};
|
};
|
||||||
|
|
||||||
const coerceOpsFromAiJson = (raw: Record<string, unknown>): MindmapOp[] => {
|
const coerceOpsFromAiJson = (raw: Record<string, unknown>) => {
|
||||||
const ops = (raw as any)?.ops;
|
const ops = (raw as any)?.ops;
|
||||||
return Array.isArray(ops) ? (ops as MindmapOp[]) : [];
|
return Array.isArray(ops) ? ops : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@@ -294,16 +295,58 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(baseData, fixed);
|
const context = buildDocumentBridgeContextWithActor({
|
||||||
await writeMindmapLocal(payload.documentId, payload.mindmapId, nextData, doc.title ?? "无标题");
|
request,
|
||||||
|
actor: {
|
||||||
|
actorType: "service",
|
||||||
|
actorId: "mindmap-expand-node",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
workspaceId: (doc as any).workspace_id ?? null,
|
||||||
|
source: {
|
||||||
|
channel: "mindmap-expand-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeRustBridgeTool<{
|
||||||
|
ok: boolean;
|
||||||
|
applied?: number;
|
||||||
|
errors?: string[];
|
||||||
|
ops?: unknown[];
|
||||||
|
data?: unknown;
|
||||||
|
meta?: unknown;
|
||||||
|
}>({
|
||||||
|
context,
|
||||||
|
toolName: "mindmap_expand_node",
|
||||||
|
invocationKind: "command",
|
||||||
|
args: {
|
||||||
|
documentId: payload.documentId,
|
||||||
|
mindmapId: payload.mindmapId,
|
||||||
|
targetUid: payload.targetUid,
|
||||||
|
instruction: payload.instruction ?? "",
|
||||||
|
ops: fixed,
|
||||||
|
searchResults: searxResults.map((r) => ({
|
||||||
|
title: r.title,
|
||||||
|
url: r.url,
|
||||||
|
snippet: r.snippet ?? "",
|
||||||
|
})),
|
||||||
|
reason: payload.instruction ?? "",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
data: baseData,
|
||||||
|
source: "mindmap-expand-node",
|
||||||
|
},
|
||||||
|
mode: "result",
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
providerUsed: "online",
|
providerUsed: "online",
|
||||||
applied,
|
applied: (result.result as any)?.applied ?? 0,
|
||||||
errors,
|
errors: (result.result as any)?.errors ?? [],
|
||||||
ops: fixed,
|
ops: (result.result as any)?.ops ?? fixed,
|
||||||
data: nextData,
|
data: (result.result as any)?.data ?? baseData,
|
||||||
meta: {
|
meta: {
|
||||||
finishReason,
|
finishReason,
|
||||||
searched: useSearx,
|
searched: useSearx,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import path from "path";
|
|||||||
import os from "os";
|
import os from "os";
|
||||||
import { execFile } from "child_process";
|
import { execFile } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
|
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
|
||||||
|
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
|
||||||
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
|
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
|
||||||
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
||||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||||
@@ -877,6 +879,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
let pdfTitle = "文档";
|
let pdfTitle = "文档";
|
||||||
let pdfUrlForLink: ((p: number) => string) | null = null;
|
let pdfUrlForLink: ((p: number) => string) | null = null;
|
||||||
|
let pageLinkPattern = "#page={page}";
|
||||||
let cleanupDir: string | null = null;
|
let cleanupDir: string | null = null;
|
||||||
let pdfFile: string | null = null;
|
let pdfFile: string | null = null;
|
||||||
|
|
||||||
@@ -893,6 +896,7 @@ export async function POST(request: Request) {
|
|||||||
pdfFile = file;
|
pdfFile = file;
|
||||||
pdfTitle = testName.replace(/\.pdf$/i, "");
|
pdfTitle = testName.replace(/\.pdf$/i, "");
|
||||||
pdfUrlForLink = (p) => `/api/mindmap-ai/test-pdf?name=${encodeURIComponent(testName)}#page=${p}`;
|
pdfUrlForLink = (p) => `/api/mindmap-ai/test-pdf?name=${encodeURIComponent(testName)}#page=${p}`;
|
||||||
|
pageLinkPattern = `/api/mindmap-ai/test-pdf?name=${encodeURIComponent(testName)}#page={page}`;
|
||||||
} else if (payload.source.kind === "url") {
|
} else if (payload.source.kind === "url") {
|
||||||
const safe = safeFetchableUrl(payload.source.fileUrl);
|
const safe = safeFetchableUrl(payload.source.fileUrl);
|
||||||
if (!safe) {
|
if (!safe) {
|
||||||
@@ -903,6 +907,7 @@ export async function POST(request: Request) {
|
|||||||
pdfFile = downloaded.file;
|
pdfFile = downloaded.file;
|
||||||
pdfTitle = payload.source.title?.trim() || "文档";
|
pdfTitle = payload.source.title?.trim() || "文档";
|
||||||
pdfUrlForLink = (p) => `${safe}#page=${p}`;
|
pdfUrlForLink = (p) => `${safe}#page=${p}`;
|
||||||
|
pageLinkPattern = `${safe}#page={page}`;
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json({ error: "不支持的 source.kind" }, { status: 400 });
|
return NextResponse.json({ error: "不支持的 source.kind" }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -1104,13 +1109,61 @@ export async function POST(request: Request) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mindmapData = plan
|
const finalOutline = plan
|
||||||
? buildMindmapFromPlan(pdfTitle, plan, linkForPage)
|
? plan.chapters.flatMap((chapter, chapterIndex) => [
|
||||||
: buildMindmapTree(pdfTitle, outline ?? [], linkForPage);
|
{
|
||||||
|
title: chapter.title,
|
||||||
|
level: 1,
|
||||||
|
page: chapter.page,
|
||||||
|
},
|
||||||
|
...((chapter.sections ?? []).map((section) => ({
|
||||||
|
title: section.title,
|
||||||
|
level: 2,
|
||||||
|
page: section.page,
|
||||||
|
}))),
|
||||||
|
])
|
||||||
|
: (outline ?? []);
|
||||||
|
|
||||||
|
const context = buildDocumentBridgeContextWithActor({
|
||||||
|
request,
|
||||||
|
actor: {
|
||||||
|
actorType: "service",
|
||||||
|
actorId: "mindmap-outline-to-mindmap",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
workspaceId: null,
|
||||||
|
source: {
|
||||||
|
channel: "mindmap-outline-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const payload = {
|
||||||
|
documentId: "mindmap-outline-to-mindmap",
|
||||||
|
mindmapId: "mindmap-outline-to-mindmap",
|
||||||
|
rootTitle: pdfTitle,
|
||||||
|
pageLinkPattern,
|
||||||
|
outline: finalOutline,
|
||||||
|
};
|
||||||
|
const result = await executeRustBridgeTool<{
|
||||||
|
ok: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
meta?: { title?: string | null; outlineCount?: number | null } | null;
|
||||||
|
}>({
|
||||||
|
context,
|
||||||
|
toolName: "mindmap_outline_to_mindmap",
|
||||||
|
invocationKind: "command",
|
||||||
|
args: payload,
|
||||||
|
data: payload,
|
||||||
|
mode: "result",
|
||||||
|
});
|
||||||
|
const mindmapData = (result as any)?.data ?? {
|
||||||
|
data: { text: pdfTitle },
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
mindmapData,
|
mindmapData,
|
||||||
outline: plan ? null : outline,
|
outline: null,
|
||||||
plan,
|
plan,
|
||||||
candidates,
|
candidates,
|
||||||
tocEntries,
|
tocEntries,
|
||||||
@@ -1122,6 +1175,7 @@ export async function POST(request: Request) {
|
|||||||
onlineAttempted,
|
onlineAttempted,
|
||||||
onlineSucceeded,
|
onlineSucceeded,
|
||||||
onlineError,
|
onlineError,
|
||||||
|
outlineCount: Array.isArray(finalOutline) ? finalOutline.length : 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { NextResponse } from "next/server";
|
|||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import { api } from "@/lib/convex/api";
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
|
||||||
|
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -17,6 +19,28 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const context = buildDocumentBridgeContextWithActor({
|
||||||
|
request,
|
||||||
|
actor: {
|
||||||
|
actorType: "service",
|
||||||
|
actorId: "mindmap-trash-empty",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
workspaceId,
|
||||||
|
source: {
|
||||||
|
channel: "mindmap-trash-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await executeRustBridgeTool({
|
||||||
|
context,
|
||||||
|
toolName: "mindmap_empty_trash",
|
||||||
|
invocationKind: "command",
|
||||||
|
args: { workspaceId },
|
||||||
|
data: { workspaceId, source: "mindmap-trash-empty" },
|
||||||
|
mode: "result",
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await client.mutation(api.mindmaps.emptyTrashByWorkspace, { workspaceId });
|
const result = await client.mutation(api.mindmaps.emptyTrashByWorkspace, { workspaceId });
|
||||||
return NextResponse.json({ ok: true, removed: result?.deletedCount ?? 0 });
|
return NextResponse.json({ ok: true, removed: result?.deletedCount ?? 0 });
|
||||||
@@ -27,4 +51,3 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,18 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
|
|||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import { api } from "@/lib/convex/api";
|
import { api } from "@/lib/convex/api";
|
||||||
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
|
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
|
||||||
|
import {
|
||||||
|
buildDocumentBridgeContextWithActor,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
buildDocumentQueryEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
executeRustBridgeQueryTransport,
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
resolveRustBridgeQueryPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
const defaultMindmapData = {
|
const defaultMindmapData = {
|
||||||
data: { text: "中心主题" },
|
data: { text: "中心主题" },
|
||||||
@@ -17,7 +29,43 @@ export async function GET(
|
|||||||
|
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
const res = await client.query(api.mindmaps.get, { docId, mindmapId });
|
try {
|
||||||
|
const context = buildDocumentBridgeContextWithActor({
|
||||||
|
request,
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: auth.userId,
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
workspaceId: null,
|
||||||
|
source: {
|
||||||
|
channel: "mindmap-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentQueryEnvelope({
|
||||||
|
name: "mindmaps.get",
|
||||||
|
payload: {
|
||||||
|
documentId: docId,
|
||||||
|
mindmapId,
|
||||||
|
workspaceId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeQueryPlan({ context, envelope });
|
||||||
|
const res = await executeRustBridgeQueryTransport<{
|
||||||
|
ok?: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
meta?: {
|
||||||
|
workspace_id?: string | null;
|
||||||
|
exists?: boolean;
|
||||||
|
deleted_at?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
} | null;
|
||||||
|
}>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: res?.data ?? defaultMindmapData,
|
data: res?.data ?? defaultMindmapData,
|
||||||
source: "convex",
|
source: "convex",
|
||||||
@@ -34,6 +82,9 @@ export async function GET(
|
|||||||
updatedAt: res?.meta?.updated_at ?? null,
|
updatedAt: res?.meta?.updated_at ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
@@ -53,11 +104,44 @@ export async function POST(
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await client.mutation(api.mindmaps.put, {
|
const context = buildDocumentBridgeContextWithActor({
|
||||||
docId,
|
request,
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: auth.userId,
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
workspaceId: null,
|
||||||
|
source: {
|
||||||
|
channel: "mindmap-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "mindmaps.put",
|
||||||
|
payload: {
|
||||||
|
documentId: docId,
|
||||||
mindmapId,
|
mindmapId,
|
||||||
data: data ?? defaultMindmapData,
|
data: data ?? defaultMindmapData,
|
||||||
...(typeof createOnly === "boolean" ? { createOnly } : {}),
|
createOnly: typeof createOnly === "boolean" ? createOnly : false,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId: null,
|
||||||
|
pageId: docId,
|
||||||
|
blockId: mindmapId,
|
||||||
|
},
|
||||||
|
reason: "mindmap-route:put",
|
||||||
|
refs: ["task-032", "mindmap-route"],
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||||
|
const result = await executeRustBridgeMutationTransport<{
|
||||||
|
ok?: boolean;
|
||||||
|
workspace_id?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
});
|
});
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
...(result ?? { ok: true }),
|
...(result ?? { ok: true }),
|
||||||
@@ -72,7 +156,7 @@ export async function POST(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
return documentBridgeErrorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ import { NextResponse } from "next/server";
|
|||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import { api } from "@/lib/convex/api";
|
import { api } from "@/lib/convex/api";
|
||||||
|
import {
|
||||||
|
buildDocumentBridgeContextWithActor,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
buildDocumentQueryEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
executeRustBridgeQueryTransport,
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
resolveRustBridgeQueryPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
|
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
|
||||||
|
|
||||||
const defaultMindmapData = {
|
const defaultMindmapData = {
|
||||||
@@ -9,6 +21,10 @@ const defaultMindmapData = {
|
|||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getLegacyMindmapId(docId: string) {
|
||||||
|
return `legacy-${docId}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ docId: string }> },
|
{ params }: { params: Promise<{ docId: string }> },
|
||||||
@@ -17,8 +33,45 @@ export async function GET(
|
|||||||
|
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
const mindmapId = `legacy-${docId}`;
|
const mindmapId = getLegacyMindmapId(docId);
|
||||||
const res = await client.query(api.mindmaps.get, { docId, mindmapId });
|
|
||||||
|
try {
|
||||||
|
const context = buildDocumentBridgeContextWithActor({
|
||||||
|
request,
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: auth.userId,
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
workspaceId: null,
|
||||||
|
source: {
|
||||||
|
channel: "mindmap-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentQueryEnvelope({
|
||||||
|
name: "mindmaps.get",
|
||||||
|
payload: {
|
||||||
|
documentId: docId,
|
||||||
|
mindmapId,
|
||||||
|
workspaceId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeQueryPlan({ context, envelope });
|
||||||
|
const res = await executeRustBridgeQueryTransport<{
|
||||||
|
ok?: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
meta?: {
|
||||||
|
workspace_id?: string | null;
|
||||||
|
exists?: boolean;
|
||||||
|
deleted_at?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
} | null;
|
||||||
|
}>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: res?.data ?? defaultMindmapData,
|
data: res?.data ?? defaultMindmapData,
|
||||||
source: "convex",
|
source: "convex",
|
||||||
@@ -35,6 +88,9 @@ export async function GET(
|
|||||||
updatedAt: res?.meta?.updated_at ?? null,
|
updatedAt: res?.meta?.updated_at ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
@@ -48,12 +104,51 @@ export async function POST(
|
|||||||
|
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
const mindmapId = `legacy-${docId}`;
|
const mindmapId = getLegacyMindmapId(docId);
|
||||||
const payload = (await request.json().catch(() => ({}))) as { data?: unknown };
|
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
|
||||||
const result = await client.mutation(api.mindmaps.put, {
|
data?: unknown;
|
||||||
docId,
|
createOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = buildDocumentBridgeContextWithActor({
|
||||||
|
request,
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: auth.userId,
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
workspaceId: null,
|
||||||
|
source: {
|
||||||
|
channel: "mindmap-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "mindmaps.put",
|
||||||
|
payload: {
|
||||||
|
documentId: docId,
|
||||||
mindmapId,
|
mindmapId,
|
||||||
data: payload.data ?? defaultMindmapData,
|
data: data ?? defaultMindmapData,
|
||||||
|
createOnly: typeof createOnly === "boolean" ? createOnly : false,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId: null,
|
||||||
|
pageId: docId,
|
||||||
|
blockId: mindmapId,
|
||||||
|
},
|
||||||
|
reason: "mindmap-route:put",
|
||||||
|
refs: ["task-032", "mindmap-route"],
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||||
|
const result = await executeRustBridgeMutationTransport<{
|
||||||
|
ok?: boolean;
|
||||||
|
workspace_id?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
});
|
});
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
...(result ?? { ok: true }),
|
...(result ?? { ok: true }),
|
||||||
@@ -67,6 +162,9 @@ export async function POST(
|
|||||||
updatedAt: result?.updated_at ?? null,
|
updatedAt: result?.updated_at ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockQuery = vi.fn();
|
||||||
|
const mockMutation = vi.fn();
|
||||||
|
const mockExecuteMediaAssetWritebackBridgeCommand = vi.fn();
|
||||||
|
const mockPrepareOnlyOfficeCallback = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/authContext", () => ({
|
||||||
|
HttpError: class HttpError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requireAuthContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api-utils", () => ({
|
||||||
|
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
|
||||||
|
message,
|
||||||
|
status,
|
||||||
|
details,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/enabled", () => ({
|
||||||
|
isConvexEnabled: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/server", () => ({
|
||||||
|
getConvexHttpClient: () => ({
|
||||||
|
query: mockQuery,
|
||||||
|
mutation: mockMutation,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/api", () => ({
|
||||||
|
api: {
|
||||||
|
mediaAssets: {
|
||||||
|
getById: { name: "mediaAssets.getById" },
|
||||||
|
generateUploadUrl: { name: "mediaAssets.generateUploadUrl" },
|
||||||
|
replaceStorageFromUpload: { name: "mediaAssets.replaceStorageFromUpload" },
|
||||||
|
},
|
||||||
|
documents: {
|
||||||
|
getPermissionForUser: { name: "documents.getPermissionForUser" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/onlyoffice/internal-url", () => ({
|
||||||
|
resolveOnlyOfficeInternalUrl: vi.fn().mockResolvedValue("http://127.0.0.1:8082"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/bridge", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@/lib/documents/bridge")>("@/lib/documents/bridge");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
buildDocumentBridgeContextWithActor: vi.fn((input) => ({
|
||||||
|
requestId: "req_test_onlyoffice_callback",
|
||||||
|
traceId: "trace_test_onlyoffice_callback",
|
||||||
|
deploymentId: null,
|
||||||
|
projectId: null,
|
||||||
|
workspaceId: input.workspaceId ?? null,
|
||||||
|
actor: input.actor,
|
||||||
|
source: input.source ?? { channel: "onlyoffice-callback", client: "vitest" },
|
||||||
|
tenantId: null,
|
||||||
|
authToken: null,
|
||||||
|
idempotencyKey: input.idempotencyKey ?? null,
|
||||||
|
validateOnly: false,
|
||||||
|
dryRun: false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/media-asset-command-adapter", () => ({
|
||||||
|
executeMediaAssetWritebackBridgeCommand: mockExecuteMediaAssetWritebackBridgeCommand,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/onlyoffice/rust-adapter", () => ({
|
||||||
|
prepareOnlyOfficeCallback: mockPrepareOnlyOfficeCallback,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("/api/onlyoffice/callback route", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockQuery.mockReset();
|
||||||
|
mockMutation.mockReset();
|
||||||
|
mockExecuteMediaAssetWritebackBridgeCommand.mockReset();
|
||||||
|
mockPrepareOnlyOfficeCallback.mockReset();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses media asset bridge adapter for callback writeback", async () => {
|
||||||
|
const { POST } = await import("./route");
|
||||||
|
|
||||||
|
mockQuery
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "asset_1",
|
||||||
|
document_id: "doc_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
mime_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
permission: "edit",
|
||||||
|
});
|
||||||
|
mockMutation.mockResolvedValueOnce("http://127.0.0.1:3210/api/upload");
|
||||||
|
mockExecuteMediaAssetWritebackBridgeCommand.mockResolvedValue({
|
||||||
|
requestId: "req_test_onlyoffice_callback",
|
||||||
|
traceId: "trace_test_onlyoffice_callback",
|
||||||
|
commandId: "cmd_test_onlyoffice_callback",
|
||||||
|
commandName: "media.assets.replace_storage",
|
||||||
|
});
|
||||||
|
mockPrepareOnlyOfficeCallback.mockResolvedValue({
|
||||||
|
shouldWrite: true,
|
||||||
|
downloadUrl: "http://127.0.0.1:8082/cache/files/output.docx",
|
||||||
|
idempotencyKey: "doc_key_1",
|
||||||
|
locator: {
|
||||||
|
assetId: "asset_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
documentId: "doc_1",
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
sessionId: "onlyoffice-callback",
|
||||||
|
assetId: "asset_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
documentId: "doc_1",
|
||||||
|
userId: "user_1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "http://127.0.0.1:8082/cache/files/output.docx") {
|
||||||
|
return new Response(Buffer.from("docx-binary"), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === "http://127.0.0.1:3210/api/upload") {
|
||||||
|
return Response.json({ storageId: "storage_new_1" }, { status: 200 });
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const request = new Request(
|
||||||
|
"http://127.0.0.1:3001/api/onlyoffice/callback?assetId=asset_1&userId=user_1",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: 2,
|
||||||
|
key: "doc_key_1",
|
||||||
|
url: "http://127.0.0.1:8082/cache/files/output.docx",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(payload).toEqual({ error: 0 });
|
||||||
|
expect(mockMutation).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockMutation.mock.calls[0]?.[1]).toEqual({ userId: "user_1" });
|
||||||
|
expect(mockExecuteMediaAssetWritebackBridgeCommand).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockExecuteMediaAssetWritebackBridgeCommand).toHaveBeenCalledWith({
|
||||||
|
client: expect.any(Object),
|
||||||
|
context: expect.objectContaining({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
actor: {
|
||||||
|
actorType: "service",
|
||||||
|
actorId: "user_1",
|
||||||
|
sessionId: "onlyoffice-callback",
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
channel: "onlyoffice-callback",
|
||||||
|
client: "onlyoffice-document-server",
|
||||||
|
},
|
||||||
|
idempotencyKey: "doc_key_1",
|
||||||
|
}),
|
||||||
|
envelope: expect.objectContaining({
|
||||||
|
name: "media.assets.replace_storage",
|
||||||
|
payload: {
|
||||||
|
assetId: "asset_1",
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
storageId: "storage_new_1",
|
||||||
|
userId: "user_1",
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pageId: "doc_1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
|
|||||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
import { api } from "@/lib/convex/api";
|
import { api } from "@/lib/convex/api";
|
||||||
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
|
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
|
||||||
|
import { prepareOnlyOfficeCallback } from "@/lib/onlyoffice/rust-adapter";
|
||||||
import {
|
import {
|
||||||
buildDocumentBridgeContextWithActor,
|
buildDocumentBridgeContextWithActor,
|
||||||
buildDocumentCommandEnvelope,
|
buildDocumentCommandEnvelope,
|
||||||
@@ -45,25 +46,8 @@ const normalizeSecret = (raw: string) => {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
};
|
};
|
||||||
|
|
||||||
const tryRewriteOnlyOfficeDownloadUrl = (raw: string, onlyofficeInternalUrl: string) => {
|
|
||||||
try {
|
|
||||||
const u = new URL(raw);
|
|
||||||
|
|
||||||
// 说明:如果回传的是通过 /onlyoffice-server 访问的地址,
|
|
||||||
// 服务端下载时优先改写为本机 ONLYOFFICE_INTERNAL_URL(避免绕公网/证书问题)。
|
|
||||||
const prefix = "/onlyoffice-server";
|
|
||||||
if (u.pathname.startsWith(prefix)) {
|
|
||||||
const nextPath = u.pathname.slice(prefix.length).replace(/^\/+/, "");
|
|
||||||
return `${onlyofficeInternalUrl}/${nextPath}${u.search}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return raw;
|
|
||||||
} catch {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
|
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const assetId = searchParams.get("assetId") || "";
|
const assetId = searchParams.get("assetId") || "";
|
||||||
@@ -83,23 +67,11 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 0 });
|
return NextResponse.json({ error: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = Number(body.status ?? -1);
|
|
||||||
// 说明:仅在文档需要保存时处理(2=ready for saving;6=force save)。
|
|
||||||
if (status !== 2 && status !== 6) {
|
|
||||||
return NextResponse.json({ error: 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!assetId) {
|
if (!assetId) {
|
||||||
// 说明:缺少 assetId 无法定位存储路径,返回非 0 让 ONLYOFFICE 显示保存失败。
|
|
||||||
return NextResponse.json({ error: 1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!body.url) {
|
|
||||||
return NextResponse.json({ error: 1 });
|
return NextResponse.json({ error: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
try {
|
|
||||||
// 说明:Convex 模式下,保存写回 Convex Files,并更新 media_assets.storage_id/file_url。
|
// 说明:Convex 模式下,保存写回 Convex Files,并更新 media_assets.storage_id/file_url。
|
||||||
// 优先使用前端在 /onlyoffice 页面透传的 userId;避免固定 dev-user 导致 workspace membership 校验失败。
|
// 优先使用前端在 /onlyoffice 页面透传的 userId;避免固定 dev-user 导致 workspace membership 校验失败。
|
||||||
const userId =
|
const userId =
|
||||||
@@ -118,6 +90,22 @@ export async function POST(request: Request) {
|
|||||||
const workspaceId = String(asset.workspace_id || "").trim() || null;
|
const workspaceId = String(asset.workspace_id || "").trim() || null;
|
||||||
const mimeType = String(asset.mime_type || "").trim() || "application/octet-stream";
|
const mimeType = String(asset.mime_type || "").trim() || "application/octet-stream";
|
||||||
|
|
||||||
|
const prepared = await prepareOnlyOfficeCallback({
|
||||||
|
request,
|
||||||
|
assetId,
|
||||||
|
documentId,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
sessionId: "onlyoffice-callback",
|
||||||
|
status: Number(body.status ?? -1),
|
||||||
|
url: body.url ?? null,
|
||||||
|
key: body.key ?? null,
|
||||||
|
onlyofficeInternalUrl,
|
||||||
|
});
|
||||||
|
if (!prepared.shouldWrite) {
|
||||||
|
return NextResponse.json({ error: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
// 只读权限:不允许通过 ONLYOFFICE 回调写回
|
// 只读权限:不允许通过 ONLYOFFICE 回调写回
|
||||||
try {
|
try {
|
||||||
const perm = (await client.query(api.documents.getPermissionForUser, {
|
const perm = (await client.query(api.documents.getPermissionForUser, {
|
||||||
@@ -131,7 +119,10 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 1 });
|
return NextResponse.json({ error: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url, onlyofficeInternalUrl);
|
const downloadUrl = String(prepared.downloadUrl || "").trim();
|
||||||
|
if (!downloadUrl) {
|
||||||
|
return NextResponse.json({ error: 1 });
|
||||||
|
}
|
||||||
const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
|
const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
return NextResponse.json({ error: 1 });
|
return NextResponse.json({ error: 1 });
|
||||||
@@ -172,7 +163,7 @@ export async function POST(request: Request) {
|
|||||||
channel: "onlyoffice-callback",
|
channel: "onlyoffice-callback",
|
||||||
client: "onlyoffice-document-server",
|
client: "onlyoffice-document-server",
|
||||||
},
|
},
|
||||||
idempotencyKey: String(body.key || assetId || "").trim() || null,
|
idempotencyKey: String(prepared.idempotencyKey || assetId || "").trim() || null,
|
||||||
});
|
});
|
||||||
const envelope = buildDocumentCommandEnvelope({
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
name: "media.assets.replace_storage",
|
name: "media.assets.replace_storage",
|
||||||
@@ -199,11 +190,12 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ error: 0 });
|
return NextResponse.json({ error: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 1 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 说明:避免异常导致 ONLYOFFICE 重试/阻塞(例如 Convex 未部署新 mutation)。
|
// 说明:避免异常导致 ONLYOFFICE 重试/阻塞(例如 Convex 未部署新 mutation)。
|
||||||
console.error("[onlyoffice/callback] convex writeback failed:", error);
|
console.error("[onlyoffice/callback] convex writeback failed:", error);
|
||||||
return NextResponse.json({ error: 1 });
|
return NextResponse.json({ error: 1 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return NextResponse.json({ error: 1 });
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,41 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import crypto from "crypto";
|
|
||||||
import { api } from "@/lib/convex/api";
|
import { api } from "@/lib/convex/api";
|
||||||
import { HttpError } from "@/lib/auth/authContext";
|
import { HttpError } from "@/lib/auth/authContext";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
|
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
|
||||||
|
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
|
||||||
|
import { prepareOnlyOfficeForcesave } from "@/lib/onlyoffice/rust-adapter";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const base64Url = (input: Buffer | string) =>
|
|
||||||
Buffer.from(input)
|
|
||||||
.toString("base64")
|
|
||||||
.replace(/=/g, "")
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_");
|
|
||||||
|
|
||||||
const signHs256 = (payload: unknown, secret: string) => {
|
|
||||||
const header = { alg: "HS256", typ: "JWT" };
|
|
||||||
const headerPart = base64Url(JSON.stringify(header));
|
|
||||||
const payloadPart = base64Url(JSON.stringify(payload));
|
|
||||||
const signingInput = `${headerPart}.${payloadPart}`;
|
|
||||||
const signature = crypto.createHmac("sha256", secret).update(signingInput).digest();
|
|
||||||
return `${signingInput}.${base64Url(signature)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeSecret = (raw: string) => {
|
|
||||||
const trimmed = String(raw || "").trim();
|
|
||||||
if (!trimmed) return "";
|
|
||||||
// 兼容用户把 .env 的值写成 "xxx" / 'xxx'
|
|
||||||
if (
|
|
||||||
(trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length >= 2) ||
|
|
||||||
(trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)
|
|
||||||
) {
|
|
||||||
return trimmed.slice(1, -1).trim();
|
|
||||||
}
|
|
||||||
return trimmed;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
|
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
|
||||||
let auth;
|
let auth;
|
||||||
@@ -72,70 +44,43 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "无权修改(只读共享的文件)" }, { status: 403 });
|
return NextResponse.json({ error: "无权修改(只读共享的文件)" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = { c: "forcesave", key, userdata: `asset:${assetId}` };
|
|
||||||
const secret = normalizeSecret(process.env.ONLYOFFICE_JWT_SECRET || "");
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const tryJson = async (res: Response) => (await res.json().catch(() => null)) as any;
|
const tryJson = async (res: Response) => (await res.json().catch(() => null)) as any;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 优先按文档推荐:使用 /command + token
|
const prepared = await prepareOnlyOfficeForcesave({
|
||||||
if (secret) {
|
request,
|
||||||
const token = signHs256(payload, secret);
|
assetId,
|
||||||
const r = await fetch(`${onlyofficeInternalUrl}/command`, {
|
key,
|
||||||
method: "POST",
|
onlyofficeInternalUrl,
|
||||||
headers: { "Content-Type": "application/json" },
|
secret: process.env.ONLYOFFICE_JWT_SECRET || "",
|
||||||
body: JSON.stringify({ token }),
|
actorId: auth.userId,
|
||||||
|
documentId: docId,
|
||||||
|
workspaceId: String((asset as any).workspace_id || "").trim() || null,
|
||||||
});
|
});
|
||||||
const j = await tryJson(r);
|
|
||||||
if (r.ok && Number(j?.error ?? 0) === 0) {
|
|
||||||
return NextResponse.json({ ok: true, via: "command", result: j });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 兜底:部分环境可能暴露 /forcesave 直连接口
|
const errors: Record<string, unknown> = {};
|
||||||
const r2 = await fetch(`${onlyofficeInternalUrl}/forcesave`, {
|
for (const outbound of prepared.requests) {
|
||||||
method: "POST",
|
const response = await fetch(outbound.url, {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: outbound.method,
|
||||||
body: JSON.stringify(payload),
|
headers: Object.fromEntries(outbound.headers.map((item) => [item.name, item.value])),
|
||||||
|
body: outbound.bodyJson,
|
||||||
});
|
});
|
||||||
const j2 = await tryJson(r2);
|
const payload = await tryJson(response);
|
||||||
if (r2.ok && Number(j2?.error ?? 0) === 0) {
|
if (response.ok && Number(payload?.error ?? 0) === 0) {
|
||||||
return NextResponse.json({ ok: true, via: "forcesave", result: j2 });
|
return NextResponse.json({ ok: true, via: outbound.via, result: payload });
|
||||||
|
}
|
||||||
|
errors[outbound.via] = payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "触发 forcesave 失败", detail: { command: j, forcesave: j2 } },
|
{ error: "触发 forcesave 失败", detail: errors },
|
||||||
{ status: 502 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// JWT 未启用:尝试 /forcesave 直连
|
|
||||||
const r = await fetch(`${onlyofficeInternalUrl}/forcesave`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const j = await tryJson(r);
|
|
||||||
if (r.ok && Number(j?.error ?? 0) === 0) {
|
|
||||||
return NextResponse.json({ ok: true, via: "forcesave", result: j });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 最后兜底:部分部署可能仍接受不带 token 的 /command(不保证)
|
|
||||||
const r2 = await fetch(`${onlyofficeInternalUrl}/command`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const j2 = await tryJson(r2);
|
|
||||||
if (r2.ok && Number(j2?.error ?? 0) === 0) {
|
|
||||||
return NextResponse.json({ ok: true, via: "command_no_token", result: j2 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "触发 forcesave 失败", detail: { forcesave: j, command: j2 } },
|
|
||||||
{ status: 502 },
|
{ status: 502 },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === "DocumentBridgeError") {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
console.error("[onlyoffice/forcesave] failed:", error);
|
console.error("[onlyoffice/forcesave] failed:", error);
|
||||||
return NextResponse.json({ error: "触发 forcesave 失败" }, { status: 502 });
|
return NextResponse.json({ error: "触发 forcesave 失败" }, { status: 502 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,217 +1,44 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
|
||||||
|
import { prepareOnlyOfficeProxyRequest } from "@/lib/onlyoffice/rust-adapter";
|
||||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||||
import { isIP } from "node:net";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const isLocalHostname = (hostname: string) =>
|
const toHeaders = (items: Array<{ name: string; value: string }>) => {
|
||||||
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";
|
const headers = new Headers();
|
||||||
|
for (const item of items) {
|
||||||
const isPrivateIpv4 = (hostname: string) => {
|
if (!item.name || !item.value) continue;
|
||||||
if (isIP(hostname) !== 4) return false;
|
headers.set(item.name, item.value);
|
||||||
const parts = hostname.split(".").map((v) => Number(v));
|
|
||||||
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true;
|
|
||||||
const [a, b] = parts;
|
|
||||||
// 10.0.0.0/8
|
|
||||||
if (a === 10) return true;
|
|
||||||
// 127.0.0.0/8
|
|
||||||
if (a === 127) return true;
|
|
||||||
// 169.254.0.0/16
|
|
||||||
if (a === 169 && b === 254) return true;
|
|
||||||
// 172.16.0.0/12
|
|
||||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
||||||
// 192.168.0.0/16
|
|
||||||
if (a === 192 && b === 168) return true;
|
|
||||||
// 0.0.0.0/8
|
|
||||||
if (a === 0) return true;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const tryParseOriginHost = (raw?: string) => {
|
|
||||||
const value = String(raw || "").trim();
|
|
||||||
if (!value) return null;
|
|
||||||
try {
|
|
||||||
if (/^https?:\/\//i.test(value)) {
|
|
||||||
const u = new URL(value);
|
|
||||||
return { hostname: u.hostname, port: u.port || "" };
|
|
||||||
}
|
|
||||||
return { hostname: value, port: "" };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const decodeBase64UrlToUtf8 = (input: string) => {
|
|
||||||
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
||||||
const padLen = (4 - (normalized.length % 4)) % 4;
|
|
||||||
const padded = normalized + "=".repeat(padLen);
|
|
||||||
return Buffer.from(padded, "base64").toString("utf8");
|
|
||||||
};
|
|
||||||
|
|
||||||
const tryParseOriginUrl = (raw?: string) => {
|
|
||||||
const value = String(raw || "").trim();
|
|
||||||
if (!value) return null;
|
|
||||||
try {
|
|
||||||
if (/^https?:\/\//i.test(value)) return new URL(value);
|
|
||||||
// 说明:仅给 host:port 的写法一个默认协议(http)
|
|
||||||
return new URL(`http://${value}`);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return headers;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handle = async (request: Request, method: "GET" | "HEAD") => {
|
const handle = async (request: Request, method: "GET" | "HEAD") => {
|
||||||
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const encoded = searchParams.get("u");
|
const encodedUrl = String(searchParams.get("u") || "").trim();
|
||||||
|
if (!encodedUrl) {
|
||||||
if (!encoded) {
|
|
||||||
return NextResponse.json({ error: "缺少 u" }, { status: 400 });
|
return NextResponse.json({ error: "缺少 u" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetUrl: string;
|
const prepared = await prepareOnlyOfficeProxyRequest({
|
||||||
try {
|
request,
|
||||||
targetUrl = decodeBase64UrlToUtf8(encoded);
|
encodedUrl,
|
||||||
|
|
||||||
new URL(targetUrl);
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "u 不是有效的 base64url URL" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtimeCfg = getMnoteRuntimeConfig();
|
|
||||||
let target: URL;
|
|
||||||
try {
|
|
||||||
target = new URL(targetUrl);
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "u 不是有效的 URL" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
||||||
return NextResponse.json({ error: "仅支持 http/https URL" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 安全:避免把该接口变成通用 SSRF 代理。
|
|
||||||
// 当前仅用于代理 Supabase Storage 的签名 URL(以及可能的 storage 回源 host override)。
|
|
||||||
const supa = tryParseOriginHost(runtimeCfg.supabaseUrl);
|
|
||||||
const supaInternalOrigin = tryParseOriginUrl(
|
|
||||||
runtimeCfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL,
|
|
||||||
);
|
|
||||||
const storageOverride = tryParseOriginHost(runtimeCfg.onlyofficeStorageHostOverride);
|
|
||||||
const convexOrigin = tryParseOriginUrl(process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL);
|
|
||||||
|
|
||||||
const isSupabasePath =
|
|
||||||
target.pathname.startsWith("/storage/v1/") ||
|
|
||||||
target.pathname.startsWith("/auth/v1/") ||
|
|
||||||
target.pathname.startsWith("/rest/v1/") ||
|
|
||||||
target.pathname.startsWith("/functions/v1/") ||
|
|
||||||
target.pathname.startsWith("/realtime/v1/");
|
|
||||||
|
|
||||||
// 说明:历史数据里可能残留旧的 Supabase 域名(例如 supabase.aichem.dpdns.org)。
|
|
||||||
// 为了保证 ONLYOFFICE 拉取文件稳定,这里统一把 Supabase 典型路径的 host 改为当前配置的 Supabase,
|
|
||||||
// 并优先使用“内部 HTTP”回源,避免 Node fetch 因自签证书失败。
|
|
||||||
if (isSupabasePath) {
|
|
||||||
if (supaInternalOrigin) {
|
|
||||||
target.protocol = supaInternalOrigin.protocol;
|
|
||||||
target.host = supaInternalOrigin.host;
|
|
||||||
targetUrl = target.toString();
|
|
||||||
} else {
|
|
||||||
const supaPublicOrigin = tryParseOriginUrl(runtimeCfg.supabaseUrl);
|
|
||||||
if (supaPublicOrigin) {
|
|
||||||
target.protocol = supaPublicOrigin.protocol;
|
|
||||||
target.host = supaPublicOrigin.host;
|
|
||||||
targetUrl = target.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowedHostnames = new Set<string>();
|
|
||||||
const allowedPortsByHostname = new Map<string, Set<string>>();
|
|
||||||
|
|
||||||
const addAllowed = (hostname: string, port: string) => {
|
|
||||||
allowedHostnames.add(hostname);
|
|
||||||
if (!allowedPortsByHostname.has(hostname)) allowedPortsByHostname.set(hostname, new Set<string>());
|
|
||||||
if (port) allowedPortsByHostname.get(hostname)!.add(port);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (supa?.hostname) {
|
|
||||||
addAllowed(supa.hostname, supa.port);
|
|
||||||
// 如果 Supabase 配置就是本地/内网回源(例如 host.docker.internal/127.0.0.1),则允许这些 hostname,
|
|
||||||
// 但仍然限制端口只能是 Supabase URL 的端口,避免误用。
|
|
||||||
if (isLocalHostname(supa.hostname)) {
|
|
||||||
addAllowed("127.0.0.1", supa.port);
|
|
||||||
addAllowed("localhost", supa.port);
|
|
||||||
addAllowed("host.docker.internal", supa.port);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (supaInternalOrigin?.hostname) {
|
|
||||||
addAllowed(supaInternalOrigin.hostname, supaInternalOrigin.port || "");
|
|
||||||
if (isLocalHostname(supaInternalOrigin.hostname)) {
|
|
||||||
addAllowed("127.0.0.1", supaInternalOrigin.port || "");
|
|
||||||
addAllowed("localhost", supaInternalOrigin.port || "");
|
|
||||||
addAllowed("host.docker.internal", supaInternalOrigin.port || "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (storageOverride?.hostname) {
|
|
||||||
addAllowed(storageOverride.hostname, storageOverride.port);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (convexOrigin?.hostname) {
|
|
||||||
addAllowed(convexOrigin.hostname, convexOrigin.port || "");
|
|
||||||
if (isLocalHostname(convexOrigin.hostname)) {
|
|
||||||
addAllowed("127.0.0.1", convexOrigin.port || "");
|
|
||||||
addAllowed("localhost", convexOrigin.port || "");
|
|
||||||
addAllowed("host.docker.internal", convexOrigin.port || "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 说明:默认禁止代理到内网/私有地址;但如果该 hostname 被显式配置为允许(例如 Docker bridge/host 回源),则放行。
|
|
||||||
if (isPrivateIpv4(target.hostname) && !isLocalHostname(target.hostname) && !allowedHostnames.has(target.hostname)) {
|
|
||||||
return NextResponse.json({ error: "禁止访问内网/私有地址" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allowedHostnames.size > 0 && !allowedHostnames.has(target.hostname)) {
|
|
||||||
return NextResponse.json({ error: "禁止代理到非允许的主机" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowedPorts = allowedPortsByHostname.get(target.hostname);
|
|
||||||
if (allowedPorts && allowedPorts.size > 0) {
|
|
||||||
const port = target.port || (target.protocol === "https:" ? "443" : "80");
|
|
||||||
if (!allowedPorts.has(port)) {
|
|
||||||
return NextResponse.json({ error: "禁止代理到该端口" }, { status: 403 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 说明:
|
|
||||||
// - Supabase Storage 的 signedUrl 会携带 `?token=...`,而 ONLYOFFICE 会把它当作
|
|
||||||
// 自己的 JWT token 参数去校验,导致报 “文档安全令牌的格式不正确/invalid signature”。
|
|
||||||
// - 这里用反向代理把 signedUrl 藏在 `u=...` 里(参数名不叫 token),ONLYOFFICE 拉取的
|
|
||||||
// URL 就不会出现 `token` 参数,从而避免冲突。
|
|
||||||
const forwardHeaders = new Headers();
|
|
||||||
const range = request.headers.get("range");
|
|
||||||
if (range) forwardHeaders.set("range", range);
|
|
||||||
|
|
||||||
// 兼容部分 Supabase/Kong 配置:某些环境下下载 Storage 资源仍要求 apikey header。
|
|
||||||
if (
|
|
||||||
runtimeCfg.supabaseAnonKey &&
|
|
||||||
((supa?.hostname && target.hostname === supa.hostname) ||
|
|
||||||
(supaInternalOrigin?.hostname && target.hostname === supaInternalOrigin.hostname))
|
|
||||||
) {
|
|
||||||
forwardHeaders.set("apikey", runtimeCfg.supabaseAnonKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
const upstream = await fetch(targetUrl, {
|
|
||||||
method,
|
method,
|
||||||
headers: forwardHeaders,
|
range: request.headers.get("range"),
|
||||||
|
runtimeConfig: getMnoteRuntimeConfig(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const upstream = await fetch(prepared.targetUrl, {
|
||||||
|
method,
|
||||||
|
headers: toHeaders(prepared.forwardHeaders),
|
||||||
redirect: "follow",
|
redirect: "follow",
|
||||||
});
|
});
|
||||||
|
|
||||||
const headers = new Headers(upstream.headers);
|
const headers = new Headers(upstream.headers);
|
||||||
// 避免把上游 cookie 透传给文档服务器
|
|
||||||
headers.delete("set-cookie");
|
headers.delete("set-cookie");
|
||||||
|
|
||||||
// 说明:ONLYOFFICE 可能会对 document.url 发起 HEAD 预检(获取 content-length/etag)。
|
|
||||||
// 若不支持 HEAD,会导致编辑器报 “下载失败”。
|
|
||||||
if (method === "HEAD") {
|
if (method === "HEAD") {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: upstream.status,
|
status: upstream.status,
|
||||||
@@ -225,12 +52,15 @@ const handle = async (request: Request, method: "GET" | "HEAD") => {
|
|||||||
statusText: upstream.statusText,
|
statusText: upstream.statusText,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
return handle(request, "GET");
|
return await handle(request, "GET");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function HEAD(request: Request) {
|
export async function HEAD(request: Request) {
|
||||||
return handle(request, "HEAD");
|
return await handle(request, "HEAD");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockSignOnlyOfficeConfig = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/lib/api-utils", () => ({
|
||||||
|
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
|
||||||
|
message,
|
||||||
|
status,
|
||||||
|
details,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/onlyoffice/rust-adapter", () => ({
|
||||||
|
signOnlyOfficeConfig: mockSignOnlyOfficeConfig,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("/api/onlyoffice/sign route", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSignOnlyOfficeConfig.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates signing to rust adapter", async () => {
|
||||||
|
const { POST } = await import("./route");
|
||||||
|
mockSignOnlyOfficeConfig.mockResolvedValue({
|
||||||
|
token: "token_all",
|
||||||
|
documentToken: "token_doc",
|
||||||
|
editorConfigToken: "token_editor",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://127.0.0.1:3001/api/onlyoffice/sign", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
config: {
|
||||||
|
document: { title: "A" },
|
||||||
|
editorConfig: { mode: "edit" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
token: "token_all",
|
||||||
|
documentToken: "token_doc",
|
||||||
|
editorConfigToken: "token_editor",
|
||||||
|
});
|
||||||
|
expect(mockSignOnlyOfficeConfig).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,52 +1,26 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import crypto from "crypto";
|
import { signOnlyOfficeConfig } from "@/lib/onlyoffice/rust-adapter";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const base64Url = (input: Buffer | string) =>
|
|
||||||
Buffer.from(input)
|
|
||||||
.toString("base64")
|
|
||||||
.replace(/=/g, "")
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_");
|
|
||||||
|
|
||||||
const signHs256 = (payload: unknown, secret: string) => {
|
|
||||||
const header = { alg: "HS256", typ: "JWT" };
|
|
||||||
const headerPart = base64Url(JSON.stringify(header));
|
|
||||||
const payloadPart = base64Url(JSON.stringify(payload));
|
|
||||||
const signingInput = `${headerPart}.${payloadPart}`;
|
|
||||||
const signature = crypto.createHmac("sha256", secret).update(signingInput).digest();
|
|
||||||
return `${signingInput}.${base64Url(signature)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeSecret = (raw: string) => {
|
|
||||||
const trimmed = String(raw || "").trim();
|
|
||||||
if (!trimmed) return "";
|
|
||||||
// 兼容用户把 .env 的值写成 "xxx" / 'xxx'
|
|
||||||
if (
|
|
||||||
(trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length >= 2) ||
|
|
||||||
(trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)
|
|
||||||
) {
|
|
||||||
return trimmed.slice(1, -1).trim();
|
|
||||||
}
|
|
||||||
return trimmed;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const secret = normalizeSecret(process.env.ONLYOFFICE_JWT_SECRET || "");
|
try {
|
||||||
const body = (await request.json().catch(() => null)) as { config?: any } | null;
|
const body = (await request.json().catch(() => null)) as { config?: Record<string, unknown> } | null;
|
||||||
if (!body?.config) {
|
if (!body?.config) {
|
||||||
return NextResponse.json({ error: "缺少 config" }, { status: 400 });
|
return NextResponse.json({ error: "缺少 config" }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!secret) {
|
|
||||||
// 兼容:如果 ONLYOFFICE 没开启 JWT,可以不需要 token。
|
|
||||||
return NextResponse.json({ token: null, documentToken: null, editorConfigToken: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = body.config;
|
const result = await signOnlyOfficeConfig({
|
||||||
const token = signHs256(config, secret);
|
request,
|
||||||
const documentToken = config?.document ? signHs256(config.document, secret) : null;
|
config: body.config,
|
||||||
const editorConfigToken = config?.editorConfig ? signHs256(config.editorConfig, secret) : null;
|
secret: process.env.ONLYOFFICE_JWT_SECRET || "",
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({ token, documentToken, editorConfigToken });
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : "OnlyOffice 签名失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +1,11 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
|
||||||
import { api } from "@/lib/convex/api";
|
|
||||||
import { HttpError } from "@/lib/auth/authContext";
|
import { HttpError } from "@/lib/auth/authContext";
|
||||||
import type {
|
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
|
||||||
DocumentSearchFilters,
|
import { executeDocumentSearchQuery } from "@/lib/search/search-query-adapter";
|
||||||
DocumentSearchRequest,
|
import type { DocumentSearchRequest } from "@/types/search";
|
||||||
DocumentSearchResponse,
|
|
||||||
DocumentSearchResult,
|
|
||||||
DocumentSearchTimeRange,
|
|
||||||
} from "@/types/search";
|
|
||||||
import { buildSnippet } from "@/lib/search/snippet";
|
|
||||||
|
|
||||||
const MAX_LIMIT = 50;
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
const DEFAULT_FILTERS: DocumentSearchFilters = {
|
|
||||||
titleOnly: false,
|
|
||||||
exact: false,
|
|
||||||
onlyCurrentPage: false,
|
|
||||||
includeOcr: false,
|
|
||||||
timeRange: "any",
|
|
||||||
timeField: "updated",
|
|
||||||
};
|
|
||||||
|
|
||||||
const TIME_RANGE_TO_MS: Record<DocumentSearchTimeRange, number | null> = {
|
|
||||||
any: null,
|
|
||||||
"7d": 1000 * 60 * 60 * 24 * 7,
|
|
||||||
"30d": 1000 * 60 * 60 * 24 * 30,
|
|
||||||
};
|
|
||||||
|
|
||||||
const TIME_FIELD_COLUMN = {
|
|
||||||
updated: "updated_at",
|
|
||||||
created: "created_at",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type RangeIso = { fromIso: string | null; toIso: string | null };
|
|
||||||
|
|
||||||
function parseDateToIso(value: string | undefined): string | null {
|
|
||||||
const raw = String(value ?? "").trim();
|
|
||||||
if (!raw) return null;
|
|
||||||
const d = new Date(raw);
|
|
||||||
if (Number.isNaN(d.getTime())) return null;
|
|
||||||
return d.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveCustomRangeIso(filters: DocumentSearchFilters): RangeIso {
|
|
||||||
const fromIso = parseDateToIso(filters.customRange?.from);
|
|
||||||
const toIso = parseDateToIso(filters.customRange?.to);
|
|
||||||
return { fromIso, toIso };
|
|
||||||
}
|
|
||||||
|
|
||||||
type MindmapNode = { data?: { text?: unknown }; children?: unknown[] };
|
|
||||||
|
|
||||||
function extractTextFromMindmapData(data: unknown, maxChars = 60000): string {
|
|
||||||
const out: string[] = [];
|
|
||||||
const pushText = (value: unknown) => {
|
|
||||||
const s = typeof value === "string" ? value : null;
|
|
||||||
if (!s) return;
|
|
||||||
const trimmed = s.replace(/\s+/g, " ").trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
out.push(trimmed);
|
|
||||||
};
|
|
||||||
|
|
||||||
const walk = (node: unknown) => {
|
|
||||||
if (out.join("\n").length >= maxChars) return;
|
|
||||||
if (!node || typeof node !== "object") return;
|
|
||||||
const n = node as MindmapNode;
|
|
||||||
pushText(n.data?.text);
|
|
||||||
if (Array.isArray(n.children)) {
|
|
||||||
for (const c of n.children) {
|
|
||||||
walk(c);
|
|
||||||
if (out.join("\n").length >= maxChars) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
walk(data);
|
|
||||||
const joined = out.join("\n").trim();
|
|
||||||
return joined.length > maxChars ? `${joined.slice(0, maxChars)}…` : joined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
@@ -86,337 +13,23 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
|
||||||
const payload = (await request.json()) as DocumentSearchRequest;
|
const payload = (await request.json()) as DocumentSearchRequest;
|
||||||
const workspaceId = payload.workspaceId;
|
const { response, meta } = await executeDocumentSearchQuery({
|
||||||
|
request,
|
||||||
if (!workspaceId) {
|
payload,
|
||||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filters: DocumentSearchFilters = {
|
|
||||||
...DEFAULT_FILTERS,
|
|
||||||
...payload.filters,
|
|
||||||
};
|
|
||||||
|
|
||||||
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
|
|
||||||
const normalizedQuery = payload.query?.trim() ?? "";
|
|
||||||
const normalizedLower = normalizedQuery.toLowerCase();
|
|
||||||
|
|
||||||
const docs = await client.query(api.documents.listSearchDataByWorkspace, { workspaceId });
|
|
||||||
const docMap = new Map(docs.map((d) => [d.id, d]));
|
|
||||||
|
|
||||||
const recentRows = await client.query(api.recents.listByWorkspace, {
|
|
||||||
userId: auth.userId,
|
|
||||||
workspaceId,
|
|
||||||
limit: 10,
|
|
||||||
});
|
|
||||||
const recent: DocumentSearchResult[] = recentRows
|
|
||||||
.map((r) => docMap.get(r.document_id))
|
|
||||||
.filter((row): row is (typeof docs)[number] => Boolean(row))
|
|
||||||
.map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
title: row.title ?? "无标题",
|
|
||||||
snippet: "",
|
|
||||||
updatedAt: row.updated_at ?? null,
|
|
||||||
createdAt: row.created_at ?? null,
|
|
||||||
matchField: "recent",
|
|
||||||
hasOcr: false,
|
|
||||||
publicPath: `/documents/${row.id}`,
|
|
||||||
score: 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (!normalizedQuery) {
|
|
||||||
const response: DocumentSearchResponse = { results: [], recent };
|
|
||||||
return NextResponse.json(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeRangeMs = TIME_RANGE_TO_MS[filters.timeRange];
|
|
||||||
const timeField = TIME_FIELD_COLUMN[filters.timeField];
|
|
||||||
const boundaryIso =
|
|
||||||
typeof timeRangeMs === "number"
|
|
||||||
? new Date(Date.now() - timeRangeMs).toISOString()
|
|
||||||
: null;
|
|
||||||
const { fromIso, toIso } = resolveCustomRangeIso(filters);
|
|
||||||
|
|
||||||
const eligibleDocs = [...docs]
|
|
||||||
.sort((a, b) => {
|
|
||||||
const ta = a.updated_at ?? a.created_at ?? "";
|
|
||||||
const tb = b.updated_at ?? b.created_at ?? "";
|
|
||||||
return tb.localeCompare(ta);
|
|
||||||
})
|
|
||||||
.filter((row) => {
|
|
||||||
if (filters.onlyCurrentPage && payload.documentId) {
|
|
||||||
if (row.id !== payload.documentId) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (boundaryIso) {
|
|
||||||
const ts = (row as any)?.[timeField] ?? null;
|
|
||||||
if (!ts || typeof ts !== "string") return false;
|
|
||||||
if (ts < boundaryIso) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fromIso || toIso) {
|
|
||||||
const ts = (row as any)?.[timeField] ?? null;
|
|
||||||
if (!ts || typeof ts !== "string") return false;
|
|
||||||
if (fromIso && ts < fromIso) return false;
|
|
||||||
if (toIso && ts > toIso) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const eligibleDocIds = new Set(eligibleDocs.map((d) => d.id));
|
return NextResponse.json(response, {
|
||||||
|
headers: {
|
||||||
type MatchInfo = {
|
"x-request-id": meta.requestId,
|
||||||
score: number;
|
"x-trace-id": meta.traceId,
|
||||||
matchField: "title" | "content";
|
"x-query-name": meta.queryName,
|
||||||
snippet: string;
|
},
|
||||||
hasOcr: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const matches = new Map<string, MatchInfo>();
|
|
||||||
|
|
||||||
const upsertMatch = (docId: string, patch: Partial<MatchInfo>) => {
|
|
||||||
const prev = matches.get(docId);
|
|
||||||
if (!prev) {
|
|
||||||
const score = typeof patch.score === "number" ? patch.score : 0;
|
|
||||||
const matchField = patch.matchField ?? "content";
|
|
||||||
const snippet = patch.snippet ?? "暂无正文内容";
|
|
||||||
const hasOcr = Boolean(patch.hasOcr);
|
|
||||||
matches.set(docId, { score, matchField, snippet, hasOcr });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next: MatchInfo = {
|
|
||||||
score: typeof patch.score === "number" ? Math.max(prev.score, patch.score) : prev.score,
|
|
||||||
matchField: patch.matchField ?? prev.matchField,
|
|
||||||
snippet: patch.snippet ?? prev.snippet,
|
|
||||||
hasOcr: prev.hasOcr || Boolean(patch.hasOcr),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 若现有是标题匹配,不用较低分覆盖文案。
|
|
||||||
if (prev.matchField === "title" && next.matchField !== "title") {
|
|
||||||
next.matchField = "title";
|
|
||||||
next.snippet = prev.snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 若新分数更高,则用新片段(除标题外)
|
|
||||||
if (typeof patch.score === "number" && patch.score > prev.score && prev.matchField !== "title") {
|
|
||||||
next.snippet = patch.snippet ?? next.snippet;
|
|
||||||
next.matchField = patch.matchField ?? next.matchField;
|
|
||||||
}
|
|
||||||
|
|
||||||
matches.set(docId, next);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const row of eligibleDocs) {
|
|
||||||
const title = (row.title ?? "无标题").trim() || "无标题";
|
|
||||||
const titleLower = title.toLowerCase();
|
|
||||||
const hitTitle = filters.exact ? titleLower === normalizedLower : titleLower.includes(normalizedLower);
|
|
||||||
if (hitTitle) {
|
|
||||||
upsertMatch(row.id, {
|
|
||||||
score: 3,
|
|
||||||
matchField: "title",
|
|
||||||
snippet: buildSnippet(title, normalizedQuery),
|
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||||
}
|
}
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
if (filters.titleOnly) continue;
|
|
||||||
|
|
||||||
const rawText = String(row.raw_text ?? "").trim();
|
|
||||||
if (rawText) {
|
|
||||||
const hitContent = rawText.toLowerCase().includes(normalizedLower);
|
|
||||||
if (hitContent) {
|
|
||||||
upsertMatch(row.id, {
|
|
||||||
score: 2,
|
|
||||||
matchField: "content",
|
|
||||||
snippet: buildSnippet(rawText, normalizedQuery),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!filters.titleOnly) {
|
|
||||||
// 思维导图(默认纳入“正文”搜索)
|
|
||||||
const mindmaps = await client
|
|
||||||
.query(api.mindmaps.listByWorkspace, { workspaceId, includeDeleted: false })
|
|
||||||
.catch(() => []);
|
|
||||||
for (const m of mindmaps) {
|
|
||||||
const docId = String(m.document_id ?? "").trim();
|
|
||||||
if (!docId || !eligibleDocIds.has(docId)) continue;
|
|
||||||
if (!docMap.has(docId)) continue;
|
|
||||||
|
|
||||||
const text = extractTextFromMindmapData(m.data ?? null);
|
|
||||||
if (!text) continue;
|
|
||||||
if (!text.toLowerCase().includes(normalizedLower)) continue;
|
|
||||||
|
|
||||||
upsertMatch(docId, {
|
|
||||||
score: 1.6,
|
|
||||||
matchField: "content",
|
|
||||||
snippet: buildSnippet(`思维导图:${text}`, normalizedQuery),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Luckysheet 在线表格(标题 + 行内容)
|
|
||||||
const [tables, tableRows] = await Promise.all([
|
|
||||||
client
|
|
||||||
.query(api.tables.listByWorkspaceForSearch, {
|
|
||||||
userId: auth.userId,
|
|
||||||
workspaceId,
|
|
||||||
includeArchived: false,
|
|
||||||
limit: 3000,
|
|
||||||
})
|
|
||||||
.catch(() => []),
|
|
||||||
client
|
|
||||||
.query(api.tables.listRowsByWorkspaceForSearch, {
|
|
||||||
userId: auth.userId,
|
|
||||||
workspaceId,
|
|
||||||
limit: 8000,
|
|
||||||
})
|
|
||||||
.catch(() => []),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const tableTitleById = new Map<string, string>();
|
|
||||||
for (const t of tables) {
|
|
||||||
if (!eligibleDocIds.has(t.document_id)) continue;
|
|
||||||
tableTitleById.set(t.id, String(t.title ?? "").trim() || "未命名表格");
|
|
||||||
|
|
||||||
const hitTableTitle = String(t.title ?? "").toLowerCase().includes(normalizedLower);
|
|
||||||
if (hitTableTitle && docMap.has(t.document_id)) {
|
|
||||||
upsertMatch(t.document_id, {
|
|
||||||
score: 1.5,
|
|
||||||
matchField: "content",
|
|
||||||
snippet: buildSnippet(`表格:${t.title}`, normalizedQuery),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const r of tableRows) {
|
|
||||||
const docId = String(r.document_id ?? "").trim();
|
|
||||||
if (!docId || !eligibleDocIds.has(docId)) continue;
|
|
||||||
if (!docMap.has(docId)) continue;
|
|
||||||
const rowHash = String(r.row_hash ?? "").trim();
|
|
||||||
if (!rowHash) continue;
|
|
||||||
if (!rowHash.toLowerCase().includes(normalizedLower)) continue;
|
|
||||||
|
|
||||||
const tableTitle = tableTitleById.get(String(r.table_id ?? "").trim()) ?? "未命名表格";
|
|
||||||
upsertMatch(docId, {
|
|
||||||
score: 1.4,
|
|
||||||
matchField: "content",
|
|
||||||
snippet: buildSnippet(`表格:${tableTitle}\n${rowHash}`, normalizedQuery),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 附件/图片:默认搜索文件名;勾选“搜索附件内容”后再纳入 OCR/解析文本。
|
|
||||||
const assets = await client
|
|
||||||
.query(api.mediaAssets.listSearchDataByWorkspace, {
|
|
||||||
userId: auth.userId,
|
|
||||||
workspaceId,
|
|
||||||
includeDeleted: false,
|
|
||||||
limit: 5000,
|
|
||||||
})
|
|
||||||
.catch(() => []);
|
|
||||||
|
|
||||||
const toEnqueueExtract: { id: string; mime: string | null; name: string | null; status: string | null; type: string | null }[] = [];
|
|
||||||
|
|
||||||
for (const a of assets) {
|
|
||||||
const docId = String(a.document_id ?? "").trim();
|
|
||||||
if (!docId || !eligibleDocIds.has(docId)) continue;
|
|
||||||
if (!docMap.has(docId)) continue;
|
|
||||||
|
|
||||||
const fileName = String(a.file_name ?? "").trim();
|
|
||||||
if (fileName && fileName.toLowerCase().includes(normalizedLower)) {
|
|
||||||
upsertMatch(docId, {
|
|
||||||
score: 1.2,
|
|
||||||
matchField: "content",
|
|
||||||
snippet: buildSnippet(`附件:${fileName}`, normalizedQuery),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!filters.includeOcr) continue;
|
|
||||||
|
|
||||||
// 说明:勾选“搜索附件内容”时,若附件尚未生成 ocr_text,则后台排队解析(避免用户长期搜不到)。
|
|
||||||
const ocrTextValue = String(a.ocr_text ?? "").trim();
|
|
||||||
if (!ocrTextValue) {
|
|
||||||
const assetType = String((a as any).asset_type ?? "").trim() || null;
|
|
||||||
const mimeType = String((a as any).mime_type ?? "").toLowerCase().trim() || null;
|
|
||||||
const ocrStatus = String((a as any).ocr_status ?? "").trim() || null;
|
|
||||||
const nameLower = String(a.file_name ?? "").toLowerCase().trim() || null;
|
|
||||||
|
|
||||||
const supported =
|
|
||||||
assetType === "file" &&
|
|
||||||
(mimeType === "application/pdf" ||
|
|
||||||
mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
|
|
||||||
mimeType === "application/vnd.openxmlformats-officedocument.presentationml.presentation" ||
|
|
||||||
mimeType === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
|
|
||||||
(nameLower ? [".pdf", ".docx", ".pptx", ".xlsx"].some((ext) => nameLower.endsWith(ext)) : false));
|
|
||||||
|
|
||||||
const busy = ocrStatus === "queued" || ocrStatus === "running";
|
|
||||||
if (supported && !busy) {
|
|
||||||
toEnqueueExtract.push({ id: a.id, mime: mimeType, name: a.file_name ?? null, status: ocrStatus, type: assetType });
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ocrTextValue.toLowerCase().includes(normalizedLower)) continue;
|
|
||||||
|
|
||||||
upsertMatch(docId, {
|
|
||||||
score: 1.7,
|
|
||||||
matchField: "content",
|
|
||||||
snippet: buildSnippet(`附件:${fileName || a.id}\n${ocrTextValue}`, normalizedQuery),
|
|
||||||
hasOcr: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toEnqueueExtract.length > 0) {
|
|
||||||
// 说明:限制每次搜索触发的解析数量,避免请求变慢;剩余附件可继续通过下一次搜索逐步排队。
|
|
||||||
await Promise.all(
|
|
||||||
toEnqueueExtract
|
|
||||||
.slice(0, 3)
|
|
||||||
.map((item) =>
|
|
||||||
client.mutation(api.mediaAssets.enqueueExtractText, { userId: auth.userId, id: item.id }).catch(() => null),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: DocumentSearchResult[] = [];
|
|
||||||
for (const [docId, match] of matches.entries()) {
|
|
||||||
const row = docMap.get(docId);
|
|
||||||
if (!row) continue;
|
|
||||||
results.push({
|
|
||||||
id: row.id,
|
|
||||||
title: row.title ?? "无标题",
|
|
||||||
snippet: match.snippet,
|
|
||||||
updatedAt: row.updated_at ?? null,
|
|
||||||
createdAt: row.created_at ?? null,
|
|
||||||
matchField: match.matchField,
|
|
||||||
hasOcr: match.hasOcr,
|
|
||||||
publicPath: `/documents/${row.id}`,
|
|
||||||
score: match.score,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
results.sort((a, b) => {
|
|
||||||
if (b.score !== a.score) return b.score - a.score;
|
|
||||||
const ta = a.updatedAt ?? a.createdAt ?? "";
|
|
||||||
const tb = b.updatedAt ?? b.createdAt ?? "";
|
|
||||||
const byTime = tb.localeCompare(ta);
|
|
||||||
if (byTime) return byTime;
|
|
||||||
return String(a.title ?? "").localeCompare(String(b.title ?? ""));
|
|
||||||
});
|
|
||||||
|
|
||||||
const limitedResults = results.slice(0, limit);
|
|
||||||
|
|
||||||
const response: DocumentSearchResponse = { results: limitedResults, recent };
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof HttpError) {
|
|
||||||
return NextResponse.json({ error: err.message }, { status: err.status });
|
|
||||||
}
|
|
||||||
const message = err instanceof Error ? err.message : "搜索失败,请稍后再试";
|
|
||||||
console.error("[search/documents] failed:", err);
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
import {
|
import {
|
||||||
buildDocumentBridgeContext,
|
buildDocumentBridgeContext,
|
||||||
buildDocumentQueryEnvelope,
|
buildDocumentQueryEnvelope,
|
||||||
documentBridgeErrorResponse,
|
documentBridgeErrorResponse,
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
executeRustBridgeQueryTransport,
|
||||||
|
resolveRustBridgeQueryPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
import {
|
import {
|
||||||
buildSidebarDatasetListQueryPayload,
|
buildSidebarDatasetListQueryPayload,
|
||||||
|
mapSidebarDatasetListQueryResultToInitialData,
|
||||||
|
type SidebarDatasetListQueryResult,
|
||||||
} from "@/lib/sidebar-data";
|
} from "@/lib/sidebar-data";
|
||||||
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -19,14 +26,11 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const workspaceIdParam = url.searchParams.get("workspaceId");
|
const workspaceIdParam = url.searchParams.get("workspaceId");
|
||||||
const {
|
const bootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||||
targetWorkspaceId,
|
|
||||||
sidebarInitialData,
|
|
||||||
} = await loadSidebarDataFromConvex({
|
|
||||||
client,
|
|
||||||
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
||||||
requestedWorkspaceId: workspaceIdParam,
|
workspaceIdIfCreate: randomUUID(),
|
||||||
});
|
});
|
||||||
|
const targetWorkspaceId = workspaceIdParam?.trim() || bootstrap.activeWorkspaceId || null;
|
||||||
|
|
||||||
if (!targetWorkspaceId) {
|
if (!targetWorkspaceId) {
|
||||||
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
|
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
|
||||||
@@ -41,12 +45,18 @@ export async function GET(request: Request) {
|
|||||||
name: "sidebar.dataset.list",
|
name: "sidebar.dataset.list",
|
||||||
payload: buildSidebarDatasetListQueryPayload(targetWorkspaceId),
|
payload: buildSidebarDatasetListQueryPayload(targetWorkspaceId),
|
||||||
});
|
});
|
||||||
if (!sidebarInitialData) {
|
const plan = await resolveRustBridgeQueryPlan({
|
||||||
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
|
context: bridgeContext,
|
||||||
}
|
envelope,
|
||||||
|
});
|
||||||
|
const sidebarDataset = await executeRustBridgeQueryTransport<SidebarDatasetListQueryResult>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
const result = mapSidebarDatasetListQueryResultToInitialData(sidebarDataset);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
...sidebarInitialData,
|
...result,
|
||||||
meta: {
|
meta: {
|
||||||
requestId: bridgeContext.requestId,
|
requestId: bridgeContext.requestId,
|
||||||
traceId: bridgeContext.traceId,
|
traceId: bridgeContext.traceId,
|
||||||
|
|||||||
@@ -3,10 +3,17 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { OnlyOfficeAiAgentPanel } from "@/components/onlyoffice/OnlyOfficeAiAgentPanel";
|
import { OnlyOfficeAiAgentPanel } from "@/components/onlyoffice/OnlyOfficeAiAgentPanel";
|
||||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
|
||||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||||
import { useConvex } from "convex/react";
|
import { useConvex } from "convex/react";
|
||||||
import { api } from "@/lib/convex/api";
|
import { api } from "@/lib/convex/api";
|
||||||
|
import {
|
||||||
|
buildOnlyOfficeCallbackUrl,
|
||||||
|
buildOnlyOfficeForcesaveUrl,
|
||||||
|
buildOnlyOfficeOpenFileId,
|
||||||
|
docTypeFromExt,
|
||||||
|
hashOnlyOfficeKey,
|
||||||
|
resolveOnlyOfficeDocumentUrl,
|
||||||
|
} from "@/lib/onlyoffice/client-session";
|
||||||
|
|
||||||
type EditorMode = "view" | "edit";
|
type EditorMode = "view" | "edit";
|
||||||
|
|
||||||
@@ -146,25 +153,6 @@ const loadScriptCandidates = async (candidates: string[]) => {
|
|||||||
throw lastError ?? new Error("加载 ONLYOFFICE 脚本失败");
|
throw lastError ?? new Error("加载 ONLYOFFICE 脚本失败");
|
||||||
};
|
};
|
||||||
|
|
||||||
const hashKey = (input: string) => {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < input.length; i += 1) {
|
|
||||||
hash = (hash << 5) - hash + input.charCodeAt(i);
|
|
||||||
hash |= 0;
|
|
||||||
}
|
|
||||||
return Math.abs(hash).toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const base64UrlEncodeUtf8 = (input: string) => {
|
|
||||||
// 说明:浏览器端 base64url(UTF-8)编码,用于把带 `?token=...` 的 URL 藏到 `u=...` 里。
|
|
||||||
const bytes = new TextEncoder().encode(input);
|
|
||||||
let binary = "";
|
|
||||||
for (let i = 0; i < bytes.length; i += 1) {
|
|
||||||
binary += String.fromCharCode(bytes[i] as number);
|
|
||||||
}
|
|
||||||
return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
||||||
};
|
|
||||||
|
|
||||||
const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUrlDesktop?: string | null) => {
|
const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUrlDesktop?: string | null) => {
|
||||||
// 说明:当我们用 `/onlyoffice-server` 反代 ONLYOFFICE 时,编辑器运行期仍可能发起指向
|
// 说明:当我们用 `/onlyoffice-server` 反代 ONLYOFFICE 时,编辑器运行期仍可能发起指向
|
||||||
// `http://127.0.0.1:8081/cache/...` 的绝对请求(来自 ONLYOFFICE 内部),导致浏览器跨域被 CORS 拦截。
|
// `http://127.0.0.1:8081/cache/...` 的绝对请求(来自 ONLYOFFICE 内部),导致浏览器跨域被 CORS 拦截。
|
||||||
@@ -348,19 +336,6 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const docTypeFromExt = (ext: string) => {
|
|
||||||
const word = ["doc", "docx", "odt", "rtf"];
|
|
||||||
const slide = ["ppt", "pptx", "odp"];
|
|
||||||
const sheet = ["xls", "xlsx", "ods", "csv"];
|
|
||||||
const pdf = ["pdf"];
|
|
||||||
// 说明:ONLYOFFICE 文档类型使用 word/cell/slide/pdf(旧的 text/spreadsheet/presentation 已逐步弃用)
|
|
||||||
if (word.includes(ext)) return "word";
|
|
||||||
if (slide.includes(ext)) return "slide";
|
|
||||||
if (sheet.includes(ext)) return "cell";
|
|
||||||
if (pdf.includes(ext)) return "pdf";
|
|
||||||
return "word";
|
|
||||||
};
|
|
||||||
|
|
||||||
const waitForDocEditorReady = async (timeoutMs = 120_000) => {
|
const waitForDocEditorReady = async (timeoutMs = 120_000) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
@@ -689,88 +664,18 @@ export default function OnlyOfficePage() {
|
|||||||
const sid = String(assetStorageId || "").trim();
|
const sid = String(assetStorageId || "").trim();
|
||||||
// 说明:ONLYOFFICE 的 document.key 允许字符集为 [0-9a-zA-Z_.=-],不包含 ":" 等字符;
|
// 说明:ONLYOFFICE 的 document.key 允许字符集为 [0-9a-zA-Z_.=-],不包含 ":" 等字符;
|
||||||
// 否则可能报错(例如 errorCode=-23)。这里用 hash 生成安全 key,同时在 storage_id 变化时自动失效。
|
// 否则可能报错(例如 errorCode=-23)。这里用 hash 生成安全 key,同时在 storage_id 变化时自动失效。
|
||||||
return sid ? `${assetId}_${hashKey(sid)}` : assetId;
|
return sid ? `${assetId}_${hashOnlyOfficeKey(sid)}` : assetId;
|
||||||
}
|
}
|
||||||
return hashKey(`${effectiveFileUrl}-${fileName}`);
|
return hashOnlyOfficeKey(`${effectiveFileUrl}-${fileName}`);
|
||||||
}, [assetId, assetStorageId, effectiveFileUrl, fileName]);
|
}, [assetId, assetStorageId, effectiveFileUrl, fileName]);
|
||||||
const resolvedFileUrl = useMemo(() => {
|
const resolvedFileUrl = useMemo(() => {
|
||||||
if (!effectiveFileUrl) return "";
|
return resolveOnlyOfficeDocumentUrl({
|
||||||
// 说明:OnlyOffice 的 document.url 由“文档服务器”拉取(不是浏览器)。
|
effectiveFileUrl,
|
||||||
// 如果我们已经配置了专用回源(storageHostOverride),就不要再把 URL 改写成公网,
|
proxyOrigin,
|
||||||
// 否则会把 http://host.docker.internal:18000 错误改成 https://host.docker.internal:18000,
|
storageHostOverride,
|
||||||
// 导致 ONLYOFFICE 报 “下载失败(EPROTO wrong version number)”。
|
runtimeSupabaseUrl: runtimeConfig.supabaseUrl || "",
|
||||||
const isConvexStorageUrl = (() => {
|
useConvex: Boolean(runtimeConfig.useConvex),
|
||||||
// 说明:Convex Files 的直链通常形如:
|
});
|
||||||
// - http://127.0.0.1:3210/api/storage/<id>
|
|
||||||
// - https://<convex-host>/api/storage/<id>
|
|
||||||
// 这类 URL 不应套用 Supabase 的 rewriteToPublicOrigin,否则会被误改写到 supabaseInternalUrl(例如 18000),
|
|
||||||
// 进而导致 ONLYOFFICE 报 “下载失败(-4)”。
|
|
||||||
try {
|
|
||||||
const u = new URL(effectiveFileUrl);
|
|
||||||
return u.pathname.startsWith("/api/storage/");
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
let base =
|
|
||||||
storageHostOverride || runtimeConfig.useConvex || isConvexStorageUrl
|
|
||||||
? effectiveFileUrl
|
|
||||||
: rewriteToPublicOrigin(effectiveFileUrl, runtimeConfig.supabaseUrl);
|
|
||||||
try {
|
|
||||||
// 关键兜底:即使外部传进来的 fileUrl 是 Supabase signedUrl(含 token=...),也要避免 token 参数
|
|
||||||
// 出现在 document.url 上,否则 ONLYOFFICE 会把它当作 JWT 去解析并报
|
|
||||||
// “文档安全令牌格式不正确 / invalid compact jws / invalid signature”。
|
|
||||||
const raw = new URL(base);
|
|
||||||
let alreadyProxy = raw.pathname.includes("/api/onlyoffice/proxy");
|
|
||||||
|
|
||||||
const isLocalHost =
|
|
||||||
raw.hostname === "127.0.0.1" || raw.hostname === "localhost" || raw.hostname === "host.docker.internal";
|
|
||||||
|
|
||||||
// 关键修复:当 fileUrl 已经是 /api/onlyoffice/proxy,但来源是外网 https(例如 frp/隧道域名)时,
|
|
||||||
// ONLYOFFICE 容器会去请求该 https 地址并因证书/自签失败,从而报“下载失败(-4)”。
|
|
||||||
// 因此这里强制把 proxy 的 origin 改写成我们显式配置的回源(通常是容器可达的 http://172.31.224.1:3000)。
|
|
||||||
if (alreadyProxy && proxyOrigin) {
|
|
||||||
const po = new URL(proxyOrigin);
|
|
||||||
raw.protocol = po.protocol;
|
|
||||||
raw.host = po.host;
|
|
||||||
base = raw.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关键兜底:OnlyOffice 的 document.url 由“文档服务器容器”去拉取。
|
|
||||||
// 如果这里是 localhost/127.0.0.1(对容器而言指向它自己),会导致“下载失败(-4)”。
|
|
||||||
// 因此在配置了 proxyOrigin 时,强制走 /api/onlyoffice/proxy 把回源留给 Next 服务端完成。
|
|
||||||
if (!alreadyProxy && proxyOrigin && isLocalHost) {
|
|
||||||
const proxyBase = proxyOrigin || window.location.origin;
|
|
||||||
const proxy = new URL("/api/onlyoffice/proxy", proxyBase);
|
|
||||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
|
|
||||||
base = proxy.toString();
|
|
||||||
alreadyProxy = true;
|
|
||||||
}
|
|
||||||
if (!alreadyProxy && raw.searchParams.has("token")) {
|
|
||||||
const proxyBase = proxyOrigin || window.location.origin;
|
|
||||||
const proxy = new URL("/api/onlyoffice/proxy", proxyBase);
|
|
||||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
|
|
||||||
base = proxy.toString();
|
|
||||||
alreadyProxy = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const u = new URL(base);
|
|
||||||
if (!storageHostOverride || alreadyProxy) return u.toString();
|
|
||||||
|
|
||||||
// 兼容两种写法:hostname 或完整 origin(https://xxx)
|
|
||||||
if (/^https?:\/\//i.test(storageHostOverride)) {
|
|
||||||
const ov = new URL(storageHostOverride);
|
|
||||||
u.protocol = ov.protocol;
|
|
||||||
u.host = ov.host;
|
|
||||||
return u.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
u.hostname = storageHostOverride;
|
|
||||||
return u.toString();
|
|
||||||
} catch {
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
}, [effectiveFileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl, runtimeConfig.useConvex]);
|
}, [effectiveFileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl, runtimeConfig.useConvex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -933,11 +838,13 @@ export default function OnlyOfficePage() {
|
|||||||
// 说明:ONLYOFFICE 文档服务器会通过 callbackUrl 回传保存事件,
|
// 说明:ONLYOFFICE 文档服务器会通过 callbackUrl 回传保存事件,
|
||||||
// 我们在 /api/onlyoffice/callback 中接收并回写到 Supabase Storage。
|
// 我们在 /api/onlyoffice/callback 中接收并回写到 Supabase Storage。
|
||||||
callbackUrl: (() => {
|
callbackUrl: (() => {
|
||||||
const base = callbackOrigin || proxyOrigin || window.location.origin;
|
return buildOnlyOfficeCallbackUrl({
|
||||||
const cb = new URL("/api/onlyoffice/callback", base);
|
callbackOrigin,
|
||||||
if (assetId) cb.searchParams.set("assetId", assetId);
|
proxyOrigin,
|
||||||
if (authedUserId) cb.searchParams.set("userId", authedUserId);
|
windowOrigin: window.location.origin,
|
||||||
return cb.toString();
|
assetId,
|
||||||
|
userId: authedUserId,
|
||||||
|
});
|
||||||
})(),
|
})(),
|
||||||
customization: {
|
customization: {
|
||||||
feedback: { visible: false },
|
feedback: { visible: false },
|
||||||
@@ -1068,10 +975,14 @@ export default function OnlyOfficePage() {
|
|||||||
if (forceSaveState.busy) return;
|
if (forceSaveState.busy) return;
|
||||||
setForceSaveState({ busy: true, message: "正在触发同步保存…", ok: null });
|
setForceSaveState({ busy: true, message: "正在触发同步保存…", ok: null });
|
||||||
try {
|
try {
|
||||||
const url = new URL("/api/onlyoffice/forcesave", window.location.origin);
|
const r = await fetch(
|
||||||
url.searchParams.set("assetId", assetId);
|
buildOnlyOfficeForcesaveUrl({
|
||||||
url.searchParams.set("key", docKey);
|
windowOrigin: window.location.origin,
|
||||||
const r = await fetch(url.toString(), { method: "POST" });
|
assetId,
|
||||||
|
key: docKey,
|
||||||
|
}),
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const payload = (await r.json().catch(() => null)) as { error?: string } | null;
|
const payload = (await r.json().catch(() => null)) as { error?: string } | null;
|
||||||
throw new Error(payload?.error ?? "触发失败");
|
throw new Error(payload?.error ?? "触发失败");
|
||||||
@@ -1129,7 +1040,12 @@ export default function OnlyOfficePage() {
|
|||||||
)}
|
)}
|
||||||
<OnlyOfficeAiAgentPanel
|
<OnlyOfficeAiAgentPanel
|
||||||
openFile={{
|
openFile={{
|
||||||
id: assetId || `onlyoffice_${docKey || hashKey(`${resolvedFileUrl}-${fileName}`)}`,
|
id: buildOnlyOfficeOpenFileId({
|
||||||
|
assetId,
|
||||||
|
docKey,
|
||||||
|
resolvedFileUrl,
|
||||||
|
fileName,
|
||||||
|
}),
|
||||||
title: fileName,
|
title: fileName,
|
||||||
fileUrl: resolvedFileUrl,
|
fileUrl: resolvedFileUrl,
|
||||||
mimeType: null,
|
mimeType: null,
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export {
|
||||||
|
createBlockSnapshotOpsAdapter as createDocBlockOpsAdapter,
|
||||||
|
type BlockSnapshot as DocBlockSnapshot,
|
||||||
|
} from "@/lib/blocks/block-ops-adapter";
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createDocServerTools } from "./docServerTools";
|
||||||
|
|
||||||
|
describe("createDocServerTools", () => {
|
||||||
|
it("doc_insert_blocks 和 doc_replace_range 只操作快照,不直连 loadBlocks", async () => {
|
||||||
|
const baseBlocks = [
|
||||||
|
{
|
||||||
|
id: "block_1",
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "hello" }],
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const loadBlocks = vi.fn(async () => ({ blocks: baseBlocks, source: "client" }));
|
||||||
|
const tools = createDocServerTools({
|
||||||
|
ctx: { documentId: "doc_1", userId: "user_1", baseBlocks },
|
||||||
|
allowedToolIds: new Set(["doc_insert_blocks", "doc_replace_range", "doc_get"]),
|
||||||
|
loadBlocks,
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertResult = await tools.run("doc_insert_blocks", {
|
||||||
|
afterBlockId: "block_1",
|
||||||
|
blocks: [{ type: "heading", text: "新标题", level: 2 }],
|
||||||
|
});
|
||||||
|
expect(insertResult.ok).toBe(true);
|
||||||
|
expect(loadBlocks).not.toHaveBeenCalled();
|
||||||
|
expect(Array.isArray((insertResult as any).data)).toBe(true);
|
||||||
|
expect((insertResult as any).data).toHaveLength(2);
|
||||||
|
|
||||||
|
const replaceResult = await tools.run("doc_replace_range", {
|
||||||
|
blockId: "block_1",
|
||||||
|
text: " world",
|
||||||
|
mode: "append",
|
||||||
|
});
|
||||||
|
expect(replaceResult.ok).toBe(true);
|
||||||
|
expect((replaceResult as any).data).toHaveLength(1);
|
||||||
|
expect(((replaceResult as any).data[0] as any).content?.[0]?.text).toBe("helloworld");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,14 @@
|
|||||||
import type { Json } from "@/types/supabase";
|
import { createDocBlockOpsAdapter } from "./blockOpsAdapter";
|
||||||
|
|
||||||
|
export const DOC_TOOL_IDS = ["doc_get", "doc_find", "doc_insert_blocks", "doc_replace_range"] as const;
|
||||||
|
|
||||||
|
export type DocToolId = (typeof DOC_TOOL_IDS)[number];
|
||||||
|
|
||||||
|
export const isDocToolId = (toolId: string): toolId is DocToolId =>
|
||||||
|
(DOC_TOOL_IDS as readonly string[]).includes(toolId);
|
||||||
|
|
||||||
|
export const getDocToolInvocationKind = (toolId: DocToolId) =>
|
||||||
|
toolId === "doc_get" || toolId === "doc_find" ? "query" : "command";
|
||||||
|
|
||||||
export type DocToolContext = {
|
export type DocToolContext = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -36,12 +46,6 @@ type DocBlockSummary = {
|
|||||||
childCount: number;
|
childCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DocBlockSpec = {
|
|
||||||
type: "paragraph" | "heading";
|
|
||||||
text: string;
|
|
||||||
level?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||||
|
|
||||||
@@ -85,50 +89,6 @@ const walkSummaries = (rootBlocks: unknown[], maxNodes: number): DocBlockSummary
|
|||||||
return list;
|
return list;
|
||||||
};
|
};
|
||||||
|
|
||||||
const findContainerById = (
|
|
||||||
blocks: unknown[],
|
|
||||||
targetId: string,
|
|
||||||
): { container: unknown[]; index: number } | null => {
|
|
||||||
const id = String(targetId || "").trim();
|
|
||||||
if (!id) return null;
|
|
||||||
for (let i = 0; i < blocks.length; i += 1) {
|
|
||||||
const b = blocks[i];
|
|
||||||
const bid = String(getValue(b, "id") ?? "").trim();
|
|
||||||
if (bid === id) return { container: blocks, index: i };
|
|
||||||
const childrenRaw = getValue(b, "children");
|
|
||||||
const children = Array.isArray(childrenRaw) ? childrenRaw : [];
|
|
||||||
const found = findContainerById(children, id);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createTextContent = (text: string): unknown[] => [{ type: "text", text }];
|
|
||||||
|
|
||||||
const generateId = () => {
|
|
||||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
return `bn_${Math.random().toString(36).slice(2, 10)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildBlockFromSpec = (spec: DocBlockSpec): Record<string, unknown> => {
|
|
||||||
const type = spec.type;
|
|
||||||
const text = String(spec.text ?? "").trim();
|
|
||||||
const base: Record<string, unknown> = {
|
|
||||||
id: generateId(),
|
|
||||||
type,
|
|
||||||
props: {},
|
|
||||||
content: createTextContent(text),
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
if (type === "heading") {
|
|
||||||
const level = Number(spec.level ?? 2);
|
|
||||||
base.props = { level: Math.max(1, Math.min(5, Number.isFinite(level) ? Math.floor(level) : 2)) };
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadDocumentBlocks = async (supabase: DocSupabaseClient, ctx: DocToolContext) => {
|
const loadDocumentBlocks = async (supabase: DocSupabaseClient, ctx: DocToolContext) => {
|
||||||
const base = normalizeBlocks(ctx.baseBlocks);
|
const base = normalizeBlocks(ctx.baseBlocks);
|
||||||
if (base.length > 0) {
|
if (base.length > 0) {
|
||||||
@@ -149,41 +109,24 @@ const loadDocumentBlocks = async (supabase: DocSupabaseClient, ctx: DocToolConte
|
|||||||
return { blocks, source: "db" as const };
|
return { blocks, source: "db" as const };
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveDocumentBlocks = async (supabase: DocSupabaseClient, ctx: DocToolContext, blocks: unknown[]) => {
|
|
||||||
const resp = (await (supabase
|
|
||||||
.from("documents")
|
|
||||||
.update({ content: blocks as unknown as Json })
|
|
||||||
.eq("id", ctx.documentId)
|
|
||||||
.eq("user_id", ctx.userId) as unknown as Promise<{ error: unknown }>)) ?? { error: null };
|
|
||||||
const error = resp.error;
|
|
||||||
if (error) {
|
|
||||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "保存文档失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createDocServerTools = (args: {
|
export const createDocServerTools = (args: {
|
||||||
supabase?: DocSupabaseClient;
|
supabase?: DocSupabaseClient;
|
||||||
ctx: DocToolContext;
|
ctx: DocToolContext;
|
||||||
allowedToolIds: Set<string>;
|
allowedToolIds: Set<string>;
|
||||||
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
|
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
|
||||||
loadBlocks?: () => Promise<{ blocks: unknown[]; source: string }>;
|
loadBlocks?: () => Promise<{ blocks: unknown[]; source: string }>;
|
||||||
saveBlocks?: (blocks: unknown[]) => Promise<void>;
|
|
||||||
}) => {
|
}) => {
|
||||||
|
const blockOps = createDocBlockOpsAdapter({
|
||||||
|
baseBlocks: args.ctx.baseBlocks,
|
||||||
|
loadBlocks: args.loadBlocks,
|
||||||
|
});
|
||||||
|
|
||||||
const loadBlocks = async () => {
|
const loadBlocks = async () => {
|
||||||
if (args.loadBlocks) return await args.loadBlocks();
|
if (args.loadBlocks) return await args.loadBlocks();
|
||||||
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadBlocks/saveBlocks)");
|
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadBlocks/saveBlocks)");
|
||||||
return await loadDocumentBlocks(args.supabase, args.ctx);
|
return await loadDocumentBlocks(args.supabase, args.ctx);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveBlocks = async (blocks: unknown[]) => {
|
|
||||||
if (args.saveBlocks) {
|
|
||||||
await args.saveBlocks(blocks);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadBlocks/saveBlocks)");
|
|
||||||
await saveDocumentBlocks(args.supabase, args.ctx, blocks);
|
|
||||||
};
|
|
||||||
|
|
||||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||||
if (!args.allowedToolIds.has(toolId)) {
|
if (!args.allowedToolIds.has(toolId)) {
|
||||||
throw new Error(`工具未被允许:${toolId}`);
|
throw new Error(`工具未被允许:${toolId}`);
|
||||||
@@ -216,35 +159,27 @@ export const createDocServerTools = (args: {
|
|||||||
if (!Array.isArray(specsRaw) || specsRaw.length === 0) throw new Error("缺少 blocks");
|
if (!Array.isArray(specsRaw) || specsRaw.length === 0) throw new Error("缺少 blocks");
|
||||||
if (specsRaw.length > 20) throw new Error("blocks 过多(最多 20)");
|
if (specsRaw.length > 20) throw new Error("blocks 过多(最多 20)");
|
||||||
|
|
||||||
const specs: DocBlockSpec[] = specsRaw.map((x) => {
|
const specs = specsRaw.map((x) => {
|
||||||
const t = isRecord(x) ? String(x.type ?? "paragraph") : "paragraph";
|
const t = isRecord(x) ? String(x.type ?? "paragraph") : "paragraph";
|
||||||
const text = isRecord(x) ? String(x.text ?? "") : "";
|
const text = isRecord(x) ? String(x.text ?? "") : "";
|
||||||
const level = isRecord(x) ? Number(x.level ?? 2) : 2;
|
const level = isRecord(x) ? Number(x.level ?? 2) : 2;
|
||||||
return { type: t === "heading" ? "heading" : "paragraph", text, level };
|
return {
|
||||||
|
type: (t === "heading" ? "heading" : "paragraph") as "heading" | "paragraph",
|
||||||
|
text,
|
||||||
|
level,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const created = specs.map(buildBlockFromSpec);
|
const { data, inserted, source } = await blockOps.insertBlocks({
|
||||||
|
afterBlockId: afterBlockId || undefined,
|
||||||
const { blocks, source } = await loadBlocks();
|
beforeBlockId: beforeBlockId || undefined,
|
||||||
const targetId = beforeBlockId || afterBlockId;
|
blocks: specs,
|
||||||
const found = targetId ? findContainerById(blocks, targetId) : null;
|
});
|
||||||
if (targetId && !found) {
|
|
||||||
throw new Error(`未找到 blockId:${targetId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!found) {
|
|
||||||
blocks.push(...created);
|
|
||||||
} else {
|
|
||||||
const insertAt = beforeBlockId ? found.index : found.index + 1;
|
|
||||||
found.container.splice(insertAt, 0, ...created);
|
|
||||||
}
|
|
||||||
|
|
||||||
await saveBlocks(blocks);
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
source,
|
source,
|
||||||
inserted: created.map((b) => String(b.id ?? "")),
|
inserted,
|
||||||
data: blocks,
|
data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,17 +191,12 @@ export const createDocServerTools = (args: {
|
|||||||
const modeRaw = String(toolArgs.mode ?? "replace").trim();
|
const modeRaw = String(toolArgs.mode ?? "replace").trim();
|
||||||
const mode = modeRaw === "append" || modeRaw === "prepend" ? modeRaw : "replace";
|
const mode = modeRaw === "append" || modeRaw === "prepend" ? modeRaw : "replace";
|
||||||
|
|
||||||
const { blocks, source } = await loadBlocks();
|
const { data, source } = await blockOps.replaceRange({
|
||||||
const found = findContainerById(blocks, blockId);
|
blockId,
|
||||||
if (!found) throw new Error(`未找到 blockId:${blockId}`);
|
text,
|
||||||
const block = found.container[found.index];
|
mode,
|
||||||
if (!isRecord(block)) throw new Error(`block 数据异常:${blockId}`);
|
});
|
||||||
const prevText = extractInlineText(block);
|
return { ok: true, source, blockId, mode, data };
|
||||||
const nextText = mode === "append" ? `${prevText}${text}` : mode === "prepend" ? `${text}${prevText}` : text;
|
|
||||||
found.container[found.index] = { ...block, content: createTextContent(nextText) };
|
|
||||||
|
|
||||||
await saveBlocks(blocks);
|
|
||||||
return { ok: true, source, blockId, mode, data: blocks };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`未知工具:${toolId}`);
|
throw new Error(`未知工具:${toolId}`);
|
||||||
|
|||||||
@@ -37,10 +37,9 @@ export const createMediaServerTools = (args: {
|
|||||||
loadById?: (id: string) => Promise<unknown | null>;
|
loadById?: (id: string) => Promise<unknown | null>;
|
||||||
loadByFileUrl?: (fileUrl: string) => Promise<unknown | null>;
|
loadByFileUrl?: (fileUrl: string) => Promise<unknown | null>;
|
||||||
}) => {
|
}) => {
|
||||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
const resolveImageReadTransport = async (toolArgs: Record<string, unknown>) => {
|
||||||
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
|
if (!args.allowedToolIds.has("image_read")) throw new Error("工具未被允许:image_read");
|
||||||
|
|
||||||
if (toolId === "image_read") {
|
|
||||||
const assetId = String(toolArgs.assetId ?? "").trim();
|
const assetId = String(toolArgs.assetId ?? "").trim();
|
||||||
const fileUrl = String(toolArgs.fileUrl ?? "").trim();
|
const fileUrl = String(toolArgs.fileUrl ?? "").trim();
|
||||||
const attachmentRef = String(toolArgs.attachmentRef ?? "").trim();
|
const attachmentRef = String(toolArgs.attachmentRef ?? "").trim();
|
||||||
@@ -91,7 +90,7 @@ export const createMediaServerTools = (args: {
|
|||||||
const ocrText = String(pick(row, "ocr_text") ?? "");
|
const ocrText = String(pick(row, "ocr_text") ?? "");
|
||||||
const ocrStatus = String(pick(row, "ocr_status") ?? "");
|
const ocrStatus = String(pick(row, "ocr_status") ?? "");
|
||||||
const mimeType = String(pick(row, "mime_type") ?? "");
|
const mimeType = String(pick(row, "mime_type") ?? "");
|
||||||
const result = {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
found: true,
|
found: true,
|
||||||
asset: {
|
asset: {
|
||||||
@@ -114,11 +113,7 @@ export const createMediaServerTools = (args: {
|
|||||||
? "已返回 ocr_text。"
|
? "已返回 ocr_text。"
|
||||||
: "该图片暂未生成 ocr_text(可等待后台 OCR,或后续再补一个 image_ocr 写工具)。",
|
: "该图片暂未生成 ocr_text(可等待后台 OCR,或后续再补一个 image_ocr 写工具)。",
|
||||||
};
|
};
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`未知工具:${toolId}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { run };
|
return { resolveImageReadTransport };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { OpenAiCompatibleChatOptions } from "@/lib/ai/openaiCompatibleChat";
|
import type { OpenAiCompatibleChatOptions } from "@/lib/ai/openaiCompatibleChat";
|
||||||
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
||||||
import { applyMindmapOps, ensureMindmapUids, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
|
import { DocumentBridgeError } from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
applyMindmapOps,
|
||||||
|
classifyMindmapOpErrors,
|
||||||
|
ensureMindmapUids,
|
||||||
|
type MindmapOp,
|
||||||
|
type MindmapTreeNode,
|
||||||
|
type NodeRef,
|
||||||
|
} from "@/lib/mindmap/mindmapOps";
|
||||||
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
||||||
import { searchSearxng, type SearxResult } from "../searchWeb";
|
import { searchSearxng, type SearxResult } from "../searchWeb";
|
||||||
|
|
||||||
@@ -28,6 +36,16 @@ type SupabaseQuery = {
|
|||||||
|
|
||||||
export type MindmapSupabaseClient = SupabaseRouteClient;
|
export type MindmapSupabaseClient = SupabaseRouteClient;
|
||||||
|
|
||||||
|
type MindmapRustToolRunner = (input: {
|
||||||
|
toolId: string;
|
||||||
|
invocationKind: "query" | "command" | "job";
|
||||||
|
toolArgs: Record<string, unknown>;
|
||||||
|
data: MindmapTreeNode;
|
||||||
|
target?: { workspaceId?: string | null; pageId?: string | null; blockId?: string | null } | null;
|
||||||
|
reason?: string | null;
|
||||||
|
refs?: string[];
|
||||||
|
}) => Promise<unknown>;
|
||||||
|
|
||||||
const defaultMindmapData: MindmapTreeNode = { data: { text: "中心主题" }, children: [] };
|
const defaultMindmapData: MindmapTreeNode = { data: { text: "中心主题" }, children: [] };
|
||||||
|
|
||||||
const safeUrlOrNull = (value: unknown) => {
|
const safeUrlOrNull = (value: unknown) => {
|
||||||
@@ -123,6 +141,23 @@ const mergeRefsUnique = (a: NodeRef[], b: NodeRef[]) => {
|
|||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const throwMindmapOperationError = (
|
||||||
|
message: string,
|
||||||
|
kind: "rejected" | "failed",
|
||||||
|
details?: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
if (kind === "rejected") {
|
||||||
|
throw new DocumentBridgeError(message, 409, "REJECTED", {
|
||||||
|
reason: "mindmap_rejected",
|
||||||
|
...details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new DocumentBridgeError(message, 500, "TRANSPORT_ERROR", {
|
||||||
|
reason: "mindmap_failed",
|
||||||
|
...details,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const sanitizeAddChildOps = (args: {
|
const sanitizeAddChildOps = (args: {
|
||||||
targetUid: string;
|
targetUid: string;
|
||||||
currentChildren: string[];
|
currentChildren: string[];
|
||||||
@@ -192,6 +227,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
ctx: MindmapToolContext;
|
ctx: MindmapToolContext;
|
||||||
cfg: OpenAiCompatibleChatOptions;
|
cfg: OpenAiCompatibleChatOptions;
|
||||||
allowedToolIds: Set<string>;
|
allowedToolIds: Set<string>;
|
||||||
|
runRustTool?: MindmapRustToolRunner;
|
||||||
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase/local 文件。
|
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase/local 文件。
|
||||||
loadMindmap?: () => Promise<{
|
loadMindmap?: () => Promise<{
|
||||||
doc: { id: string; title: string | null; workspace_id: string | null };
|
doc: { id: string; title: string | null; workspace_id: string | null };
|
||||||
@@ -242,9 +278,38 @@ export const createMindmapServerTools = (args: {
|
|||||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mindmapTarget = (doc: { id: string; title: string | null; workspace_id: string | null }) => ({
|
||||||
|
pageId: doc.id,
|
||||||
|
workspaceId: doc.workspace_id ?? null,
|
||||||
|
blockId: args.ctx.mindmapId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const withMindmapIds = (toolArgs: Record<string, unknown>) => ({
|
||||||
|
...toolArgs,
|
||||||
|
documentId: args.ctx.documentId,
|
||||||
|
mindmapId: args.ctx.mindmapId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistResultData = async (doc: { id: string; title: string | null; workspace_id: string | null }, result: unknown) => {
|
||||||
|
if (!result || typeof result !== "object" || Array.isArray(result)) return;
|
||||||
|
const nextData = (result as Record<string, unknown>).data;
|
||||||
|
if (!nextData || typeof nextData !== "object" || Array.isArray(nextData)) return;
|
||||||
|
await persistMindmap(doc, nextData as MindmapTreeNode);
|
||||||
|
};
|
||||||
|
|
||||||
const mindmap_get = async (toolArgs: Record<string, unknown>) => {
|
const mindmap_get = async (toolArgs: Record<string, unknown>) => {
|
||||||
const maxNodes = Number(toolArgs.maxNodes ?? 120);
|
const maxNodes = Number(toolArgs.maxNodes ?? 120);
|
||||||
const { base } = await loadMindmap();
|
const loaded = await loadMindmap();
|
||||||
|
const { base } = loaded;
|
||||||
|
if (args.runRustTool) {
|
||||||
|
return await args.runRustTool({
|
||||||
|
toolId: "mindmap_get",
|
||||||
|
invocationKind: "query",
|
||||||
|
toolArgs: withMindmapIds({ maxNodes }),
|
||||||
|
data: base,
|
||||||
|
target: mindmapTarget(loaded.doc),
|
||||||
|
});
|
||||||
|
}
|
||||||
const list = walkSummaries(base, Number.isFinite(maxNodes) ? Math.max(10, Math.min(300, Math.floor(maxNodes))) : 120);
|
const list = walkSummaries(base, Number.isFinite(maxNodes) ? Math.max(10, Math.min(300, Math.floor(maxNodes))) : 120);
|
||||||
return { ok: true, documentId: args.ctx.documentId, mindmapId: args.ctx.mindmapId, nodes: list };
|
return { ok: true, documentId: args.ctx.documentId, mindmapId: args.ctx.mindmapId, nodes: list };
|
||||||
};
|
};
|
||||||
@@ -254,7 +319,17 @@ export const createMindmapServerTools = (args: {
|
|||||||
if (!uid) throw new Error("缺少 uid");
|
if (!uid) throw new Error("缺少 uid");
|
||||||
const depth = Number(toolArgs.depth ?? 2);
|
const depth = Number(toolArgs.depth ?? 2);
|
||||||
const maxNodes = Number(toolArgs.maxNodes ?? 60);
|
const maxNodes = Number(toolArgs.maxNodes ?? 60);
|
||||||
const { base } = await loadMindmap();
|
const loaded = await loadMindmap();
|
||||||
|
const { base } = loaded;
|
||||||
|
if (args.runRustTool) {
|
||||||
|
return await args.runRustTool({
|
||||||
|
toolId: "mindmap_get_subtree",
|
||||||
|
invocationKind: "query",
|
||||||
|
toolArgs: withMindmapIds({ uid, depth, maxNodes }),
|
||||||
|
data: base,
|
||||||
|
target: mindmapTarget(loaded.doc),
|
||||||
|
});
|
||||||
|
}
|
||||||
const hit = findNodeByUid(base, uid);
|
const hit = findNodeByUid(base, uid);
|
||||||
if (!hit) throw new Error(`未找到 uid=${uid}`);
|
if (!hit) throw new Error(`未找到 uid=${uid}`);
|
||||||
const list = summarizeSubtree(
|
const list = summarizeSubtree(
|
||||||
@@ -265,6 +340,33 @@ export const createMindmapServerTools = (args: {
|
|||||||
return { ok: true, uid, nodes: list };
|
return { ok: true, uid, nodes: list };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveMindmapPutTree = (toolArgs: Record<string, unknown>) => {
|
||||||
|
const raw = toolArgs.data ?? toolArgs.tree ?? toolArgs.mindmap ?? null;
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
throw new Error("缺少 data");
|
||||||
|
}
|
||||||
|
return raw as MindmapTreeNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mindmap_put = async (toolArgs: Record<string, unknown>) => {
|
||||||
|
const loaded = await loadMindmap();
|
||||||
|
const data = resolveMindmapPutTree(toolArgs);
|
||||||
|
if (args.runRustTool) {
|
||||||
|
const result = await args.runRustTool({
|
||||||
|
toolId: "mindmap_put",
|
||||||
|
invocationKind: "command",
|
||||||
|
toolArgs: withMindmapIds({ ...toolArgs, data }),
|
||||||
|
data: loaded.base,
|
||||||
|
target: mindmapTarget(loaded.doc),
|
||||||
|
});
|
||||||
|
await persistResultData(loaded.doc, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
ensureMindmapUids(data);
|
||||||
|
await persistMindmap(loaded.doc, data);
|
||||||
|
return { ok: true, data };
|
||||||
|
};
|
||||||
|
|
||||||
const mindmap_apply_ops = async (toolArgs: Record<string, unknown>) => {
|
const mindmap_apply_ops = async (toolArgs: Record<string, unknown>) => {
|
||||||
const ops = (toolArgs.ops ?? []) as unknown;
|
const ops = (toolArgs.ops ?? []) as unknown;
|
||||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||||
@@ -336,8 +438,28 @@ export const createMindmapServerTools = (args: {
|
|||||||
const normalized = ops.map((x) => normalizeOp(x)).filter(Boolean) as MindmapOp[];
|
const normalized = ops.map((x) => normalizeOp(x)).filter(Boolean) as MindmapOp[];
|
||||||
if (normalized.length === 0) throw new Error("ops 无有效操作(请使用 MindmapOp 协议或已支持的别名)");
|
if (normalized.length === 0) throw new Error("ops 无有效操作(请使用 MindmapOp 协议或已支持的别名)");
|
||||||
|
|
||||||
const { doc, base } = await loadMindmap();
|
const loaded = await loadMindmap();
|
||||||
|
const { base } = loaded;
|
||||||
|
if (args.runRustTool) {
|
||||||
|
const result = await args.runRustTool({
|
||||||
|
toolId: "mindmap_apply_ops",
|
||||||
|
invocationKind: "command",
|
||||||
|
toolArgs: withMindmapIds({ ops: normalized, reason }),
|
||||||
|
data: base,
|
||||||
|
target: mindmapTarget(doc),
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
await persistResultData(doc, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, normalized);
|
const { data: nextData, applied, errors } = applyMindmapOps(base, normalized);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throwMindmapOperationError("mindmap_apply_ops 存在冲突或非法操作", classifyMindmapOpErrors(errors) ?? "failed", {
|
||||||
|
errors,
|
||||||
|
applied,
|
||||||
|
opCount: normalized.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
await persistMindmap(doc, nextData);
|
await persistMindmap(doc, nextData);
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
};
|
};
|
||||||
@@ -352,15 +474,12 @@ export const createMindmapServerTools = (args: {
|
|||||||
if (!parentUid) throw new Error("缺少 parentUid");
|
if (!parentUid) throw new Error("缺少 parentUid");
|
||||||
if (!text) throw new Error("缺少 text");
|
if (!text) throw new Error("缺少 text");
|
||||||
|
|
||||||
const { doc, base } = await loadMindmap();
|
|
||||||
const op: MindmapOp = {
|
const op: MindmapOp = {
|
||||||
op: "addChild",
|
op: "addChild",
|
||||||
parentUid,
|
parentUid,
|
||||||
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
|
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
|
||||||
};
|
};
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
return await mindmap_apply_ops({ ops: [op], reason });
|
||||||
await persistMindmap(doc, nextData);
|
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mindmap_add_sibling_after = async (toolArgs: Record<string, unknown>) => {
|
const mindmap_add_sibling_after = async (toolArgs: Record<string, unknown>) => {
|
||||||
@@ -373,15 +492,12 @@ export const createMindmapServerTools = (args: {
|
|||||||
if (!targetUid) throw new Error("缺少 targetUid");
|
if (!targetUid) throw new Error("缺少 targetUid");
|
||||||
if (!text) throw new Error("缺少 text");
|
if (!text) throw new Error("缺少 text");
|
||||||
|
|
||||||
const { doc, base } = await loadMindmap();
|
|
||||||
const op: MindmapOp = {
|
const op: MindmapOp = {
|
||||||
op: "addSiblingAfter",
|
op: "addSiblingAfter",
|
||||||
targetUid,
|
targetUid,
|
||||||
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
|
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
|
||||||
};
|
};
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
return await mindmap_apply_ops({ ops: [op], reason });
|
||||||
await persistMindmap(doc, nextData);
|
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mindmap_update_node_text = async (toolArgs: Record<string, unknown>) => {
|
const mindmap_update_node_text = async (toolArgs: Record<string, unknown>) => {
|
||||||
@@ -390,11 +506,8 @@ export const createMindmapServerTools = (args: {
|
|||||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||||
if (!uid) throw new Error("缺少 uid");
|
if (!uid) throw new Error("缺少 uid");
|
||||||
if (!text) throw new Error("缺少 text");
|
if (!text) throw new Error("缺少 text");
|
||||||
const { doc, base } = await loadMindmap();
|
|
||||||
const op: MindmapOp = { op: "updateText", uid, text };
|
const op: MindmapOp = { op: "updateText", uid, text };
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
return await mindmap_apply_ops({ ops: [op], reason });
|
||||||
await persistMindmap(doc, nextData);
|
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mindmap_set_hyperlink = async (toolArgs: Record<string, unknown>) => {
|
const mindmap_set_hyperlink = async (toolArgs: Record<string, unknown>) => {
|
||||||
@@ -406,11 +519,8 @@ export const createMindmapServerTools = (args: {
|
|||||||
if (hyperlinkRaw !== null && hyperlinkRaw !== undefined && !hyperlink) {
|
if (hyperlinkRaw !== null && hyperlinkRaw !== undefined && !hyperlink) {
|
||||||
throw new Error("hyperlink 必须是 http(s) URL 或 null");
|
throw new Error("hyperlink 必须是 http(s) URL 或 null");
|
||||||
}
|
}
|
||||||
const { doc, base } = await loadMindmap();
|
|
||||||
const op: MindmapOp = { op: "setHyperlink", uid, hyperlink: hyperlinkRaw === null ? null : hyperlink };
|
const op: MindmapOp = { op: "setHyperlink", uid, hyperlink: hyperlinkRaw === null ? null : hyperlink };
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
return await mindmap_apply_ops({ ops: [op], reason });
|
||||||
await persistMindmap(doc, nextData);
|
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mindmap_append_note = async (toolArgs: Record<string, unknown>) => {
|
const mindmap_append_note = async (toolArgs: Record<string, unknown>) => {
|
||||||
@@ -419,11 +529,8 @@ export const createMindmapServerTools = (args: {
|
|||||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||||
if (!uid) throw new Error("缺少 uid");
|
if (!uid) throw new Error("缺少 uid");
|
||||||
if (!markdown) throw new Error("缺少 markdown");
|
if (!markdown) throw new Error("缺少 markdown");
|
||||||
const { doc, base } = await loadMindmap();
|
|
||||||
const op: MindmapOp = { op: "appendNote", uid, markdown };
|
const op: MindmapOp = { op: "appendNote", uid, markdown };
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
return await mindmap_apply_ops({ ops: [op], reason });
|
||||||
await persistMindmap(doc, nextData);
|
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mindmap_set_refs = async (toolArgs: Record<string, unknown>) => {
|
const mindmap_set_refs = async (toolArgs: Record<string, unknown>) => {
|
||||||
@@ -432,22 +539,16 @@ export const createMindmapServerTools = (args: {
|
|||||||
const refs = Array.isArray(toolArgs.refs) ? (toolArgs.refs as NodeRef[]) : [];
|
const refs = Array.isArray(toolArgs.refs) ? (toolArgs.refs as NodeRef[]) : [];
|
||||||
if (!uid) throw new Error("缺少 uid");
|
if (!uid) throw new Error("缺少 uid");
|
||||||
if (!refs.length) throw new Error("缺少 refs");
|
if (!refs.length) throw new Error("缺少 refs");
|
||||||
const { doc, base } = await loadMindmap();
|
|
||||||
const op: MindmapOp = { op: "setRefs", uid, refs };
|
const op: MindmapOp = { op: "setRefs", uid, refs };
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
return await mindmap_apply_ops({ ops: [op], reason });
|
||||||
await persistMindmap(doc, nextData);
|
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mindmap_delete_node = async (toolArgs: Record<string, unknown>) => {
|
const mindmap_delete_node = async (toolArgs: Record<string, unknown>) => {
|
||||||
const uid = String(toolArgs.uid ?? "").trim();
|
const uid = String(toolArgs.uid ?? "").trim();
|
||||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||||
if (!uid) throw new Error("缺少 uid");
|
if (!uid) throw new Error("缺少 uid");
|
||||||
const { doc, base } = await loadMindmap();
|
|
||||||
const op: MindmapOp = { op: "deleteNode", uid };
|
const op: MindmapOp = { op: "deleteNode", uid };
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
return await mindmap_apply_ops({ ops: [op], reason });
|
||||||
await persistMindmap(doc, nextData);
|
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mindmap_add_attachment_ref = async (toolArgs: Record<string, unknown>) => {
|
const mindmap_add_attachment_ref = async (toolArgs: Record<string, unknown>) => {
|
||||||
@@ -462,7 +563,8 @@ export const createMindmapServerTools = (args: {
|
|||||||
if (!uid) throw new Error("缺少 uid");
|
if (!uid) throw new Error("缺少 uid");
|
||||||
if (!attachmentId && !fileUrlDirect) throw new Error("缺少 attachmentId 或 fileUrl");
|
if (!attachmentId && !fileUrlDirect) throw new Error("缺少 attachmentId 或 fileUrl");
|
||||||
|
|
||||||
const { doc, base } = await loadMindmap();
|
const loaded = await loadMindmap();
|
||||||
|
const { base } = loaded;
|
||||||
const node = findNodeByUid(base, uid);
|
const node = findNodeByUid(base, uid);
|
||||||
if (!node) throw new Error(`未找到 uid=${uid}`);
|
if (!node) throw new Error(`未找到 uid=${uid}`);
|
||||||
|
|
||||||
@@ -499,9 +601,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
const prevRefs = Array.isArray(node.data?.refs) ? (node.data.refs as NodeRef[]) : [];
|
const prevRefs = Array.isArray(node.data?.refs) ? (node.data.refs as NodeRef[]) : [];
|
||||||
const nextRefs = mode === "replace" ? [ref] : mergeRefsUnique(prevRefs, [ref]);
|
const nextRefs = mode === "replace" ? [ref] : mergeRefsUnique(prevRefs, [ref]);
|
||||||
const op: MindmapOp = { op: "setRefs", uid, refs: nextRefs };
|
const op: MindmapOp = { op: "setRefs", uid, refs: nextRefs };
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
return await mindmap_apply_ops({ ops: [op], reason });
|
||||||
await persistMindmap(doc, nextData);
|
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mindmap_add_attachment_child = async (toolArgs: Record<string, unknown>) => {
|
const mindmap_add_attachment_child = async (toolArgs: Record<string, unknown>) => {
|
||||||
@@ -517,7 +617,6 @@ export const createMindmapServerTools = (args: {
|
|||||||
if (!parentUid) throw new Error("缺少 parentUid");
|
if (!parentUid) throw new Error("缺少 parentUid");
|
||||||
if (!attachmentId && !fileUrlDirect) throw new Error("缺少 attachmentId 或 fileUrl");
|
if (!attachmentId && !fileUrlDirect) throw new Error("缺少 attachmentId 或 fileUrl");
|
||||||
|
|
||||||
const { doc, base } = await loadMindmap();
|
|
||||||
const resolved = attachmentId ? resolveAttachmentFromContext(args.ctx, attachmentId) : null;
|
const resolved = attachmentId ? resolveAttachmentFromContext(args.ctx, attachmentId) : null;
|
||||||
const finalUrl = String(resolved?.fileUrl || fileUrlDirect || "").trim();
|
const finalUrl = String(resolved?.fileUrl || fileUrlDirect || "").trim();
|
||||||
const finalTitle = String(titleOverride || resolved?.title || attachmentId || "附件").trim();
|
const finalTitle = String(titleOverride || resolved?.title || attachmentId || "附件").trim();
|
||||||
@@ -552,9 +651,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
parentUid,
|
parentUid,
|
||||||
node: { text, ...(hyperlink ? { hyperlink } : {}), refs: [ref], ...(note ? { note } : {}) },
|
node: { text, ...(hyperlink ? { hyperlink } : {}), refs: [ref], ...(note ? { note } : {}) },
|
||||||
};
|
};
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
return await mindmap_apply_ops({ ops: [op], reason });
|
||||||
await persistMindmap(doc, nextData);
|
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mindmap_add_image_child = async (toolArgs: Record<string, unknown>) => {
|
const mindmap_add_image_child = async (toolArgs: Record<string, unknown>) => {
|
||||||
@@ -583,11 +680,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
refs: [{ kind: "url", ...(resolved?.id ? { assetId: resolved.id } : {}), fileUrl: url, title }],
|
refs: [{ kind: "url", ...(resolved?.id ? { assetId: resolved.id } : {}), fileUrl: url, title }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
return await mindmap_apply_ops({ ops: [op], reason });
|
||||||
const { doc, base } = await loadMindmap();
|
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
|
||||||
await persistMindmap(doc, nextData);
|
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mindmap_append_image_note = async (toolArgs: Record<string, unknown>) => {
|
const mindmap_append_image_note = async (toolArgs: Record<string, unknown>) => {
|
||||||
@@ -604,16 +697,18 @@ export const createMindmapServerTools = (args: {
|
|||||||
if (!url) throw new Error("图片缺少 URL");
|
if (!url) throw new Error("图片缺少 URL");
|
||||||
|
|
||||||
const markdown = ``;
|
const markdown = ``;
|
||||||
const { doc, base } = await loadMindmap();
|
|
||||||
const op: MindmapOp = { op: "appendNote", uid, markdown };
|
const op: MindmapOp = { op: "appendNote", uid, markdown };
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
const result = await mindmap_apply_ops({ ops: [op], reason });
|
||||||
await persistMindmap(doc, nextData);
|
if (result && typeof result === "object") {
|
||||||
|
return {
|
||||||
|
...(result as Record<string, unknown>),
|
||||||
|
meta: { reason, ref: { kind: "url", ...(resolved?.id ? { assetId: resolved.id } : {}), fileUrl: url, title } },
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
applied,
|
|
||||||
errors,
|
|
||||||
data: nextData,
|
|
||||||
meta: { reason, ref: { kind: "url", ...(resolved?.id ? { assetId: resolved.id } : {}), fileUrl: url, title } },
|
meta: { reason, ref: { kind: "url", ...(resolved?.id ? { assetId: resolved.id } : {}), fileUrl: url, title } },
|
||||||
|
result,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -623,7 +718,8 @@ export const createMindmapServerTools = (args: {
|
|||||||
const instruction = String(toolArgs.instruction ?? "").trim();
|
const instruction = String(toolArgs.instruction ?? "").trim();
|
||||||
const useSearx = args.allowedToolIds.has("search_web");
|
const useSearx = args.allowedToolIds.has("search_web");
|
||||||
|
|
||||||
const { doc, base } = await loadMindmap();
|
const loaded = await loadMindmap();
|
||||||
|
const { base } = loaded;
|
||||||
const target = findNodeByUid(base, targetUid);
|
const target = findNodeByUid(base, targetUid);
|
||||||
if (!target) throw new Error("未找到目标节点(uid 不存在)");
|
if (!target) throw new Error("未找到目标节点(uid 不存在)");
|
||||||
|
|
||||||
@@ -707,22 +803,26 @@ export const createMindmapServerTools = (args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fixed = sanitizeAddChildOps({ targetUid, currentChildren, ops, searxResults });
|
const fixed = sanitizeAddChildOps({ targetUid, currentChildren, ops, searxResults });
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, fixed);
|
const result = await mindmap_apply_ops({ ops: fixed, reason: instruction || null });
|
||||||
await persistMindmap(doc, nextData);
|
if (result && typeof result === "object") {
|
||||||
|
return {
|
||||||
|
...(result as Record<string, unknown>),
|
||||||
|
ops: fixed,
|
||||||
|
meta: { finishReason, searched: useSearx, searxCount: searxResults.length },
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
applied,
|
|
||||||
errors,
|
|
||||||
ops: fixed,
|
ops: fixed,
|
||||||
data: nextData,
|
|
||||||
meta: { finishReason, searched: useSearx, searxCount: searxResults.length },
|
meta: { finishReason, searched: useSearx, searxCount: searxResults.length },
|
||||||
|
result,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||||
if (toolId === "mindmap_get") return await mindmap_get(toolArgs);
|
if (toolId === "mindmap_get") return await mindmap_get(toolArgs);
|
||||||
if (toolId === "mindmap_get_subtree") return await mindmap_get_subtree(toolArgs);
|
if (toolId === "mindmap_get_subtree") return await mindmap_get_subtree(toolArgs);
|
||||||
|
if (toolId === "mindmap_put") return await mindmap_put(toolArgs);
|
||||||
if (toolId === "mindmap_apply_ops") return await mindmap_apply_ops(toolArgs);
|
if (toolId === "mindmap_apply_ops") return await mindmap_apply_ops(toolArgs);
|
||||||
if (toolId === "mindmap_add_child") return await mindmap_add_child(toolArgs);
|
if (toolId === "mindmap_add_child") return await mindmap_add_child(toolArgs);
|
||||||
if (toolId === "mindmap_add_sibling_after") return await mindmap_add_sibling_after(toolArgs);
|
if (toolId === "mindmap_add_sibling_after") return await mindmap_add_sibling_after(toolArgs);
|
||||||
|
|||||||
@@ -394,3 +394,43 @@ export const builtinToolSets: AiAgentToolSet[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export type BuiltinRustCutoverStatus = "rust" | "mixed" | "ts" | "transport";
|
||||||
|
|
||||||
|
export type BuiltinRustCutoverBinding = {
|
||||||
|
rustToolsetId: string;
|
||||||
|
rustToolName: string | null;
|
||||||
|
status: BuiltinRustCutoverStatus;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前只把已经进入 Rust runtime 的 builtin tool 做成代码侧绑定。
|
||||||
|
* 完整的一一对应矩阵见 `design/ai-tool-cutover-matrix.md`。
|
||||||
|
*/
|
||||||
|
export const builtinRustCutoverBindings: Record<string, BuiltinRustCutoverBinding> = {
|
||||||
|
search_web: {
|
||||||
|
rustToolsetId: "toolset.readonly",
|
||||||
|
rustToolName: "search_web",
|
||||||
|
status: "rust",
|
||||||
|
note: "联网检索已切到 Rust runtime,TS 仅保留 route transport 壳。",
|
||||||
|
},
|
||||||
|
image_read: {
|
||||||
|
rustToolsetId: "toolset.media_read",
|
||||||
|
rustToolName: "image_read",
|
||||||
|
status: "rust",
|
||||||
|
note: "图片/附件 OCR 结果归一化已切到 Rust runtime,TS 只负责最小 transport 取数。",
|
||||||
|
},
|
||||||
|
slash_run: {
|
||||||
|
rustToolsetId: "toolset.slash_write",
|
||||||
|
rustToolName: "slash_run",
|
||||||
|
status: "rust",
|
||||||
|
note: "斜杠命令解析已切到 Rust runtime,TS 仅保留创建/重命名 transport 写壳。",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const builtinRustRuntimeToolIds = new Set<string>(
|
||||||
|
Object.entries(builtinRustCutoverBindings)
|
||||||
|
.filter(([, binding]) => binding.status === "rust" || binding.status === "mixed")
|
||||||
|
.map(([toolId]) => toolId),
|
||||||
|
);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const searchSearxng = async (q: string, count = 6): Promise<SearxResult[]
|
|||||||
const value = obj.results;
|
const value = obj.results;
|
||||||
return Array.isArray(value) ? (value as unknown[]) : [];
|
return Array.isArray(value) ? (value as unknown[]) : [];
|
||||||
})();
|
})();
|
||||||
return results
|
const normalized = results
|
||||||
.map((r: unknown) => {
|
.map((r: unknown) => {
|
||||||
const obj = (typeof r === "object" && r ? (r as Record<string, unknown>) : {}) as Record<string, unknown>;
|
const obj = (typeof r === "object" && r ? (r as Record<string, unknown>) : {}) as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
@@ -50,4 +50,5 @@ export const searchSearxng = async (q: string, count = 6): Promise<SearxResult[]
|
|||||||
})
|
})
|
||||||
.filter((r: SearxResult) => r.title && safeUrlOrNull(r.url))
|
.filter((r: SearxResult) => r.title && safeUrlOrNull(r.url))
|
||||||
.slice(0, Math.max(1, Math.min(10, count)));
|
.slice(0, Math.max(1, Math.min(10, count)));
|
||||||
|
return normalized;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -85,37 +85,11 @@ export const createSlashServerTools = (args: {
|
|||||||
updatedAt: unknown;
|
updatedAt: unknown;
|
||||||
}>;
|
}>;
|
||||||
}) => {
|
}) => {
|
||||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
const executeSlashTransport = async (parsed: ParsedSlash) => {
|
||||||
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
|
if (!args.allowedToolIds.has("slash_run")) throw new Error("工具未被允许:slash_run");
|
||||||
|
if (!parsed.ok) {
|
||||||
if (toolId === "slash_run") {
|
throw new Error("error" in parsed ? parsed.error : "缺少 text(以 / 开头)或 command");
|
||||||
const text = String(toolArgs.text ?? "").trim();
|
|
||||||
const command = String(toolArgs.command ?? "").trim();
|
|
||||||
const params = isRecord(toolArgs.params) ? toolArgs.params : null;
|
|
||||||
|
|
||||||
const parsed: ParsedSlash =
|
|
||||||
text && text.startsWith("/") ? parseSlash(text) : command === "new_doc"
|
|
||||||
? {
|
|
||||||
ok: true,
|
|
||||||
command: "new_doc",
|
|
||||||
params: {
|
|
||||||
title: String(pick(params, "title") ?? "").trim(),
|
|
||||||
parentId: pick(params, "parentId") ? String(pick(params, "parentId")) : null,
|
|
||||||
workspaceId: pick(params, "workspaceId") ? String(pick(params, "workspaceId")) : null,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
: command === "rename_doc"
|
|
||||||
? {
|
|
||||||
ok: true,
|
|
||||||
command: "rename_doc",
|
|
||||||
params: {
|
|
||||||
documentId: String(pick(params, "documentId") ?? "").trim(),
|
|
||||||
title: String(pick(params, "title") ?? "").trim(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: { ok: false, error: "缺少 text(以 / 开头)或 command" };
|
|
||||||
|
|
||||||
if (!parsed.ok) throw new Error(parsed.error);
|
|
||||||
|
|
||||||
if (parsed.command === "new_doc") {
|
if (parsed.command === "new_doc") {
|
||||||
const title = String(parsed.params.title ?? "").trim();
|
const title = String(parsed.params.title ?? "").trim();
|
||||||
@@ -216,10 +190,7 @@ export const createSlashServerTools = (args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`未实现命令:${(parsed as any).command}`);
|
throw new Error(`未实现命令:${(parsed as any).command}`);
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`未知工具:${toolId}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { run };
|
return { parseSlash, executeSlashTransport };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,627 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import {
|
||||||
|
findBlockInTree,
|
||||||
|
getBlocksFromDocumentContent,
|
||||||
|
removeBlockSubtree,
|
||||||
|
replaceBlockInTree,
|
||||||
|
withBlocksWrittenBack,
|
||||||
|
} from "@/lib/blocks";
|
||||||
|
import { createBlockSnapshotOpsAdapter, type BlockInsertSpec } from "@/lib/blocks/block-ops-adapter";
|
||||||
|
import {
|
||||||
|
assertBlockId,
|
||||||
|
assertDocumentId,
|
||||||
|
assertNextBlock,
|
||||||
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
buildDocumentQueryEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
DocumentBridgeError,
|
||||||
|
type BridgeContext,
|
||||||
|
type CommandEnvelope,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
||||||
|
import {
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
executeRustBridgeQueryTransport,
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
resolveRustBridgeQueryPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
|
type DocumentContentQueryResult = {
|
||||||
|
content?: unknown;
|
||||||
|
revision?: number | null;
|
||||||
|
conflict_detection_key?: string | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
type DocumentMetaResult = {
|
||||||
|
id?: string;
|
||||||
|
workspace_id?: string | null;
|
||||||
|
embed_default_block_id?: string | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
type DocumentState = {
|
||||||
|
documentId: string;
|
||||||
|
workspaceId: string | null;
|
||||||
|
content: unknown;
|
||||||
|
blocks: ReturnType<typeof getBlocksFromDocumentContent>;
|
||||||
|
revision: number | null;
|
||||||
|
conflictDetectionKey: string | null;
|
||||||
|
meta: DocumentMetaResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BlockCommandMeta = {
|
||||||
|
requestId: string;
|
||||||
|
traceId: string;
|
||||||
|
commandId: string;
|
||||||
|
commandName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BlockCommandResult<TResult> = BlockCommandMeta & {
|
||||||
|
result: TResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetBlockResult = {
|
||||||
|
block: Record<string, unknown>;
|
||||||
|
meta: {
|
||||||
|
requestId: string;
|
||||||
|
traceId: string;
|
||||||
|
queryName: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRevision(value: number | null | undefined): number | null {
|
||||||
|
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConflictDetectionKey(
|
||||||
|
documentId: string,
|
||||||
|
revision: number | null,
|
||||||
|
value: string | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
return revision === null ? null : `${documentId}:${revision}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDocumentState(input: {
|
||||||
|
request: Request;
|
||||||
|
documentId: string;
|
||||||
|
workspaceId?: string | null;
|
||||||
|
}) {
|
||||||
|
const { client } = await getAuthedConvexClient();
|
||||||
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request: input.request,
|
||||||
|
workspaceId: input.workspaceId ?? null,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentQueryEnvelope({
|
||||||
|
name: "documents.content.get",
|
||||||
|
payload: {
|
||||||
|
documentId: input.documentId,
|
||||||
|
workspaceId: input.workspaceId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeQueryPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
const result = await executeRustBridgeQueryTransport<DocumentContentQueryResult>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
if (!result) {
|
||||||
|
throw new DocumentBridgeError("页面不存在或无权限", 404, "NOT_FOUND");
|
||||||
|
}
|
||||||
|
const meta = await client.query(api.documents.getMeta, { id: input.documentId });
|
||||||
|
const revision = normalizeRevision(result.revision);
|
||||||
|
const workspaceId = (meta?.workspace_id ?? input.workspaceId ?? null) as string | null;
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
queryName: envelope.name,
|
||||||
|
state: {
|
||||||
|
documentId: input.documentId,
|
||||||
|
workspaceId,
|
||||||
|
content: result.content ?? null,
|
||||||
|
blocks: getBlocksFromDocumentContent(result.content ?? null),
|
||||||
|
revision,
|
||||||
|
conflictDetectionKey: normalizeConflictDetectionKey(
|
||||||
|
input.documentId,
|
||||||
|
revision,
|
||||||
|
result.conflict_detection_key,
|
||||||
|
),
|
||||||
|
meta: meta as DocumentMetaResult,
|
||||||
|
} satisfies DocumentState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveBlockCommandEnvelope<TPayload>(input: {
|
||||||
|
context: BridgeContext;
|
||||||
|
envelope: CommandEnvelope<TPayload>;
|
||||||
|
}) {
|
||||||
|
await resolveRustBridgeCommandPlan({
|
||||||
|
context: input.context,
|
||||||
|
envelope: input.envelope,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
requestId: input.context.requestId,
|
||||||
|
traceId: input.context.traceId,
|
||||||
|
commandId: input.envelope.commandId,
|
||||||
|
commandName: input.envelope.name,
|
||||||
|
} satisfies BlockCommandMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDocumentSaveTransport(input: {
|
||||||
|
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
|
||||||
|
request: Request;
|
||||||
|
documentId: string;
|
||||||
|
workspaceId: string | null;
|
||||||
|
content: unknown;
|
||||||
|
revision: number | null;
|
||||||
|
conflictDetectionKey: string | null;
|
||||||
|
recordArtifacts?: boolean;
|
||||||
|
}) {
|
||||||
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request: input.request,
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.save",
|
||||||
|
payload: {
|
||||||
|
documentId: input.documentId,
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
revision: input.revision,
|
||||||
|
content: input.content,
|
||||||
|
conflictDetectionKey: input.conflictDetectionKey,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
pageId: input.documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
const result = await executeRustBridgeMutationTransport<{
|
||||||
|
revision?: number | null;
|
||||||
|
conflict_detection_key?: string | null;
|
||||||
|
}>({
|
||||||
|
client: input.client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
if (input.recordArtifacts !== false) {
|
||||||
|
await recordBridgeCommandArtifacts({
|
||||||
|
client: input.client,
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
commandId: envelope.commandId,
|
||||||
|
commandName: envelope.name,
|
||||||
|
result,
|
||||||
|
} satisfies BlockCommandResult<{
|
||||||
|
revision?: number | null;
|
||||||
|
conflict_detection_key?: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeBlockGetQuery(input: {
|
||||||
|
request: Request;
|
||||||
|
sourceDocumentId: string;
|
||||||
|
blockId: string;
|
||||||
|
}): Promise<GetBlockResult> {
|
||||||
|
const documentId = assertDocumentId(input.sourceDocumentId);
|
||||||
|
const blockId = assertBlockId(input.blockId);
|
||||||
|
const { context, state } = await loadDocumentState({
|
||||||
|
request: input.request,
|
||||||
|
documentId,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentQueryEnvelope({
|
||||||
|
name: "blocks.get",
|
||||||
|
payload: {
|
||||||
|
blockId,
|
||||||
|
workspaceId: state.workspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await resolveRustBridgeQueryPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
const hit = findBlockInTree(state.blocks as never[], blockId);
|
||||||
|
if (!hit || !hit.block || typeof hit.block !== "object") {
|
||||||
|
throw new DocumentBridgeError("块不存在或无权限", 404, "NOT_FOUND");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
block: hit.block as Record<string, unknown>,
|
||||||
|
meta: {
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
queryName: envelope.name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeBlockPatchCommand(input: {
|
||||||
|
request: Request;
|
||||||
|
sourceDocumentId: string;
|
||||||
|
workspaceId?: string | null;
|
||||||
|
blockId: string;
|
||||||
|
nextBlock: unknown;
|
||||||
|
}) {
|
||||||
|
const documentId = assertDocumentId(input.sourceDocumentId);
|
||||||
|
const blockId = assertBlockId(input.blockId);
|
||||||
|
assertNextBlock(input.nextBlock);
|
||||||
|
|
||||||
|
const { client, state } = await loadDocumentState({
|
||||||
|
request: input.request,
|
||||||
|
documentId,
|
||||||
|
workspaceId: input.workspaceId ?? null,
|
||||||
|
});
|
||||||
|
const replaced = replaceBlockInTree(state.blocks as never[], blockId, input.nextBlock as never);
|
||||||
|
if (!replaced.ok) {
|
||||||
|
throw new DocumentBridgeError("块不存在或无权限", 404, "NOT_FOUND");
|
||||||
|
}
|
||||||
|
const nextContent = withBlocksWrittenBack(state.content, replaced.nextBlocks as never[]);
|
||||||
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request: input.request,
|
||||||
|
workspaceId: state.workspaceId,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "blocks.patch",
|
||||||
|
payload: {
|
||||||
|
documentId,
|
||||||
|
workspaceId: state.workspaceId,
|
||||||
|
blockId,
|
||||||
|
nextBlock: input.nextBlock,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId: state.workspaceId,
|
||||||
|
pageId: documentId,
|
||||||
|
blockId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const meta = await resolveBlockCommandEnvelope({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
const save = await executeDocumentSaveTransport({
|
||||||
|
client,
|
||||||
|
request: input.request,
|
||||||
|
documentId,
|
||||||
|
workspaceId: state.workspaceId,
|
||||||
|
content: nextContent,
|
||||||
|
revision: state.revision,
|
||||||
|
conflictDetectionKey: state.conflictDetectionKey,
|
||||||
|
recordArtifacts: false,
|
||||||
|
});
|
||||||
|
await recordBridgeCommandArtifacts({
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
result: save.result,
|
||||||
|
} satisfies BlockCommandResult<{ revision?: number | null; conflict_detection_key?: string | null }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeBlockMoveCommand(input: {
|
||||||
|
request: Request;
|
||||||
|
sourceDocumentId: string;
|
||||||
|
blockId: string;
|
||||||
|
targetDocumentId: string;
|
||||||
|
}) {
|
||||||
|
const sourceDocumentId = assertDocumentId(input.sourceDocumentId);
|
||||||
|
const targetDocumentId = assertDocumentId(input.targetDocumentId);
|
||||||
|
const blockId = assertBlockId(input.blockId);
|
||||||
|
|
||||||
|
if (sourceDocumentId === targetDocumentId) {
|
||||||
|
const context = await buildDocumentBridgeContext({ request: input.request, workspaceId: null });
|
||||||
|
return {
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
commandId: "noop",
|
||||||
|
commandName: "blocks.move",
|
||||||
|
result: { ok: true, noop: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { client, state: sourceState } = await loadDocumentState({
|
||||||
|
request: input.request,
|
||||||
|
documentId: sourceDocumentId,
|
||||||
|
});
|
||||||
|
const removed = removeBlockSubtree(sourceState.blocks as never[], blockId);
|
||||||
|
if (!removed.removed) {
|
||||||
|
throw new DocumentBridgeError("源块不存在或无权限", 404, "NOT_FOUND");
|
||||||
|
}
|
||||||
|
const { state: targetState } = await loadDocumentState({
|
||||||
|
request: input.request,
|
||||||
|
documentId: targetDocumentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextSourceContent = withBlocksWrittenBack(sourceState.content, removed.nextBlocks as never[]);
|
||||||
|
const nextTargetContent = withBlocksWrittenBack(targetState.content, [
|
||||||
|
...(targetState.blocks as never[]),
|
||||||
|
removed.removed as never,
|
||||||
|
]);
|
||||||
|
const workspaceId = sourceState.workspaceId ?? targetState.workspaceId ?? null;
|
||||||
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request: input.request,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "blocks.move",
|
||||||
|
payload: {
|
||||||
|
sourceDocumentId,
|
||||||
|
targetDocumentId,
|
||||||
|
blockId,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
pageId: targetDocumentId,
|
||||||
|
blockId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const meta = await resolveBlockCommandEnvelope({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
const sourceSave = await executeDocumentSaveTransport({
|
||||||
|
client,
|
||||||
|
request: input.request,
|
||||||
|
documentId: sourceDocumentId,
|
||||||
|
workspaceId: sourceState.workspaceId,
|
||||||
|
content: nextSourceContent,
|
||||||
|
revision: sourceState.revision,
|
||||||
|
conflictDetectionKey: sourceState.conflictDetectionKey,
|
||||||
|
recordArtifacts: false,
|
||||||
|
});
|
||||||
|
const targetSave = await executeDocumentSaveTransport({
|
||||||
|
client,
|
||||||
|
request: input.request,
|
||||||
|
documentId: targetDocumentId,
|
||||||
|
workspaceId: targetState.workspaceId,
|
||||||
|
content: nextTargetContent,
|
||||||
|
revision: targetState.revision,
|
||||||
|
conflictDetectionKey: targetState.conflictDetectionKey,
|
||||||
|
recordArtifacts: false,
|
||||||
|
});
|
||||||
|
await recordBridgeCommandArtifacts({
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
result: {
|
||||||
|
ok: true,
|
||||||
|
sourceRevision: sourceSave.result.revision ?? null,
|
||||||
|
targetRevision: targetSave.result.revision ?? null,
|
||||||
|
},
|
||||||
|
} satisfies BlockCommandResult<{
|
||||||
|
ok: boolean;
|
||||||
|
sourceRevision: number | null;
|
||||||
|
targetRevision: number | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeBlockEmbedCommand(input: {
|
||||||
|
request: Request;
|
||||||
|
sourceDocumentId: string;
|
||||||
|
blockId: string;
|
||||||
|
targetDocumentId: string;
|
||||||
|
}) {
|
||||||
|
const sourceDocumentId = assertDocumentId(input.sourceDocumentId);
|
||||||
|
const targetDocumentId = assertDocumentId(input.targetDocumentId);
|
||||||
|
const blockId = assertBlockId(input.blockId);
|
||||||
|
|
||||||
|
if (sourceDocumentId === targetDocumentId) {
|
||||||
|
throw new DocumentBridgeError("禁止嵌入到当前页面", 400, "VALIDATION_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { client, state: sourceState } = await loadDocumentState({
|
||||||
|
request: input.request,
|
||||||
|
documentId: sourceDocumentId,
|
||||||
|
});
|
||||||
|
const hit = findBlockInTree(sourceState.blocks as never[], blockId);
|
||||||
|
if (!hit) {
|
||||||
|
throw new DocumentBridgeError("源块不存在或无权限", 404, "NOT_FOUND");
|
||||||
|
}
|
||||||
|
const { state: targetState } = await loadDocumentState({
|
||||||
|
request: input.request,
|
||||||
|
documentId: targetDocumentId,
|
||||||
|
});
|
||||||
|
const targetBlocks = [...(targetState.blocks as Array<Record<string, unknown>>)];
|
||||||
|
const anchorId =
|
||||||
|
typeof targetState.meta?.embed_default_block_id === "string" && targetState.meta.embed_default_block_id.trim()
|
||||||
|
? targetState.meta.embed_default_block_id.trim()
|
||||||
|
: null;
|
||||||
|
const anchorIndex = anchorId
|
||||||
|
? targetBlocks.findIndex((block) => String(block?.id ?? "") === anchorId)
|
||||||
|
: -1;
|
||||||
|
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : targetBlocks.length;
|
||||||
|
const referenceBlock = {
|
||||||
|
id: randomUUID(),
|
||||||
|
type: "blockReference",
|
||||||
|
props: {
|
||||||
|
sourceDocumentId,
|
||||||
|
targetBlockId: blockId,
|
||||||
|
display: "embed",
|
||||||
|
},
|
||||||
|
content: [],
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
const nextBlocks = [
|
||||||
|
...targetBlocks.slice(0, insertIndex),
|
||||||
|
referenceBlock,
|
||||||
|
...targetBlocks.slice(insertIndex),
|
||||||
|
];
|
||||||
|
const nextContent = withBlocksWrittenBack(targetState.content, nextBlocks as never[]);
|
||||||
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request: input.request,
|
||||||
|
workspaceId: targetState.workspaceId,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "blocks.embed",
|
||||||
|
payload: {
|
||||||
|
sourceDocumentId,
|
||||||
|
targetDocumentId,
|
||||||
|
blockId,
|
||||||
|
targetBlockId: anchorId,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId: targetState.workspaceId,
|
||||||
|
pageId: targetDocumentId,
|
||||||
|
blockId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const meta = await resolveBlockCommandEnvelope({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
const save = await executeDocumentSaveTransport({
|
||||||
|
client,
|
||||||
|
request: input.request,
|
||||||
|
documentId: targetDocumentId,
|
||||||
|
workspaceId: targetState.workspaceId,
|
||||||
|
content: nextContent,
|
||||||
|
revision: targetState.revision,
|
||||||
|
conflictDetectionKey: targetState.conflictDetectionKey,
|
||||||
|
recordArtifacts: false,
|
||||||
|
});
|
||||||
|
await recordBridgeCommandArtifacts({
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
result: {
|
||||||
|
ok: true,
|
||||||
|
revision: save.result.revision ?? null,
|
||||||
|
referenceBlockId: String(referenceBlock.id),
|
||||||
|
},
|
||||||
|
} satisfies BlockCommandResult<{
|
||||||
|
ok: boolean;
|
||||||
|
revision: number | null;
|
||||||
|
referenceBlockId: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeDocumentSnapshotSaveCommand(input: {
|
||||||
|
request: Request;
|
||||||
|
documentId: string;
|
||||||
|
workspaceId?: string | null;
|
||||||
|
content: unknown;
|
||||||
|
recordArtifacts?: boolean;
|
||||||
|
}) {
|
||||||
|
const documentId = assertDocumentId(input.documentId);
|
||||||
|
const { client, state } = await loadDocumentState({
|
||||||
|
request: input.request,
|
||||||
|
documentId,
|
||||||
|
workspaceId: input.workspaceId ?? null,
|
||||||
|
});
|
||||||
|
return executeDocumentSaveTransport({
|
||||||
|
client,
|
||||||
|
request: input.request,
|
||||||
|
documentId,
|
||||||
|
workspaceId: state.workspaceId,
|
||||||
|
content: input.content,
|
||||||
|
revision: state.revision,
|
||||||
|
conflictDetectionKey: state.conflictDetectionKey,
|
||||||
|
recordArtifacts: input.recordArtifacts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeDocumentBlockInsertCommand(input: {
|
||||||
|
request: Request;
|
||||||
|
documentId: string;
|
||||||
|
afterBlockId?: string | null;
|
||||||
|
beforeBlockId?: string | null;
|
||||||
|
blocks: BlockInsertSpec[];
|
||||||
|
baseBlocks?: unknown;
|
||||||
|
}) {
|
||||||
|
const adapter = createBlockSnapshotOpsAdapter({
|
||||||
|
baseBlocks: input.baseBlocks,
|
||||||
|
loadBlocks: async () => {
|
||||||
|
const { state } = await loadDocumentState({
|
||||||
|
request: input.request,
|
||||||
|
documentId: input.documentId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
blocks: state.blocks as unknown[],
|
||||||
|
source: "server",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const snapshot = await adapter.insertBlocks({
|
||||||
|
afterBlockId: input.afterBlockId ?? undefined,
|
||||||
|
beforeBlockId: input.beforeBlockId ?? undefined,
|
||||||
|
blocks: input.blocks,
|
||||||
|
});
|
||||||
|
const save = await executeDocumentSnapshotSaveCommand({
|
||||||
|
request: input.request,
|
||||||
|
documentId: input.documentId,
|
||||||
|
content: snapshot.data,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...save,
|
||||||
|
inserted: snapshot.inserted,
|
||||||
|
data: snapshot.data,
|
||||||
|
source: snapshot.source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeDocumentBlockReplaceRangeCommand(input: {
|
||||||
|
request: Request;
|
||||||
|
documentId: string;
|
||||||
|
blockId: string;
|
||||||
|
text: string;
|
||||||
|
mode: "replace" | "append" | "prepend";
|
||||||
|
baseBlocks?: unknown;
|
||||||
|
}) {
|
||||||
|
const adapter = createBlockSnapshotOpsAdapter({
|
||||||
|
baseBlocks: input.baseBlocks,
|
||||||
|
loadBlocks: async () => {
|
||||||
|
const { state } = await loadDocumentState({
|
||||||
|
request: input.request,
|
||||||
|
documentId: input.documentId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
blocks: state.blocks as unknown[],
|
||||||
|
source: "server",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const snapshot = await adapter.replaceRange({
|
||||||
|
blockId: assertBlockId(input.blockId),
|
||||||
|
text: input.text,
|
||||||
|
mode: input.mode,
|
||||||
|
});
|
||||||
|
const save = await executeDocumentSnapshotSaveCommand({
|
||||||
|
request: input.request,
|
||||||
|
documentId: input.documentId,
|
||||||
|
content: snapshot.data,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...save,
|
||||||
|
blockId: input.blockId,
|
||||||
|
mode: input.mode,
|
||||||
|
data: snapshot.data,
|
||||||
|
source: snapshot.source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { documentBridgeErrorResponse };
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { getBlocksFromDocumentContent, replaceBlockInTree } from "@/lib/blocks";
|
||||||
|
import type { Json } from "@/types/supabase";
|
||||||
|
|
||||||
|
type TreeBlockLike = {
|
||||||
|
id: string;
|
||||||
|
type?: string;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
content?: unknown;
|
||||||
|
children?: TreeBlockLike[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BlockLike = {
|
||||||
|
id: string;
|
||||||
|
type?: string;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
content?: unknown;
|
||||||
|
children?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BlockInsertSpec = {
|
||||||
|
type: "paragraph" | "heading";
|
||||||
|
text: string;
|
||||||
|
level?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BlockSnapshot = Json;
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
const normalizeBlocks = (content: unknown): BlockLike[] => {
|
||||||
|
return getBlocksFromDocumentContent(content) as BlockLike[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTextContent = (text: string): unknown[] => [{ type: "text", text }];
|
||||||
|
|
||||||
|
const generateId = () => {
|
||||||
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `bn_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildBlockFromSpec = (spec: BlockInsertSpec): BlockLike => {
|
||||||
|
const base: BlockLike = {
|
||||||
|
id: generateId(),
|
||||||
|
type: spec.type,
|
||||||
|
props: {},
|
||||||
|
content: createTextContent(String(spec.text ?? "").trim()),
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
if (spec.type === "heading") {
|
||||||
|
const level = Number(spec.level ?? 2);
|
||||||
|
base.props = { level: Math.max(1, Math.min(5, Number.isFinite(level) ? Math.floor(level) : 2)) };
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cloneBlock = (block: BlockLike): BlockLike => ({
|
||||||
|
...block,
|
||||||
|
props: isRecord(block.props) ? { ...block.props } : block.props,
|
||||||
|
content: Array.isArray(block.content) ? [...block.content] : block.content,
|
||||||
|
children: Array.isArray(block.children)
|
||||||
|
? (block.children as BlockLike[]).map((child) => cloneBlock(child))
|
||||||
|
: block.children,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const findBlockContainerById = (
|
||||||
|
blocks: BlockLike[],
|
||||||
|
targetId: string,
|
||||||
|
): { container: BlockLike[]; index: number } | null => {
|
||||||
|
const id = String(targetId || "").trim();
|
||||||
|
if (!id) return null;
|
||||||
|
for (let index = 0; index < blocks.length; index += 1) {
|
||||||
|
const block = blocks[index];
|
||||||
|
if (String(block?.id ?? "").trim() === id) {
|
||||||
|
return { container: blocks, index };
|
||||||
|
}
|
||||||
|
const children = Array.isArray(block?.children)
|
||||||
|
? (block.children as BlockLike[]).filter(
|
||||||
|
(item): item is BlockLike => Boolean(item && typeof item.id === "string"),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const found = findBlockContainerById(children, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readInlineBlockText = (block: BlockLike): string => {
|
||||||
|
const inline = Array.isArray(block.content) ? (block.content as Array<{ text?: unknown }>) : [];
|
||||||
|
return inline.map((node) => (typeof node?.text === "string" ? node.text : "")).join("").trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createBlockSnapshotOpsAdapter(input: {
|
||||||
|
baseBlocks?: unknown;
|
||||||
|
loadBlocks?: () => Promise<{ blocks: unknown[]; source: string }>;
|
||||||
|
}) {
|
||||||
|
const loadSnapshot = async () => {
|
||||||
|
if (input.baseBlocks) {
|
||||||
|
return {
|
||||||
|
blocks: normalizeBlocks(input.baseBlocks),
|
||||||
|
source: "client" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (input.loadBlocks) {
|
||||||
|
const loaded = await input.loadBlocks();
|
||||||
|
return {
|
||||||
|
blocks: normalizeBlocks(loaded.blocks),
|
||||||
|
source: "route" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
blocks: [] as BlockLike[],
|
||||||
|
source: "empty" as const,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getSnapshot: loadSnapshot,
|
||||||
|
async insertBlocks(input: {
|
||||||
|
afterBlockId?: string;
|
||||||
|
beforeBlockId?: string;
|
||||||
|
blocks: BlockInsertSpec[];
|
||||||
|
}) {
|
||||||
|
const snapshot = await loadSnapshot();
|
||||||
|
const blocks = snapshot.blocks.map((block) => cloneBlock(block));
|
||||||
|
const created = input.blocks.map(buildBlockFromSpec);
|
||||||
|
const targetId = input.beforeBlockId || input.afterBlockId;
|
||||||
|
const found = targetId ? findBlockContainerById(blocks, targetId) : null;
|
||||||
|
if (targetId && !found) {
|
||||||
|
throw new Error(`未找到 blockId:${targetId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
blocks.push(...created);
|
||||||
|
} else {
|
||||||
|
const insertAt = input.beforeBlockId ? found.index : found.index + 1;
|
||||||
|
found.container.splice(insertAt, 0, ...created);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: blocks as BlockSnapshot,
|
||||||
|
inserted: created.map((block) => String(block.id ?? "")),
|
||||||
|
source: snapshot.source,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async replaceRange(input: {
|
||||||
|
blockId: string;
|
||||||
|
text: string;
|
||||||
|
mode: "replace" | "append" | "prepend";
|
||||||
|
}) {
|
||||||
|
const snapshot = await loadSnapshot();
|
||||||
|
const blocks = snapshot.blocks.map((block) => cloneBlock(block));
|
||||||
|
const found = findBlockContainerById(blocks, input.blockId);
|
||||||
|
if (!found) {
|
||||||
|
throw new Error(`未找到 blockId:${input.blockId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = found.container[found.index];
|
||||||
|
const prevText = readInlineBlockText(block);
|
||||||
|
const nextText =
|
||||||
|
input.mode === "append"
|
||||||
|
? `${prevText}${input.text}`
|
||||||
|
: input.mode === "prepend"
|
||||||
|
? `${input.text}${prevText}`
|
||||||
|
: input.text;
|
||||||
|
|
||||||
|
const replaced = replaceBlockInTree(blocks, input.blockId, {
|
||||||
|
...(block as TreeBlockLike),
|
||||||
|
content: createTextContent(nextText),
|
||||||
|
} as TreeBlockLike);
|
||||||
|
if (!replaced.ok) {
|
||||||
|
throw new Error(`未找到 blockId:${input.blockId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: replaced.nextBlocks as BlockSnapshot,
|
||||||
|
source: snapshot.source,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import {
|
||||||
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
buildDocumentQueryEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
type BridgeContext,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
recordBridgeCommandArtifacts,
|
||||||
|
recordBridgeCommandFailureArtifacts,
|
||||||
|
} from "@/lib/documents/bridge-log";
|
||||||
|
import {
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
executeRustBridgeQueryTransport,
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
resolveRustBridgeQueryPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||||
|
|
||||||
|
type BlockLike = {
|
||||||
|
id: string;
|
||||||
|
type?: string;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
content?: unknown;
|
||||||
|
children?: BlockLike[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function cloneBlock(block: BlockLike): BlockLike {
|
||||||
|
return {
|
||||||
|
...block,
|
||||||
|
props: block.props ? { ...block.props } : undefined,
|
||||||
|
content: Array.isArray(block.content) ? [...block.content] : block.content,
|
||||||
|
children: Array.isArray(block.children) ? block.children.map((item) => cloneBlock(item)) : block.children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBlockInTree(
|
||||||
|
blocks: BlockLike[],
|
||||||
|
blockId: string,
|
||||||
|
): { block: BlockLike; parent: BlockLike | null; index: number } | null {
|
||||||
|
const stack: Array<{ list: BlockLike[]; parent: BlockLike | null }> = [{ list: blocks, parent: null }];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop();
|
||||||
|
if (!current) continue;
|
||||||
|
for (let i = 0; i < current.list.length; i += 1) {
|
||||||
|
const block = current.list[i]!;
|
||||||
|
if (block.id === blockId) {
|
||||||
|
return { block, parent: current.parent, index: i };
|
||||||
|
}
|
||||||
|
if (Array.isArray(block.children) && block.children.length > 0) {
|
||||||
|
stack.push({ list: block.children, parent: block });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBlockSubtree(blocks: BlockLike[], blockId: string) {
|
||||||
|
const nextTop = blocks.map((item) => cloneBlock(item));
|
||||||
|
const hit = findBlockInTree(nextTop, blockId);
|
||||||
|
if (!hit) {
|
||||||
|
return { removed: null as BlockLike | null, nextBlocks: nextTop };
|
||||||
|
}
|
||||||
|
if (hit.parent) {
|
||||||
|
const nextChildren = Array.isArray(hit.parent.children) ? hit.parent.children.map((item) => cloneBlock(item)) : [];
|
||||||
|
const removed = nextChildren.splice(hit.index, 1)[0] ?? null;
|
||||||
|
hit.parent.children = nextChildren;
|
||||||
|
return { removed, nextBlocks: nextTop };
|
||||||
|
}
|
||||||
|
const removed = nextTop.splice(hit.index, 1)[0] ?? null;
|
||||||
|
return { removed, nextBlocks: nextTop };
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceBlockInTree(blocks: BlockLike[], blockId: string, nextBlock: unknown) {
|
||||||
|
const nextTop = blocks.map((item) => cloneBlock(item));
|
||||||
|
const hit = findBlockInTree(nextTop, blockId);
|
||||||
|
if (!hit || !nextBlock || typeof nextBlock !== "object" || Array.isArray(nextBlock)) {
|
||||||
|
return { ok: false, nextBlocks: nextTop };
|
||||||
|
}
|
||||||
|
const normalized = cloneBlock({ ...(nextBlock as BlockLike), id: blockId });
|
||||||
|
if (hit.parent) {
|
||||||
|
const nextChildren = Array.isArray(hit.parent.children) ? hit.parent.children.map((item) => cloneBlock(item)) : [];
|
||||||
|
nextChildren[hit.index] = normalized;
|
||||||
|
hit.parent.children = nextChildren;
|
||||||
|
return { ok: true, nextBlocks: nextTop };
|
||||||
|
}
|
||||||
|
nextTop[hit.index] = normalized;
|
||||||
|
return { ok: true, nextBlocks: nextTop };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReferenceBlock(sourceDocumentId: string, blockId: string): BlockLike {
|
||||||
|
return {
|
||||||
|
id: randomUUID(),
|
||||||
|
type: "blockReference",
|
||||||
|
props: {
|
||||||
|
sourceDocumentId,
|
||||||
|
targetBlockId: blockId,
|
||||||
|
display: "embed",
|
||||||
|
},
|
||||||
|
content: [],
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildBridgeContext(request: Request, workspaceId: string | null): Promise<BridgeContext> {
|
||||||
|
return await buildDocumentBridgeContext({ request, workspaceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeBlockGetBridgeQuery(input: {
|
||||||
|
request: Request;
|
||||||
|
sourceDocumentId: string;
|
||||||
|
blockId: string;
|
||||||
|
}) {
|
||||||
|
const context = await buildBridgeContext(input.request, null);
|
||||||
|
const envelope = buildDocumentQueryEnvelope({
|
||||||
|
name: "blocks.get",
|
||||||
|
payload: {
|
||||||
|
sourceDocumentId: input.sourceDocumentId,
|
||||||
|
blockId: input.blockId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeQueryPlan({ context, envelope });
|
||||||
|
const { client } = await getAuthedConvexClient();
|
||||||
|
const doc = await executeRustBridgeQueryTransport<{ content?: unknown } | null>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
if (!doc) {
|
||||||
|
throw new Error("页面不存在或无权限");
|
||||||
|
}
|
||||||
|
const blocks = extractBlocksFromContent(doc.content) as BlockLike[];
|
||||||
|
const hit = findBlockInTree(blocks, input.blockId);
|
||||||
|
if (!hit) {
|
||||||
|
throw new Error("块不存在或无权限");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
queryName: envelope.name,
|
||||||
|
result: { block: hit.block },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeBlockPatchBridgeCommand(input: {
|
||||||
|
request: Request;
|
||||||
|
sourceDocumentId: string;
|
||||||
|
workspaceId: string | null;
|
||||||
|
blockId: string;
|
||||||
|
nextBlock: unknown;
|
||||||
|
}) {
|
||||||
|
const context = await buildBridgeContext(input.request, input.workspaceId);
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "blocks.patch",
|
||||||
|
payload: {
|
||||||
|
documentId: input.sourceDocumentId,
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
blockId: input.blockId,
|
||||||
|
nextBlock: input.nextBlock,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
pageId: input.sourceDocumentId,
|
||||||
|
blockId: input.blockId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||||
|
const { client } = await getAuthedConvexClient();
|
||||||
|
const doc = await client.query(api.documents.getContent, { id: input.sourceDocumentId });
|
||||||
|
if (!doc) {
|
||||||
|
throw new Error("页面不存在或无权限");
|
||||||
|
}
|
||||||
|
const blocks = extractBlocksFromContent(doc.content) as BlockLike[];
|
||||||
|
const replaced = replaceBlockInTree(blocks, input.blockId, input.nextBlock);
|
||||||
|
if (!replaced.ok) {
|
||||||
|
throw new Error("块不存在或无权限");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await executeRustBridgeMutationTransport({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
await client.mutation(api.documents.updateContent, {
|
||||||
|
id: input.sourceDocumentId,
|
||||||
|
content: composeContentWithBlocks(doc.content, replaced.nextBlocks as never),
|
||||||
|
});
|
||||||
|
await recordBridgeCommandArtifacts({ context, envelope });
|
||||||
|
} catch (error) {
|
||||||
|
await recordBridgeCommandFailureArtifacts({
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
commandId: envelope.commandId,
|
||||||
|
commandName: envelope.name,
|
||||||
|
result: { ok: true as const },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeBlockMoveBridgeCommand(input: {
|
||||||
|
request: Request;
|
||||||
|
sourceDocumentId: string;
|
||||||
|
targetDocumentId: string;
|
||||||
|
blockId: string;
|
||||||
|
}) {
|
||||||
|
const { client } = await getAuthedConvexClient();
|
||||||
|
const source = await client.query(api.documents.getContent, { id: input.sourceDocumentId });
|
||||||
|
if (!source) throw new Error("源页面不存在或无权限");
|
||||||
|
const target = await client.query(api.documents.getContent, { id: input.targetDocumentId });
|
||||||
|
if (!target) throw new Error("目标页面不存在或无权限");
|
||||||
|
const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
|
||||||
|
const removedRes = removeBlockSubtree(sourceBlocks, input.blockId);
|
||||||
|
if (!removedRes.removed) throw new Error("源块不存在或无权限");
|
||||||
|
const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
|
||||||
|
const nextSourceContent = composeContentWithBlocks(source.content, removedRes.nextBlocks as never);
|
||||||
|
const nextTargetContent = composeContentWithBlocks(target.content, [...targetBlocks, removedRes.removed] as never);
|
||||||
|
const context = await buildBridgeContext(input.request, null);
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "blocks.move",
|
||||||
|
payload: {
|
||||||
|
sourceDocumentId: input.sourceDocumentId,
|
||||||
|
targetDocumentId: input.targetDocumentId,
|
||||||
|
blockId: input.blockId,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
pageId: input.targetDocumentId,
|
||||||
|
blockId: input.blockId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||||
|
try {
|
||||||
|
await executeRustBridgeMutationTransport({ client, plan });
|
||||||
|
await client.mutation(api.documents.updateContent, { id: input.sourceDocumentId, content: nextSourceContent });
|
||||||
|
await client.mutation(api.documents.updateContent, { id: input.targetDocumentId, content: nextTargetContent });
|
||||||
|
await recordBridgeCommandArtifacts({ context, envelope });
|
||||||
|
} catch (error) {
|
||||||
|
await recordBridgeCommandFailureArtifacts({
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
commandId: envelope.commandId,
|
||||||
|
commandName: envelope.name,
|
||||||
|
result: { ok: true as const },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeBlockEmbedBridgeCommand(input: {
|
||||||
|
request: Request;
|
||||||
|
sourceDocumentId: string;
|
||||||
|
targetDocumentId: string;
|
||||||
|
blockId: string;
|
||||||
|
}) {
|
||||||
|
const { client } = await getAuthedConvexClient();
|
||||||
|
const source = await client.query(api.documents.getContent, { id: input.sourceDocumentId });
|
||||||
|
if (!source) throw new Error("源页面不存在或无权限");
|
||||||
|
const target = await client.query(api.documents.getContent, { id: input.targetDocumentId });
|
||||||
|
if (!target) throw new Error("目标页面不存在或无权限");
|
||||||
|
const targetMeta = await client.query(api.documents.getMeta, { id: input.targetDocumentId });
|
||||||
|
const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
|
||||||
|
const hit = findBlockInTree(sourceBlocks, input.blockId);
|
||||||
|
if (!hit) throw new Error("源块不存在或无权限");
|
||||||
|
const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
|
||||||
|
const anchorId = (targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id ?? null;
|
||||||
|
const anchorIndex =
|
||||||
|
typeof anchorId === "string" && anchorId.trim()
|
||||||
|
? targetBlocks.findIndex((block) => String(block.id ?? "") === anchorId)
|
||||||
|
: -1;
|
||||||
|
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : targetBlocks.length;
|
||||||
|
const nextTargetBlocks = [
|
||||||
|
...targetBlocks.slice(0, insertIndex),
|
||||||
|
buildReferenceBlock(input.sourceDocumentId, input.blockId),
|
||||||
|
...targetBlocks.slice(insertIndex),
|
||||||
|
];
|
||||||
|
const context = await buildBridgeContext(input.request, null);
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "blocks.embed",
|
||||||
|
payload: {
|
||||||
|
sourceDocumentId: input.sourceDocumentId,
|
||||||
|
targetDocumentId: input.targetDocumentId,
|
||||||
|
blockId: input.blockId,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
pageId: input.targetDocumentId,
|
||||||
|
blockId: input.blockId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||||
|
try {
|
||||||
|
await executeRustBridgeMutationTransport({ client, plan });
|
||||||
|
await client.mutation(api.documents.updateContent, {
|
||||||
|
id: input.targetDocumentId,
|
||||||
|
content: composeContentWithBlocks(target.content, nextTargetBlocks as never),
|
||||||
|
});
|
||||||
|
await recordBridgeCommandArtifacts({ context, envelope });
|
||||||
|
} catch (error) {
|
||||||
|
await recordBridgeCommandFailureArtifacts({
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
commandId: envelope.commandId,
|
||||||
|
commandName: envelope.name,
|
||||||
|
result: { ok: true as const },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleBlockBridgeError(error: unknown) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
import { api } from "@/lib/convex/api";
|
import { api } from "@/lib/convex/api";
|
||||||
import type { BridgeContext, BridgeTarget, CommandEnvelope } from "@/lib/documents/bridge";
|
import {
|
||||||
|
DocumentBridgeError,
|
||||||
|
type BridgeContext,
|
||||||
|
type BridgeTarget,
|
||||||
|
type CommandEnvelope,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import type { ConvexHttpClient } from "convex/browser";
|
import type { ConvexHttpClient } from "convex/browser";
|
||||||
|
|
||||||
|
export type BridgeCommandLogStatus = "pending" | "succeeded" | "failed" | "rolled_back";
|
||||||
|
export type BridgeDomainEventStatus = "pending" | "committed" | "rejected" | "failed";
|
||||||
|
|
||||||
function buildPayloadSummary(commandName: string, context: BridgeContext): string {
|
function buildPayloadSummary(commandName: string, context: BridgeContext): string {
|
||||||
return `command=${commandName};request_id=${context.requestId};trace_id=${context.traceId}`;
|
return `command=${commandName};request_id=${context.requestId};trace_id=${context.traceId}`;
|
||||||
}
|
}
|
||||||
@@ -15,6 +23,10 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
|||||||
context: BridgeContext;
|
context: BridgeContext;
|
||||||
envelope: CommandEnvelope<T>;
|
envelope: CommandEnvelope<T>;
|
||||||
client?: ConvexHttpClient;
|
client?: ConvexHttpClient;
|
||||||
|
status?: BridgeCommandLogStatus;
|
||||||
|
eventStatus?: BridgeDomainEventStatus;
|
||||||
|
error?: string | null;
|
||||||
|
now?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const workspaceId = normalizeWorkspaceId(input.context, input.envelope.target);
|
const workspaceId = normalizeWorkspaceId(input.context, input.envelope.target);
|
||||||
if (!workspaceId) return;
|
if (!workspaceId) return;
|
||||||
@@ -22,8 +34,15 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
|||||||
const client = input.client ?? (await getAuthedConvexClient()).client;
|
const client = input.client ?? (await getAuthedConvexClient()).client;
|
||||||
const commandLogId = `clog_${input.envelope.commandId}`;
|
const commandLogId = `clog_${input.envelope.commandId}`;
|
||||||
const eventId = `evt_${input.envelope.commandId}`;
|
const eventId = `evt_${input.envelope.commandId}`;
|
||||||
const now = new Date().toISOString();
|
const now = input.now ?? new Date().toISOString();
|
||||||
const payload = input.envelope.payload as Record<string, unknown>;
|
const payload = input.envelope.payload as Record<string, unknown>;
|
||||||
|
const status = input.status ?? "succeeded";
|
||||||
|
const eventStatus =
|
||||||
|
input.eventStatus ??
|
||||||
|
(status === "pending" ? "pending" : status === "failed" || status === "rolled_back" ? "failed" : "committed");
|
||||||
|
const aggregateType = input.envelope.target?.blockId ? "block" : input.envelope.target?.pageId ? "page" : "workspace";
|
||||||
|
const aggregateId =
|
||||||
|
input.envelope.target?.blockId ?? input.envelope.target?.pageId ?? workspaceId;
|
||||||
|
|
||||||
await client.mutation(api.bridgeLogs.recordCommandLog, {
|
await client.mutation(api.bridgeLogs.recordCommandLog, {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -36,16 +55,16 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
|||||||
actorType: input.context.actor.actorType,
|
actorType: input.context.actor.actorType,
|
||||||
sourceChannel: input.context.source.channel,
|
sourceChannel: input.context.source.channel,
|
||||||
sourceClient: input.context.source.client,
|
sourceClient: input.context.source.client,
|
||||||
status: "succeeded",
|
status,
|
||||||
targetPageId: input.envelope.target?.pageId ?? null,
|
targetPageId: input.envelope.target?.pageId ?? null,
|
||||||
targetBlockId: input.envelope.target?.blockId ?? null,
|
targetBlockId: input.envelope.target?.blockId ?? null,
|
||||||
payload,
|
payload,
|
||||||
payloadSummary: buildPayloadSummary(input.envelope.name, input.context),
|
payloadSummary: buildPayloadSummary(input.envelope.name, input.context),
|
||||||
refs: input.envelope.refs,
|
refs: input.envelope.refs,
|
||||||
idempotencyKey: input.envelope.idempotencyKey,
|
idempotencyKey: input.envelope.idempotencyKey,
|
||||||
error: null,
|
error: input.error ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
finishedAt: now,
|
finishedAt: status === "pending" ? null : now,
|
||||||
});
|
});
|
||||||
|
|
||||||
await client.mutation(api.bridgeLogs.recordDomainEvent, {
|
await client.mutation(api.bridgeLogs.recordDomainEvent, {
|
||||||
@@ -56,18 +75,77 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
|||||||
commandId: input.envelope.commandId,
|
commandId: input.envelope.commandId,
|
||||||
commandLogId,
|
commandLogId,
|
||||||
eventType: `${input.envelope.name}.requested`,
|
eventType: `${input.envelope.name}.requested`,
|
||||||
aggregateType: input.envelope.target?.blockId ? "block" : "page",
|
aggregateType,
|
||||||
aggregateId:
|
aggregateId,
|
||||||
input.envelope.target?.blockId ?? input.envelope.target?.pageId ?? workspaceId,
|
|
||||||
eventVersion: 1,
|
eventVersion: 1,
|
||||||
status: "committed",
|
status: eventStatus,
|
||||||
actorType: input.context.actor.actorType,
|
actorType: input.context.actor.actorType,
|
||||||
payload: {
|
payload: {
|
||||||
request_id: input.context.requestId,
|
request_id: input.context.requestId,
|
||||||
trace_id: input.context.traceId,
|
trace_id: input.context.traceId,
|
||||||
command_id: input.envelope.commandId,
|
command_id: input.envelope.commandId,
|
||||||
command_name: input.envelope.name,
|
command_name: input.envelope.name,
|
||||||
|
idempotency_key: input.envelope.idempotencyKey,
|
||||||
|
error: input.error ?? null,
|
||||||
},
|
},
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeBridgeErrorMessage(error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
if (typeof error === "string" && error.trim()) {
|
||||||
|
return error.trim();
|
||||||
|
}
|
||||||
|
return "未知 bridge 错误";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFailureStatuses(error: unknown): {
|
||||||
|
status: BridgeCommandLogStatus;
|
||||||
|
eventStatus: BridgeDomainEventStatus;
|
||||||
|
} {
|
||||||
|
const details =
|
||||||
|
error instanceof DocumentBridgeError && error.details && typeof error.details === "object"
|
||||||
|
? (error.details as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const reason = typeof details?.reason === "string" ? details.reason.trim() : "";
|
||||||
|
if (reason === "rolled_back" || reason === "compensation_applied") {
|
||||||
|
return {
|
||||||
|
status: "rolled_back",
|
||||||
|
eventStatus: "failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (error instanceof DocumentBridgeError && error.code === "REJECTED") {
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
eventStatus: "rejected",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
eventStatus: "failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordBridgeCommandFailureArtifacts<T>(input: {
|
||||||
|
context: BridgeContext;
|
||||||
|
envelope: CommandEnvelope<T>;
|
||||||
|
client?: ConvexHttpClient;
|
||||||
|
error: unknown;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { status, eventStatus } = resolveFailureStatuses(input.error);
|
||||||
|
try {
|
||||||
|
await recordBridgeCommandArtifacts({
|
||||||
|
context: input.context,
|
||||||
|
envelope: input.envelope,
|
||||||
|
client: input.client,
|
||||||
|
status,
|
||||||
|
eventStatus,
|
||||||
|
error: normalizeBridgeErrorMessage(input.error),
|
||||||
|
});
|
||||||
|
} catch (loggingError) {
|
||||||
|
console.warn("[bridge-log] failure artifacts skipped:", loggingError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { ConvexHttpClient } from "convex/browser";
|
import type { ConvexHttpClient } from "convex/browser";
|
||||||
|
|
||||||
vi.mock("@/lib/auth/authContext", () => ({
|
vi.mock("@/lib/auth/authContext", () => ({
|
||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
import { executeMediaAssetWritebackBridgeCommand } from "@/lib/documents/media-asset-command-adapter";
|
import { executeMediaAssetWritebackBridgeCommand } from "@/lib/documents/media-asset-command-adapter";
|
||||||
import { executeMetadataBridgeCommand } from "@/lib/documents/metadata-command-adapter";
|
import { executeMetadataBridgeCommand } from "@/lib/documents/metadata-command-adapter";
|
||||||
|
import { executePageLifecycleBridgeCommand } from "@/lib/documents/page-command-adapter";
|
||||||
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
|
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
|
||||||
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
||||||
|
|
||||||
@@ -46,6 +47,14 @@ vi.mock("@/lib/convex/route", () => ({
|
|||||||
|
|
||||||
vi.mock("@/lib/documents/bridge-log", () => ({
|
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||||
recordBridgeCommandArtifacts: vi.fn(),
|
recordBridgeCommandArtifacts: vi.fn(),
|
||||||
|
recordBridgeCommandFailureArtifacts: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||||
|
resolveRustBridgeCommandPlan: vi.fn(),
|
||||||
|
executeRustBridgeMutationTransport: vi.fn(),
|
||||||
|
resolveRustBridgeQueryPlan: vi.fn(),
|
||||||
|
executeRustBridgeQueryTransport: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockContext: BridgeContext = {
|
const mockContext: BridgeContext = {
|
||||||
@@ -70,6 +79,10 @@ const mockContext: BridgeContext = {
|
|||||||
dryRun: false,
|
dryRun: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
describe("documents bridge helpers", () => {
|
describe("documents bridge helpers", () => {
|
||||||
it("assertDocumentId returns trimmed id", () => {
|
it("assertDocumentId returns trimmed id", () => {
|
||||||
expect(assertDocumentId(" doc_1 ")).toBe("doc_1");
|
expect(assertDocumentId(" doc_1 ")).toBe("doc_1");
|
||||||
@@ -270,6 +283,45 @@ describe("documents bridge helpers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("buildDocumentBridgeMutationRequest builds page lifecycle runtime request", () => {
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.create",
|
||||||
|
payload: {
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
title: "无标题",
|
||||||
|
accessScope: "private",
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
context: mockContext,
|
||||||
|
target: { workspaceId: "ws_1", pageId: "doc_1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = buildDocumentBridgeMutationRequest({
|
||||||
|
context: mockContext,
|
||||||
|
envelope,
|
||||||
|
mapConvexArgs: (payload) => ({
|
||||||
|
id: payload.documentId,
|
||||||
|
workspaceId: payload.workspaceId,
|
||||||
|
parentId: payload.parentId,
|
||||||
|
title: payload.title,
|
||||||
|
accessScope: payload.accessScope,
|
||||||
|
content: payload.content,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.functionName).toBe("documents:createWithParentReference");
|
||||||
|
expect(request.args).toEqual({
|
||||||
|
id: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
title: "无标题",
|
||||||
|
accessScope: "private",
|
||||||
|
content: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("buildDocumentBridgeContextWithActor keeps explicit actor and source", () => {
|
it("buildDocumentBridgeContextWithActor keeps explicit actor and source", () => {
|
||||||
const request = new Request("http://127.0.0.1:3001/api/onlyoffice/callback", {
|
const request = new Request("http://127.0.0.1:3001/api/onlyoffice/callback", {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -308,14 +360,34 @@ describe("documents bridge helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("executeMetadataBridgeCommand routes title update through adapter", async () => {
|
it("executeMetadataBridgeCommand routes title update through adapter", async () => {
|
||||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
|
||||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||||
|
const {
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
} = await import("@/lib/documents/rust-runtime");
|
||||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||||
auth: { userId: "user_1" },
|
auth: { userId: "user_1" },
|
||||||
client: {
|
client: {
|
||||||
mutation,
|
mutation: vi.fn(),
|
||||||
} as unknown as ConvexHttpClient,
|
} as unknown as ConvexHttpClient,
|
||||||
});
|
});
|
||||||
|
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "documents.title.update",
|
||||||
|
commandId: "cmd_title_1",
|
||||||
|
functionName: "documents:updateTitle",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
payloadJson: "{\"kind\":\"command\"}",
|
||||||
|
argsJson: {
|
||||||
|
id: "doc_1",
|
||||||
|
title: "新标题",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
const result = await executeMetadataBridgeCommand({
|
const result = await executeMetadataBridgeCommand({
|
||||||
context: mockContext,
|
context: mockContext,
|
||||||
@@ -331,10 +403,21 @@ describe("documents bridge helpers", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mutation).toHaveBeenCalledTimes(1);
|
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||||
expect(mutation.mock.calls[0]?.[1]).toEqual({
|
context: mockContext,
|
||||||
|
envelope: expect.objectContaining({
|
||||||
|
name: "documents.title.update",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(executeRustBridgeMutationTransport).toHaveBeenCalledWith({
|
||||||
|
client: expect.any(Object),
|
||||||
|
plan: expect.objectContaining({
|
||||||
|
functionName: "documents:updateTitle",
|
||||||
|
argsJson: {
|
||||||
id: "doc_1",
|
id: "doc_1",
|
||||||
title: "新标题",
|
title: "新标题",
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
expect(result.commandName).toBe("documents.title.update");
|
expect(result.commandName).toBe("documents.title.update");
|
||||||
});
|
});
|
||||||
@@ -390,16 +473,41 @@ describe("documents bridge helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("executeSaveBridgeCommand routes documents.save through adapter", async () => {
|
it("executeSaveBridgeCommand routes documents.save through adapter", async () => {
|
||||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
|
||||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||||
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||||
|
const {
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
} = await import("@/lib/documents/rust-runtime");
|
||||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||||
auth: { userId: "user_1" },
|
auth: { userId: "user_1" },
|
||||||
client: {
|
client: {
|
||||||
mutation,
|
mutation: vi.fn(),
|
||||||
} as unknown as ConvexHttpClient,
|
} as unknown as ConvexHttpClient,
|
||||||
});
|
});
|
||||||
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
|
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "documents.save",
|
||||||
|
commandId: "cmd_save_1",
|
||||||
|
functionName: "documents:updateContent",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
payloadJson: "{\"kind\":\"command\"}",
|
||||||
|
argsJson: {
|
||||||
|
id: "doc_1",
|
||||||
|
content: [{ id: "block_1" }],
|
||||||
|
expectedRevision: 7,
|
||||||
|
conflictDetectionKey: "conflict_1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||||
|
revision: 7,
|
||||||
|
conflict_detection_key: "conflict_1",
|
||||||
|
});
|
||||||
const previousBridgeArtifactCalls = vi.mocked(recordBridgeCommandArtifacts).mock.calls.length;
|
const previousBridgeArtifactCalls = vi.mocked(recordBridgeCommandArtifacts).mock.calls.length;
|
||||||
|
|
||||||
const payload = buildDocumentSavePayload({
|
const payload = buildDocumentSavePayload({
|
||||||
@@ -422,12 +530,23 @@ describe("documents bridge helpers", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mutation).toHaveBeenCalledTimes(1);
|
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||||
expect(mutation.mock.calls[0]?.[1]).toEqual({
|
context: mockContext,
|
||||||
|
envelope: expect.objectContaining({
|
||||||
|
name: "documents.save",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(executeRustBridgeMutationTransport).toHaveBeenCalledWith({
|
||||||
|
client: expect.any(Object),
|
||||||
|
plan: expect.objectContaining({
|
||||||
|
functionName: "documents:updateContent",
|
||||||
|
argsJson: {
|
||||||
id: "doc_1",
|
id: "doc_1",
|
||||||
content: [{ id: "block_1" }],
|
content: [{ id: "block_1" }],
|
||||||
expectedRevision: 7,
|
expectedRevision: 7,
|
||||||
conflictDetectionKey: "conflict_1",
|
conflictDetectionKey: "conflict_1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
expect(vi.mocked(recordBridgeCommandArtifacts).mock.calls.length).toBe(
|
expect(vi.mocked(recordBridgeCommandArtifacts).mock.calls.length).toBe(
|
||||||
previousBridgeArtifactCalls + 1,
|
previousBridgeArtifactCalls + 1,
|
||||||
@@ -446,14 +565,38 @@ describe("documents bridge helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("executeSaveBridgeCommand 将冲突错误归一为 bridge rejected", async () => {
|
it("executeSaveBridgeCommand 将冲突错误归一为 bridge rejected", async () => {
|
||||||
const mutation = vi.fn().mockRejectedValue(new Error("正文内容已变更,请刷新后重试"));
|
|
||||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||||
|
const {
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
} = await import("@/lib/documents/rust-runtime");
|
||||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||||
auth: { userId: "user_1" },
|
auth: { userId: "user_1" },
|
||||||
client: {
|
client: {
|
||||||
mutation,
|
mutation: vi.fn(),
|
||||||
} as unknown as ConvexHttpClient,
|
} as unknown as ConvexHttpClient,
|
||||||
});
|
});
|
||||||
|
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "documents.save",
|
||||||
|
commandId: "cmd_save_1",
|
||||||
|
functionName: "documents:updateContent",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
payloadJson: "{\"kind\":\"command\"}",
|
||||||
|
argsJson: {
|
||||||
|
id: "doc_1",
|
||||||
|
content: [{ id: "block_1" }],
|
||||||
|
expectedRevision: 7,
|
||||||
|
conflictDetectionKey: "conflict_1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(executeRustBridgeMutationTransport).mockRejectedValue(
|
||||||
|
new Error("正文内容已变更,请刷新后重试"),
|
||||||
|
);
|
||||||
|
|
||||||
const payload = buildDocumentSavePayload({
|
const payload = buildDocumentSavePayload({
|
||||||
documentId: "doc_1",
|
documentId: "doc_1",
|
||||||
@@ -480,6 +623,87 @@ describe("documents bridge helpers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("executePageLifecycleBridgeCommand routes page mutation through rust runtime", async () => {
|
||||||
|
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||||
|
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||||
|
const {
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
} = await import("@/lib/documents/rust-runtime");
|
||||||
|
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||||
|
auth: { userId: "user_1" },
|
||||||
|
client: {
|
||||||
|
mutation: vi.fn(),
|
||||||
|
} as unknown as ConvexHttpClient,
|
||||||
|
});
|
||||||
|
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "documents.create",
|
||||||
|
commandId: "cmd_create_1",
|
||||||
|
functionName: "documents:createWithParentReference",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
payloadJson: "{\"kind\":\"command\"}",
|
||||||
|
argsJson: {
|
||||||
|
id: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
title: "无标题",
|
||||||
|
accessScope: "private",
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||||
|
id: "doc_1",
|
||||||
|
title: "无标题",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executePageLifecycleBridgeCommand({
|
||||||
|
context: mockContext,
|
||||||
|
envelope: buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.create",
|
||||||
|
payload: {
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
title: "无标题",
|
||||||
|
accessScope: "private",
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
context: mockContext,
|
||||||
|
target: { workspaceId: "ws_1", pageId: "doc_1" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||||
|
context: mockContext,
|
||||||
|
envelope: expect.objectContaining({
|
||||||
|
name: "documents.create",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(executeRustBridgeMutationTransport).toHaveBeenCalledWith({
|
||||||
|
client: expect.any(Object),
|
||||||
|
plan: expect.objectContaining({
|
||||||
|
functionName: "documents:createWithParentReference",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||||
|
client: expect.any(Object),
|
||||||
|
context: mockContext,
|
||||||
|
envelope: expect.objectContaining({
|
||||||
|
name: "documents.create",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(result.result).toEqual({
|
||||||
|
id: "doc_1",
|
||||||
|
title: "无标题",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("executeMediaAssetWritebackBridgeCommand routes callback writeback through adapter", async () => {
|
it("executeMediaAssetWritebackBridgeCommand routes callback writeback through adapter", async () => {
|
||||||
const mutation = vi.fn().mockResolvedValue({ ok: true, fileUrl: "https://example.com/file.docx" });
|
const mutation = vi.fn().mockResolvedValue({ ok: true, fileUrl: "https://example.com/file.docx" });
|
||||||
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||||
|
|||||||
@@ -96,7 +96,36 @@ export type DocumentBridgeMutationRequest<
|
|||||||
args: TArgs;
|
args: TArgs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DocumentBridgeQueryRequest<
|
||||||
|
TArgs extends Record<string, unknown> = Record<string, unknown>,
|
||||||
|
> = {
|
||||||
|
functionName: string;
|
||||||
|
deploymentId: string | null;
|
||||||
|
projectId: string | null;
|
||||||
|
workspaceId: string | null;
|
||||||
|
requestId: string;
|
||||||
|
traceId: string;
|
||||||
|
actorId: string;
|
||||||
|
payloadJson: string;
|
||||||
|
args: TArgs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOCUMENT_BRIDGE_QUERY_FUNCTIONS = {
|
||||||
|
"documents.content.get": "documents:getContent",
|
||||||
|
"documents.meta.get": "documents:getMeta",
|
||||||
|
"blocks.get": "documents:getContent",
|
||||||
|
} as const satisfies Record<string, string>;
|
||||||
|
|
||||||
const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
|
const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
|
||||||
|
"documents.create": "documents:createWithParentReference",
|
||||||
|
"documents.move": "documents:move",
|
||||||
|
"documents.delete": "documents:softDelete",
|
||||||
|
"documents.restore": "documents:restore",
|
||||||
|
"documents.duplicate": "documents:duplicateWithMindmaps",
|
||||||
|
"documents.copy_tree": "documents:copyTree",
|
||||||
|
"blocks.patch": "documents:updateContent",
|
||||||
|
"blocks.move": "documents:updateContent",
|
||||||
|
"blocks.embed": "documents:updateContent",
|
||||||
"documents.title.update": "documents:updateTitle",
|
"documents.title.update": "documents:updateTitle",
|
||||||
"documents.stats.update": "documents:updateStats",
|
"documents.stats.update": "documents:updateStats",
|
||||||
"documents.options.update": "documents:updateOptions",
|
"documents.options.update": "documents:updateOptions",
|
||||||
@@ -303,6 +332,28 @@ function buildDocumentCommandPayloadJson(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDocumentQueryPayloadJson(input: {
|
||||||
|
context: BridgeContext;
|
||||||
|
queryName: string;
|
||||||
|
workspaceId: string | null;
|
||||||
|
}): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
kind: "query",
|
||||||
|
name: input.queryName,
|
||||||
|
request_id: input.context.requestId,
|
||||||
|
trace_id: input.context.traceId,
|
||||||
|
deployment_id: input.context.deploymentId,
|
||||||
|
project_id: input.context.projectId,
|
||||||
|
workspace_id: input.workspaceId,
|
||||||
|
tenant_id: input.context.tenantId,
|
||||||
|
actor_id: input.context.actor.actorId,
|
||||||
|
source: {
|
||||||
|
channel: input.context.source.channel,
|
||||||
|
client: input.context.source.client,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDocumentBridgeMutationRequest<
|
export function buildDocumentBridgeMutationRequest<
|
||||||
TPayload,
|
TPayload,
|
||||||
TArgs extends Record<string, unknown>,
|
TArgs extends Record<string, unknown>,
|
||||||
@@ -333,6 +384,47 @@ export function buildDocumentBridgeMutationRequest<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDocumentBridgeQueryFunctionName(queryName: string): string {
|
||||||
|
const functionName =
|
||||||
|
DOCUMENT_BRIDGE_QUERY_FUNCTIONS[
|
||||||
|
queryName as keyof typeof DOCUMENT_BRIDGE_QUERY_FUNCTIONS
|
||||||
|
];
|
||||||
|
if (!functionName) {
|
||||||
|
throw new DocumentBridgeError(
|
||||||
|
`未注册文档 bridge query: ${queryName}`,
|
||||||
|
500,
|
||||||
|
"TRANSPORT_ERROR",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return functionName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDocumentBridgeQueryRequest<
|
||||||
|
TPayload,
|
||||||
|
TArgs extends Record<string, unknown>,
|
||||||
|
>(input: {
|
||||||
|
context: BridgeContext;
|
||||||
|
envelope: QueryEnvelope<TPayload>;
|
||||||
|
mapConvexArgs: (payload: TPayload) => TArgs;
|
||||||
|
}): DocumentBridgeQueryRequest<TArgs> {
|
||||||
|
const workspaceId = input.context.workspaceId ?? null;
|
||||||
|
return {
|
||||||
|
functionName: getDocumentBridgeQueryFunctionName(input.envelope.name),
|
||||||
|
deploymentId: input.context.deploymentId,
|
||||||
|
projectId: input.context.projectId,
|
||||||
|
workspaceId,
|
||||||
|
requestId: input.context.requestId,
|
||||||
|
traceId: input.context.traceId,
|
||||||
|
actorId: input.context.actor.actorId,
|
||||||
|
payloadJson: buildDocumentQueryPayloadJson({
|
||||||
|
context: input.context,
|
||||||
|
queryName: input.envelope.name,
|
||||||
|
workspaceId,
|
||||||
|
}),
|
||||||
|
args: input.mapConvexArgs(input.envelope.payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function executeDocumentBridgeMutationRequest<
|
export async function executeDocumentBridgeMutationRequest<
|
||||||
TArgs extends Record<string, unknown>,
|
TArgs extends Record<string, unknown>,
|
||||||
TResult,
|
TResult,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ConvexHttpClient } from "convex/browser";
|
import type { ConvexHttpClient } from "convex/browser";
|
||||||
|
import type { Id } from "../../../convex/_generated/dataModel";
|
||||||
import { api } from "@/lib/convex/api";
|
import { api } from "@/lib/convex/api";
|
||||||
import {
|
import {
|
||||||
buildDocumentBridgeMutationRequest,
|
buildDocumentBridgeMutationRequest,
|
||||||
@@ -6,7 +7,10 @@ import {
|
|||||||
type CommandEnvelope,
|
type CommandEnvelope,
|
||||||
type BridgeContext,
|
type BridgeContext,
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
import {
|
||||||
|
recordBridgeCommandArtifacts,
|
||||||
|
recordBridgeCommandFailureArtifacts,
|
||||||
|
} from "@/lib/documents/bridge-log";
|
||||||
|
|
||||||
export type MediaAssetReplaceStoragePayload = {
|
export type MediaAssetReplaceStoragePayload = {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
@@ -34,15 +38,25 @@ export async function executeMediaAssetWritebackBridgeCommand(input: {
|
|||||||
mapConvexArgs: (payload) => ({
|
mapConvexArgs: (payload) => ({
|
||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
id: payload.assetId,
|
id: payload.assetId,
|
||||||
storageId: payload.storageId as any,
|
storageId: payload.storageId as Id<"_storage">,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
await executeDocumentBridgeMutationRequest({
|
await executeDocumentBridgeMutationRequest({
|
||||||
client: input.client,
|
client: input.client,
|
||||||
mutation: api.mediaAssets.replaceStorageFromUpload,
|
mutation: api.mediaAssets.replaceStorageFromUpload,
|
||||||
request: mutationRequest,
|
request: mutationRequest,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await recordBridgeCommandFailureArtifacts({
|
||||||
|
client: input.client,
|
||||||
|
context: input.context,
|
||||||
|
envelope: input.envelope,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await recordBridgeCommandArtifacts({
|
await recordBridgeCommandArtifacts({
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import {
|
|||||||
type CommandEnvelope,
|
type CommandEnvelope,
|
||||||
type BridgeContext,
|
type BridgeContext,
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
import {
|
||||||
|
recordBridgeCommandArtifacts,
|
||||||
|
recordBridgeCommandFailureArtifacts,
|
||||||
|
} from "@/lib/documents/bridge-log";
|
||||||
|
import {
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
import type { PageOptionsState } from "@/types/page-options";
|
import type { PageOptionsState } from "@/types/page-options";
|
||||||
|
|
||||||
export type DocumentTitleUpdatePayload = {
|
export type DocumentTitleUpdatePayload = {
|
||||||
@@ -106,8 +113,19 @@ export async function executeMetadataBridgeCommand<TPayload>(input: {
|
|||||||
context: BridgeContext;
|
context: BridgeContext;
|
||||||
envelope: CommandEnvelope<TPayload>;
|
envelope: CommandEnvelope<TPayload>;
|
||||||
}): Promise<MetadataCommandExecutionResult> {
|
}): Promise<MetadataCommandExecutionResult> {
|
||||||
const adapter = getMetadataWriteAdapter<TPayload>(input.envelope.name);
|
|
||||||
const { client } = await getAuthedConvexClient();
|
const { client } = await getAuthedConvexClient();
|
||||||
|
try {
|
||||||
|
if (input.envelope.name === "documents.title.update") {
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
|
context: input.context,
|
||||||
|
envelope: input.envelope,
|
||||||
|
});
|
||||||
|
await executeRustBridgeMutationTransport({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const adapter = getMetadataWriteAdapter<TPayload>(input.envelope.name);
|
||||||
const mutationRequest = buildDocumentBridgeMutationRequest({
|
const mutationRequest = buildDocumentBridgeMutationRequest({
|
||||||
context: input.context,
|
context: input.context,
|
||||||
envelope: input.envelope,
|
envelope: input.envelope,
|
||||||
@@ -119,6 +137,16 @@ export async function executeMetadataBridgeCommand<TPayload>(input: {
|
|||||||
mutation: adapter.convexMutation,
|
mutation: adapter.convexMutation,
|
||||||
request: mutationRequest,
|
request: mutationRequest,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await recordBridgeCommandFailureArtifacts({
|
||||||
|
context: input.context,
|
||||||
|
envelope: input.envelope,
|
||||||
|
client,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
await recordBridgeCommandArtifacts({
|
await recordBridgeCommandArtifacts({
|
||||||
context: input.context,
|
context: input.context,
|
||||||
envelope: input.envelope,
|
envelope: input.envelope,
|
||||||
|
|||||||
@@ -0,0 +1,534 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { ConvexHttpClient } from "convex/browser";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import {
|
||||||
|
assertDocumentId,
|
||||||
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
type BridgeContext,
|
||||||
|
type CommandEnvelope,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
recordBridgeCommandArtifacts,
|
||||||
|
recordBridgeCommandFailureArtifacts,
|
||||||
|
} from "@/lib/documents/bridge-log";
|
||||||
|
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||||
|
import {
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
import type { Json } from "@/types/supabase";
|
||||||
|
|
||||||
|
export type DocumentCreatePayload = {
|
||||||
|
documentId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
title: string;
|
||||||
|
accessScope: "private" | "shared" | "public";
|
||||||
|
content: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentMovePayload = {
|
||||||
|
documentId: string;
|
||||||
|
workspaceId: string | null;
|
||||||
|
parentId: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentDeletePayload = {
|
||||||
|
documentId: string;
|
||||||
|
workspaceId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentRestorePayload = {
|
||||||
|
documentId: string;
|
||||||
|
workspaceId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentDuplicatePayload = {
|
||||||
|
sourceDocumentId: string;
|
||||||
|
newDocumentId: string;
|
||||||
|
workspaceId: string | null;
|
||||||
|
title: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentCopyTreePayload = {
|
||||||
|
workspaceId: string | null;
|
||||||
|
targetParentId: string | null;
|
||||||
|
items: Array<{
|
||||||
|
documentId: string;
|
||||||
|
recursive: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageCommandExecutionResult<TResult> = {
|
||||||
|
requestId: string;
|
||||||
|
traceId: string;
|
||||||
|
commandId: string;
|
||||||
|
commandName: string;
|
||||||
|
result: TResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
function trimOrNull(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTitle(title: string | null | undefined): string {
|
||||||
|
const safe = typeof title === "string" ? title.trim() : "";
|
||||||
|
return safe.length > 0 ? safe : "无标题";
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRandomId(): string {
|
||||||
|
return typeof crypto.randomUUID === "function" ? crypto.randomUUID() : randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWorkspaceId(value: string | null | undefined): string | null {
|
||||||
|
return trimOrNull(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withAuthedClient() {
|
||||||
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
return { auth, client };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildRuntimeContext(request: Request, workspaceId: string | null) {
|
||||||
|
return buildDocumentBridgeContext({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordLifecycleArtifacts<T>(input: {
|
||||||
|
context: BridgeContext;
|
||||||
|
envelope: CommandEnvelope<T>;
|
||||||
|
client: ConvexHttpClient;
|
||||||
|
}) {
|
||||||
|
await recordBridgeCommandArtifacts({
|
||||||
|
context: input.context,
|
||||||
|
envelope: input.envelope,
|
||||||
|
client: input.client,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordLifecycleFailureArtifacts<T>(input: {
|
||||||
|
context: BridgeContext;
|
||||||
|
envelope: CommandEnvelope<T>;
|
||||||
|
client: ConvexHttpClient;
|
||||||
|
error: unknown;
|
||||||
|
}) {
|
||||||
|
await recordBridgeCommandFailureArtifacts({
|
||||||
|
context: input.context,
|
||||||
|
envelope: input.envelope,
|
||||||
|
client: input.client,
|
||||||
|
error: input.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executePageLifecycleBridgeCommand<TPayload, TResult>(input: {
|
||||||
|
context: BridgeContext;
|
||||||
|
envelope: CommandEnvelope<TPayload>;
|
||||||
|
client?: ConvexHttpClient;
|
||||||
|
}): Promise<PageCommandExecutionResult<TResult>> {
|
||||||
|
const client = input.client ?? (await getAuthedConvexClient()).client;
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
|
context: input.context,
|
||||||
|
envelope: input.envelope,
|
||||||
|
});
|
||||||
|
const result = await executeRustBridgeMutationTransport<TResult>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordBridgeCommandArtifacts({
|
||||||
|
context: input.context,
|
||||||
|
envelope: input.envelope,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestId: input.context.requestId,
|
||||||
|
traceId: input.context.traceId,
|
||||||
|
commandId: input.envelope.commandId,
|
||||||
|
commandName: input.envelope.name,
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeDocumentCreateChildBridgeCommand(request: Request): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as {
|
||||||
|
parentId?: string | null;
|
||||||
|
title?: string;
|
||||||
|
blocks?: unknown;
|
||||||
|
};
|
||||||
|
if (typeof payload.parentId === "undefined") {
|
||||||
|
return NextResponse.json({ error: "缺少 parentId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { auth, client } = await withAuthedClient();
|
||||||
|
const parentId = trimOrNull(payload.parentId);
|
||||||
|
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||||
|
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
||||||
|
workspaceIdIfCreate: safeRandomId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let workspaceId: string | null = null;
|
||||||
|
let accessScope: "private" | "shared" | "public" = "private";
|
||||||
|
|
||||||
|
if (parentId) {
|
||||||
|
const parentDoc = await client.query(api.documents.getMeta, { id: parentId });
|
||||||
|
if (!parentDoc) {
|
||||||
|
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
workspaceId = normalizeWorkspaceId(parentDoc.workspace_id);
|
||||||
|
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||||
|
} else {
|
||||||
|
workspaceId = normalizeWorkspaceId(workspaceBootstrap.activeWorkspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedTitle = normalizeTitle(payload.title);
|
||||||
|
const contentPayload = Array.isArray(payload.blocks) ? payload.blocks : [];
|
||||||
|
const pageId = safeRandomId();
|
||||||
|
const context = await buildRuntimeContext(request, workspaceId);
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.createChild",
|
||||||
|
payload: {
|
||||||
|
documentId: pageId,
|
||||||
|
workspaceId,
|
||||||
|
parentId,
|
||||||
|
title: resolvedTitle,
|
||||||
|
accessScope,
|
||||||
|
content: contentPayload,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
pageId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let created;
|
||||||
|
try {
|
||||||
|
created = await client.mutation(api.documents.create, {
|
||||||
|
id: pageId,
|
||||||
|
workspaceId,
|
||||||
|
parentId,
|
||||||
|
title: resolvedTitle,
|
||||||
|
accessScope,
|
||||||
|
content: contentPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordLifecycleArtifacts({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await recordLifecycleFailureArtifacts({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
pageId: created.id,
|
||||||
|
title: created.title ?? resolvedTitle,
|
||||||
|
meta: {
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
commandId: envelope.commandId,
|
||||||
|
commandName: envelope.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeDocumentEmbedBridgeCommand(request: Request): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const { sourceId, targetId } = (await request.json()) as {
|
||||||
|
sourceId?: string;
|
||||||
|
targetId?: string;
|
||||||
|
};
|
||||||
|
const normalizedSourceId = assertDocumentId(sourceId);
|
||||||
|
const normalizedTargetId = assertDocumentId(targetId);
|
||||||
|
const { client } = await withAuthedClient();
|
||||||
|
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: normalizedSourceId });
|
||||||
|
if (!sourceDoc) {
|
||||||
|
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetContent = await client.query(api.documents.getContent, { id: normalizedTargetId });
|
||||||
|
if (!targetContent) {
|
||||||
|
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
const targetMeta = await client.query(api.documents.getMeta, { id: normalizedTargetId });
|
||||||
|
|
||||||
|
const currentBlocks = extractBlocksFromContent(targetContent.content);
|
||||||
|
const anchorId = trimOrNull((targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id);
|
||||||
|
const anchorIndex =
|
||||||
|
anchorId
|
||||||
|
? currentBlocks.findIndex(
|
||||||
|
(block) => typeof block === "object" && block !== null && String((block as { id?: string }).id ?? "") === anchorId,
|
||||||
|
)
|
||||||
|
: -1;
|
||||||
|
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
|
||||||
|
|
||||||
|
const nextBlocks: Json[] = [
|
||||||
|
...currentBlocks.slice(0, insertIndex),
|
||||||
|
{
|
||||||
|
id: safeRandomId(),
|
||||||
|
type: "pageReference",
|
||||||
|
props: {
|
||||||
|
pageId: normalizedSourceId,
|
||||||
|
title: sourceDoc.title ?? "无标题",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...currentBlocks.slice(insertIndex),
|
||||||
|
];
|
||||||
|
|
||||||
|
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
|
||||||
|
const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id) ?? normalizeWorkspaceId((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
|
||||||
|
const context = await buildRuntimeContext(request, workspaceId);
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.embed",
|
||||||
|
payload: {
|
||||||
|
sourceId: normalizedSourceId,
|
||||||
|
targetId: normalizedTargetId,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
pageId: normalizedTargetId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.mutation(api.documents.updateContent, {
|
||||||
|
id: normalizedTargetId,
|
||||||
|
content: payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordLifecycleArtifacts({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await recordLifecycleFailureArtifacts({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
meta: {
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
commandId: envelope.commandId,
|
||||||
|
commandName: envelope.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeDocumentTemplateBridgeCommand(request: Request): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as {
|
||||||
|
documentId?: string;
|
||||||
|
isTemplate?: boolean;
|
||||||
|
};
|
||||||
|
const normalizedDocumentId = assertDocumentId(payload.documentId);
|
||||||
|
if (typeof payload.isTemplate !== "boolean") {
|
||||||
|
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { client } = await withAuthedClient();
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: normalizedDocumentId });
|
||||||
|
if (!sourceDoc) {
|
||||||
|
return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id);
|
||||||
|
const context = await buildRuntimeContext(request, workspaceId);
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.template",
|
||||||
|
payload: {
|
||||||
|
documentId: normalizedDocumentId,
|
||||||
|
isTemplate: payload.isTemplate,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
pageId: normalizedDocumentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.mutation(api.documents.setTemplate, {
|
||||||
|
id: normalizedDocumentId,
|
||||||
|
isTemplate: payload.isTemplate,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordLifecycleArtifacts({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await recordLifecycleFailureArtifacts({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
meta: {
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
commandId: envelope.commandId,
|
||||||
|
commandName: envelope.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeDocumentEmptyTrashBridgeCommand(request: Request): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as {
|
||||||
|
workspaceId?: string;
|
||||||
|
};
|
||||||
|
const workspaceId = normalizeWorkspaceId(payload.workspaceId);
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { client } = await withAuthedClient();
|
||||||
|
const context = await buildRuntimeContext(request, workspaceId);
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.emptyTrashByWorkspace",
|
||||||
|
payload: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.mutation(api.documents.emptyTrashByWorkspace, { workspaceId });
|
||||||
|
|
||||||
|
await recordLifecycleArtifacts({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await recordLifecycleFailureArtifacts({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
meta: {
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
commandId: envelope.commandId,
|
||||||
|
commandName: envelope.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeDocumentPurgeBridgeCommand(request: Request): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as {
|
||||||
|
documentId?: string;
|
||||||
|
};
|
||||||
|
const normalizedDocumentId = assertDocumentId(payload.documentId);
|
||||||
|
const { client } = await withAuthedClient();
|
||||||
|
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: normalizedDocumentId });
|
||||||
|
if (!sourceDoc) {
|
||||||
|
return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id);
|
||||||
|
const context = await buildRuntimeContext(request, workspaceId);
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.purge",
|
||||||
|
payload: {
|
||||||
|
documentId: normalizedDocumentId,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
pageId: normalizedDocumentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.mutation(api.documents.purge, { id: normalizedDocumentId });
|
||||||
|
|
||||||
|
await recordLifecycleArtifacts({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await recordLifecycleFailureArtifacts({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
meta: {
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
commandId: envelope.commandId,
|
||||||
|
commandName: envelope.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import {
|
||||||
|
buildDocumentBridgeContextWithActor,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
type BridgeContext,
|
||||||
|
type CommandEnvelope,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
recordBridgeCommandArtifacts,
|
||||||
|
recordBridgeCommandFailureArtifacts,
|
||||||
|
} from "@/lib/documents/bridge-log";
|
||||||
|
import { executeRustBridgeMutationTransport, resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
|
||||||
|
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||||
|
|
||||||
|
type CreatePayload = {
|
||||||
|
parentId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MovePayload = {
|
||||||
|
documentId?: string | null;
|
||||||
|
parentId?: string | null;
|
||||||
|
position?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeletePayload = {
|
||||||
|
documentId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RestorePayload = {
|
||||||
|
documentId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DuplicatePayload = {
|
||||||
|
documentId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CopyTreeItem = {
|
||||||
|
documentId: string;
|
||||||
|
recursive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CopyTreePayload = {
|
||||||
|
items?: CopyTreeItem[] | null;
|
||||||
|
targetParentId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const documentsBaseDir = getDocumentsBaseDir();
|
||||||
|
|
||||||
|
function trimOrNull(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTitle(title: string | null): string {
|
||||||
|
const safe = title?.trim();
|
||||||
|
return safe && safe.length > 0 ? safe : "无标题";
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRandomId() {
|
||||||
|
return typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDocumentScaffold(id: string, title: string | null) {
|
||||||
|
const folder = path.join(documentsBaseDir, id);
|
||||||
|
const indexFile = path.join(folder, "index.md");
|
||||||
|
await fs.mkdir(folder, { recursive: true });
|
||||||
|
try {
|
||||||
|
await fs.access(indexFile);
|
||||||
|
} catch {
|
||||||
|
const safeTitle = normalizeTitle(title);
|
||||||
|
await fs.writeFile(indexFile, `# ${safeTitle}\n`, "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyMindmapIfExists(sourceId: string, targetId: string) {
|
||||||
|
try {
|
||||||
|
const srcDir = path.join(documentsBaseDir, sourceId);
|
||||||
|
const destDir = path.join(documentsBaseDir, targetId);
|
||||||
|
const entries = await fs.readdir(srcDir);
|
||||||
|
const mindmapFiles = entries.filter((name) => name === "mindmap.json" || /^mindmap-.+\.json$/i.test(name));
|
||||||
|
if (mindmapFiles.length === 0) return;
|
||||||
|
await fs.mkdir(destDir, { recursive: true });
|
||||||
|
await Promise.all(
|
||||||
|
mindmapFiles.map(async (name) => {
|
||||||
|
try {
|
||||||
|
const buf = await fs.readFile(path.join(srcDir, name));
|
||||||
|
await fs.writeFile(path.join(destDir, name), buf);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// 如果源文件不存在就跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildBridgeContext(request: Request, workspaceId: string | null, authUserId: string): Promise<BridgeContext> {
|
||||||
|
const sessionId = trimOrNull(request.headers.get("x-session-id")) ?? trimOrNull(request.headers.get("x-mnote-session-id"));
|
||||||
|
return buildDocumentBridgeContextWithActor({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: authUserId,
|
||||||
|
sessionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCommandPlan<TPayload>(input: {
|
||||||
|
request: Request;
|
||||||
|
workspaceId: string | null;
|
||||||
|
authUserId: string;
|
||||||
|
envelope: CommandEnvelope<TPayload>;
|
||||||
|
}) {
|
||||||
|
const context = await buildBridgeContext(input.request, input.workspaceId, input.authUserId);
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
|
context,
|
||||||
|
envelope: input.envelope,
|
||||||
|
});
|
||||||
|
return { context, plan };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLifecycleError(error: unknown) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDocumentCreateRequest(request: Request): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as CreatePayload;
|
||||||
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
|
||||||
|
const parentId = trimOrNull(payload.parentId);
|
||||||
|
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||||
|
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
||||||
|
workspaceIdIfCreate: safeRandomId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let workspaceId: string | null = null;
|
||||||
|
let accessScope: "private" | "shared" | "public" = "private";
|
||||||
|
|
||||||
|
if (parentId) {
|
||||||
|
const parentDoc = await client.query(api.documents.getMeta, { id: parentId });
|
||||||
|
if (!parentDoc) {
|
||||||
|
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceId = parentDoc.workspace_id;
|
||||||
|
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||||
|
} else {
|
||||||
|
workspaceId = workspaceBootstrap.activeWorkspaceId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentId = safeRandomId();
|
||||||
|
const context = await buildBridgeContext(request, workspaceId, auth.userId);
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.create",
|
||||||
|
payload: {
|
||||||
|
documentId,
|
||||||
|
workspaceId,
|
||||||
|
parentId,
|
||||||
|
title: "无标题",
|
||||||
|
accessScope,
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
pageId: documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { context, plan } = await resolveCommandPlan({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
authUserId: auth.userId,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await executeRustBridgeMutationTransport<{
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
parent_id: string | null;
|
||||||
|
sort_order: number | null;
|
||||||
|
is_starred: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
workspace_id: string;
|
||||||
|
access_scope: "private" | "shared" | "public";
|
||||||
|
is_template: boolean;
|
||||||
|
}>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (created?.id) {
|
||||||
|
await ensureDocumentScaffold(created.id, created.title ?? "无标题");
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordBridgeCommandArtifacts({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(created);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
const payload = (await request.clone().json().catch(() => ({}))) as MovePayload;
|
||||||
|
const documentId = trimOrNull(payload.documentId);
|
||||||
|
if (documentId) {
|
||||||
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
|
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.move",
|
||||||
|
payload: {
|
||||||
|
documentId,
|
||||||
|
parentId: trimOrNull(payload.parentId),
|
||||||
|
sortOrder: Number.isFinite(payload.position ?? Number.NaN) ? Math.floor(payload.position ?? 0) : 0,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId: sourceDoc?.workspace_id ?? null,
|
||||||
|
pageId: documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await recordBridgeCommandFailureArtifacts({
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略日志写失败,保留原始错误返回
|
||||||
|
}
|
||||||
|
return handleLifecycleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDocumentMoveRequest(request: Request): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as MovePayload;
|
||||||
|
const documentId = trimOrNull(payload.documentId);
|
||||||
|
if (!documentId) {
|
||||||
|
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
|
if (!sourceDoc) {
|
||||||
|
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await buildBridgeContext(request, sourceDoc.workspace_id ?? null, auth.userId);
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.move",
|
||||||
|
payload: {
|
||||||
|
documentId,
|
||||||
|
parentId: trimOrNull(payload.parentId),
|
||||||
|
sortOrder: Number.isFinite(payload.position ?? Number.NaN) ? Math.floor(payload.position ?? 0) : 0,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
pageId: documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { context: runtimeContext, plan } = await resolveCommandPlan({
|
||||||
|
request,
|
||||||
|
workspaceId: null,
|
||||||
|
authUserId: auth.userId,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
await executeRustBridgeMutationTransport({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
await recordBridgeCommandArtifacts({
|
||||||
|
context: runtimeContext,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
const payload = (await request.clone().json().catch(() => ({}))) as DeletePayload;
|
||||||
|
const documentId = trimOrNull(payload.documentId);
|
||||||
|
if (documentId) {
|
||||||
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
|
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.delete",
|
||||||
|
payload: { documentId },
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId: sourceDoc?.workspace_id ?? null,
|
||||||
|
pageId: documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await recordBridgeCommandFailureArtifacts({
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略日志写失败,保留原始错误返回
|
||||||
|
}
|
||||||
|
return handleLifecycleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDocumentDeleteRequest(request: Request): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as DeletePayload;
|
||||||
|
const documentId = trimOrNull(payload.documentId);
|
||||||
|
if (!documentId) {
|
||||||
|
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
|
if (!sourceDoc) {
|
||||||
|
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await buildBridgeContext(request, sourceDoc.workspace_id ?? null, auth.userId);
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.delete",
|
||||||
|
payload: { documentId },
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
pageId: documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { context: runtimeContext, plan } = await resolveCommandPlan({
|
||||||
|
request,
|
||||||
|
workspaceId: null,
|
||||||
|
authUserId: auth.userId,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
await executeRustBridgeMutationTransport({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
await recordBridgeCommandArtifacts({
|
||||||
|
context: runtimeContext,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
const payload = (await request.clone().json().catch(() => ({}))) as RestorePayload;
|
||||||
|
const documentId = trimOrNull(payload.documentId);
|
||||||
|
if (documentId) {
|
||||||
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
|
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.restore",
|
||||||
|
payload: { documentId },
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId: sourceDoc?.workspace_id ?? null,
|
||||||
|
pageId: documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await recordBridgeCommandFailureArtifacts({
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略日志写失败,保留原始错误返回
|
||||||
|
}
|
||||||
|
return handleLifecycleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDocumentRestoreRequest(request: Request): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as RestorePayload;
|
||||||
|
const documentId = trimOrNull(payload.documentId);
|
||||||
|
if (!documentId) {
|
||||||
|
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
|
if (!sourceDoc) {
|
||||||
|
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await buildBridgeContext(request, sourceDoc.workspace_id ?? null, auth.userId);
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.restore",
|
||||||
|
payload: { documentId },
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
pageId: documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { context: runtimeContext, plan } = await resolveCommandPlan({
|
||||||
|
request,
|
||||||
|
workspaceId: null,
|
||||||
|
authUserId: auth.userId,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
await executeRustBridgeMutationTransport({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
await recordBridgeCommandArtifacts({
|
||||||
|
context: runtimeContext,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
const payload = (await request.clone().json().catch(() => ({}))) as DuplicatePayload;
|
||||||
|
const documentId = trimOrNull(payload.documentId);
|
||||||
|
if (documentId) {
|
||||||
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
|
const workspaceId = sourceDoc?.workspace_id ?? null;
|
||||||
|
const context = await buildBridgeContext(request, workspaceId, auth.userId);
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.duplicate",
|
||||||
|
payload: {
|
||||||
|
sourceDocumentId: documentId,
|
||||||
|
newDocumentId: "duplicate_failed",
|
||||||
|
title: sourceDoc?.title ?? null,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await recordBridgeCommandFailureArtifacts({
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略日志写失败,保留原始错误返回
|
||||||
|
}
|
||||||
|
return handleLifecycleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDocumentDuplicateRequest(request: Request): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as DuplicatePayload;
|
||||||
|
const documentId = trimOrNull(payload.documentId);
|
||||||
|
if (!documentId) {
|
||||||
|
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
|
if (!sourceDoc) {
|
||||||
|
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceId = sourceDoc.workspace_id ?? null;
|
||||||
|
const context = await buildBridgeContext(request, workspaceId, auth.userId);
|
||||||
|
const fallbackTitle = normalizeTitle(sourceDoc.title);
|
||||||
|
const duplicatedTitle = `${fallbackTitle} 副本`;
|
||||||
|
const newId = safeRandomId();
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.duplicate",
|
||||||
|
payload: {
|
||||||
|
sourceDocumentId: documentId,
|
||||||
|
newDocumentId: newId,
|
||||||
|
title: duplicatedTitle,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
pageId: newId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { context: runtimeContext, plan } = await resolveCommandPlan({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
authUserId: auth.userId,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
const duplicated = await executeRustBridgeMutationTransport<{
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
parent_id: string | null;
|
||||||
|
sort_order: number | null;
|
||||||
|
workspace_id: string;
|
||||||
|
access_scope: "private" | "shared" | "public";
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
|
||||||
|
await copyMindmapIfExists(documentId, duplicated.id);
|
||||||
|
|
||||||
|
await recordBridgeCommandArtifacts({
|
||||||
|
context: runtimeContext,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: duplicated.id,
|
||||||
|
title: duplicated.title ?? duplicatedTitle,
|
||||||
|
parent_id: duplicated.parent_id ?? null,
|
||||||
|
sort_order: duplicated.sort_order ?? null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
const payload = (await request.clone().json().catch(() => ({}))) as CopyTreePayload;
|
||||||
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
const context = await buildBridgeContext(request, null, auth.userId);
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.copy_tree",
|
||||||
|
payload: {
|
||||||
|
items: (payload.items ?? []).map((item) => ({
|
||||||
|
documentId: trimOrNull(item.documentId) ?? "unknown",
|
||||||
|
recursive: Boolean(item.recursive),
|
||||||
|
})),
|
||||||
|
targetParentId: trimOrNull(payload.targetParentId),
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
await recordBridgeCommandFailureArtifacts({
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 忽略日志写失败,保留原始错误返回
|
||||||
|
}
|
||||||
|
return handleLifecycleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDocumentCopyTreeRequest(request: Request): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as CopyTreePayload;
|
||||||
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
|
||||||
|
const normalizedItems = (payload.items ?? []).filter((it) => trimOrNull(it?.documentId));
|
||||||
|
if (normalizedItems.length === 0) {
|
||||||
|
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetParentId = trimOrNull(payload.targetParentId);
|
||||||
|
let workspaceId: string | null = null;
|
||||||
|
|
||||||
|
if (targetParentId) {
|
||||||
|
const targetDoc = await client.query(api.documents.getMeta, { id: targetParentId });
|
||||||
|
if (!targetDoc) {
|
||||||
|
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
workspaceId = targetDoc.workspace_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceIds = Array.from(new Set(normalizedItems.map((it) => trimOrNull(it.documentId) as string)));
|
||||||
|
const firstMeta = await client.query(api.documents.getMeta, { id: sourceIds[0] });
|
||||||
|
if (!firstMeta) {
|
||||||
|
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
workspaceId = firstMeta.workspace_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await buildBridgeContext(request, workspaceId, auth.userId);
|
||||||
|
const outerEnvelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.copy_tree",
|
||||||
|
payload: {
|
||||||
|
items: normalizedItems.map((item) => ({
|
||||||
|
documentId: trimOrNull(item.documentId) as string,
|
||||||
|
recursive: Boolean(item.recursive),
|
||||||
|
})),
|
||||||
|
targetParentId,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
pageId: targetParentId ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { context: runtimeContext, plan } = await resolveCommandPlan({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
authUserId: auth.userId,
|
||||||
|
envelope: outerEnvelope,
|
||||||
|
});
|
||||||
|
const result = await executeRustBridgeMutationTransport<{
|
||||||
|
items: Array<{ oldId: string; newId: string }>;
|
||||||
|
}>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertedDocs = Array.isArray(result?.items) ? result.items : [];
|
||||||
|
for (const item of insertedDocs) {
|
||||||
|
const nextDoc = await client.query(api.documents.getMeta, { id: item.newId });
|
||||||
|
await ensureDocumentScaffold(item.newId, nextDoc?.title ?? null);
|
||||||
|
await copyMindmapIfExists(item.oldId, item.newId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordBridgeCommandArtifacts({
|
||||||
|
context: runtimeContext,
|
||||||
|
envelope: outerEnvelope,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
items: insertedDocs,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleLifecycleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||||
|
|
||||||
|
const documentsBaseDir = getDocumentsBaseDir();
|
||||||
|
|
||||||
|
export async function ensureDocumentScaffold(id: string, title: string | null) {
|
||||||
|
const folder = path.join(documentsBaseDir, id);
|
||||||
|
const indexFile = path.join(folder, "index.md");
|
||||||
|
await fs.mkdir(folder, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(indexFile);
|
||||||
|
} catch {
|
||||||
|
const safeTitle = title && title.trim() ? title.trim() : "无标题";
|
||||||
|
await fs.writeFile(indexFile, `# ${safeTitle}\n`, "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyMindmapFilesIfExists(sourceId: string, targetId: string) {
|
||||||
|
const srcDir = path.join(documentsBaseDir, sourceId);
|
||||||
|
const destDir = path.join(documentsBaseDir, targetId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(srcDir);
|
||||||
|
const mindmapFiles = entries.filter(
|
||||||
|
(name) => name === "mindmap.json" || /^mindmap-.+\.json$/i.test(name),
|
||||||
|
);
|
||||||
|
if (mindmapFiles.length === 0) return;
|
||||||
|
|
||||||
|
await fs.mkdir(destDir, { recursive: true });
|
||||||
|
await Promise.all(
|
||||||
|
mindmapFiles.map(async (name) => {
|
||||||
|
try {
|
||||||
|
const sourceFile = path.join(srcDir, name);
|
||||||
|
const targetFile = path.join(destDir, name);
|
||||||
|
const buffer = await fs.readFile(sourceFile);
|
||||||
|
await fs.writeFile(targetFile, buffer);
|
||||||
|
} catch {
|
||||||
|
// 说明:本地思维导图副作用失败不应反向打断主页面操作。
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// 说明:源页面不存在本地思维导图文件时直接忽略。
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { constants as fsConstants } from "node:fs";
|
||||||
|
import { access } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { ConvexHttpClient } from "convex/browser";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import {
|
||||||
|
DocumentBridgeError,
|
||||||
|
type BridgeTarget,
|
||||||
|
type BridgeContext,
|
||||||
|
type CommandEnvelope,
|
||||||
|
type QueryEnvelope,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
|
||||||
|
export type RustRuntimeExecutedQuery<TResult = unknown> = {
|
||||||
|
ok: true;
|
||||||
|
result: TResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RustRuntimeErrorKind =
|
||||||
|
| "validation"
|
||||||
|
| "unauthorized"
|
||||||
|
| "conflict"
|
||||||
|
| "not_found"
|
||||||
|
| "transport"
|
||||||
|
| "rejected";
|
||||||
|
|
||||||
|
type RustRuntimeErrorPayload = {
|
||||||
|
kind: RustRuntimeErrorKind | string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RustRuntimeResponse =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
plan: RustBridgeQueryPlan | RustBridgeCommandPlan | RustBridgeToolPlan | RustBridgeBuiltinToolPlan;
|
||||||
|
}
|
||||||
|
| RustRuntimeExecutedQuery
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
error: RustRuntimeErrorPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RustBridgeQueryPlan = {
|
||||||
|
kind: "query";
|
||||||
|
queryName: string;
|
||||||
|
functionName: string;
|
||||||
|
workspaceId: string | null;
|
||||||
|
requestId: string;
|
||||||
|
traceId: string;
|
||||||
|
actorId: string;
|
||||||
|
payloadJson: string;
|
||||||
|
argsJson: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RustBridgeCommandPlan = {
|
||||||
|
kind: "command";
|
||||||
|
commandName: string;
|
||||||
|
commandId: string;
|
||||||
|
functionName: string;
|
||||||
|
workspaceId: string | null;
|
||||||
|
requestId: string;
|
||||||
|
traceId: string;
|
||||||
|
actorId: string;
|
||||||
|
idempotencyKey: string | null;
|
||||||
|
payloadJson: string;
|
||||||
|
argsJson: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RustBridgeToolPlanStep = {
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
functionName: string | null;
|
||||||
|
description: string;
|
||||||
|
argsJson: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RustBridgeToolPlan = {
|
||||||
|
kind: "tool";
|
||||||
|
toolName: string;
|
||||||
|
invocationKind: string;
|
||||||
|
executionMode: string;
|
||||||
|
effect: string;
|
||||||
|
toolsetId: string;
|
||||||
|
requiresConfirmation: boolean;
|
||||||
|
requestId: string;
|
||||||
|
traceId: string;
|
||||||
|
actorId: string;
|
||||||
|
validateOnly: boolean;
|
||||||
|
dryRun: boolean;
|
||||||
|
payloadJson: string;
|
||||||
|
argsJson: Record<string, unknown>;
|
||||||
|
target: Record<string, unknown> | null;
|
||||||
|
steps: RustBridgeToolPlanStep[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RustBridgeBuiltinToolPlan = {
|
||||||
|
kind: "builtin_tool";
|
||||||
|
toolName: string;
|
||||||
|
toolsetId: string;
|
||||||
|
status: "rust" | "mixed" | "ts" | "transport";
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RustBridgeToolResult<TResult = unknown> = {
|
||||||
|
plan: RustBridgeToolPlan;
|
||||||
|
result: TResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuntimeProcessResult = {
|
||||||
|
code: number | null;
|
||||||
|
signal: NodeJS.Signals | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuntimeInvocation = {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const RUST_RUNTIME_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
async function pathExists(targetPath: string) {
|
||||||
|
try {
|
||||||
|
await access(targetPath, fsConstants.F_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRepoRoot() {
|
||||||
|
const candidates = [process.cwd(), path.resolve(process.cwd(), "..")];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (await pathExists(path.join(candidate, "rust", "Cargo.toml"))) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new DocumentBridgeError("未找到 mnote 仓库根目录", 500, "TRANSPORT_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRuntimeInvocation(): Promise<RuntimeInvocation> {
|
||||||
|
const explicitBin = process.env.MNOTE_RUST_BRIDGE_BIN?.trim();
|
||||||
|
if (explicitBin) {
|
||||||
|
return {
|
||||||
|
command: explicitBin,
|
||||||
|
args: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoRoot = await resolveRepoRoot();
|
||||||
|
const builtBinary = path.join(repoRoot, "rust", "target", "debug", "bridge-runtime");
|
||||||
|
if (await pathExists(builtBinary)) {
|
||||||
|
return {
|
||||||
|
command: builtBinary,
|
||||||
|
args: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: "cargo",
|
||||||
|
args: [
|
||||||
|
"run",
|
||||||
|
"--quiet",
|
||||||
|
"--manifest-path",
|
||||||
|
path.join(repoRoot, "rust", "Cargo.toml"),
|
||||||
|
"-p",
|
||||||
|
"bridge-runtime",
|
||||||
|
"--",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRustRuntime(input: Record<string, unknown>): Promise<RustRuntimeResponse> {
|
||||||
|
const invocation = await resolveRuntimeInvocation();
|
||||||
|
const result = await new Promise<RuntimeProcessResult>((resolve, reject) => {
|
||||||
|
const child = spawn(invocation.command, invocation.args, {
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CARGO_TERM_COLOR: "never",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let timedOut = false;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}, RUST_RUNTIME_TIMEOUT_MS);
|
||||||
|
|
||||||
|
child.stdout.setEncoding("utf8");
|
||||||
|
child.stderr.setEncoding("utf8");
|
||||||
|
child.stdout.on("data", (chunk: string) => {
|
||||||
|
stdout += chunk;
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk: string) => {
|
||||||
|
stderr += chunk;
|
||||||
|
});
|
||||||
|
child.on("error", (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
child.on("close", (code, signal) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (timedOut) {
|
||||||
|
reject(new DocumentBridgeError("Rust runtime 执行超时", 504, "TRANSPORT_ERROR"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
code,
|
||||||
|
signal,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdin.end(JSON.stringify(input));
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
if (error instanceof DocumentBridgeError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new DocumentBridgeError(
|
||||||
|
error instanceof Error ? `Rust runtime 启动失败: ${error.message}` : "Rust runtime 启动失败",
|
||||||
|
500,
|
||||||
|
"TRANSPORT_ERROR",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = result.stdout.trim();
|
||||||
|
if (!raw) {
|
||||||
|
throw new DocumentBridgeError(
|
||||||
|
result.stderr.trim() || "Rust runtime 未返回任何结果",
|
||||||
|
500,
|
||||||
|
"TRANSPORT_ERROR",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: RustRuntimeResponse;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw) as RustRuntimeResponse;
|
||||||
|
} catch (error) {
|
||||||
|
throw new DocumentBridgeError(
|
||||||
|
`Rust runtime 返回了非法 JSON: ${error instanceof Error ? error.message : "unknown"}`,
|
||||||
|
500,
|
||||||
|
"TRANSPORT_ERROR",
|
||||||
|
{
|
||||||
|
stdout: raw,
|
||||||
|
stderr: result.stderr.trim() || null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.code !== 0 || !parsed.ok) {
|
||||||
|
if (!parsed.ok) {
|
||||||
|
throw toDocumentBridgeError("error" in parsed ? parsed.error : { kind: "transport", message: "Rust runtime 执行失败" });
|
||||||
|
}
|
||||||
|
throw new DocumentBridgeError(
|
||||||
|
result.stderr.trim() ||
|
||||||
|
`Rust runtime 执行失败(code=${String(result.code)}, signal=${String(result.signal)})`,
|
||||||
|
500,
|
||||||
|
"TRANSPORT_ERROR",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDocumentBridgeError(error: RustRuntimeErrorPayload) {
|
||||||
|
switch (error.kind) {
|
||||||
|
case "validation":
|
||||||
|
return new DocumentBridgeError(error.message, 400, "VALIDATION_ERROR");
|
||||||
|
case "unauthorized":
|
||||||
|
return new DocumentBridgeError(error.message, 401, "UNAUTHORIZED");
|
||||||
|
case "not_found":
|
||||||
|
return new DocumentBridgeError(error.message, 404, "NOT_FOUND");
|
||||||
|
case "conflict":
|
||||||
|
return new DocumentBridgeError(error.message, 409, "REJECTED");
|
||||||
|
case "rejected":
|
||||||
|
return new DocumentBridgeError(error.message, 409, "REJECTED");
|
||||||
|
case "transport":
|
||||||
|
default:
|
||||||
|
return new DocumentBridgeError(error.message, 500, "TRANSPORT_ERROR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertObjectArgs(argsJson: Record<string, unknown>) {
|
||||||
|
if (!argsJson || typeof argsJson !== "object" || Array.isArray(argsJson)) {
|
||||||
|
throw new DocumentBridgeError("Rust runtime 返回了非法 transport args", 500, "TRANSPORT_ERROR");
|
||||||
|
}
|
||||||
|
return argsJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertToolPlan(plan: RustBridgeQueryPlan | RustBridgeCommandPlan | RustBridgeToolPlan): RustBridgeToolPlan {
|
||||||
|
if (plan.kind !== "tool") {
|
||||||
|
throw new DocumentBridgeError("Rust runtime 返回了错误的 tool plan", 500, "TRANSPORT_ERROR");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...plan,
|
||||||
|
argsJson: assertObjectArgs(plan.argsJson),
|
||||||
|
target:
|
||||||
|
plan.target && typeof plan.target === "object" && !Array.isArray(plan.target)
|
||||||
|
? (plan.target as Record<string, unknown>)
|
||||||
|
: null,
|
||||||
|
steps: Array.isArray(plan.steps)
|
||||||
|
? plan.steps.map((step) => ({
|
||||||
|
kind: String(step.kind ?? ""),
|
||||||
|
name: String(step.name ?? ""),
|
||||||
|
functionName: typeof step.functionName === "string" ? step.functionName : null,
|
||||||
|
description: String(step.description ?? ""),
|
||||||
|
argsJson:
|
||||||
|
step.argsJson && typeof step.argsJson === "object" && !Array.isArray(step.argsJson)
|
||||||
|
? (step.argsJson as Record<string, unknown>)
|
||||||
|
: {},
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertStringArg(argsJson: Record<string, unknown>, field: string) {
|
||||||
|
const value = argsJson[field];
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalIntegerArg(argsJson: Record<string, unknown>, field: string) {
|
||||||
|
const value = argsJson[field];
|
||||||
|
if (value === null || typeof value === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof value === "number" && Number.isInteger(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
throw new DocumentBridgeError(`Rust runtime 的 ${field} 非法`, 500, "TRANSPORT_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalStringArg(argsJson: Record<string, unknown>, field: string) {
|
||||||
|
const value = argsJson[field];
|
||||||
|
if (value === null || typeof value === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
throw new DocumentBridgeError(`Rust runtime 的 ${field} 非法`, 500, "TRANSPORT_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRequiredNumberArg(argsJson: Record<string, unknown>, field: string) {
|
||||||
|
const value = argsJson[field];
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveRustBridgeQueryPlan<TPayload>(input: {
|
||||||
|
context: BridgeContext;
|
||||||
|
envelope: QueryEnvelope<TPayload>;
|
||||||
|
}): Promise<RustBridgeQueryPlan> {
|
||||||
|
const response = await runRustRuntime({
|
||||||
|
kind: "query",
|
||||||
|
context: input.context,
|
||||||
|
query: input.envelope,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!("plan" in response) || response.plan.kind !== "query") {
|
||||||
|
throw new DocumentBridgeError("Rust runtime 返回了错误的 query plan", 500, "TRANSPORT_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response.plan,
|
||||||
|
argsJson: assertObjectArgs(response.plan.argsJson),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeRustBridgeQuery<TResult>(input: {
|
||||||
|
context: BridgeContext;
|
||||||
|
envelope: QueryEnvelope<unknown>;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}): Promise<TResult> {
|
||||||
|
const response = await runRustRuntime({
|
||||||
|
kind: "query",
|
||||||
|
context: input.context,
|
||||||
|
query: input.envelope,
|
||||||
|
data: input.data ?? {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!("result" in response)) {
|
||||||
|
throw new DocumentBridgeError("Rust runtime 未返回 query result", 500, "TRANSPORT_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.result as TResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveRustBridgeCommandPlan<TPayload>(input: {
|
||||||
|
context: BridgeContext;
|
||||||
|
envelope: CommandEnvelope<TPayload>;
|
||||||
|
}): Promise<RustBridgeCommandPlan> {
|
||||||
|
const response = await runRustRuntime({
|
||||||
|
kind: "command",
|
||||||
|
context: input.context,
|
||||||
|
command: input.envelope,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!("plan" in response) || response.plan.kind !== "command") {
|
||||||
|
throw new DocumentBridgeError("Rust runtime 返回了错误的 command plan", 500, "TRANSPORT_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response.plan,
|
||||||
|
argsJson: assertObjectArgs(response.plan.argsJson),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveRustBridgeToolPlan(input: {
|
||||||
|
context: BridgeContext;
|
||||||
|
toolName: string;
|
||||||
|
invocationKind: "command" | "query" | "job";
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
target?: BridgeTarget | null;
|
||||||
|
reason?: string | null;
|
||||||
|
refs?: string[];
|
||||||
|
mode?: "plan" | "result" | "explain-plan";
|
||||||
|
}): Promise<RustBridgeToolPlan> {
|
||||||
|
const response = await runRustRuntime({
|
||||||
|
kind: "tool",
|
||||||
|
context: input.context,
|
||||||
|
tool: {
|
||||||
|
tool: input.toolName,
|
||||||
|
kind: input.invocationKind,
|
||||||
|
mode: input.mode ?? "plan",
|
||||||
|
argsJson: input.args,
|
||||||
|
target: input.target ?? null,
|
||||||
|
reason: input.reason ?? null,
|
||||||
|
refs: input.refs ?? [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!("plan" in response)) {
|
||||||
|
throw new DocumentBridgeError("Rust runtime 未返回 tool plan", 500, "TRANSPORT_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.plan.kind !== "tool") {
|
||||||
|
throw new DocumentBridgeError("Rust runtime 返回了错误的 tool plan", 500, "TRANSPORT_ERROR");
|
||||||
|
}
|
||||||
|
return assertToolPlan(response.plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeRustBridgeTool<TResult>(input: {
|
||||||
|
context: BridgeContext;
|
||||||
|
toolName: string;
|
||||||
|
invocationKind: "command" | "query" | "job";
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
target?: BridgeTarget | null;
|
||||||
|
reason?: string | null;
|
||||||
|
refs?: string[];
|
||||||
|
mode?: "result" | "explain-plan";
|
||||||
|
}): Promise<RustBridgeToolResult<TResult>> {
|
||||||
|
const response = await runRustRuntime({
|
||||||
|
kind: "tool",
|
||||||
|
context: input.context,
|
||||||
|
tool: {
|
||||||
|
tool: input.toolName,
|
||||||
|
kind: input.invocationKind,
|
||||||
|
mode: input.mode ?? "result",
|
||||||
|
argsJson: input.args,
|
||||||
|
target: input.target ?? null,
|
||||||
|
reason: input.reason ?? null,
|
||||||
|
refs: input.refs ?? [],
|
||||||
|
},
|
||||||
|
data: input.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!("result" in response)) {
|
||||||
|
throw new DocumentBridgeError("Rust runtime 未返回 tool result", 500, "TRANSPORT_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = await resolveRustBridgeToolPlan({
|
||||||
|
context: input.context,
|
||||||
|
toolName: input.toolName,
|
||||||
|
invocationKind: input.invocationKind,
|
||||||
|
args: input.args,
|
||||||
|
target: input.target,
|
||||||
|
reason: input.reason,
|
||||||
|
refs: input.refs,
|
||||||
|
mode: input.mode === "explain-plan" ? "explain-plan" : "plan",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
plan,
|
||||||
|
result: response.result as TResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeRustBridgeQueryTransport<TResult>(input: {
|
||||||
|
client: ConvexHttpClient;
|
||||||
|
plan: RustBridgeQueryPlan;
|
||||||
|
}): Promise<TResult> {
|
||||||
|
const bridgeLogsApi = api as any;
|
||||||
|
const query = input.client.query.bind(input.client) as (
|
||||||
|
queryReference: unknown,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
) => Promise<TResult>;
|
||||||
|
|
||||||
|
switch (input.plan.functionName) {
|
||||||
|
case "documents:getContent":
|
||||||
|
return query(api.documents.getContent, {
|
||||||
|
id: assertStringArg(input.plan.argsJson, "id"),
|
||||||
|
});
|
||||||
|
case "mindmaps:get":
|
||||||
|
return query(api.mindmaps.get, {
|
||||||
|
docId: assertStringArg(input.plan.argsJson, "docId"),
|
||||||
|
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
|
||||||
|
});
|
||||||
|
case "sidebar:datasetList":
|
||||||
|
return query(api.sidebar.datasetList, {
|
||||||
|
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||||
|
});
|
||||||
|
case "blocks:getById":
|
||||||
|
return query(api.blocks.getById, {
|
||||||
|
userId: assertStringArg(input.plan.argsJson, "userId"),
|
||||||
|
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||||
|
blockId: assertStringArg(input.plan.argsJson, "blockId"),
|
||||||
|
});
|
||||||
|
case "bridgeLogs:listByRequest":
|
||||||
|
return query(bridgeLogsApi.bridgeLogs.listByRequest, {
|
||||||
|
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||||
|
requestId: assertStringArg(input.plan.argsJson, "requestId"),
|
||||||
|
});
|
||||||
|
case "bridgeLogs:listByTrace":
|
||||||
|
return query(bridgeLogsApi.bridgeLogs.listByTrace, {
|
||||||
|
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||||
|
traceId: assertStringArg(input.plan.argsJson, "traceId"),
|
||||||
|
});
|
||||||
|
case "bridgeLogs:listByCommand":
|
||||||
|
return query(bridgeLogsApi.bridgeLogs.listByCommand, {
|
||||||
|
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||||
|
commandId: assertStringArg(input.plan.argsJson, "commandId"),
|
||||||
|
});
|
||||||
|
case "bridgeLogs:listWorkspaceOverview":
|
||||||
|
return query(bridgeLogsApi.bridgeLogs.listWorkspaceOverview, {
|
||||||
|
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||||
|
limit:
|
||||||
|
typeof input.plan.argsJson.limit === "number" && Number.isFinite(input.plan.argsJson.limit)
|
||||||
|
? input.plan.argsJson.limit
|
||||||
|
: undefined,
|
||||||
|
cursor: readOptionalStringArg(input.plan.argsJson, "cursor"),
|
||||||
|
commandStatus: readOptionalStringArg(input.plan.argsJson, "commandStatus"),
|
||||||
|
eventStatus: readOptionalStringArg(input.plan.argsJson, "eventStatus"),
|
||||||
|
targetPageId: readOptionalStringArg(input.plan.argsJson, "targetPageId"),
|
||||||
|
targetBlockId: readOptionalStringArg(input.plan.argsJson, "targetBlockId"),
|
||||||
|
aggregateType: readOptionalStringArg(input.plan.argsJson, "aggregateType"),
|
||||||
|
aggregateId: readOptionalStringArg(input.plan.argsJson, "aggregateId"),
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
throw new DocumentBridgeError(
|
||||||
|
`未注册的 Rust query transport: ${input.plan.functionName}`,
|
||||||
|
500,
|
||||||
|
"TRANSPORT_ERROR",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeRustBridgeMutationTransport<TResult>(input: {
|
||||||
|
client: ConvexHttpClient;
|
||||||
|
plan: RustBridgeCommandPlan;
|
||||||
|
}): Promise<TResult> {
|
||||||
|
const mutation = input.client.mutation.bind(input.client) as (
|
||||||
|
mutationReference: unknown,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
) => Promise<TResult>;
|
||||||
|
|
||||||
|
switch (input.plan.functionName) {
|
||||||
|
case "documents:createWithParentReference":
|
||||||
|
return mutation(api.documents.create, {
|
||||||
|
id: assertStringArg(input.plan.argsJson, "id"),
|
||||||
|
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||||
|
parentId: readOptionalStringArg(input.plan.argsJson, "parentId"),
|
||||||
|
title: assertStringArg(input.plan.argsJson, "title"),
|
||||||
|
accessScope: assertStringArg(input.plan.argsJson, "accessScope"),
|
||||||
|
content: input.plan.argsJson.content,
|
||||||
|
});
|
||||||
|
case "documents:move":
|
||||||
|
return mutation(api.documents.move, {
|
||||||
|
id: assertStringArg(input.plan.argsJson, "id"),
|
||||||
|
parentId: readOptionalStringArg(input.plan.argsJson, "parentId"),
|
||||||
|
sortOrder: readRequiredNumberArg(input.plan.argsJson, "sortOrder"),
|
||||||
|
});
|
||||||
|
case "documents:softDelete":
|
||||||
|
return mutation(api.documents.softDelete, {
|
||||||
|
id: assertStringArg(input.plan.argsJson, "id"),
|
||||||
|
});
|
||||||
|
case "documents:restore":
|
||||||
|
return mutation(api.documents.restore, {
|
||||||
|
id: assertStringArg(input.plan.argsJson, "id"),
|
||||||
|
});
|
||||||
|
case "documents:duplicateWithMindmaps":
|
||||||
|
return mutation(api.documents.duplicate, {
|
||||||
|
sourceId: assertStringArg(input.plan.argsJson, "sourceId"),
|
||||||
|
newId: assertStringArg(input.plan.argsJson, "newId"),
|
||||||
|
title: readOptionalStringArg(input.plan.argsJson, "title"),
|
||||||
|
});
|
||||||
|
case "documents:updateTitle":
|
||||||
|
return mutation(api.documents.updateTitle, {
|
||||||
|
id: assertStringArg(input.plan.argsJson, "id"),
|
||||||
|
title: assertStringArg(input.plan.argsJson, "title"),
|
||||||
|
});
|
||||||
|
case "documents:updateContent":
|
||||||
|
return mutation(api.documents.updateContent, {
|
||||||
|
id: assertStringArg(input.plan.argsJson, "id"),
|
||||||
|
content: input.plan.argsJson.content,
|
||||||
|
expectedRevision: readOptionalIntegerArg(input.plan.argsJson, "expectedRevision"),
|
||||||
|
conflictDetectionKey: readOptionalStringArg(input.plan.argsJson, "conflictDetectionKey"),
|
||||||
|
});
|
||||||
|
case "mindmaps:put":
|
||||||
|
return mutation(api.mindmaps.put, {
|
||||||
|
docId: assertStringArg(input.plan.argsJson, "docId"),
|
||||||
|
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
|
||||||
|
data: input.plan.argsJson.data,
|
||||||
|
createOnly:
|
||||||
|
typeof input.plan.argsJson.createOnly === "boolean"
|
||||||
|
? input.plan.argsJson.createOnly
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
throw new DocumentBridgeError(
|
||||||
|
`未注册的 Rust mutation transport: ${input.plan.functionName}`,
|
||||||
|
500,
|
||||||
|
"TRANSPORT_ERROR",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import { api } from "@/lib/convex/api";
|
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import {
|
import {
|
||||||
buildDocumentBridgeMutationRequest,
|
|
||||||
executeDocumentBridgeMutationRequest,
|
|
||||||
type CommandEnvelope,
|
type CommandEnvelope,
|
||||||
type BridgeContext,
|
type BridgeContext,
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
import {
|
||||||
|
recordBridgeCommandArtifacts,
|
||||||
|
recordBridgeCommandFailureArtifacts,
|
||||||
|
} from "@/lib/documents/bridge-log";
|
||||||
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
|
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
|
||||||
import { DocumentBridgeError } from "@/lib/documents/bridge";
|
import { DocumentBridgeError } from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
export type DocumentSaveExecutionResult = {
|
export type DocumentSaveExecutionResult = {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@@ -24,25 +28,27 @@ export async function executeSaveBridgeCommand(input: {
|
|||||||
envelope: CommandEnvelope<DocumentSavePayload>;
|
envelope: CommandEnvelope<DocumentSavePayload>;
|
||||||
}): Promise<DocumentSaveExecutionResult> {
|
}): Promise<DocumentSaveExecutionResult> {
|
||||||
const { client } = await getAuthedConvexClient();
|
const { client } = await getAuthedConvexClient();
|
||||||
const mutationRequest = buildDocumentBridgeMutationRequest({
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
context: input.context,
|
context: input.context,
|
||||||
envelope: input.envelope,
|
envelope: input.envelope,
|
||||||
mapConvexArgs: (payload) => ({
|
|
||||||
id: payload.documentId,
|
|
||||||
content: payload.content,
|
|
||||||
expectedRevision: payload.revision,
|
|
||||||
conflictDetectionKey: payload.conflictDetectionKey,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let mutationResult;
|
let mutationResult;
|
||||||
try {
|
try {
|
||||||
mutationResult = await executeDocumentBridgeMutationRequest({
|
mutationResult = await executeRustBridgeMutationTransport<{
|
||||||
|
revision?: number | null;
|
||||||
|
conflict_detection_key?: string | null;
|
||||||
|
}>({
|
||||||
client,
|
client,
|
||||||
mutation: api.documents.updateContent,
|
plan,
|
||||||
request: mutationRequest,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await recordBridgeCommandFailureArtifacts({
|
||||||
|
client,
|
||||||
|
context: input.context,
|
||||||
|
envelope: input.envelope,
|
||||||
|
error,
|
||||||
|
});
|
||||||
if (error instanceof Error && /正文(内容已变更|冲突检测失败)/.test(error.message)) {
|
if (error instanceof Error && /正文(内容已变更|冲突检测失败)/.test(error.message)) {
|
||||||
throw new DocumentBridgeError(error.message, 409, "REJECTED", {
|
throw new DocumentBridgeError(error.message, 409, "REJECTED", {
|
||||||
reason: "content_conflict",
|
reason: "content_conflict",
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export type MindmapOp =
|
|||||||
| { op: "appendNote"; uid: string; markdown: string }
|
| { op: "appendNote"; uid: string; markdown: string }
|
||||||
| { op: "deleteNode"; uid: string };
|
| { op: "deleteNode"; uid: string };
|
||||||
|
|
||||||
|
export type MindmapOpErrorKind = "rejected" | "failed";
|
||||||
|
|
||||||
const createUid = () => {
|
const createUid = () => {
|
||||||
const anyCrypto = globalThis.crypto as unknown as { randomUUID?: () => string } | undefined;
|
const anyCrypto = globalThis.crypto as unknown as { randomUUID?: () => string } | undefined;
|
||||||
if (anyCrypto?.randomUUID) return anyCrypto.randomUUID();
|
if (anyCrypto?.randomUUID) return anyCrypto.randomUUID();
|
||||||
@@ -94,6 +96,20 @@ const safeUrlOrNull = (value: unknown) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const classifyMindmapOpErrors = (errors: string[]): MindmapOpErrorKind | null => {
|
||||||
|
if (!Array.isArray(errors) || errors.length === 0) return null;
|
||||||
|
const rejectedPatterns = [
|
||||||
|
"无效 op",
|
||||||
|
"找不到",
|
||||||
|
"不能删除根节点",
|
||||||
|
"不支持的 op",
|
||||||
|
];
|
||||||
|
const rejectedOnly = errors.every((error) =>
|
||||||
|
rejectedPatterns.some((pattern) => String(error || "").includes(pattern)),
|
||||||
|
);
|
||||||
|
return rejectedOnly ? "rejected" : "failed";
|
||||||
|
};
|
||||||
|
|
||||||
export const applyMindmapOps = (
|
export const applyMindmapOps = (
|
||||||
raw: unknown,
|
raw: unknown,
|
||||||
ops: MindmapOp[],
|
ops: MindmapOp[],
|
||||||
@@ -238,4 +254,3 @@ export const applyMindmapOps = (
|
|||||||
ensureMindmapUids(root);
|
ensureMindmapUids(root);
|
||||||
return { data: root, applied, errors };
|
return { data: root, applied, errors };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user