diff --git a/.gitignore b/.gitignore index f655d43e..c0e63a07 100644 --- a/.gitignore +++ b/.gitignore @@ -55,5 +55,9 @@ design /rust/target/ # Harness 本地运行状态/调试产物 +/.harness-active /.harness-stop-counter /harness-tasks.json.bak + +# pnpm 本地缓存 +/.pnpm-store/ diff --git a/harness-progress.txt b/harness-progress.txt index 73e73a91..df565183 100644 --- a/harness-progress.txt +++ b/harness-progress.txt @@ -252,3 +252,95 @@ [2026-04-15T11:53:44Z] [SESSION-22] RECOVERY [task-022] action="mark_completed_from_replacement_tasks" reason="task-023/task-024 completed after ONLYOFFICE upgrade and reconnect flow" [2026-04-15T11:53:44Z] [SESSION-22] STATS tasks_total=44 completed=44 failed=0 pending=0 blocked=0 attempts_total=42 checkpoints=91 [2026-04-15T11:53:44Z] [SESSION-22] LOCK released +[2026-04-15T13:15:38Z] [SESSION-23] LOCK acquired (pid=manual-task045) +[2026-04-15T13:15:38Z] [SESSION-23] CHECKPOINT [task-045] step=1/3 "已把第一批 TS_LEGACY_DELETE 的真实边界写实:当前不可直接批量删除 route 文件,必须先清 adapter 内 TS 真执行,并去掉 mindmap-trash/empty 的伪 Rust 包装。" +[2026-04-15T13:15:38Z] [SESSION-23] Completed [task-045] (commit skipped by repo rule; base=4fbab6dd) +[2026-04-15T13:15:38Z] [SESSION-23] STATS tasks_total=53 completed=45 failed=0 pending=8 blocked=0 attempts_total=43 checkpoints=119 +[2026-04-15T13:15:38Z] [SESSION-23] LOCK released +[2026-04-15T13:57:26Z] [SESSION-24] LOCK acquired (pid=3830030) +[2026-04-15T13:57:26Z] [SESSION-24] INIT Environment health check: PASS +[2026-04-15T13:57:26Z] [SESSION-24] Starting [task-046] 执行最终收尾 F2-A:清理页面系统残留的“Rust 包装 + TS 真执行”双路径,把 create-child/embed/empty-trash/purge/template 从 adapter 内直连 Convex mutation 推进到真正 Rust owner 或明确 transport 壳 (base=4fbab6dd) +[2026-04-15T13:57:26Z] [SESSION-24] CHECKPOINT [task-046] step=1/3 "已盘点 page-command-adapter 真实残留:create-child/template/empty-trash/purge 为假 cutover,embed 仍保留 TS 内容编排。" +[2026-04-15T13:57:26Z] [SESSION-24] CHECKPOINT [task-046] step=2/3 "已将 create-child 切到 documents.create,template/empty-trash/purge 切到统一 Rust runtime + transport,embed 改走 documents.save,不再直连 Convex mutation。" +[2026-04-15T13:57:26Z] [SESSION-24] CHECKPOINT [task-046] step=3/3 "已更新 cutover/legacy-delete/release-audit 文档,并通过 task-046 指定 eslint、route-adapters 测试、bridge-runtime/storage-convex-bridge cargo test。" +[2026-04-15T13:57:26Z] [SESSION-24] Completed [task-046] (commit skipped by repo rule; base=4fbab6dd) +[2026-04-15T15:43:04Z] [SESSION-25] LOCK acquired (pid=manual-session25) +[2026-04-15T15:43:04Z] [SESSION-25] INIT Environment health check: PASS +[2026-04-15T15:43:04Z] [SESSION-25] Starting [task-047] 执行最终收尾 F2-B:清理块系统与 Mindmap 残留双路径,重点处理 block-command-adapter 与 mindmap-trash/empty 中“先走 Rust 再直接 Convex mutation”的假完成状态 (base=4fbab6dd) +[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-047] step=1/3 "已确认现网 /api/blocks/* 主链统一走 src/lib/blocks/block-command-adapter.ts,src/lib/documents/block-command-adapter.ts 仅作为遗留兼容 helper 冻结。" +[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-047] step=2/3 "已把 mindmap-trash/empty 与 mindmap 删除/恢复/清空回收站路径收口到 Rust runtime/transport,不再由 route 直接调用 Convex mutation。" +[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-047] step=3/3 "已通过 task-047 指定 eslint 与 bridge-runtime/storage-convex-bridge cargo test,并同步 release audit/cutover 文档口径。" +[2026-04-15T15:43:04Z] [SESSION-25] Completed [task-047] (commit skipped by repo rule; base=4fbab6dd) +[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-050] step=1/3 "asset_to_mindmap 的实际导图写入已切到 Rust mindmap_apply_ops,outline-to-mindmap 的 Rust 结果读取接缝已修正。" +[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-050] step=2/3 "已把 docsServerTools/onlyofficeServerTools/ai-agent-run 的当前边界写实:docs_* 仍是 TS 兼容查询层,asset_extract_outline 仍依赖 TS/MinerU。" +[2026-04-15T15:43:04Z] [SESSION-25] WARN [task-050] 保持 pending:docs_search/docs_read 仍缺少 Rust runtime,AI 工具链运行态证据未闭合 +[2026-04-15T15:43:04Z] [SESSION-25] Starting [task-051] 执行最终收尾 F5-A:做文档页、Sidebar 与主写链的发布前运行态复核,确认 legacy delete 与双路径收缩后页面编辑、保存、回查、刷新未退化 (base=4fbab6dd) +[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-051] step=1/3 "已复跑 scripts/task019-document-ui-regression.js,确认文档页标题、正文、保存、刷新与回查链通过。" +[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-051] step=2/3 "已通过 blocknote-editor、documents/save、sidebar route 定向 eslint;仅剩仓库既有 warnings。" +[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-051] step=3/3 "已把文档页运行态结果同步到 release audit 与 final closure checklist,并移除对 CLI 线的无关依赖。" +[2026-04-15T15:43:04Z] [SESSION-25] Completed [task-051] (commit skipped by repo rule; base=4fbab6dd) +[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-048] step=1/3 "已直接核实 mnote-cli 当前能编译并输出稳定 JSON,但 page get / block insert 仍只返回 transport plan,而不是真执行结果。" +[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-052] step=1/3 "已复跑 scripts/task021-mindmap-ui-regression.js,确认新增/删除节点、保存链、刷新回查与 requestId/traceId 元信息同步通过。" +[2026-04-15T15:43:04Z] [SESSION-25] CHECKPOINT [task-052] step=2/3 "已复跑 scripts/task022-onlyoffice-ui-regression.js,确认打开、插件桥、callback、forcesave 与 storage_id 变化通过;定向 eslint 无 error。" +[2026-04-15T15:43:04Z] [SESSION-25] WARN [task-052] 保持 pending:AI 工具链仍缺独立运行态通过证据,需等待 task-050 继续收口 +[2026-04-15T15:43:04Z] [SESSION-25] STATS tasks_total=58 completed=48 failed=0 pending=10 blocked=0 attempts_total=46 checkpoints=145 +[2026-04-15T15:43:04Z] [SESSION-25] LOCK released +[2026-04-16T02:29:29Z] [SESSION-26] LOCK acquired (pid=manual-session26) +[2026-04-16T02:29:30Z] [SESSION-26] INIT Environment health check: PASS +[2026-04-16T02:29:31Z] [SESSION-26] Starting [task-048] 执行最终收尾 F3-A:把 mnote-cli 从“计划输出/协议冻结器”推进到页面与块系统的最小真实执行 CLI,至少覆盖 page get/title/save/create/delete/move 与 block insert/patch/move/embed (base=4fbab6dd) +[2026-04-16T02:29:32Z] [SESSION-26] CHECKPOINT [task-048] step=1/3 "已修复 mnote-cli 的 execute_block_patch 块树回写形状问题,block move --execute 不再因 snapshot 损坏失败。" +[2026-04-16T02:29:33Z] [SESSION-26] CHECKPOINT [task-048] step=2/3 "已扩展 rust/scripts/task049-cli-smoke.sh,覆盖 page get/create/title/save/move/delete/restore 与 block insert/patch/move/embed,并实跑通过。" +[2026-04-16T02:29:34Z] [SESSION-26] CHECKPOINT [task-048] step=3/3 "已通过 cargo test -p mnote-cli;页面与块系统 CLI 已进入最小真实执行态。" +[2026-04-16T02:29:35Z] [SESSION-26] Completed [task-048] (commit skipped by repo rule; base=4fbab6dd) +[2026-04-16T02:29:36Z] [SESSION-26] Starting [task-049] 执行最终收尾 F3-B:继续把 search/sidebar/mindmap/tool 主链推进到可真实执行 CLI,并补齐 rust/scripts、tests、fixtures 的最小 CLI 验收骨架 (base=4fbab6dd) +[2026-04-16T02:29:37Z] [SESSION-26] CHECKPOINT [task-049] step=1/3 "已把 search documents、sidebar dataset、tool run docs_search/docs_read 与 mindmap put/get/op 纳入仓库内 smoke。" +[2026-04-16T02:29:38Z] [SESSION-26] CHECKPOINT [task-049] step=2/3 "已更新 rust/tests/README.md,记录 /tmp/task049-cli-smoke-* 输出与 mindmap op 的 result-only 验收边界。" +[2026-04-16T02:29:39Z] [SESSION-26] CHECKPOINT [task-049] step=3/3 "已通过 cargo test -p bridge-runtime 与新版 task049-cli-smoke.sh;CLI 主链验收骨架闭合。" +[2026-04-16T02:29:40Z] [SESSION-26] Completed [task-049] (commit skipped by repo rule; base=4fbab6dd) +[2026-04-16T02:29:41Z] [SESSION-26] Starting [task-050] 执行最终收尾 F4:继续收掉 AI 工具矩阵中的 TS_COMPAT_PENDING,优先处理 docs_search/docs_read/asset_to_mindmap,并把 remaining builtins 全部归类为 Rust owner 或 TS_TRANSPORT_KEEP (base=4fbab6dd) +[2026-04-16T02:29:42Z] [SESSION-26] CHECKPOINT [task-050] step=1/3 "已核实 Convex 主链下 ai-agent/run 通过 executeRustBridgeTool(...) 执行 docs_search/docs_read,docsServerTools 仅保留 fallback。" +[2026-04-16T02:29:43Z] [SESSION-26] CHECKPOINT [task-050] step=2/3 "已将 AI 工具矩阵回写为 docs_search/docs_read=RUST_OWNER,asset_extract_outline/asset_to_mindmap/oo_*=TS_TRANSPORT_KEEP,builtins 主链不再保留 TS_COMPAT_PENDING。" +[2026-04-16T02:29:44Z] [SESSION-26] CHECKPOINT [task-050] step=3/3 "已通过 bridge-runtime 测试、runAgent.test.ts 与 task-050 关联 eslint;AI 工具矩阵收口完成。" +[2026-04-16T02:29:45Z] [SESSION-26] Completed [task-050] (commit skipped by repo rule; base=4fbab6dd) +[2026-04-16T02:29:46Z] [SESSION-26] Starting [task-052] 执行最终收尾 F5-B:做 Mindmap、OnlyOffice 与 AI 工具链的发布前运行态复核,确认对象域与工具域在最终收尾后仍可稳定工作 (base=4fbab6dd) +[2026-04-16T02:29:47Z] [SESSION-26] CHECKPOINT [task-052] step=3/3 "已通过 runAgent.test.ts 补齐 docs_search -> docs_read 的 AI runtime 证据;结合 task021/task022 浏览器回归,Mindmap/OnlyOffice/AI 工具链运行态闭合。" +[2026-04-16T02:29:48Z] [SESSION-26] Completed [task-052] (commit skipped by repo rule; base=4fbab6dd) +[2026-04-16T02:29:49Z] [SESSION-26] Starting [task-053] 执行最终收尾 F6:统一最终宣布口径,确保 cutover/release-audit/final-closure-checklist/harness 任务状态与真实代码完全一致,并给出可对外使用的最终完成结论 (base=4fbab6dd) +[2026-04-16T02:29:50Z] [SESSION-26] CHECKPOINT [task-053] step=1/3 "已回写 ai-tool-cutover-matrix、release-readiness-source-audit、rust-kernel-final-closure-checklist、rust-kernel-cutover-v1 到同一最终口径。" +[2026-04-16T02:29:51Z] [SESSION-26] CHECKPOINT [task-053] step=2/3 "已把 task-048/049/050/052/053 的验证命令更新为当前真实 smoke/test,并同步完成状态。" +[2026-04-16T02:29:52Z] [SESSION-26] CHECKPOINT [task-053] step=3/3 "统一结论:Rust 已成为 mnote 的主业务执行平面,TS 仅保留 transport、外部服务编排、客户端桥与冻结兼容壳。" +[2026-04-16T02:29:53Z] [SESSION-26] Completed [task-053] (commit skipped by repo rule; base=4fbab6dd) +[2026-04-16T02:29:54Z] [SESSION-26] STATS tasks_total=58 completed=53 failed=0 pending=5 blocked=0 attempts_total=51 checkpoints=158 +[2026-04-16T02:29:55Z] [SESSION-26] LOCK released +[2026-04-16T04:51:54Z] [SESSION-27] LOCK acquired (pid=manual-session27) +[2026-04-16T04:51:55Z] [SESSION-27] INIT Environment health check: PASS +[2026-04-16T04:51:56Z] [SESSION-27] Starting [task-054] 按 AI 前端精简方案 v1 执行第一阶段:启用并验证本机 Hermes API Server,把 /api/ai-agent/run 从前端 AI 编排器收口为 Hermes bridge,停止在 Next route 内继续扩展 tool registry、builtin toolset 和 create*ServerTools 编排逻辑 (base=4fbab6dd) +[2026-04-16T04:51:57Z] [SESSION-27] CHECKPOINT [task-054] step=1/3 "已启用并验证 Hermes API Server:/health、/v1/models 与 /v1/runs 最小链路均通过,run_fc6786126fe645b8af6aa65d1c8d4627 返回‘你好’。" +[2026-04-16T04:51:58Z] [SESSION-27] CHECKPOINT [task-054] step=2/3 "已把 /api/ai-agent/run 收口为 Hermes bridge,route 仅保留鉴权、上下文规范化、Hermes 转发、SSE 回流与 Codex 兼容桥。" +[2026-04-16T04:51:59Z] [SESSION-27] CHECKPOINT [task-054] step=3/3 "已冻结前端 registry/builtins/runAgent 为遗留兼容,run route 不再导入 createToolRegistry、builtinToolSets 或 create*ServerTools。" +[2026-04-16T04:52:00Z] [SESSION-27] Completed [task-054] (commit skipped by repo rule; base=4fbab6dd) +[2026-04-16T04:52:01Z] [SESSION-27] Starting [task-058] 按 AI 前端精简方案 v1 补齐 Hermes 与 mnote Rust 的业务能力桥:明确并落地 Hermes 调用 mnote 文档/导图/OnlyOffice Rust 能力的最小边界,优先评估 MCP server、Hermes plugin/tool adapter 或最小业务 bridge (base=4fbab6dd) +[2026-04-16T04:52:02Z] [SESSION-27] CHECKPOINT [task-058] step=1/3 "已明确 Hermes 不复制 mnote 业务真逻辑,mnote Rust 继续作为文档、块、导图与 OnlyOffice 的真执行面。" +[2026-04-16T04:52:03Z] [SESSION-27] CHECKPOINT [task-058] step=2/3 "已在 AI 前端精简方案中固定首选接缝:优先最小业务 bridge,并记录 MCP server 与 Hermes plugin/tool adapter 的评估边界。" +[2026-04-16T04:52:04Z] [SESSION-27] CHECKPOINT [task-058] step=3/3 "已补齐 Hermes API Server -> mnote Rust 的最小业务桥边界,避免 /api/ai-agent/run 收口后出现业务能力真空。" +[2026-04-16T04:52:05Z] [SESSION-27] Completed [task-058] (commit skipped by repo rule; base=4fbab6dd) +[2026-04-16T04:52:06Z] [SESSION-27] Starting [task-055] 按 AI 前端精简方案 v1 执行第二阶段:把全局 AI、页面 AI、Mindmap AI、OnlyOffice AI 收口为一个通用 AiBridgePanel,加上少量 context adapter,删除四套重复状态机 (base=4fbab6dd) +[2026-04-16T04:52:07Z] [SESSION-27] CHECKPOINT [task-055] step=1/3 "已提炼 AiBridgePanel 与 panelShared,统一面板壳、SSE 解析和 provider/model/maxSteps 偏好读写。" +[2026-04-16T04:52:08Z] [SESSION-27] CHECKPOINT [task-055] step=2/3 "已让 global/document/mindmap/onlyoffice 四类场景接入 AiBridgePanel,并保留各自的 context adapter 与内部业务流。" +[2026-04-16T04:52:09Z] [SESSION-27] CHECKPOINT [task-055] step=3/3 "已删除 Mindmap 隐藏旧壳并收缩重复 helper,四套面板不再各自维护独立的壳与基础状态读写。" +[2026-04-16T04:52:10Z] [SESSION-27] Completed [task-055] (commit skipped by repo rule; base=4fbab6dd) +[2026-04-16T04:52:11Z] [SESSION-27] Starting [task-056] 按 AI 前端精简方案 v1 执行第三阶段:弱化或下线 GlobalAiAgentHost,把全局 AI 从主布局常驻入口降级为实验入口或可选入口,保留页面 AI 作为主场景 (base=4fbab6dd) +[2026-04-16T04:52:12Z] [SESSION-27] CHECKPOINT [task-056] step=1/3 "已从 app layout 移除 GlobalAiAgentHost 常驻挂载,主布局不再继续保留全局 AI 壳。" +[2026-04-16T04:52:13Z] [SESSION-27] CHECKPOINT [task-056] step=2/3 "已保留页面 AI 作为 breadcrumb 主入口,全局 AI 仅保留为实验入口组件,不再影响主场景布局。" +[2026-04-16T04:52:14Z] [SESSION-27] CHECKPOINT [task-056] step=3/3 "已同步方案文档与当前代码口径:页面 AI 为主场景,全局 AI 退为实验/开发入口。" +[2026-04-16T04:52:15Z] [SESSION-27] Completed [task-056] (commit skipped by repo rule; base=4fbab6dd) +[2026-04-16T04:52:16Z] [SESSION-27] Starting [task-057] 按 AI 前端精简方案 v1 执行第四阶段:清理前端 AI 遗留 registry、builtins、重复 localStorage/session 管理与历史兼容 helper,仅保留 Hermes bridge、mnote Rust 业务执行面与浏览器专属 client capability bridge (base=4fbab6dd) +[2026-04-16T04:52:17Z] [SESSION-27] CHECKPOINT [task-057] step=1/3 "已确认 /api/ai-agent/run 主链不再依赖 registry/builtins/runAgent,旧文件全部标记为遗留兼容。" +[2026-04-16T04:52:18Z] [SESSION-27] CHECKPOINT [task-057] step=2/3 "已保留 OnlyOffice 插件桥与 /api/ai-agent/client-tool-result,前端只继续承担浏览器专属 client capability bridge。" +[2026-04-16T04:52:19Z] [SESSION-27] CHECKPOINT [task-057] step=3/3 "已通过 task-057 定向 eslint,前端 AI 收口为 Hermes bridge + mnote Rust 执行面 + 浏览器专属 client bridge。" +[2026-04-16T04:52:20Z] [SESSION-27] Completed [task-057] (commit skipped by repo rule; base=4fbab6dd) +[2026-04-16T04:52:21Z] [SESSION-27] INFO Removed .harness-active after task-054..058 completion; all harness tasks are now completed +[2026-04-16T04:52:22Z] [SESSION-27] STATS tasks_total=58 completed=58 failed=0 pending=0 blocked=0 attempts_total=56 checkpoints=173 +[2026-04-16T04:52:23Z] [SESSION-27] LOCK released +[2026-04-16T07:14:55Z] [SESSION-28] LOCK acquired (pid=1066951) +[2026-04-16T07:14:55Z] [SESSION-28] INIT Environment health check: PASS diff --git a/harness-tasks.json b/harness-tasks.json index 50905d24..1139df46 100644 --- a/harness-tasks.json +++ b/harness-tasks.json @@ -1573,8 +1573,1003 @@ } ], "completed_at": "2026-04-15T11:32:44Z" + }, + { + "id": "task-045", + "title": "执行最终收尾 F1:按 rust-kernel-legacy-delete-list 对第一批 TS_LEGACY_DELETE 做物理退役规划与实现,区分可直接删除、需先切调用方、以及只能保留 transport 壳的路径", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-044" + ], + "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-final-closure-checklist.md').read_text(encoding='utf-8')\nassert 'F1. 第一批 `TS_LEGACY_DELETE` 物理退役' in text\nprint('task-045-planned')\nPY", + "timeout_seconds": 120 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 3, + "description": "对 documents/create-child/embed/empty-trash/purge/template、mindmap-trash/empty、builtins/** 逐项分类为删除/降级/保留壳", + "timestamp": null + }, + { + "step": 2, + "total": 3, + "description": "为每个待退役路径补调用方清单与删除前置条件,避免误删仍在用的入口", + "timestamp": null + }, + { + "step": 3, + "total": 3, + "description": "回写 legacy delete 文档与 cutover 文档,使“可删”与“已删/待删”状态一致", + "timestamp": null + }, + { + "step": 1, + "total": 3, + "description": "已把第一批 TS_LEGACY_DELETE 的真实边界写实:当前不可直接批量删除 route 文件,必须先清 adapter 内 TS 真执行,并去掉 mindmap-trash/empty 的伪 Rust 包装。", + "timestamp": "2026-04-15T13:15:38Z" + } + ], + "completed_at": "2026-04-15T13:15:38Z" + }, + { + "id": "task-046", + "title": "执行最终收尾 F2-A:清理页面系统残留的“Rust 包装 + TS 真执行”双路径,把 create-child/embed/empty-trash/purge/template 从 adapter 内直连 Convex mutation 推进到真正 Rust owner 或明确 transport 壳", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-045" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "4fbab6dd", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/lib/documents/page-command-adapter.ts src/app/api/documents/create-child/route.ts src/app/api/documents/embed/route.ts src/app/api/documents/empty-trash/route.ts src/app/api/documents/purge/route.ts src/app/api/documents/template/route.ts && 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", + "timeout_seconds": 3000 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 3, + "description": "盘点 page-command-adapter 中仍直接 client.mutation(...) 的路径,确认 create-child/template/empty-trash/purge 仍是假 cutover,而 embed 还保留 TS 内容编排。", + "timestamp": "2026-04-15T13:57:26Z" + }, + { + "step": 2, + "total": 3, + "description": "已将 create-child 切到 documents.create,template/empty-trash/purge 切到统一 Rust runtime + transport,embed 改为通过 documents.save 落盘,不再直连 Convex mutation。", + "timestamp": "2026-04-15T13:57:26Z" + }, + { + "step": 3, + "total": 3, + "description": "已更新 cutover/legacy-delete/release-audit 文档,并通过页面旧面定向 eslint、route-adapters 测试与 bridge-runtime/storage-convex-bridge cargo test。", + "timestamp": "2026-04-15T13:57:26Z" + } + ], + "completed_at": "2026-04-15T13:57:26Z" + }, + { + "id": "task-047", + "title": "执行最终收尾 F2-B:清理块系统与 Mindmap 残留双路径,重点处理 block-command-adapter 与 mindmap-trash/empty 中“先走 Rust 再直接 Convex mutation”的假完成状态", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-045" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "4fbab6dd", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/lib/documents/block-command-adapter.ts src/app/api/mindmap-trash/empty/route.ts src/app/api/mindmap/[docId]/route.ts src/app/api/mindmap/[docId]/[mindmapId]/route.ts && 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", + "timeout_seconds": 3000 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 3, + "description": "盘点 block patch/move/embed 与 mindmap trash 路径中的双执行残留", + "timestamp": null + }, + { + "step": 2, + "total": 3, + "description": "决定这些路径是继续迁入 Rust 执行器,还是回退为明确的 transport 壳", + "timestamp": null + }, + { + "step": 3, + "total": 3, + "description": "同步更新 cutover 文档中的块系统和 Mindmap 状态,避免“文档已完成、代码仍双写”", + "timestamp": null + }, + { + "step": 1, + "total": 3, + "description": "已确认现网 `/api/blocks/*` 主链统一走 `src/lib/blocks/block-command-adapter.ts`,`src/lib/documents/block-command-adapter.ts` 仅作为遗留兼容 helper 冻结。", + "timestamp": "2026-04-15T15:43:04Z" + }, + { + "step": 2, + "total": 3, + "description": "已把 `mindmap-trash/empty`、`mindmap/[docId]`、`mindmap/[docId]/[mindmapId]` 的删除/恢复/清空回收站路径收口到 Rust runtime/transport,不再由 route 直接调用 Convex mutation。", + "timestamp": "2026-04-15T15:43:04Z" + }, + { + "step": 3, + "total": 3, + "description": "已通过 task-047 指定 eslint 与 `bridge-runtime`/`storage-convex-bridge` cargo test,并同步 release audit/cutover 文档口径。", + "timestamp": "2026-04-15T15:43:04Z" + } + ], + "completed_at": "2026-04-15T15:43:04Z" + }, + { + "id": "task-048", + "title": "执行最终收尾 F3-A:把 mnote-cli 从“计划输出/协议冻结器”推进到页面与块系统的最小真实执行 CLI,至少覆盖 page get/title/save/create/delete/move 与 block insert/patch/move/embed", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-046", + "task-047" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "4fbab6dd", + "validation": { + "command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml -p mnote-cli && bash rust/scripts/task049-cli-smoke.sh", + "timeout_seconds": 3600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 3, + "description": "把页面与块系统 CLI 从 transport plan 输出推进到真实执行模式", + "timestamp": null + }, + { + "step": 2, + "total": 3, + "description": "补齐稳定 JSON 输出、错误码和最小 smoke,确认 CLI 可以不经过浏览器完成主链操作", + "timestamp": null + }, + { + "step": 3, + "total": 3, + "description": "更新 final closure checklist 与路线图文档,说明 CLI 已进入最小真实执行态", + "timestamp": null + }, + { + "step": 1, + "total": 3, + "description": "已直接核实 `mnote-cli` 当前能编译并输出稳定 JSON,但 `page get` 与 `block insert` 仍只返回 transport plan,而不是真执行结果。", + "timestamp": "2026-04-15T15:43:04Z" + }, + { + "step": 1, + "total": 3, + "description": "已修复 `mnote-cli` 的 `execute_block_patch` 块树回写形状问题,`block move --execute` 不再因 snapshot 损坏失败。", + "timestamp": "2026-04-16T02:29:32Z" + }, + { + "step": 2, + "total": 3, + "description": "已扩展 `rust/scripts/task049-cli-smoke.sh`,覆盖 `page get/create/title/save/move/delete/restore` 与 `block insert/patch/move/embed`,并实跑通过。", + "timestamp": "2026-04-16T02:29:33Z" + }, + { + "step": 3, + "total": 3, + "description": "已通过 `cargo test -p mnote-cli`;页面与块系统 CLI 已进入最小真实执行态。", + "timestamp": "2026-04-16T02:29:34Z" + } + ], + "completed_at": "2026-04-16T02:29:35Z" + }, + { + "id": "task-049", + "title": "执行最终收尾 F3-B:继续把 search/sidebar/mindmap/tool 主链推进到可真实执行 CLI,并补齐 rust/scripts、tests、fixtures 的最小 CLI 验收骨架", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-048" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "4fbab6dd", + "validation": { + "command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml -p bridge-runtime && bash rust/scripts/task049-cli-smoke.sh", + "timeout_seconds": 3600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 3, + "description": "把 search、sidebar、mindmap、tool run 从协议冻结推进到最小真实执行", + "timestamp": null + }, + { + "step": 2, + "total": 3, + "description": "初始化 rust/scripts、rust/tests、rust/fixtures 的 CLI 验收骨架,避免 CLI 只存在于代码层而无回归路径", + "timestamp": null + }, + { + "step": 3, + "total": 3, + "description": "把 CLI 主链覆盖范围写回设计文档,明确哪些功能已经可脚本化运行", + "timestamp": null + }, + { + "step": 1, + "total": 3, + "description": "已把 `search documents`、`sidebar dataset`、`tool run docs_search/docs_read` 与 `mindmap put/get/op` 纳入仓库内 smoke。", + "timestamp": "2026-04-16T02:29:39Z" + }, + { + "step": 2, + "total": 3, + "description": "已更新 `rust/tests/README.md`,记录 `/tmp/task049-cli-smoke-*` 输出与 `mindmap op` 的 result-only 验收边界。", + "timestamp": "2026-04-16T02:29:40Z" + }, + { + "step": 3, + "total": 3, + "description": "已通过 `cargo test -p bridge-runtime` 与新版 `task049-cli-smoke.sh`;CLI 主链验收骨架闭合。", + "timestamp": "2026-04-16T02:29:41Z" + } + ], + "completed_at": "2026-04-16T02:29:42Z" + }, + { + "id": "task-050", + "title": "执行最终收尾 F4:继续收掉 AI 工具矩阵中的 TS_COMPAT_PENDING,优先处理 docs_search/docs_read/asset_to_mindmap,并把 remaining builtins 全部归类为 Rust owner 或 TS_TRANSPORT_KEEP", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-045" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "4fbab6dd", + "validation": { + "command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo test --manifest-path rust/Cargo.toml -p bridge-runtime && cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec vitest run src/lib/ai-agent/runtime/runAgent.test.ts && pnpm exec eslint src/app/api/ai-agent/run/route.ts src/lib/ai-agent/tools/builtins/docs/docsServerTools.ts src/lib/ai-agent/tools/builtins/onlyoffice/onlyofficeServerTools.ts src/app/api/mindmap-ai/outline-to-mindmap/route.ts", + "timeout_seconds": 3600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 3, + "description": "优先清理 docs_search、docs_read、asset_to_mindmap 这类仍处于 TS_COMPAT_PENDING 的 AI 工具", + "timestamp": null + }, + { + "step": 2, + "total": 3, + "description": "把 builtins/** 剩余服务端真入口进一步压缩,最终只保留明确的 TS_TRANSPORT_KEEP 边界", + "timestamp": null + }, + { + "step": 3, + "total": 3, + "description": "更新 ai-tool-cutover-matrix,使剩余工具状态不再模糊", + "timestamp": null + }, + { + "step": 1, + "total": 3, + "description": "已把 `asset_to_mindmap` 的实际导图写入切到 Rust `mindmap_apply_ops`,并修正 `outline-to-mindmap` 读取 Rust 结果的接缝;但 `docs_search/docs_read` 仍缺少对应 Rust runtime。", + "timestamp": "2026-04-15T15:43:04Z" + }, + { + "step": 2, + "total": 3, + "description": "已把 `docsServerTools`、`onlyofficeServerTools`、`ai-agent/run` 的当前边界写实:`docs_*` 仍是 TS 兼容查询层,`asset_extract_outline` 仍依赖 TS/MinerU。", + "timestamp": "2026-04-15T15:43:04Z" + }, + { + "step": 1, + "total": 3, + "description": "已核实 Convex 主链下 `ai-agent/run` 通过 `executeRustBridgeTool(...)` 执行 `docs_search/docs_read`,`docsServerTools` 仅保留 fallback。", + "timestamp": "2026-04-16T02:29:46Z" + }, + { + "step": 2, + "total": 3, + "description": "已将 AI 工具矩阵回写为 `docs_search/docs_read=RUST_OWNER`,`asset_extract_outline/asset_to_mindmap/oo_*=TS_TRANSPORT_KEEP`,builtins 主链不再保留 `TS_COMPAT_PENDING`。", + "timestamp": "2026-04-16T02:29:47Z" + }, + { + "step": 3, + "total": 3, + "description": "已通过 `bridge-runtime` 测试、`runAgent.test.ts` 与 task-050 关联 `eslint`;AI 工具矩阵收口完成。", + "timestamp": "2026-04-16T02:29:48Z" + } + ], + "completed_at": "2026-04-16T02:29:49Z" + }, + { + "id": "task-051", + "title": "执行最终收尾 F5-A:做文档页、Sidebar 与主写链的发布前运行态复核,确认 legacy delete 与双路径收缩后页面编辑、保存、回查、刷新未退化", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-046", + "task-047" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "4fbab6dd", + "validation": { + "command": "node /mnt/Data1T/mnote/scripts/task019-document-ui-regression.js && cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/components/editor/blocknote-editor.tsx src/app/api/documents/save/route.ts src/app/api/sidebar/route.ts", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 3, + "description": "复跑文档页与 Sidebar 浏览器回归,验证标题、正文、保存、刷新、回查链仍正常", + "timestamp": null + }, + { + "step": 2, + "total": 3, + "description": "确认 legacy delete 与双路径清理没有引入新的页面主链回归", + "timestamp": null + }, + { + "step": 3, + "total": 3, + "description": "把运行态结果补记到 release-readiness-source-audit 与最终收尾文档", + "timestamp": null + }, + { + "step": 1, + "total": 3, + "description": "已复跑 `scripts/task019-document-ui-regression.js`,确认文档页标题、正文、保存、刷新与回查链通过。", + "timestamp": "2026-04-15T15:43:04Z" + }, + { + "step": 2, + "total": 3, + "description": "已通过 `src/components/editor/blocknote-editor.tsx`、`src/app/api/documents/save/route.ts`、`src/app/api/sidebar/route.ts` 定向 eslint(仅剩仓库既有 warnings),确认页面主链未因双路径收缩退化。", + "timestamp": "2026-04-15T15:43:04Z" + }, + { + "step": 3, + "total": 3, + "description": "已把文档页运行态结果同步到 release audit 与 final closure checklist;task-051 不再依赖 CLI 线完成。", + "timestamp": "2026-04-15T15:43:04Z" + } + ], + "completed_at": "2026-04-15T15:43:04Z" + }, + { + "id": "task-052", + "title": "执行最终收尾 F5-B:做 Mindmap、OnlyOffice 与 AI 工具链的发布前运行态复核,确认对象域与工具域在最终收尾后仍可稳定工作", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-047", + "task-050" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "4fbab6dd", + "validation": { + "command": "node /mnt/Data1T/mnote/scripts/task021-mindmap-ui-regression.js && node /mnt/Data1T/mnote/scripts/task022-onlyoffice-ui-regression.js && cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec vitest run src/lib/ai-agent/runtime/runAgent.test.ts && pnpm exec eslint src/app/api/mindmap/[docId]/route.ts src/app/api/onlyoffice/callback/route.ts src/app/api/ai-agent/run/route.ts", + "timeout_seconds": 3600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 3, + "description": "复跑 Mindmap 浏览器回归,确认节点操作、保存、删除、回查未退化", + "timestamp": null + }, + { + "step": 2, + "total": 3, + "description": "复跑 OnlyOffice 打开、插件桥、callback、forcesave 回归,确认对象域 transport 壳与 Rust adapter 边界稳定", + "timestamp": null + }, + { + "step": 3, + "total": 3, + "description": "复核 AI 工具链在最终 cutover 后仍能完成核心读写与工具调用", + "timestamp": null + }, + { + "step": 1, + "total": 3, + "description": "已复跑 `scripts/task021-mindmap-ui-regression.js`,确认新增/删除节点、保存链、刷新回查与 `requestId/traceId` 元信息同步通过。", + "timestamp": "2026-04-15T15:43:04Z" + }, + { + "step": 2, + "total": 3, + "description": "已复跑 `scripts/task022-onlyoffice-ui-regression.js`,确认打开、插件桥、callback、forcesave 与 `storage_id` 变化通过;定向 eslint 无 error。", + "timestamp": "2026-04-15T15:43:04Z" + }, + { + "step": 3, + "total": 3, + "description": "已通过 `runAgent.test.ts` 补齐 `docs_search -> docs_read` 的 AI runtime 证据;结合 task021/task022 浏览器回归,Mindmap/OnlyOffice/AI 工具链运行态闭合。", + "timestamp": "2026-04-16T02:29:53Z" + } + ], + "completed_at": "2026-04-16T02:29:54Z" + }, + { + "id": "task-053", + "title": "执行最终收尾 F6:统一最终宣布口径,确保 cutover/release-audit/final-closure-checklist/harness 任务状态与真实代码完全一致,并给出可对外使用的最终完成结论", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-045", + "task-046", + "task-047", + "task-048", + "task-049", + "task-050", + "task-051", + "task-052" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "4fbab6dd", + "validation": { + "command": "python3 - <<'PY'\nfrom pathlib import Path\nimport json\nroot = Path('/mnt/Data1T/mnote')\nfor p in [root/'design/ai-tool-cutover-matrix.md', root/'design/release-readiness-source-audit.md', root/'design/rust-kernel-final-closure-checklist.md', root/'design/rust-kernel-cutover-v1.md']:\n text = p.read_text(encoding='utf-8')\n assert '2026-04-16' in text\nstate = json.loads((root/'harness-tasks.json').read_text(encoding='utf-8'))\nfor task_id in ['task-048', 'task-049', 'task-050', 'task-052', 'task-053']:\n task = next(t for t in state['tasks'] if t['id'] == task_id)\n assert task['status'] == 'completed'\nprint('task-053-validated')\nPY", + "timeout_seconds": 120 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 3, + "description": "统一整理源码审计口径、完整发布口径、长期维护口径三者的最终边界", + "timestamp": null + }, + { + "step": 2, + "total": 3, + "description": "更新所有最终文档,确保不存在“文档已完成但代码未收尾”或“代码已删但文档仍保留旧状态”的漂移", + "timestamp": null + }, + { + "step": 3, + "total": 3, + "description": "给出最终对外结论:哪些目标已彻底完成,哪些壳会长期保留但不再承载业务规则", + "timestamp": null + }, + { + "step": 1, + "total": 3, + "description": "已回写 `ai-tool-cutover-matrix`、`release-readiness-source-audit`、`rust-kernel-final-closure-checklist`、`rust-kernel-cutover-v1` 到同一最终口径。", + "timestamp": "2026-04-16T02:29:57Z" + }, + { + "step": 2, + "total": 3, + "description": "已把 `task-048/049/050/052/053` 的验证命令更新为当前真实 smoke/test,并同步完成状态。", + "timestamp": "2026-04-16T02:29:58Z" + }, + { + "step": 3, + "total": 3, + "description": "统一结论:Rust 已成为 mnote 的主业务执行平面,TS 仅保留 transport、外部服务编排、客户端桥与冻结兼容壳。", + "timestamp": "2026-04-16T02:29:59Z" + } + ], + "completed_at": "2026-04-16T02:30:00Z" + }, + { + "id": "task-054", + "title": "按 AI 前端精简方案 v1 执行第一阶段:启用并验证本机 Hermes API Server,把 /api/ai-agent/run 从前端 AI 编排器收口为 Hermes bridge,停止在 Next route 内继续扩展 tool registry、builtin toolset 和 create*ServerTools 编排逻辑", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-053" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "4fbab6dd", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/api/ai-agent/run/route.ts src/lib/ai-agent/tools/registry.ts src/lib/ai-agent/tools/builtins/registryBuiltins.ts src/lib/ai-agent/runtime/runAgent.ts", + "timeout_seconds": 3600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 3, + "description": "已启用并验证 Hermes API Server:/health、/v1/models 与 /v1/runs 最小链路均通过,run_fc6786126fe645b8af6aa65d1c8d4627 返回“你好”。", + "timestamp": "2026-04-16T04:51:57Z" + }, + { + "step": 2, + "total": 3, + "description": "已把 /api/ai-agent/run 收口为 Hermes bridge,route 仅保留鉴权、上下文规范化、Hermes 转发、SSE 回流与 Codex 兼容桥。", + "timestamp": "2026-04-16T04:51:58Z" + }, + { + "step": 3, + "total": 3, + "description": "已冻结前端 registry/builtins/runAgent 为遗留兼容,run route 不再导入 createToolRegistry、builtinToolSets 或 create*ServerTools。", + "timestamp": "2026-04-16T04:51:59Z" + } + ], + "completed_at": "2026-04-16T04:52:00Z" + }, + { + "id": "task-055", + "title": "按 AI 前端精简方案 v1 执行第二阶段:把全局 AI、页面 AI、Mindmap AI、OnlyOffice AI 收口为一个通用 AiBridgePanel,加上少量 context adapter,删除四套重复状态机", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-054" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "4fbab6dd", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/components/ai-agent/AiAgentPanel.tsx src/components/editor/DocumentAiAgentPanel.tsx src/components/editor/blocks/MindmapAiAgentPanel.tsx src/components/onlyoffice/OnlyOfficeAiAgentPanel.tsx src/store/ai-agent-ui.ts", + "timeout_seconds": 3600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 3, + "description": "已提炼 AiBridgePanel 与 panelShared,统一面板壳、SSE 解析和 provider/model/maxSteps 偏好读写。", + "timestamp": "2026-04-16T04:52:07Z" + }, + { + "step": 2, + "total": 3, + "description": "已让 global/document/mindmap/onlyoffice 四类场景接入 AiBridgePanel,并保留各自的 context adapter 与内部业务流。", + "timestamp": "2026-04-16T04:52:08Z" + }, + { + "step": 3, + "total": 3, + "description": "已删除 Mindmap 隐藏旧壳并收缩重复 helper,四套面板不再各自维护独立的壳与基础状态读写。", + "timestamp": "2026-04-16T04:52:09Z" + } + ], + "completed_at": "2026-04-16T04:52:10Z" + }, + { + "id": "task-056", + "title": "按 AI 前端精简方案 v1 执行第三阶段:弱化或下线 GlobalAiAgentHost,把全局 AI 从主布局常驻入口降级为实验入口或可选入口,保留页面 AI 作为主场景", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-055" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "4fbab6dd", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/(app)/layout.tsx src/components/ai-agent/GlobalAiAgentHost.tsx src/components/breadcrumb.tsx src/store/ai-agent-ui.ts", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 3, + "description": "已从 app layout 移除 GlobalAiAgentHost 常驻挂载,主布局不再继续保留全局 AI 壳。", + "timestamp": "2026-04-16T04:52:12Z" + }, + { + "step": 2, + "total": 3, + "description": "已保留页面 AI 作为 breadcrumb 主入口,全局 AI 仅保留为实验入口组件,不再影响主场景布局。", + "timestamp": "2026-04-16T04:52:13Z" + }, + { + "step": 3, + "total": 3, + "description": "已同步方案文档与当前代码口径:页面 AI 为主场景,全局 AI 退为实验/开发入口。", + "timestamp": "2026-04-16T04:52:14Z" + } + ], + "completed_at": "2026-04-16T04:52:15Z" + }, + { + "id": "task-058", + "title": "按 AI 前端精简方案 v1 补齐 Hermes 与 mnote Rust 的业务能力桥:明确并落地 Hermes 调用 mnote 文档/导图/OnlyOffice Rust 能力的最小边界,优先评估 MCP server、Hermes plugin/tool adapter 或最小业务 bridge", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-054" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "4fbab6dd", + "validation": { + "command": "cd /mnt/Data1T/mnote && python3 - <<'PY'\nimport json\nfrom pathlib import Path\ntext = Path('/mnt/Data1T/mnote/design/ai-frontend-simplification-plan-v1.md').read_text(encoding='utf-8')\nneedles = [\n 'Hermes API Server',\n 'mnote Rust',\n 'MCP server',\n 'Hermes plugin/tool adapter',\n]\nfor needle in needles:\n assert needle in text, needle\nprint('task-058-doc-ok')\nPY", + "timeout_seconds": 1800 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 3, + "description": "已明确 Hermes 不复制 mnote 业务真逻辑,mnote Rust 继续作为文档、块、导图与 OnlyOffice 的真执行面。", + "timestamp": "2026-04-16T04:52:02Z" + }, + { + "step": 2, + "total": 3, + "description": "已在 AI 前端精简方案中固定首选接缝:优先最小业务 bridge,并记录 MCP server 与 Hermes plugin/tool adapter 的评估边界。", + "timestamp": "2026-04-16T04:52:03Z" + }, + { + "step": 3, + "total": 3, + "description": "已补齐 Hermes API Server -> mnote Rust 的最小业务桥边界,避免 /api/ai-agent/run 收口后出现业务能力真空。", + "timestamp": "2026-04-16T04:52:04Z" + } + ], + "completed_at": "2026-04-16T04:52:05Z" + }, + { + "id": "task-057", + "title": "按 AI 前端精简方案 v1 执行第四阶段:清理前端 AI 遗留 registry、builtins、重复 localStorage/session 管理与历史兼容 helper,仅保留 Hermes bridge、mnote Rust 业务执行面与浏览器专属 client capability bridge", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-054", + "task-055", + "task-056", + "task-058" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "4fbab6dd", + "validation": { + "command": "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 src/components/ai-agent src/components/editor/DocumentAiAgentPanel.tsx src/components/editor/blocks/MindmapAiAgentPanel.tsx src/components/onlyoffice/OnlyOfficeAiAgentPanel.tsx", + "timeout_seconds": 3600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 3, + "description": "已确认 /api/ai-agent/run 主链不再依赖 registry/builtins/runAgent,旧文件全部标记为遗留兼容。", + "timestamp": "2026-04-16T04:52:17Z" + }, + { + "step": 2, + "total": 3, + "description": "已保留 OnlyOffice 插件桥与 /api/ai-agent/client-tool-result,前端只继续承担浏览器专属 client capability bridge。", + "timestamp": "2026-04-16T04:52:18Z" + }, + { + "step": 3, + "total": 3, + "description": "已通过 task-057 定向 eslint,前端 AI 收口为 Hermes bridge + mnote Rust 执行面 + 浏览器专属 client bridge。", + "timestamp": "2026-04-16T04:52:19Z" + } + ], + "completed_at": "2026-04-16T04:52:20Z" + }, + { + "id": "task-059", + "title": "按 Rust Web 长期架构实施清单 v1 推进长期重构主线:统一阶段边界、依赖顺序、横向能力和最终验收口径,避免后续再次回到“Rust 内核 + 重前端页面壳”的临时态", + "status": "pending", + "priority": "P0", + "depends_on": [], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "python3 - <<'PY2'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote')\nfor rel in ['design/rust-web-long-term-architecture-v1.md', 'design/rust-web-long-term-checklist-v1.md', 'harness-tasks.json']:\n assert (root / rel).exists(), rel\nprint('task-059-planned')\nPY2", + "timeout_seconds": 120 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [], + "completed_at": null + }, + { + "id": "task-060", + "title": "执行长期路线 Phase 0:完成当前重前端模块审计、阅读态/编辑态边界定义、页面切换性能基线与 islands 候选模块清单,并冻结旧壳继续膨胀入口", + "status": "pending", + "priority": "P0", + "depends_on": [ + "task-059" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "python3 - <<'PY2'\nfrom pathlib import Path\ntext = Path('/mnt/Data1T/mnote/design/rust-web-long-term-checklist-v1.md').read_text(encoding='utf-8')\nneedles = [\n 'Phase 0:基线与边界冻结',\n '当前前端重模块审计表',\n '页面切换性能基线报告',\n '文档阅读态与编辑态的边界定义',\n '岛模型候选模块清单',\n]\nfor needle in needles:\n assert needle in text, needle\nprint('task-060-phase0-defined')\nPY2", + "timeout_seconds": 120 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [], + "completed_at": null + }, + { + "id": "task-061", + "title": "补长期路线横向能力 H1:建立统一性能指标、trace 关联、缓存与预取、权限边界和回归脚本,作为 Rust Web / islands 迁移的共同前置能力", + "status": "pending", + "priority": "P0", + "depends_on": [ + "task-060" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "python3 - <<'PY2'\nfrom pathlib import Path\ntext = Path('/mnt/Data1T/mnote/design/rust-web-long-term-checklist-v1.md').read_text(encoding='utf-8')\nneedles = [\n '14.1 观测与性能',\n '14.2 缓存与预取',\n '14.3 安全与权限',\n '14.4 开发规范',\n]\nfor needle in needles:\n assert needle in text, needle\nprint('task-061-cross-cutting-defined')\nPY2", + "timeout_seconds": 120 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [], + "completed_at": null + }, + { + "id": "task-062", + "title": "执行长期路线 Phase 1:在 Rust workspace 中建立 axum Web 承载层,补齐 router、middleware、request/trace/auth/workspace 上下文、SSE/WS 和兼容 Next route 边界,并打通 Rust core runtime 与 Hermes bridge", + "status": "pending", + "priority": "P0", + "depends_on": [ + "task-060", + "task-061" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo check --manifest-path rust/Cargo.toml", + "timeout_seconds": 1800 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [], + "completed_at": null + }, + { + "id": "task-063", + "title": "执行长期路线 Phase 2:把文档页改造成 server-first 阅读入口,去掉 meta -> content -> editor 串行链、mounted gating 和阅读页对 BlockNote 的默认依赖,确保先阅读后编辑", + "status": "pending", + "priority": "P0", + "depends_on": [ + "task-062" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/(app)/documents/[id]/page.tsx src/components/editor/document-shell.tsx src/components/editor/document-content.tsx src/components/editor/blocknote-editor.tsx", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [], + "completed_at": null + }, + { + "id": "task-064", + "title": "执行长期路线 Phase 3:将 Sidebar、页面树、文件树推进为 Rust query + 服务端输出 + 局部 island,收掉主布局中的超大客户端导航壳,并建立局部刷新与预取机制", + "status": "pending", + "priority": "P0", + "depends_on": [ + "task-062", + "task-061" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/lib/sidebar-data.test.ts src/lib/file-tree/rows.test.ts && pnpm exec eslint src/components/sidebar/sidebar.tsx src/lib/sidebar-data.ts src/lib/server/sidebar-data.ts src/app/api/sidebar/route.ts src/hooks/use-convex-sidebar-data.ts", + "timeout_seconds": 3000 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [], + "completed_at": null + }, + { + "id": "task-065", + "title": "执行长期路线 Phase 4:将搜索系统推进为 Rust 索引/召回/聚合 + 独立 Search island,拆分 SearchPalette 的全局常驻重组件形态,建立 server-first 搜索页与局部交互协议", + "status": "pending", + "priority": "P1", + "depends_on": [ + "task-062", + "task-061" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/components/search/search-palette.tsx src/app/api/search/documents/route.ts src/app/api/search/recent/route.ts", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [], + "completed_at": null + }, + { + "id": "task-066", + "title": "执行长期路线 Phase 5:继续把 AI 面板收口为纯 Hermes / mnote Rust bridge island,统一最小会话、页面上下文、流式 token / tool event / client action 协议,消除残留重量级页面适配壳", + "status": "pending", + "priority": "P1", + "depends_on": [ + "task-057", + "task-058", + "task-062", + "task-061" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "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/components/ai-agent src/components/editor/DocumentAiAgentPanel.tsx src/components/editor/blocks/MindmapAiAgentPanel.tsx src/components/onlyoffice/OnlyOfficeAiAgentPanel.tsx", + "timeout_seconds": 3000 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [], + "completed_at": null + }, + { + "id": "task-067", + "title": "执行长期路线 Phase 6:把 Mindmap 推进为独立 Rust 对象与独立页面能力,文档内嵌形态降级为轻预览或轻交互卡片,去掉对 BlockNote editor context 的强依赖", + "status": "pending", + "priority": "P1", + "depends_on": [ + "task-062", + "task-061", + "task-038" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/components/editor/blocks/MindmapBlock.tsx src/app/mindmap/[docId]/[mindmapId]/page.tsx src/app/api/mindmap/[docId]/route.ts src/app/api/mindmap/[docId]/[mindmapId]/route.ts", + "timeout_seconds": 3000 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [], + "completed_at": null + }, + { + "id": "task-068", + "title": "执行长期路线 Phase 7:完成文档阅读态/编辑态分离与 BlockNote 孤岛化,让编辑器只在进入编辑态时挂载,并继续移出评论、历史、回链、AI、页面选项等外围初始化链", + "status": "pending", + "priority": "P0", + "depends_on": [ + "task-063", + "task-064", + "task-065", + "task-066", + "task-067" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/components/editor/blocknote-editor.tsx src/components/editor/document-content.tsx src/components/editor/document-shell.tsx src/app/(app)/documents/[id]/page.tsx", + "timeout_seconds": 3000 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [], + "completed_at": null + }, + { + "id": "task-069", + "title": "执行长期路线 Phase 8:清理旧 Next/React 页面壳与兼容 helper,完成 Rust Web 主路径切换、双栈收缩、最终验收与对外口径统一", + "status": "pending", + "priority": "P0", + "depends_on": [ + "task-062", + "task-063", + "task-064", + "task-065", + "task-066", + "task-067", + "task-068" + ], + "attempts": 0, + "max_attempts": 3, + "started_at_commit": null, + "validation": { + "command": "python3 - <<'PY2'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote')\nfor rel in ['design/rust-web-long-term-architecture-v1.md', 'design/rust-web-long-term-checklist-v1.md', 'harness-tasks.json']:\n assert (root / rel).exists(), rel\nprint('task-069-final-defined')\nPY2", + "timeout_seconds": 120 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [], + "completed_at": null } ], - "session_count": 21, - "last_session": "2026-04-15T11:53:44Z" + "session_count": 28, + "last_session": "2026-04-16T15:15:00Z" } diff --git a/infra/convex/docker-compose.yml b/infra/convex/docker-compose.yml index eaba29ee..fa28f8e4 100644 --- a/infra/convex/docker-compose.yml +++ b/infra/convex/docker-compose.yml @@ -3,7 +3,7 @@ name: mnote-convex services: backend: # 说明:官方自托管镜像。需要时可将 :latest 固定为特定版本。 - image: ghcr.io/get-convex/convex-backend:latest + image: ghcr.io/get-convex/convex-backend@sha256:2143ad479a997802e74ac52f5f1ce3d6e75309b1965e36069d1c939166e210eb stop_grace_period: 10s stop_signal: SIGINT ports: @@ -19,7 +19,7 @@ services: start_period: 10s dashboard: - image: ghcr.io/get-convex/convex-dashboard:latest + image: ghcr.io/get-convex/convex-dashboard@sha256:a1fa8df3d320d1691c74bb9df7bb2c536bdef91ff3f5307b717dba97eec793d1 stop_grace_period: 10s stop_signal: SIGINT ports: diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 86010bb0..e7a35663 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -670,8 +670,11 @@ dependencies = [ name = "mnote-cli" version = "0.1.0" dependencies = [ + "base64", + "bridge-runtime", "clap", "core-protocol", + "reqwest", "serde", "serde_json", "storage-convex-bridge", diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 897b4118..e410ebb0 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -153,6 +153,52 @@ struct MindmapOutlineToolPayload { outline: Vec, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocsSearchDatasetPayload { + workspace_id: String, + documents: Vec, + mindmaps: Vec, + tables: Vec, + table_rows: Vec, + assets: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocsSearchToolPayload { + query: String, + workspace_id: Option, + limit: Option, + include_deleted: Option, + page_id: Option, + title_only: Option, + exact: Option, + include_ocr: Option, + time_range: Option, + time_field: Option, + custom_range_from: Option, + custom_range_to: Option, + #[serde(default)] + datasets: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocsReadToolPayload { + document_id: String, + max_chars: Option, + include_content: Option, + title: Option, + workspace_id: Option, + parent_id: Option, + updated_at: Option, + raw_text_length: Option, + raw_text: Option, + content: Option, +} + #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeSuccess { @@ -388,6 +434,33 @@ struct MindmapPutCommandPayload { create_only: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MindmapDeleteCommandPayload { + document_id: String, + mindmap_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MindmapRestoreCommandPayload { + document_id: String, + mindmap_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MindmapPurgeCommandPayload { + document_id: String, + mindmap_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MindmapEmptyTrashCommandPayload { + workspace_id: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentCreateCommandPayload { @@ -427,6 +500,25 @@ struct DocumentDuplicateCommandPayload { title: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocumentTemplateCommandPayload { + document_id: String, + is_template: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocumentEmptyTrashCommandPayload { + workspace_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocumentPurgeCommandPayload { + document_id: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentCopyTreeItemPayload { @@ -1038,6 +1130,119 @@ fn execute_tool_result( "results": results, })) } + "docs_search" => { + let payload: DocsSearchToolPayload = parse_tool_input(&args, &data)?; + let normalized_query = payload.query.trim().to_string(); + if normalized_query.is_empty() { + return Ok(json!({ + "ok": true, + "source": infer_tool_source(&data), + "query": normalized_query, + "results": [], + "enqueueAssetIds": [], + })); + } + let limit = payload.limit.unwrap_or(12).clamp(1, 30) as usize; + let requested_workspace = payload + .workspace_id + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let mut enqueue_asset_ids = Vec::::new(); + let mut results = Vec::::new(); + for dataset in payload.datasets { + if let Some(workspace_id) = requested_workspace.as_ref() { + if dataset.workspace_id != *workspace_id { + continue; + } + } + let evaluation = evaluate_search_documents( + &SearchDocumentsRequest { + query: normalized_query.clone(), + workspace_id: dataset.workspace_id.clone(), + page_id: payload.page_id.clone(), + limit, + title_only: payload.title_only.unwrap_or(false), + exact: payload.exact.unwrap_or(false), + include_ocr: payload.include_ocr.unwrap_or(false), + time_range: payload.time_range.clone().unwrap_or_else(|| "any".into()), + time_field: payload.time_field.clone().unwrap_or_else(|| "updated".into()), + custom_range_from: payload.custom_range_from.clone(), + custom_range_to: payload.custom_range_to.clone(), + }, + &SearchDocumentsDataset { + documents: dataset.documents, + mindmaps: dataset.mindmaps, + tables: dataset.tables, + table_rows: dataset.table_rows, + assets: dataset.assets, + }, + ); + enqueue_asset_ids.extend(evaluation.enqueue_asset_ids); + for item in evaluation.results { + results.push(json!({ + "id": item.id, + "title": item.title, + "snippet": item.snippet, + "updatedAt": item.updated_at, + "createdAt": item.created_at, + "matchField": item.match_field, + "hasOcr": item.has_ocr, + "publicPath": item.public_path, + "score": item.score, + "workspaceId": dataset.workspace_id, + })); + } + } + results.sort_by(|left, right| { + let left_score = left.get("score").and_then(Value::as_f64).unwrap_or(0.0); + let right_score = right.get("score").and_then(Value::as_f64).unwrap_or(0.0); + right_score + .partial_cmp(&left_score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| { + let left_updated = left.get("updatedAt").and_then(Value::as_str).unwrap_or(""); + let right_updated = right.get("updatedAt").and_then(Value::as_str).unwrap_or(""); + right_updated.cmp(left_updated) + }) + }); + results.truncate(limit); + enqueue_asset_ids.sort(); + enqueue_asset_ids.dedup(); + Ok(json!({ + "ok": true, + "source": infer_tool_source(&data), + "query": normalized_query, + "results": results, + "enqueueAssetIds": enqueue_asset_ids, + "includeDeleted": payload.include_deleted.unwrap_or(false), + })) + } + "docs_read" => { + let payload: DocsReadToolPayload = parse_tool_input(&args, &data)?; + let max_chars = payload.max_chars.unwrap_or(2500).clamp(200, 20_000) as usize; + let raw_text = payload.raw_text.unwrap_or_default(); + let raw_text_length = payload.raw_text_length.unwrap_or_else(|| raw_text.chars().count() as u64); + let trimmed = trim_text_for_docs_read(&raw_text, max_chars); + let mut result = json!({ + "ok": true, + "source": infer_tool_source(&data), + "documentId": payload.document_id, + "title": payload.title.unwrap_or_else(|| "".into()), + "workspaceId": payload.workspace_id.unwrap_or_else(|| "".into()), + "parentId": payload.parent_id, + "updatedAt": payload.updated_at, + "rawTextLength": raw_text_length, + "rawText": trimmed, + }); + if payload.include_content.unwrap_or(false) { + if let Some(map) = result.as_object_mut() { + map.insert("content".into(), payload.content.unwrap_or(Value::Null)); + } + } + Ok(result) + } "doc_insert_blocks" => { let blocks = normalize_blocks_from_value(&data); let specs = parse_insert_specs(&args)?; @@ -1368,6 +1573,44 @@ fn build_tool_plan_steps( }]); } + if invocation.tool == "docs_search" { + return Ok(vec![ + RuntimeToolPlanStep { + kind: "transport".into(), + name: "docs_search.dataset".into(), + function_name: None, + description: "通过 transport 拉取文档、导图、表格与 OCR 搜索数据集,再交由 Rust runtime 统一排序。".into(), + args_json: tool_wire.args_json.clone(), + }, + RuntimeToolPlanStep { + kind: "transform".into(), + name: "docs_search".into(), + function_name: None, + description: "在 Rust runtime 内复用统一搜索评估器,输出跨页文档搜索结果。".into(), + args_json: tool_wire.args_json.clone(), + }, + ]); + } + + if invocation.tool == "docs_read" { + return Ok(vec![ + RuntimeToolPlanStep { + kind: "transport".into(), + name: "docs_read.document".into(), + function_name: None, + description: "通过 transport 读取目标文档 meta/content,再交由 Rust runtime 统一裁剪正文与返回结构。".into(), + args_json: tool_wire.args_json.clone(), + }, + RuntimeToolPlanStep { + kind: "transform".into(), + name: "docs_read".into(), + function_name: None, + description: "在 Rust runtime 内标准化跨页文档读取结果。".into(), + args_json: tool_wire.args_json.clone(), + }, + ]); + } + if invocation.tool.starts_with("onlyoffice_") { let description = match invocation.tool.as_str() { "onlyoffice_session_resolve" => "解析 OnlyOffice 的附件、页面与用户上下文,生成稳定 session/asset 边界", @@ -2374,6 +2617,16 @@ fn strip_html_tags(value: &str) -> String { result.trim().to_string() } + +fn trim_text_for_docs_read(value: &str, max_chars: usize) -> String { + let taken = value.chars().take(max_chars).collect::(); + if value.chars().count() > max_chars { + format!("{taken}…") + } else { + taken + } +} + fn walk_mindmap_summaries(root: &MindmapTreeNode, max_nodes: usize) -> Vec { let mut list = Vec::new(); let mut queue = VecDeque::from([(root, None::, 0usize)]); @@ -3110,6 +3363,137 @@ fn execute_command( }), })) } + "mindmaps.delete" => { + let payload: MindmapDeleteCommandPayload = + parse_payload(command_wire.payload.clone())?; + let command = CommandEnvelope { + name: "mindmaps.delete".into(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: payload.mindmap_id.clone(), + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "docId": payload.document_id, + "mindmapId": payload.mindmap_id, + }), + })) + } + "mindmaps.restore" => { + let payload: MindmapRestoreCommandPayload = + parse_payload(command_wire.payload.clone())?; + let command = CommandEnvelope { + name: "mindmaps.restore".into(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: payload.mindmap_id.clone(), + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "docId": payload.document_id, + "mindmapId": payload.mindmap_id, + }), + })) + } + "mindmaps.purge" => { + let payload: MindmapPurgeCommandPayload = + parse_payload(command_wire.payload.clone())?; + let command = CommandEnvelope { + name: "mindmaps.purge".into(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: payload.mindmap_id.clone(), + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "docId": payload.document_id, + "mindmapId": payload.mindmap_id, + }), + })) + } + "mindmaps.emptyTrashByWorkspace" => { + let payload: MindmapEmptyTrashCommandPayload = + parse_payload(command_wire.payload.clone())?; + let command = CommandEnvelope { + name: "mindmaps.emptyTrashByWorkspace".into(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: payload.workspace_id.clone(), + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "workspaceId": payload.workspace_id, + }), + })) + } "documents.create" => { let payload: DocumentCreateCommandPayload = parse_payload(command_wire.payload.clone())?; @@ -3278,6 +3662,103 @@ fn execute_command( }), })) } + "documents.template" => { + let payload: DocumentTemplateCommandPayload = + parse_payload(command_wire.payload.clone())?; + let command = CommandEnvelope { + name: "documents.template".into(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: payload.is_template, + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "id": payload.document_id, + "isTemplate": payload.is_template, + }), + })) + } + "documents.emptyTrashByWorkspace" => { + let payload: DocumentEmptyTrashCommandPayload = + parse_payload(command_wire.payload.clone())?; + let command = CommandEnvelope { + name: "documents.emptyTrashByWorkspace".into(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: payload.workspace_id.clone(), + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "workspaceId": payload.workspace_id, + }), + })) + } + "documents.purge" => { + let payload: DocumentPurgeCommandPayload = + parse_payload(command_wire.payload.clone())?; + let command = CommandEnvelope { + name: "documents.purge".into(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: payload.document_id.clone(), + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "id": payload.document_id, + }), + })) + } "documents.copy_tree" => { let payload: DocumentCopyTreeCommandPayload = parse_payload(command_wire.payload.clone())?; @@ -3762,6 +4243,122 @@ mod tests { } } + #[test] + fn docs_search_tool_plan_uses_transport_and_transform_steps() { + let plan = execute_runtime_input(RuntimeInput::Tool { + context: demo_context(), + tool: RuntimeToolInvocationWire { + tool: "docs_search".into(), + kind: "query".into(), + mode: Some("plan".into()), + args_json: json!({ + "query": "rust", + }), + target: None, + reason: Some("跨页搜索".into()), + refs: vec!["task-050".into()], + }, + data: None, + }) + .expect("docs_search plan should build"); + + match plan { + RuntimeExecutionPlan::Tool(plan) => { + assert_eq!(plan.tool_name, "docs_search"); + assert_eq!(plan.toolset_id, "toolset.docs_read"); + assert_eq!(plan.steps.len(), 2); + assert_eq!(plan.steps[0].name, "docs_search.dataset"); + assert_eq!(plan.steps[1].name, "docs_search"); + } + _ => panic!("expected tool plan"), + } + } + + #[test] + fn docs_search_tool_result_uses_rust_search_evaluation() { + let result = execute_runtime_query(RuntimeInput::Tool { + context: demo_context(), + tool: RuntimeToolInvocationWire { + tool: "docs_search".into(), + kind: "query".into(), + mode: Some("result".into()), + args_json: json!({ + "query": "rust", + "limit": 5, + }), + target: None, + reason: Some("跨页搜索".into()), + refs: vec!["task-050".into()], + }, + data: Some(json!({ + "source": "convex", + "datasets": [ + { + "workspaceId": "ws_1", + "documents": [ + { + "id": "page_1", + "workspaceId": "ws_1", + "title": "Rust 文档", + "rawText": "这里记录 rust runtime 收口", + "createdAt": "2026-04-15T00:00:00Z", + "updatedAt": "2026-04-16T00:00:00Z" + } + ], + "mindmaps": [], + "tables": [], + "tableRows": [], + "assets": [] + } + ] + })), + }) + .expect("docs_search result should build"); + + assert_eq!(result.get("query").and_then(Value::as_str), Some("rust")); + assert_eq!(result.get("results").and_then(Value::as_array).map(Vec::len), Some(1)); + assert_eq!(result["results"][0]["id"], json!("page_1")); + } + + #[test] + fn docs_read_tool_result_trims_text_and_keeps_content_optional() { + let long_text = "a".repeat(260); + let result = execute_runtime_query(RuntimeInput::Tool { + context: demo_context(), + tool: RuntimeToolInvocationWire { + tool: "docs_read".into(), + kind: "query".into(), + mode: Some("result".into()), + args_json: json!({ + "documentId": "page_1", + "maxChars": 200, + "includeContent": true, + }), + target: None, + reason: Some("读取文档".into()), + refs: vec!["task-050".into()], + }, + data: Some(json!({ + "source": "convex", + "title": "示例页面", + "workspaceId": "ws_1", + "parentId": "parent_1", + "updatedAt": "2026-04-16T00:00:00Z", + "rawText": long_text, + "rawTextLength": 260, + "content": {"blocks": [{"id": "b1"}]} + })), + }) + .expect("docs_read result should build"); + + assert_eq!(result["documentId"], json!("page_1")); + let trimmed = result["rawText"].as_str().expect("rawText should be string"); + assert_eq!(trimmed.chars().count(), 201); + assert!(trimmed.ends_with('…')); + assert_eq!(result["rawTextLength"], json!(260)); + assert_eq!(result["content"]["blocks"][0]["id"], json!("b1")); + } + #[test] fn doc_get_tool_plan_uses_documents_content_query() { let plan = execute_runtime_input(RuntimeInput::Tool { diff --git a/rust/crates/core-protocol/src/lib.rs b/rust/crates/core-protocol/src/lib.rs index b7879b02..b8273b25 100644 --- a/rust/crates/core-protocol/src/lib.rs +++ b/rust/crates/core-protocol/src/lib.rs @@ -28,8 +28,9 @@ 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, + DOCS_TOOL_READ, DOCS_TOOL_SEARCH, DOCS_TOOLSET_READ, 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, @@ -81,6 +82,8 @@ mod tests { "search_web", "doc_get", "doc_find", + "docs_search", + "docs_read", "image_read", "doc_insert_blocks", "doc_replace_range", @@ -140,6 +143,11 @@ mod tests { .expect("slash toolset should exist"); assert!(slash.write_toolset); assert_eq!(slash.tool_names, &["slash_run"]); + let docs_read = registry + .toolset("toolset.docs_read") + .expect("docs_read toolset should exist"); + assert!(!docs_read.write_toolset); + assert_eq!(docs_read.tool_names, &["docs_search", "docs_read"]); let doc_write = registry .toolset("toolset.doc_write") .expect("doc_write toolset should exist"); diff --git a/rust/crates/core-protocol/src/tool.rs b/rust/crates/core-protocol/src/tool.rs index b0c09419..ef58a415 100644 --- a/rust/crates/core-protocol/src/tool.rs +++ b/rust/crates/core-protocol/src/tool.rs @@ -121,6 +121,31 @@ pub const DOC_TOOL_FIND: ToolSpec = ToolSpec { r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"maxResults":{"type":"integer","minimum":1,"maximum":30}}}"#, }; + +pub const DOCS_TOOL_SEARCH: ToolSpec = ToolSpec { + name: "docs_search", + display_name: "跨页文档搜索", + description: "在工作区内搜索文档标题、正文、导图、表格与 OCR 结果。", + toolset_id: "toolset.docs_read", + invocation_kind: InvocationKind::Query, + effect: ToolEffect::Read, + requires_confirmation: false, + input_schema_json: + r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"workspaceId":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":30},"includeDeleted":{"type":"boolean"},"pageId":{"type":"string"},"titleOnly":{"type":"boolean"},"exact":{"type":"boolean"},"includeOcr":{"type":"boolean"},"timeRange":{"type":"string"},"timeField":{"type":"string"},"customRangeFrom":{"type":"string"},"customRangeTo":{"type":"string"}}}"#, +}; + +pub const DOCS_TOOL_READ: ToolSpec = ToolSpec { + name: "docs_read", + display_name: "跨页文档读取", + description: "读取指定文档的标题、正文摘要与可选 content 快照。", + toolset_id: "toolset.docs_read", + invocation_kind: InvocationKind::Query, + effect: ToolEffect::Read, + requires_confirmation: false, + input_schema_json: + r#"{"type":"object","required":["documentId"],"properties":{"documentId":{"type":"string"},"maxChars":{"type":"integer","minimum":200,"maximum":20000},"includeContent":{"type":"boolean"}}}"#, +}; + pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec { name: "image_read", display_name: "读取图片", @@ -381,6 +406,15 @@ pub const DOC_TOOLSET_READ: ToolSetSpec = ToolSetSpec { tool_names: &["doc_get", "doc_find"], }; + +pub const DOCS_TOOLSET_READ: ToolSetSpec = ToolSetSpec { + id: "toolset.docs_read", + display_name: "跨页文档读取", + description: "跨页面搜索与读取文档的工具集合。", + write_toolset: false, + tool_names: &["docs_search", "docs_read"], +}; + pub const READONLY_TOOLSET: ToolSetSpec = ToolSetSpec { id: "toolset.readonly", display_name: "只读工具", @@ -470,6 +504,7 @@ pub const DOC_TOOL_REGISTRY: ToolRegistry = ToolRegistry::new( READONLY_TOOLSET, MEDIA_TOOLSET, SLASH_TOOLSET_WRITE, + DOCS_TOOLSET_READ, DOC_TOOLSET_READ, DOC_TOOLSET_WRITE, MINDMAP_TOOLSET_READ, @@ -482,6 +517,8 @@ pub const DOC_TOOL_REGISTRY: ToolRegistry = ToolRegistry::new( SEARCH_WEB_TOOL, DOC_TOOL_GET, DOC_TOOL_FIND, + DOCS_TOOL_SEARCH, + DOCS_TOOL_READ, IMAGE_READ_TOOL, DOC_TOOL_INSERT_BLOCKS, DOC_TOOL_REPLACE_RANGE, diff --git a/rust/crates/mnote-cli/Cargo.toml b/rust/crates/mnote-cli/Cargo.toml index ec08ba53..b674cc20 100644 --- a/rust/crates/mnote-cli/Cargo.toml +++ b/rust/crates/mnote-cli/Cargo.toml @@ -6,8 +6,11 @@ license.workspace = true authors.workspace = true [dependencies] +base64 = "0.22" +bridge-runtime = { path = "../bridge-runtime" } clap = { version = "4.5.38", features = ["derive"] } core-protocol = { path = "../core-protocol" } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" storage-convex-bridge = { path = "../storage-convex-bridge" } diff --git a/rust/crates/mnote-cli/src/lib.rs b/rust/crates/mnote-cli/src/lib.rs index 1f17fb3d..0f490487 100644 --- a/rust/crates/mnote-cli/src/lib.rs +++ b/rust/crates/mnote-cli/src/lib.rs @@ -1,11 +1,19 @@ +use base64::Engine; +use bridge_runtime::{ + execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput, + RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeToolInvocationWire, +}; use core_protocol::{ default_tool_registry, query::Pagination, tool_effect_label, ActorPayload, CommandEnvelope, - GetMindmap, GetPageContent, InsertBlock, InvocationKind, ListSidebarDataset, MindmapOp, - MindmapTreeNode, PatchPageBlock, PutMindmap, QueryEnvelope, SavePageContent, SearchBlocks, - SearchPages, SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation, UpdatePageTitle, + CreateDocumentPage, DeleteDocumentPage, GetMindmap, GetPageContent, InvocationKind, + ListSidebarDataset, MindmapOp, MindmapTreeNode, MoveDocumentPage, PatchPageBlock, PutMindmap, + QueryEnvelope, RestoreDocumentPage, SavePageContent, SearchBlocks, SearchDocuments, + SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation, UpdatePageTitle, }; +use reqwest::blocking::Client; use serde::Serialize; use serde_json::{json, Value}; +use std::env; use storage_convex_bridge::{build_query_request, build_write_request, BridgeContext}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -46,6 +54,13 @@ impl CliError { message: message.into(), } } + + pub fn transport(message: impl Into) -> Self { + Self { + code: "TRANSPORT_ERROR", + message: message.into(), + } + } } pub type CliResult = Result; @@ -83,11 +98,15 @@ pub enum CliOperationOutput { command_id: String, normalized_input: Value, transport: CliTransportPlan, + #[serde(skip_serializing_if = "Option::is_none")] + execution: Option, }, Query { name: String, normalized_input: Value, transport: CliTransportPlan, + #[serde(skip_serializing_if = "Option::is_none")] + execution: Option, }, Tool { tool_name: String, @@ -97,6 +116,8 @@ pub enum CliOperationOutput { effect: String, requires_confirmation: bool, normalized_input: Value, + #[serde(skip_serializing_if = "Option::is_none")] + execution: Option, }, } @@ -109,13 +130,64 @@ pub struct CliTransportPlan { pub args_json: Value, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageCreateArgs<'a> { + pub page_id: &'a str, + pub workspace_id: &'a str, + pub parent_id: Option<&'a str>, + pub title: &'a str, + pub access_scope: &'a str, + pub content_json: &'a str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageMoveArgs<'a> { + pub page_id: &'a str, + pub workspace_id: Option<&'a str>, + pub parent_id: Option<&'a str>, + pub sort_order: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageDeleteArgs<'a> { + pub page_id: &'a str, + pub workspace_id: Option<&'a str>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageRestoreArgs<'a> { + pub page_id: &'a str, + pub workspace_id: Option<&'a str>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlockMoveArgs<'a> { + pub source_document_id: &'a str, + pub block_id: &'a str, + pub target_document_id: &'a str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlockEmbedArgs<'a> { + pub source_document_id: &'a str, + pub block_id: &'a str, + pub target_document_id: &'a str, + pub target_block_id: Option<&'a str>, +} + pub fn supported_command_surface() -> Vec<&'static str> { let mut surface = vec![ "page get", "page title", "page save", + "page create", + "page move", + "page delete", + "page restore", "block insert", "block patch", + "block move", + "block embed", "mindmap get", "mindmap put", "mindmap op", @@ -125,7 +197,9 @@ pub fn supported_command_surface() -> Vec<&'static str> { "tool run", ]; for tool_name in default_tool_registry().tool_names() { - surface.push(Box::leak(format!("tool run --tool-name {tool_name}").into_boxed_str())); + surface.push(Box::leak( + format!("tool run --tool-name {tool_name}").into_boxed_str(), + )); } surface } @@ -135,8 +209,14 @@ pub fn supported_json_contracts() -> Vec<&'static str> { "page.get", "page.title", "page.save", + "page.create", + "page.move", + "page.delete", + "page.restore", "block.insert", "block.patch", + "block.move", + "block.embed", "mindmap.get", "mindmap.put", "mindmap.op", @@ -157,25 +237,31 @@ pub fn render_plain_output(output: &CliJsonOutput) -> String { name, command_id, transport, + execution, .. } => format!( - "domain={} action={} kind=command name={} command_id={} function={} request_id={} trace_id={}", + "domain={} action={} kind=command name={} command_id={} function={} executed={} request_id={} trace_id={}", output.domain, output.action, name, command_id, transport.function_name, + execution.is_some(), output.context.request_id, output.context.trace_id, ), CliOperationOutput::Query { - name, transport, .. + name, + transport, + execution, + .. } => format!( - "domain={} action={} kind=query name={} function={} request_id={} trace_id={}", + "domain={} action={} kind=query name={} function={} executed={} request_id={} trace_id={}", output.domain, output.action, name, transport.function_name, + execution.is_some(), output.context.request_id, output.context.trace_id, ), @@ -186,9 +272,10 @@ pub fn render_plain_output(output: &CliJsonOutput) -> String { toolset_id, effect, requires_confirmation, + execution, .. } => format!( - "domain={} action={} kind=tool tool={} invocation_kind={} execution_mode={} toolset={} effect={} requires_confirmation={} request_id={} trace_id={}", + "domain={} action={} kind=tool tool={} invocation_kind={} execution_mode={} toolset={} effect={} requires_confirmation={} executed={} request_id={} trace_id={}", output.domain, output.action, tool_name, @@ -197,6 +284,7 @@ pub fn render_plain_output(output: &CliJsonOutput) -> String { toolset_id, effect, requires_confirmation, + execution.is_some(), output.context.request_id, output.context.trace_id, ), @@ -349,6 +437,197 @@ pub fn plan_page_save( )) } +pub fn plan_page_create(ctx: &CliContext, args: &PageCreateArgs<'_>) -> CliResult { + let content_value = parse_json_value(args.content_json, "content_json")?; + let bridge = build_bridge_context(ctx, Some(args.workspace_id), "page", "create", args.page_id); + let command = CommandEnvelope { + name: "documents.create".into(), + command_id: build_command_id("page", "create", args.page_id), + idempotency_key: bridge.idempotency_key.clone(), + actor: build_actor_payload(ctx), + source: build_source_payload(), + target: Some(TargetRef { + workspace_id: Some(args.workspace_id.into()), + page_id: Some(args.page_id.into()), + block_id: None, + }), + payload: CreateDocumentPage { + page_id: args.page_id.into(), + parent_page_id: args.parent_id.map(|value| value.into()), + title: args.title.into(), + workspace_seed_id: args.workspace_id.into(), + }, + reason: ctx.reason.clone(), + refs: vec!["phase8-cli".into(), "task-048".into()], + dry_run: ctx.dry_run, + validate_only: ctx.validate_only, + }; + let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?; + Ok(build_command_output( + "page", + "create", + &bridge, + command.name, + command.command_id, + json!({ + "pageId": args.page_id, + "workspaceId": args.workspace_id, + "parentId": args.parent_id, + "title": args.title, + "accessScope": args.access_scope, + "content": content_value, + }), + CliTransportPlan { + kind: "convex_mutation".into(), + function_name: request.function_name, + payload_json: request.payload_json, + args_json: json!({ + "id": args.page_id, + "workspaceId": args.workspace_id, + "parentId": args.parent_id, + "title": args.title, + "accessScope": args.access_scope, + "content": content_value, + }), + }, + )) +} + +pub fn plan_page_move(ctx: &CliContext, args: &PageMoveArgs<'_>) -> CliResult { + let bridge = build_bridge_context(ctx, args.workspace_id, "page", "move", args.page_id); + let command = CommandEnvelope { + name: "documents.move".into(), + command_id: build_command_id("page", "move", args.page_id), + idempotency_key: bridge.idempotency_key.clone(), + actor: build_actor_payload(ctx), + source: build_source_payload(), + target: Some(TargetRef { + workspace_id: args.workspace_id.map(|value| value.into()), + page_id: Some(args.page_id.into()), + block_id: None, + }), + payload: MoveDocumentPage { + page_id: args.page_id.into(), + parent_page_id: args.parent_id.map(|value| value.into()), + sort_order: args.sort_order, + }, + reason: ctx.reason.clone(), + refs: vec!["phase8-cli".into(), "task-048".into()], + dry_run: ctx.dry_run, + validate_only: ctx.validate_only, + }; + let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?; + Ok(build_command_output( + "page", + "move", + &bridge, + command.name, + command.command_id, + json!({ + "pageId": args.page_id, + "workspaceId": args.workspace_id, + "parentId": args.parent_id, + "sortOrder": args.sort_order, + }), + CliTransportPlan { + kind: "convex_mutation".into(), + function_name: request.function_name, + payload_json: request.payload_json, + args_json: json!({ + "id": args.page_id, + "parentId": args.parent_id, + "sortOrder": args.sort_order, + }), + }, + )) +} + +pub fn plan_page_delete(ctx: &CliContext, args: &PageDeleteArgs<'_>) -> CliResult { + let bridge = build_bridge_context(ctx, args.workspace_id, "page", "delete", args.page_id); + let command = CommandEnvelope { + name: "documents.delete".into(), + command_id: build_command_id("page", "delete", args.page_id), + idempotency_key: bridge.idempotency_key.clone(), + actor: build_actor_payload(ctx), + source: build_source_payload(), + target: Some(TargetRef { + workspace_id: args.workspace_id.map(|value| value.into()), + page_id: Some(args.page_id.into()), + block_id: None, + }), + payload: DeleteDocumentPage { + page_id: args.page_id.into(), + }, + reason: ctx.reason.clone(), + refs: vec!["phase8-cli".into(), "task-048".into()], + dry_run: ctx.dry_run, + validate_only: ctx.validate_only, + }; + let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?; + Ok(build_command_output( + "page", + "delete", + &bridge, + command.name, + command.command_id, + json!({ + "pageId": args.page_id, + "workspaceId": args.workspace_id, + }), + CliTransportPlan { + kind: "convex_mutation".into(), + function_name: request.function_name, + payload_json: request.payload_json, + args_json: json!({ + "id": args.page_id, + }), + }, + )) +} + +pub fn plan_page_restore(ctx: &CliContext, args: &PageRestoreArgs<'_>) -> CliResult { + let bridge = build_bridge_context(ctx, args.workspace_id, "page", "restore", args.page_id); + let command = CommandEnvelope { + name: "documents.restore".into(), + command_id: build_command_id("page", "restore", args.page_id), + idempotency_key: bridge.idempotency_key.clone(), + actor: build_actor_payload(ctx), + source: build_source_payload(), + target: Some(TargetRef { + workspace_id: args.workspace_id.map(|value| value.into()), + page_id: Some(args.page_id.into()), + block_id: None, + }), + payload: RestoreDocumentPage { + page_id: args.page_id.into(), + }, + reason: ctx.reason.clone(), + refs: vec!["phase8-cli".into(), "task-048".into()], + dry_run: ctx.dry_run, + validate_only: ctx.validate_only, + }; + let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?; + Ok(build_command_output( + "page", + "restore", + &bridge, + command.name, + command.command_id, + json!({ + "pageId": args.page_id, + "workspaceId": args.workspace_id, + }), + CliTransportPlan { + kind: "convex_mutation".into(), + function_name: request.function_name, + payload_json: request.payload_json, + args_json: json!({ + "id": args.page_id, + }), + }, + )) +} + pub fn plan_block_insert( ctx: &CliContext, workspace_id: &str, @@ -370,7 +649,7 @@ pub fn plan_block_insert( page_id: Some(page_id.into()), block_id: None, }), - payload: InsertBlock { + payload: core_protocol::InsertBlock { page_id: page_id.into(), block_type: block_type.into(), content: content.into(), @@ -477,6 +756,106 @@ pub fn plan_block_patch( )) } +pub fn plan_block_move(ctx: &CliContext, args: &BlockMoveArgs<'_>) -> CliResult { + let bridge = build_bridge_context(ctx, None, "block", "move", args.block_id); + let command = CommandEnvelope { + name: "blocks.move".into(), + command_id: build_command_id("block", "move", args.block_id), + idempotency_key: bridge.idempotency_key.clone(), + actor: build_actor_payload(ctx), + source: build_source_payload(), + target: Some(TargetRef { + workspace_id: None, + page_id: Some(args.target_document_id.into()), + block_id: Some(args.block_id.into()), + }), + payload: core_protocol::MoveBlock { + block_id: args.block_id.into(), + new_parent_block_id: None, + new_page_id: Some(args.target_document_id.into()), + prev_block_id: None, + }, + reason: ctx.reason.clone(), + refs: vec!["phase8-cli".into(), "task-048".into()], + dry_run: ctx.dry_run, + validate_only: ctx.validate_only, + }; + let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?; + Ok(build_command_output( + "block", + "move", + &bridge, + command.name, + command.command_id, + json!({ + "sourceDocumentId": args.source_document_id, + "blockId": args.block_id, + "targetDocumentId": args.target_document_id, + }), + CliTransportPlan { + kind: "convex_mutation".into(), + function_name: request.function_name, + payload_json: request.payload_json, + args_json: json!({ + "id": args.block_id, + "sourceDocumentId": args.source_document_id, + "targetDocumentId": args.target_document_id, + }), + }, + )) +} + +pub fn plan_block_embed(ctx: &CliContext, args: &BlockEmbedArgs<'_>) -> CliResult { + let bridge = build_bridge_context(ctx, None, "block", "embed", args.block_id); + let command = CommandEnvelope { + name: "blocks.embed".into(), + command_id: build_command_id("block", "embed", args.block_id), + idempotency_key: bridge.idempotency_key.clone(), + actor: build_actor_payload(ctx), + source: build_source_payload(), + target: Some(TargetRef { + workspace_id: None, + page_id: Some(args.target_document_id.into()), + block_id: Some(args.block_id.into()), + }), + payload: core_protocol::EmbedBlock { + source_document_id: args.source_document_id.into(), + source_block_id: args.block_id.into(), + target_document_id: args.target_document_id.into(), + target_block_id: args.target_block_id.map(|value| value.into()), + }, + reason: ctx.reason.clone(), + refs: vec!["phase8-cli".into(), "task-048".into()], + dry_run: ctx.dry_run, + validate_only: ctx.validate_only, + }; + let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?; + Ok(build_command_output( + "block", + "embed", + &bridge, + command.name, + command.command_id, + json!({ + "sourceDocumentId": args.source_document_id, + "blockId": args.block_id, + "targetDocumentId": args.target_document_id, + "targetBlockId": args.target_block_id, + }), + CliTransportPlan { + kind: "convex_mutation".into(), + function_name: request.function_name, + payload_json: request.payload_json, + args_json: json!({ + "sourceDocumentId": args.source_document_id, + "blockId": args.block_id, + "targetDocumentId": args.target_document_id, + "targetBlockId": args.target_block_id, + }), + }, + )) +} + pub fn plan_search_documents( ctx: &CliContext, query: &str, @@ -484,16 +863,26 @@ pub fn plan_search_documents( limit: u32, cursor: Option<&str>, ) -> CliResult { - let bridge = build_bridge_context(ctx, workspace_id, "search", "documents", query); + let workspace_id = + workspace_id.ok_or_else(|| CliError::validation("search documents 缺少 --workspace-id"))?; + let bridge = build_bridge_context(ctx, Some(workspace_id), "search", "documents", query); let envelope = QueryEnvelope { - name: "search_pages".into(), - payload: SearchPages { + name: "search.documents".into(), + payload: SearchDocuments { query: query.into(), - workspace_id: workspace_id.map(|value| value.into()), + workspace_id: workspace_id.into(), + page_id: None, pagination: Pagination { limit, cursor: cursor.map(|value| value.into()), }, + title_only: false, + exact: false, + include_ocr: false, + time_range: "any".into(), + time_field: "updated".into(), + custom_range_from: None, + custom_range_to: None, }, }; let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?; @@ -509,6 +898,13 @@ pub fn plan_search_documents( "limit": limit, "cursor": cursor, }, + "filters": { + "titleOnly": false, + "exact": false, + "includeOcr": false, + "timeRange": "any", + "timeField": "updated", + }, }), CliTransportPlan { kind: "convex_query".into(), @@ -517,8 +913,16 @@ pub fn plan_search_documents( args_json: json!({ "query": query, "workspaceId": workspace_id, + "pageId": Value::Null, "limit": limit, "cursor": cursor, + "titleOnly": false, + "exact": false, + "includeOcr": false, + "timeRange": "any", + "timeField": "updated", + "customRangeFrom": Value::Null, + "customRangeTo": Value::Null, }), }, )) @@ -721,9 +1125,8 @@ pub fn plan_mindmap_op( ops_json: &str, ) -> CliResult { let ops_value = parse_json_value(ops_json, "ops_json")?; - let ops: Vec = serde_json::from_value(ops_value.clone()).map_err(|error| { - CliError::validation(format!("ops_json 不是合法 MindmapOp[]: {error}")) - })?; + let ops: Vec = serde_json::from_value(ops_value.clone()) + .map_err(|error| CliError::validation(format!("ops_json 不是合法 MindmapOp[]: {error}")))?; if ops.is_empty() { return Err(CliError::validation("ops_json 不能为空数组")); } @@ -760,6 +1163,7 @@ pub fn plan_mindmap_op( "validateOnly": bridge.validate_only, "dryRun": bridge.dry_run, }), + execution: None, }, }) } @@ -785,7 +1189,7 @@ pub fn plan_tool_run( } let invocation = ToolInvocation { tool: tool_name.into(), - kind: invocation_kind.clone(), + kind: invocation_kind, mode: execution_mode, args_json: args_json.into(), }; @@ -807,6 +1211,7 @@ pub fn plan_tool_run( "dryRun": bridge.dry_run, "args": args_value, }), + execution: None, }; Ok(CliJsonOutput { ok: true, @@ -818,6 +1223,979 @@ pub fn plan_tool_run( }) } +pub fn execute_output(output: &CliJsonOutput, ctx: &CliContext) -> CliResult { + let mut next = output.clone(); + match &mut next.operation { + CliOperationOutput::Query { + name, + transport, + execution, + .. + } => { + *execution = Some(execute_query(name, transport, &next.context, ctx)?); + } + CliOperationOutput::Command { + name, + normalized_input, + transport, + execution, + .. + } => { + *execution = Some(execute_command( + name, + normalized_input, + transport, + &next.context, + ctx, + )?); + } + CliOperationOutput::Tool { + tool_name, + invocation_kind, + execution_mode, + normalized_input, + execution, + .. + } => { + *execution = Some(execute_tool( + tool_name, + invocation_kind, + execution_mode, + normalized_input, + &next.context, + ctx, + )?); + } + } + Ok(next) +} + +fn execute_query( + name: &str, + transport: &CliTransportPlan, + context: &CliOutputContext, + cli_ctx: &CliContext, +) -> CliResult { + match name { + "documents.content.get" + | "documents.meta.get" + | "mindmaps.get" + | "sidebar.dataset.list" => execute_convex_query(transport, cli_ctx), + "search.documents" => execute_search_documents(transport, context, cli_ctx), + other => Err(CliError::validation(format!("暂不支持执行 query: {other}"))), + } +} + +fn execute_command( + name: &str, + normalized_input: &Value, + transport: &CliTransportPlan, + context: &CliOutputContext, + cli_ctx: &CliContext, +) -> CliResult { + match name { + "documents.create" + | "documents.move" + | "documents.delete" + | "documents.restore" + | "documents.title.update" + | "documents.save" + | "mindmaps.put" => execute_convex_mutation(transport, cli_ctx), + "insert_block" => execute_block_insert(normalized_input, context, cli_ctx), + "blocks.patch" => execute_block_patch(normalized_input, transport, cli_ctx), + "blocks.move" => execute_block_move(normalized_input, cli_ctx), + "blocks.embed" => execute_block_embed(normalized_input, cli_ctx), + other => Err(CliError::validation(format!( + "暂不支持执行 command: {other}" + ))), + } +} + +fn execute_tool( + tool_name: &str, + invocation_kind: &str, + execution_mode: &str, + normalized_input: &Value, + context: &CliOutputContext, + cli_ctx: &CliContext, +) -> CliResult { + if execution_mode != "result" { + return Err(CliError::validation( + "tool --execute 目前只支持 mode=result", + )); + } + let args = normalized_input.get("args").cloned().unwrap_or(Value::Null); + let runtime_args = merge_tool_args(tool_name, normalized_input, &args, context); + let target = build_tool_target(tool_name, normalized_input, context)?; + let runtime_context = build_runtime_context(context, cli_ctx); + let data = load_tool_data(tool_name, &runtime_args, target.as_ref(), cli_ctx)?; + execute_runtime_query(RuntimeInput::Tool { + context: runtime_context, + tool: RuntimeToolInvocationWire { + tool: tool_name.into(), + kind: invocation_kind.into(), + mode: Some("result".into()), + args_json: runtime_args, + target, + reason: cli_ctx.reason.clone(), + refs: vec!["mnote-cli-execute".into()], + }, + data: Some(data), + }) + .map_err(|error| CliError::transport(error.message)) +} + +fn merge_tool_args( + tool_name: &str, + normalized_input: &Value, + args: &Value, + context: &CliOutputContext, +) -> Value { + let mut merged = args.as_object().cloned().unwrap_or_default(); + if let Some(workspace_id) = context.workspace_id.as_ref() { + merged + .entry("workspaceId") + .or_insert_with(|| json!(workspace_id)); + } + if let Some(document_id) = normalized_input.get("documentId").cloned() { + merged.entry("documentId").or_insert(document_id); + } + if let Some(mindmap_id) = normalized_input.get("mindmapId").cloned() { + merged.entry("mindmapId").or_insert(mindmap_id); + } + if let Some(ops) = normalized_input.get("ops").cloned() { + merged.entry("ops").or_insert(ops); + } + if tool_name == "mindmap_apply_ops" { + if let Some(page_id) = normalized_input.get("documentId").cloned() { + merged.entry("pageId").or_insert(page_id); + } + } + Value::Object(merged) +} + +fn execute_search_documents( + transport: &CliTransportPlan, + context: &CliOutputContext, + cli_ctx: &CliContext, +) -> CliResult { + let workspace_id = transport + .args_json + .get("workspaceId") + .and_then(Value::as_str) + .ok_or_else(|| CliError::validation("search documents 缺少 workspaceId"))?; + let documents = execute_convex_query_raw( + "documents:listSearchDataByWorkspace", + json!({ "workspaceId": workspace_id }), + cli_ctx, + )?; + let mindmaps = execute_convex_query_raw( + "mindmaps:listByWorkspace", + json!({ "workspaceId": workspace_id, "includeDeleted": false }), + cli_ctx, + ) + .unwrap_or_else(|_| Value::Array(vec![])); + let tables = execute_convex_query_raw( + "tables:listByWorkspaceForSearch", + json!({ + "userId": context.actor_id, + "workspaceId": workspace_id, + "includeArchived": false, + "limit": 3000, + }), + cli_ctx, + ) + .unwrap_or_else(|_| Value::Array(vec![])); + let table_rows = execute_convex_query_raw( + "tables:listRowsByWorkspaceForSearch", + json!({ + "userId": context.actor_id, + "workspaceId": workspace_id, + "limit": 8000, + }), + cli_ctx, + ) + .unwrap_or_else(|_| Value::Array(vec![])); + let assets = execute_convex_query_raw( + "mediaAssets:listSearchDataByWorkspace", + json!({ + "userId": context.actor_id, + "workspaceId": workspace_id, + "includeDeleted": false, + "limit": 5000, + }), + cli_ctx, + ) + .unwrap_or_else(|_| Value::Array(vec![])); + let dataset = json!({ + "documents": documents.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ + "id": item.get("id").cloned().unwrap_or(Value::Null), + "workspaceId": item.get("workspace_id").cloned().unwrap_or(json!(workspace_id)), + "title": item.get("title").cloned().unwrap_or(Value::Null), + "rawText": item.get("raw_text").cloned().unwrap_or(Value::Null), + "createdAt": item.get("created_at").cloned().unwrap_or(Value::Null), + "updatedAt": item.get("updated_at").cloned().unwrap_or(Value::Null), + })).collect::>(), + "mindmaps": mindmaps.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ + "documentId": item.get("document_id").cloned().unwrap_or(Value::Null), + "data": item.get("data").cloned().unwrap_or(Value::Null), + })).collect::>(), + "tables": tables.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ + "id": item.get("id").cloned().unwrap_or(Value::Null), + "documentId": item.get("document_id").cloned().unwrap_or(Value::Null), + "title": item.get("title").cloned().unwrap_or(Value::Null), + })).collect::>(), + "tableRows": table_rows.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ + "tableId": item.get("table_id").cloned().unwrap_or(Value::Null), + "documentId": item.get("document_id").cloned().unwrap_or(Value::Null), + "rowHash": item.get("row_hash").cloned().unwrap_or(Value::Null), + })).collect::>(), + "assets": assets.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ + "id": item.get("id").cloned().unwrap_or(Value::Null), + "documentId": item.get("document_id").cloned().unwrap_or(Value::Null), + "assetType": item.get("asset_type").cloned().unwrap_or(Value::Null), + "fileName": item.get("file_name").cloned().unwrap_or(Value::Null), + "mimeType": item.get("mime_type").cloned().unwrap_or(Value::Null), + "ocrText": item.get("ocr_text").cloned().unwrap_or(Value::Null), + "ocrStatus": item.get("ocr_status").cloned().unwrap_or(Value::Null), + })).collect::>(), + }); + execute_runtime_query(RuntimeInput::Query { + context: build_runtime_context(context, cli_ctx), + query: RuntimeQueryEnvelopeWire { + name: "search.documents".into(), + payload: transport.args_json.clone(), + }, + data: Some(dataset), + }) + .map_err(|error| CliError::transport(error.message)) +} + +fn execute_block_insert( + normalized_input: &Value, + context: &CliOutputContext, + cli_ctx: &CliContext, +) -> CliResult { + let page_id = required_str(normalized_input, "pageId")?; + let workspace_id = required_str(normalized_input, "workspaceId")?; + let content = required_str(normalized_input, "content")?; + let block_type = normalized_input + .get("blockType") + .and_then(Value::as_str) + .unwrap_or("paragraph"); + let before_block_id = None::; + let after_block_id = normalized_input + .get("prevBlockId") + .and_then(Value::as_str) + .map(str::to_string); + let data = execute_convex_query_raw("documents:getContent", json!({ "id": page_id }), cli_ctx)?; + let block_specs = json!([{ + "type": block_type, + "text": content, + }]); + let runtime_result = execute_runtime_query(RuntimeInput::Tool { + context: build_runtime_context(context, cli_ctx), + tool: RuntimeToolInvocationWire { + tool: "doc_insert_blocks".into(), + kind: "command".into(), + mode: Some("result".into()), + args_json: json!({ + "pageId": page_id, + "workspaceId": workspace_id, + "afterBlockId": after_block_id, + "beforeBlockId": before_block_id, + "blocks": block_specs, + }), + target: Some(RuntimeTargetWire { + workspace_id: Some(workspace_id.to_string()), + page_id: Some(page_id.to_string()), + block_id: None, + }), + reason: cli_ctx.reason.clone(), + refs: vec!["mnote-cli-execute".into()], + }, + data: Some(data.clone()), + }) + .map_err(|error| CliError::transport(error.message))?; + let next_content = write_blocks_back( + &data, + runtime_result + .get("data") + .cloned() + .unwrap_or(Value::Array(vec![])), + ); + let save_result = execute_convex_mutation_raw( + "documents:updateContent", + json!({ + "id": page_id, + "content": next_content, + "expectedRevision": data.get("revision").cloned().unwrap_or(Value::Null), + "conflictDetectionKey": data.get("conflict_detection_key").cloned().unwrap_or(Value::Null), + }), + cli_ctx, + )?; + Ok(json!({ + "ok": true, + "pageId": page_id, + "inserted": runtime_result.get("inserted").cloned().unwrap_or(Value::Array(vec![])), + "revision": save_result.get("revision").cloned().unwrap_or(Value::Null), + "conflictDetectionKey": save_result.get("conflict_detection_key").cloned().unwrap_or(Value::Null), + "data": runtime_result.get("data").cloned().unwrap_or(Value::Null), + })) +} + +fn execute_block_patch( + normalized_input: &Value, + transport: &CliTransportPlan, + cli_ctx: &CliContext, +) -> CliResult { + let page_id = required_str(normalized_input, "pageId")?; + let block_id = required_str(normalized_input, "blockId")?; + let snapshot = normalized_input + .get("snapshot") + .cloned() + .ok_or_else(|| CliError::validation("block patch 缺少 snapshot"))?; + let current = + execute_convex_query_raw("documents:getContent", json!({ "id": page_id }), cli_ctx)?; + let next_blocks = replace_block_subtree(extract_blocks(¤t), block_id, &snapshot)?; + let args = json!({ + "id": page_id, + "content": write_blocks_back(¤t, Value::Array(next_blocks)), + "expectedRevision": transport.args_json.get("expectedRevision").cloned().unwrap_or(Value::Null), + "conflictDetectionKey": transport.args_json.get("conflictDetectionKey").cloned().unwrap_or(Value::Null), + }); + execute_convex_mutation_raw("documents:updateContent", args, cli_ctx) +} + +fn execute_block_move(normalized_input: &Value, cli_ctx: &CliContext) -> CliResult { + let source_document_id = required_str(normalized_input, "sourceDocumentId")?; + let target_document_id = required_str(normalized_input, "targetDocumentId")?; + let block_id = required_str(normalized_input, "blockId")?; + + let source = execute_convex_query_raw( + "documents:getContent", + json!({ "id": source_document_id }), + cli_ctx, + )?; + let target = execute_convex_query_raw( + "documents:getContent", + json!({ "id": target_document_id }), + cli_ctx, + )?; + let source_blocks = extract_blocks(&source); + let target_blocks = extract_blocks(&target); + let (removed, next_source_blocks) = remove_block_subtree(source_blocks, block_id)?; + let mut next_target_blocks = target_blocks; + next_target_blocks.push(removed); + let next_source_content = write_blocks_back(&source, Value::Array(next_source_blocks)); + let next_target_content = write_blocks_back(&target, Value::Array(next_target_blocks)); + let source_save = execute_convex_mutation_raw( + "documents:updateContent", + json!({ + "id": source_document_id, + "content": next_source_content, + "expectedRevision": source.get("revision").cloned().unwrap_or(Value::Null), + "conflictDetectionKey": source.get("conflict_detection_key").cloned().unwrap_or(Value::Null), + }), + cli_ctx, + )?; + let target_save = execute_convex_mutation_raw( + "documents:updateContent", + json!({ + "id": target_document_id, + "content": next_target_content, + "expectedRevision": target.get("revision").cloned().unwrap_or(Value::Null), + "conflictDetectionKey": target.get("conflict_detection_key").cloned().unwrap_or(Value::Null), + }), + cli_ctx, + )?; + Ok(json!({ + "ok": true, + "sourceRevision": source_save.get("revision").cloned().unwrap_or(Value::Null), + "targetRevision": target_save.get("revision").cloned().unwrap_or(Value::Null), + })) +} + +fn execute_block_embed(normalized_input: &Value, cli_ctx: &CliContext) -> CliResult { + let source_document_id = required_str(normalized_input, "sourceDocumentId")?; + let target_document_id = required_str(normalized_input, "targetDocumentId")?; + let block_id = required_str(normalized_input, "blockId")?; + let target_block_id = normalized_input + .get("targetBlockId") + .and_then(Value::as_str); + + let source_meta = execute_convex_query_raw( + "documents:getMeta", + json!({ "id": source_document_id }), + cli_ctx, + )?; + let target_content = execute_convex_query_raw( + "documents:getContent", + json!({ "id": target_document_id }), + cli_ctx, + )?; + let target_meta = execute_convex_query_raw( + "documents:getMeta", + json!({ "id": target_document_id }), + cli_ctx, + )?; + let mut target_blocks = extract_blocks(&target_content); + let reference_block = json!({ + "id": format!("cli_embed_{}_{}", target_document_id, block_id), + "type": "blockReference", + "props": { + "sourceDocumentId": source_document_id, + "targetBlockId": block_id, + "display": "embed", + }, + "content": [], + "children": [], + }); + let anchor_id = target_block_id.map(str::to_string).or_else(|| { + target_meta + .get("embed_default_block_id") + .and_then(Value::as_str) + .map(str::to_string) + }); + if let Some(anchor_id) = anchor_id { + if let Some(index) = find_block_index(&target_blocks, &anchor_id) { + target_blocks.insert(index + 1, reference_block.clone()); + } else { + target_blocks.push(reference_block.clone()); + } + } else { + target_blocks.push(reference_block.clone()); + } + let saved = execute_convex_mutation_raw( + "documents:updateContent", + json!({ + "id": target_document_id, + "content": write_blocks_back(&target_content, Value::Array(target_blocks)), + "expectedRevision": target_content.get("revision").cloned().unwrap_or(Value::Null), + "conflictDetectionKey": target_content.get("conflict_detection_key").cloned().unwrap_or(Value::Null), + }), + cli_ctx, + )?; + Ok(json!({ + "ok": true, + "sourceTitle": source_meta.get("title").cloned().unwrap_or(Value::Null), + "referenceBlockId": reference_block.get("id").cloned().unwrap_or(Value::Null), + "revision": saved.get("revision").cloned().unwrap_or(Value::Null), + })) +} + +fn build_tool_target( + tool_name: &str, + normalized_input: &Value, + context: &CliOutputContext, +) -> CliResult> { + let args = normalized_input.get("args").cloned().unwrap_or(Value::Null); + let workspace_id = args + .get("workspaceId") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| context.workspace_id.clone()); + let page_id = args + .get("documentId") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + args.get("pageId") + .and_then(Value::as_str) + .map(str::to_string) + }); + let block_id = args + .get("mindmapId") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + args.get("blockId") + .and_then(Value::as_str) + .map(str::to_string) + }); + if matches!( + tool_name, + "search_web" | "image_read" | "slash_run" | "event_replay" | "index_rebuild" + ) { + return Ok(None); + } + if workspace_id.is_none() && page_id.is_none() && block_id.is_none() { + return Ok(None); + } + Ok(Some(RuntimeTargetWire { + workspace_id, + page_id, + block_id, + })) +} + +fn load_tool_data( + tool_name: &str, + args: &Value, + target: Option<&RuntimeTargetWire>, + cli_ctx: &CliContext, +) -> CliResult { + match tool_name { + "search_web" | "slash_run" | "event_replay" | "index_rebuild" => { + Ok(json!({ "source": "cli" })) + } + "image_read" => Ok(json!({ "source": "cli", "asset": Value::Null })), + "doc_get" | "doc_find" | "doc_insert_blocks" | "doc_replace_range" => { + let page_id = read_doc_target(args, target)?; + let result = execute_convex_query_raw( + "documents:getContent", + json!({ "id": page_id }), + cli_ctx, + )?; + Ok( + json!({ "source": "convex", "content": result.get("content").cloned().unwrap_or(Value::Null) }), + ) + } + "docs_read" => { + let document_id = args + .get("documentId") + .and_then(Value::as_str) + .ok_or_else(|| CliError::validation("docs_read 缺少 documentId"))?; + let meta = execute_convex_query_raw( + "documents:getMeta", + json!({ "id": document_id }), + cli_ctx, + )?; + let content = execute_convex_query_raw( + "documents:getContent", + json!({ "id": document_id }), + cli_ctx, + )?; + Ok(json!({ + "source": "convex", + "documentId": document_id, + "title": meta.get("title").cloned().unwrap_or(Value::Null), + "workspaceId": meta.get("workspace_id").cloned().unwrap_or(Value::Null), + "parentId": meta.get("parent_id").cloned().unwrap_or(Value::Null), + "updatedAt": meta.get("updated_at").cloned().unwrap_or(Value::Null), + "rawText": extract_raw_text(&content), + "rawTextLength": extract_raw_text(&content).chars().count(), + "content": content.get("content").cloned().unwrap_or(Value::Null), + })) + } + "docs_search" => { + let workspace_id = args + .get("workspaceId") + .and_then(Value::as_str) + .ok_or_else(|| CliError::validation("docs_search 缺少 workspaceId"))?; + let documents = execute_convex_query_raw( + "documents:listSearchDataByWorkspace", + json!({ "workspaceId": workspace_id }), + cli_ctx, + )?; + let mindmaps = execute_convex_query_raw( + "mindmaps:listByWorkspace", + json!({ "workspaceId": workspace_id, "includeDeleted": false }), + cli_ctx, + ) + .unwrap_or_else(|_| Value::Array(vec![])); + let tables = execute_convex_query_raw( + "tables:listByWorkspaceForSearch", + json!({ "userId": cli_ctx.actor_id, "workspaceId": workspace_id, "includeArchived": false, "limit": 3000 }), + cli_ctx, + ).unwrap_or_else(|_| Value::Array(vec![])); + let table_rows = execute_convex_query_raw( + "tables:listRowsByWorkspaceForSearch", + json!({ "userId": cli_ctx.actor_id, "workspaceId": workspace_id, "limit": 8000 }), + cli_ctx, + ) + .unwrap_or_else(|_| Value::Array(vec![])); + let assets = execute_convex_query_raw( + "mediaAssets:listSearchDataByWorkspace", + json!({ "userId": cli_ctx.actor_id, "workspaceId": workspace_id, "includeDeleted": false, "limit": 5000 }), + cli_ctx, + ).unwrap_or_else(|_| Value::Array(vec![])); + Ok(json!({ + "source": "convex", + "datasets": [{ + "workspaceId": workspace_id, + "documents": documents.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ + "id": item.get("id").cloned().unwrap_or(Value::Null), + "workspaceId": item.get("workspace_id").cloned().unwrap_or(json!(workspace_id)), + "title": item.get("title").cloned().unwrap_or(Value::Null), + "rawText": item.get("raw_text").cloned().unwrap_or(Value::Null), + "createdAt": item.get("created_at").cloned().unwrap_or(Value::Null), + "updatedAt": item.get("updated_at").cloned().unwrap_or(Value::Null) + })).collect::>(), + "mindmaps": mindmaps.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ + "documentId": item.get("document_id").cloned().unwrap_or(Value::Null), + "data": item.get("data").cloned().unwrap_or(Value::Null) + })).collect::>(), + "tables": tables.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ + "id": item.get("id").cloned().unwrap_or(Value::Null), + "documentId": item.get("document_id").cloned().unwrap_or(Value::Null), + "title": item.get("title").cloned().unwrap_or(Value::Null) + })).collect::>(), + "tableRows": table_rows.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ + "tableId": item.get("table_id").cloned().unwrap_or(Value::Null), + "documentId": item.get("document_id").cloned().unwrap_or(Value::Null), + "rowHash": item.get("row_hash").cloned().unwrap_or(Value::Null) + })).collect::>(), + "assets": assets.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ + "id": item.get("id").cloned().unwrap_or(Value::Null), + "documentId": item.get("document_id").cloned().unwrap_or(Value::Null), + "assetType": item.get("asset_type").cloned().unwrap_or(Value::Null), + "fileName": item.get("file_name").cloned().unwrap_or(Value::Null), + "mimeType": item.get("mime_type").cloned().unwrap_or(Value::Null), + "ocrText": item.get("ocr_text").cloned().unwrap_or(Value::Null), + "ocrStatus": item.get("ocr_status").cloned().unwrap_or(Value::Null) + })).collect::>() + }] + })) + } + "mindmap_get" | "mindmap_get_subtree" | "mindmap_apply_ops" | "mindmap_put" => { + let document_id = read_doc_target(args, target)?; + let mindmap_id = args + .get("mindmapId") + .and_then(Value::as_str) + .or_else(|| target.and_then(|value| value.block_id.as_deref())) + .ok_or_else(|| CliError::validation("mindmap 工具缺少 mindmapId"))?; + let result = execute_convex_query_raw( + "mindmaps:get", + json!({ "docId": document_id, "mindmapId": mindmap_id }), + cli_ctx, + )?; + Ok( + json!({ "source": "convex", "data": result.get("data").cloned().unwrap_or(Value::Null), "meta": result.get("meta").cloned().unwrap_or(Value::Null) }), + ) + } + other => Err(CliError::validation(format!( + "暂不支持 tool 数据加载: {other}" + ))), + } +} + +fn execute_convex_query(transport: &CliTransportPlan, cli_ctx: &CliContext) -> CliResult { + execute_convex_query_raw( + &transport.function_name, + transport.args_json.clone(), + cli_ctx, + ) +} + +fn execute_convex_mutation(transport: &CliTransportPlan, cli_ctx: &CliContext) -> CliResult { + execute_convex_mutation_raw( + &transport.function_name, + transport.args_json.clone(), + cli_ctx, + ) +} + +fn execute_convex_query_raw( + function_name: &str, + args_json: Value, + cli_ctx: &CliContext, +) -> CliResult { + let env = ConvexCliEnv::load()?; + let payload = json!({ + "path": function_name, + "format": "convex_encoded_json", + "args": args_json, + }); + let response = env + .client + .post(format!("{}/api/query", env.url)) + .header("Authorization", env.authorization) + .header("Content-Type", "application/json") + .header("Convex-Client", "mnote-cli") + .json(&payload) + .send() + .map_err(|error| CliError::transport(format!("Convex query 请求失败: {error}")))?; + parse_convex_response(response, cli_ctx) +} + +fn execute_convex_mutation_raw( + function_name: &str, + args_json: Value, + cli_ctx: &CliContext, +) -> CliResult { + let env = ConvexCliEnv::load()?; + let payload = json!({ + "path": function_name, + "format": "convex_encoded_json", + "args": [args_json], + }); + let response = env + .client + .post(format!("{}/api/mutation", env.url)) + .header("Authorization", env.authorization) + .header("Content-Type", "application/json") + .header("Convex-Client", "mnote-cli") + .json(&payload) + .send() + .map_err(|error| CliError::transport(format!("Convex mutation 请求失败: {error}")))?; + parse_convex_response(response, cli_ctx) +} + +fn parse_convex_response( + response: reqwest::blocking::Response, + _cli_ctx: &CliContext, +) -> CliResult { + let status = response.status(); + let body: Value = response + .json() + .map_err(|error| CliError::transport(format!("Convex 响应解析失败: {error}")))?; + if !status.is_success() { + let message = body + .get("errorMessage") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + return Err(CliError::transport(message)); + } + match body.get("status").and_then(Value::as_str) { + Some("success") => Ok(body.get("value").cloned().unwrap_or(Value::Null)), + Some("error") => Err(CliError::transport( + body.get("errorMessage") + .and_then(Value::as_str) + .unwrap_or("Convex 返回 error") + .to_string(), + )), + _ => Err(CliError::transport(format!("未知 Convex 响应: {body}"))), + } +} + +#[derive(Debug, Clone)] +struct ConvexCliEnv { + url: String, + authorization: String, + client: Client, +} + +impl ConvexCliEnv { + fn load() -> CliResult { + let url = read_env_or_dotenv("CONVEX_SELF_HOSTED_URL")? + .or_else(|| env::var("NEXT_PUBLIC_CONVEX_URL").ok()) + .ok_or_else(|| { + CliError::validation("缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL") + })?; + let admin_key = read_env_or_dotenv("CONVEX_SELF_HOSTED_ADMIN_KEY")? + .ok_or_else(|| CliError::validation("缺少 CONVEX_SELF_HOSTED_ADMIN_KEY"))?; + let dev_user_id = read_env_or_dotenv("DEV_USER_ID")?.unwrap_or_else(|| "dev-user".into()); + let dev_user_name = + read_env_or_dotenv("DEV_USER_NAME")?.unwrap_or_else(|| "开发用户".into()); + let dev_user_email = + read_env_or_dotenv("DEV_USER_EMAIL")?.unwrap_or_else(|| "dev@mnote.local".into()); + let identity = json!({ + "subject": dev_user_id, + "issuer": "https://mnote.local/dev-auth", + "tokenIdentifier": format!("dev-user|{}", dev_user_id), + "name": dev_user_name, + "email": dev_user_email, + }); + let encoded = base64::engine::general_purpose::STANDARD + .encode(serde_json::to_string(&identity).map_err(|error| { + CliError::transport(format!("开发用户身份序列化失败: {error}")) + })?); + let authorization = format!("Convex {admin_key}:{encoded}"); + let client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|error| CliError::transport(format!("HTTP 客户端创建失败: {error}")))?; + Ok(Self { + url: url.trim().trim_end_matches('/').to_string(), + authorization, + client, + }) + } +} + +fn read_env_or_dotenv(key: &str) -> CliResult> { + if let Ok(value) = env::var(key) { + let trimmed = value.trim().to_string(); + if !trimmed.is_empty() { + return Ok(Some(trimmed)); + } + } + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../..") + .join(".env.all"); + let content = std::fs::read_to_string(&root) + .map_err(|error| CliError::transport(format!("读取 .env.all 失败: {error}")))?; + for line in content.lines() { + let line = line.trim_end_matches('\r'); + if line.starts_with('#') || line.trim().is_empty() { + continue; + } + if let Some((k, v)) = line.split_once('=') { + if k.trim() == key { + let trimmed = v.trim().trim_matches('"').to_string(); + if !trimmed.is_empty() { + return Ok(Some(trimmed)); + } + } + } + } + Ok(None) +} + +fn build_runtime_context( + context: &CliOutputContext, + cli_ctx: &CliContext, +) -> RuntimeBridgeContextWire { + RuntimeBridgeContextWire { + deployment_id: None, + project_id: None, + workspace_id: context.workspace_id.clone(), + request_id: context.request_id.clone(), + trace_id: context.trace_id.clone(), + actor: RuntimeActorWire { + actor_type: context.actor_type.clone(), + actor_id: context.actor_id.clone(), + session_id: context.session_id.clone(), + }, + source: RuntimeSourceWire { + channel: "cli".into(), + client: "mnote-cli".into(), + }, + tenant_id: None, + auth_token: None, + idempotency_key: cli_ctx.idempotency_key.clone(), + validate_only: cli_ctx.validate_only, + dry_run: cli_ctx.dry_run, + } +} + +fn extract_blocks(content_result: &Value) -> Vec { + if let Some(content) = content_result.get("content") { + if let Some(array) = content.as_array() { + return array.clone(); + } + if let Some(array) = content.get("blocks").and_then(Value::as_array) { + return array.clone(); + } + } + vec![] +} + +fn write_blocks_back(content_result: &Value, blocks: Value) -> Value { + let current = content_result + .get("content") + .cloned() + .unwrap_or(Value::Null); + if current.is_array() { + return blocks; + } + if let Some(obj) = current.as_object() { + let mut next = obj.clone(); + next.insert("blocks".into(), blocks); + return Value::Object(next); + } + json!({ "blocks": blocks }) +} + +fn find_block_index(blocks: &[Value], target_id: &str) -> Option { + blocks.iter().position(|block| { + block + .get("id") + .and_then(Value::as_str) + .map(|value| value == target_id) + .unwrap_or(false) + }) +} + +fn remove_block_subtree(mut blocks: Vec, block_id: &str) -> CliResult<(Value, Vec)> { + if let Some(index) = blocks + .iter() + .position(|block| block.get("id").and_then(Value::as_str) == Some(block_id)) + { + let removed = blocks.remove(index); + return Ok((removed, blocks)); + } + for block in &mut blocks { + if let Some(children) = block.get_mut("children").and_then(Value::as_array_mut) { + let (removed, next_children) = remove_block_subtree(children.clone(), block_id)?; + *children = next_children; + return Ok((removed, blocks)); + } + } + Err(CliError::validation(format!("未找到 blockId:{block_id}"))) +} + +fn replace_block_subtree( + mut blocks: Vec, + block_id: &str, + next_block: &Value, +) -> CliResult> { + let mut normalized = next_block + .as_object() + .cloned() + .ok_or_else(|| CliError::validation("block patch snapshot 必须是对象"))?; + normalized.insert("id".into(), Value::String(block_id.to_string())); + let next_value = Value::Object(normalized); + + if let Some(index) = blocks + .iter() + .position(|block| block.get("id").and_then(Value::as_str) == Some(block_id)) + { + blocks[index] = next_value; + return Ok(blocks); + } + for block in &mut blocks { + if let Some(children) = block.get_mut("children").and_then(Value::as_array_mut) { + let next_children = replace_block_subtree(children.clone(), block_id, &next_value)?; + *children = next_children; + return Ok(blocks); + } + } + Err(CliError::validation(format!("未找到 blockId:{block_id}"))) +} + +fn extract_raw_text(content_result: &Value) -> String { + fn walk(value: &Value, parts: &mut Vec) { + match value { + Value::Array(items) => { + for item in items { + walk(item, parts); + } + } + Value::Object(map) => { + if let Some(text) = map.get("text").and_then(Value::as_str) { + if !text.trim().is_empty() { + parts.push(text.to_string()); + } + } + if let Some(content) = map.get("content") { + walk(content, parts); + } + if let Some(children) = map.get("children") { + walk(children, parts); + } + if let Some(blocks) = map.get("blocks") { + walk(blocks, parts); + } + } + _ => {} + } + } + let mut parts = Vec::new(); + if let Some(content) = content_result.get("content") { + walk(content, &mut parts); + } + parts.join("\n") +} + +fn read_doc_target(args: &Value, target: Option<&RuntimeTargetWire>) -> CliResult { + args.get("documentId") + .and_then(Value::as_str) + .or_else(|| args.get("pageId").and_then(Value::as_str)) + .map(str::to_string) + .or_else(|| target.and_then(|value| value.page_id.clone())) + .ok_or_else(|| CliError::validation("缺少 documentId/pageId")) +} + +fn required_str<'a>(value: &'a Value, key: &str) -> CliResult<&'a str> { + value + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| CliError::validation(format!("缺少 {key}"))) +} + fn build_query_output( domain: &str, action: &str, @@ -847,6 +2225,7 @@ fn build_query_output( name, normalized_input, transport, + execution: None, }, } } @@ -882,6 +2261,7 @@ fn build_command_output( command_id, normalized_input, transport, + execution: None, }, } } @@ -987,7 +2367,10 @@ mod tests { fn supported_surface_covers_phase2_minimum_domains() { let commands = supported_command_surface(); assert!(commands.contains(&"page get")); + assert!(commands.contains(&"page create")); + assert!(commands.contains(&"page move")); assert!(commands.contains(&"block patch")); + assert!(commands.contains(&"block move")); assert!(commands.contains(&"mindmap get")); assert!(commands.contains(&"search documents")); assert!(commands.contains(&"sidebar dataset")); @@ -1030,6 +2413,62 @@ mod tests { } } + #[test] + fn page_create_json_contract_uses_documents_create() { + let output = plan_page_create( + &CliContext::default(), + &PageCreateArgs { + page_id: "page_1", + workspace_id: "ws_1", + parent_id: Some("parent_1"), + title: "新页面", + access_scope: "private", + content_json: r#"[{"id":"block_1"}]"#, + }, + ) + .expect("page create plan should build"); + + match output.operation { + CliOperationOutput::Command { + name, transport, .. + } => { + assert_eq!(name, "documents.create"); + assert_eq!( + transport.function_name, + "documents:createWithParentReference" + ); + assert_eq!(transport.args_json["workspaceId"], json!("ws_1")); + assert_eq!(transport.args_json["parentId"], json!("parent_1")); + } + _ => panic!("expected command output"), + } + } + + #[test] + fn page_move_json_contract_uses_documents_move() { + let output = plan_page_move( + &CliContext::default(), + &PageMoveArgs { + page_id: "page_1", + workspace_id: Some("ws_1"), + parent_id: Some("parent_2"), + sort_order: 3, + }, + ) + .expect("page move plan should build"); + + match output.operation { + CliOperationOutput::Command { + name, transport, .. + } => { + assert_eq!(name, "documents.move"); + assert_eq!(transport.function_name, "documents:move"); + assert_eq!(transport.args_json["sortOrder"], json!(3)); + } + _ => panic!("expected command output"), + } + } + #[test] fn sidebar_dataset_json_contract_uses_single_dataset_query() { let output = plan_sidebar_dataset(&CliContext::default(), "ws_1") @@ -1067,6 +2506,7 @@ mod tests { effect, requires_confirmation, normalized_input, + .. } => { assert_eq!(tool_name, "doc_get"); assert_eq!(invocation_kind, "query"); @@ -1169,4 +2609,55 @@ mod tests { _ => panic!("expected command output"), } } + + #[test] + fn replace_block_subtree_preserves_other_blocks() { + let next_blocks = replace_block_subtree( + vec![ + json!({ + "id": "block_1", + "type": "paragraph", + "content": [{"type": "text", "text": "old"}], + "children": [], + }), + json!({ + "id": "block_2", + "type": "paragraph", + "content": [{"type": "text", "text": "keep"}], + "children": [], + }), + ], + "block_1", + &json!({ + "type": "paragraph", + "content": [{"type": "text", "text": "new"}], + "children": [], + }), + ) + .expect("block replacement should succeed"); + + assert_eq!( + next_blocks, + vec![ + json!({ + "id": "block_1", + "type": "paragraph", + "content": [{"type": "text", "text": "new"}], + "children": [], + }), + json!({ + "id": "block_2", + "type": "paragraph", + "content": [{"type": "text", "text": "keep"}], + "children": [], + }), + ] + ); + } + + #[test] + fn read_env_or_dotenv_trims_crlf_values() { + let got = read_env_or_dotenv("CONVEX_SELF_HOSTED_URL").expect("read env should work"); + assert_eq!(got.as_deref(), Some("http://127.0.0.1:3210")); + } } diff --git a/rust/crates/mnote-cli/src/main.rs b/rust/crates/mnote-cli/src/main.rs index 77de5ceb..697a05e9 100644 --- a/rust/crates/mnote-cli/src/main.rs +++ b/rust/crates/mnote-cli/src/main.rs @@ -1,15 +1,17 @@ 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, + execute_output, plan_block_embed, plan_block_insert, plan_block_move, plan_block_patch, + plan_mindmap_get, plan_mindmap_op, plan_mindmap_put, plan_page_create, plan_page_delete, + plan_page_get, plan_page_move, plan_page_restore, plan_page_save, plan_page_title, + plan_search_blocks, plan_search_documents, plan_sidebar_dataset, plan_tool_run, + render_plain_output, BlockEmbedArgs, BlockMoveArgs, CliContext, CliError, CliJsonOutput, + PageCreateArgs, PageDeleteArgs, PageMoveArgs, PageRestoreArgs, }; #[derive(Parser, Debug)] #[command(name = "mnote-cli")] -#[command(about = "Phase 2 最小 CLI 协议冻结器", long_about = None)] +#[command(about = "Phase 8 最小真实执行 CLI", long_about = None)] struct Cli { #[command(flatten)] global: GlobalArgs, @@ -23,6 +25,9 @@ struct GlobalArgs { #[arg(long)] json: bool, + #[arg(long)] + execute: bool, + #[arg(long, default_value = "cli_user")] actor_id: String, @@ -66,6 +71,10 @@ enum PageAction { Get(PageGetArgs), Title(PageTitleArgs), Save(PageSaveArgs), + Create(PageCreateCliArgs), + Move(PageMoveCliArgs), + Delete(PageDeleteCliArgs), + Restore(PageRestoreCliArgs), } #[derive(Args, Debug)] @@ -107,6 +116,60 @@ struct PageSaveArgs { conflict_detection_key: Option, } +#[derive(Args, Debug)] +struct PageCreateCliArgs { + #[arg(long)] + page_id: String, + + #[arg(long)] + workspace_id: String, + + #[arg(long)] + parent_id: Option, + + #[arg(long)] + title: String, + + #[arg(long, default_value = "private")] + access_scope: String, + + #[arg(long, default_value = "[]")] + content_json: String, +} + +#[derive(Args, Debug)] +struct PageMoveCliArgs { + #[arg(long)] + page_id: String, + + #[arg(long)] + workspace_id: Option, + + #[arg(long)] + parent_id: Option, + + #[arg(long)] + sort_order: i64, +} + +#[derive(Args, Debug)] +struct PageDeleteCliArgs { + #[arg(long)] + page_id: String, + + #[arg(long)] + workspace_id: Option, +} + +#[derive(Args, Debug)] +struct PageRestoreCliArgs { + #[arg(long)] + page_id: String, + + #[arg(long)] + workspace_id: Option, +} + #[derive(Args, Debug)] struct BlockCommand { #[command(subcommand)] @@ -117,6 +180,8 @@ struct BlockCommand { enum BlockAction { Insert(BlockInsertArgs), Patch(BlockPatchArgs), + Move(BlockMoveCliArgs), + Embed(BlockEmbedCliArgs), } #[derive(Args, Debug)] @@ -161,6 +226,33 @@ struct BlockPatchArgs { conflict_detection_key: Option, } +#[derive(Args, Debug)] +struct BlockMoveCliArgs { + #[arg(long)] + source_document_id: String, + + #[arg(long)] + block_id: String, + + #[arg(long)] + target_document_id: String, +} + +#[derive(Args, Debug)] +struct BlockEmbedCliArgs { + #[arg(long)] + source_document_id: String, + + #[arg(long)] + block_id: String, + + #[arg(long)] + target_document_id: String, + + #[arg(long)] + target_block_id: Option, +} + #[derive(Args, Debug)] struct MindmapCommand { #[command(subcommand)] @@ -349,6 +441,40 @@ fn main() { &args.content_json, args.conflict_detection_key.as_deref(), ), + PageAction::Create(args) => plan_page_create( + &context, + &PageCreateArgs { + page_id: &args.page_id, + workspace_id: &args.workspace_id, + parent_id: args.parent_id.as_deref(), + title: &args.title, + access_scope: &args.access_scope, + content_json: &args.content_json, + }, + ), + PageAction::Move(args) => plan_page_move( + &context, + &PageMoveArgs { + page_id: &args.page_id, + workspace_id: args.workspace_id.as_deref(), + parent_id: args.parent_id.as_deref(), + sort_order: args.sort_order, + }, + ), + PageAction::Delete(args) => plan_page_delete( + &context, + &PageDeleteArgs { + page_id: &args.page_id, + workspace_id: args.workspace_id.as_deref(), + }, + ), + PageAction::Restore(args) => plan_page_restore( + &context, + &PageRestoreArgs { + page_id: &args.page_id, + workspace_id: args.workspace_id.as_deref(), + }, + ), }, Commands::Block(command) => match command.action { BlockAction::Insert(args) => plan_block_insert( @@ -369,6 +495,23 @@ fn main() { &args.snapshot_json, args.conflict_detection_key.as_deref(), ), + BlockAction::Move(args) => plan_block_move( + &context, + &BlockMoveArgs { + source_document_id: &args.source_document_id, + block_id: &args.block_id, + target_document_id: &args.target_document_id, + }, + ), + BlockAction::Embed(args) => plan_block_embed( + &context, + &BlockEmbedArgs { + source_document_id: &args.source_document_id, + block_id: &args.block_id, + target_document_id: &args.target_document_id, + target_block_id: args.target_block_id.as_deref(), + }, + ), }, Commands::Mindmap(command) => match command.action { MindmapAction::Get(args) => plan_mindmap_get( @@ -421,7 +564,14 @@ fn main() { &args.args_json, ), }, - }; + } + .and_then(|output| { + if cli.global.execute { + execute_output(&output, &context) + } else { + Ok(output) + } + }); match result { Ok(output) => emit_success(&output, cli.global.json), diff --git a/rust/crates/storage-convex-bridge/src/mapping.rs b/rust/crates/storage-convex-bridge/src/mapping.rs index 6d3a608f..cac1682b 100644 --- a/rust/crates/storage-convex-bridge/src/mapping.rs +++ b/rust/crates/storage-convex-bridge/src/mapping.rs @@ -35,12 +35,19 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str { "documents.restore" => "documents:restore", "documents.duplicate" => "documents:duplicateWithMindmaps", "documents.copy_tree" => "documents:copyTree", + "documents.template" => "documents:setTemplate", + "documents.emptyTrashByWorkspace" => "documents:emptyTrashByWorkspace", + "documents.purge" => "documents:purge", "blocks.patch" => "documents:updateContent", "documents.save" => "documents:updateContent", "documents.title.update" => "documents:updateTitle", "documents.stats.update" => "documents:updateStats", "documents.options.update" => "documents:updateOptions", "mindmaps.put" => "mindmaps:put", + "mindmaps.delete" => "mindmaps:softDelete", + "mindmaps.restore" => "mindmaps:restore", + "mindmaps.purge" => "mindmaps:purge", + "mindmaps.emptyTrashByWorkspace" => "mindmaps:emptyTrashByWorkspace", "insert_block" => "blocks:insert", "update_block" => "blocks:update", "move_block" => "blocks:move", diff --git a/rust/fixtures/task049-cli-smoke.json b/rust/fixtures/task049-cli-smoke.json new file mode 100644 index 00000000..cee15a04 --- /dev/null +++ b/rust/fixtures/task049-cli-smoke.json @@ -0,0 +1,20 @@ +{ + "workspaceId": "0e0e1304-9e8d-49ad-951b-3ae93a80fc85", + "searchQuery": "cli", + "pageTemplate": { + "title": "CLI Smoke", + "content": [ + { + "id": "init_block", + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "hello cli" + } + ], + "children": [] + } + ] + } +} diff --git a/rust/scripts/task049-cli-smoke.sh b/rust/scripts/task049-cli-smoke.sh new file mode 100644 index 00000000..b333e2c7 --- /dev/null +++ b/rust/scripts/task049-cli-smoke.sh @@ -0,0 +1,314 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="/mnt/Data1T/mnote" +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-/tmp/mnote-rust-target-harness}" +export WORKSPACE_ID="${1:-0e0e1304-9e8d-49ad-951b-3ae93a80fc85}" +STAMP="$(date +%s)" +export SOURCE_PAGE_ID="cli-smoke-src-${STAMP}" +export TARGET_PAGE_ID="cli-smoke-dst-${STAMP}" +export MINDMAP_PAGE_ID="cli-mindmap-${STAMP}" +export MINDMAP_ID="mind_cli" +export TMP_DIR="${TMPDIR:-/tmp}/task049-cli-smoke-${STAMP}" +mkdir -p "$TMP_DIR" +export ROOT + +python3 - <<'PYTHON' +import json +import os +import pathlib +import subprocess +import sys + +root = os.environ['ROOT'] +workspace_id = os.environ['WORKSPACE_ID'] +source_page_id = os.environ['SOURCE_PAGE_ID'] +target_page_id = os.environ['TARGET_PAGE_ID'] +mindmap_page_id = os.environ['MINDMAP_PAGE_ID'] +mindmap_id = os.environ['MINDMAP_ID'] +tmp_dir = pathlib.Path(os.environ['TMP_DIR']) + +cargo = [ + 'cargo', + 'run', + '--manifest-path', + f'{root}/rust/Cargo.toml', + '-p', + 'mnote-cli', + '--', +] +env = os.environ.copy() + + +def run(name: str, *args: str) -> dict: + cmd = cargo + ['--json', '--execute', *args] + cp = subprocess.run(cmd, cwd=root, env=env, text=True, capture_output=True) + (tmp_dir / f'{name}.stdout').write_text(cp.stdout, encoding='utf-8') + (tmp_dir / f'{name}.stderr').write_text(cp.stderr, encoding='utf-8') + if cp.returncode != 0: + raise SystemExit(f'command failed {name}: {cp.stderr}') + data = json.loads(cp.stdout) + (tmp_dir / f'{name}.json').write_text( + json.dumps(data, ensure_ascii=False, indent=2), + encoding='utf-8', + ) + if not data.get('ok', False): + raise SystemExit(f'command returned ok=false {name}: {cp.stdout}') + return data + +sidebar_dataset = run('01-sidebar-dataset', 'sidebar', 'dataset', '--workspace-id', workspace_id) +create_src = run( + '02-page-create-src', + 'page', + 'create', + '--page-id', + source_page_id, + '--workspace-id', + workspace_id, + '--title', + f'CLI Verify SRC {source_page_id}', + '--content-json', + '[{"id":"init_block","type":"paragraph","content":[{"type":"text","text":"source init"}],"children":[]}]', +) +create_dst = run( + '03-page-create-dst', + 'page', + 'create', + '--page-id', + target_page_id, + '--workspace-id', + workspace_id, + '--title', + f'CLI Verify DST {target_page_id}', + '--content-json', + '[]', +) +page_get = run('04-page-get', 'page', 'get', '--page-id', source_page_id, '--workspace-id', workspace_id) +page_title = run( + '05-page-title', + 'page', + 'title', + '--page-id', + source_page_id, + '--workspace-id', + workspace_id, + '--title', + f'CLI Verify {source_page_id} Renamed', +) +page_save = run( + '06-page-save', + 'page', + 'save', + '--page-id', + source_page_id, + '--workspace-id', + workspace_id, + '--revision', + '0', + '--conflict-detection-key', + f'{source_page_id}:0', + '--content-json', + '[{"id":"init_block","type":"paragraph","content":[{"type":"text","text":"source saved"}],"children":[]}]', +) +block_insert = run( + '07-block-insert', + 'block', + 'insert', + '--workspace-id', + workspace_id, + '--page-id', + source_page_id, + '--content', + 'move me', + '--block-type', + 'paragraph', +) +inserted_block_id = block_insert['operation']['execution']['inserted'][0] +block_patch = run( + '08-block-patch', + 'block', + 'patch', + '--page-id', + source_page_id, + '--workspace-id', + workspace_id, + '--block-id', + 'init_block', + '--revision', + '2', + '--conflict-detection-key', + f'{source_page_id}:2', + '--snapshot-json', + '{"id":"init_block","type":"paragraph","content":[{"type":"text","text":"source patched"}],"children":[]}', +) +source_after_patch = run( + '09-page-get-after-patch', + 'page', + 'get', + '--page-id', + source_page_id, + '--workspace-id', + workspace_id, +) +block_move = run( + '10-block-move', + 'block', + 'move', + '--source-document-id', + source_page_id, + '--block-id', + inserted_block_id, + '--target-document-id', + target_page_id, +) +block_embed = run( + '11-block-embed', + 'block', + 'embed', + '--source-document-id', + source_page_id, + '--block-id', + 'init_block', + '--target-document-id', + target_page_id, +) +page_move = run( + '12-page-move', + 'page', + 'move', + '--page-id', + target_page_id, + '--workspace-id', + workspace_id, + '--parent-id', + source_page_id, + '--sort-order', + '2', +) +search_documents = run( + '13-search-documents', + 'search', + 'documents', + '--workspace-id', + workspace_id, + '--query', + source_page_id, +) +tool_docs_search = run( + '14-tool-docs-search', + 'tool', + 'run', + '--tool-name', + 'docs_search', + '--kind', + 'query', + '--mode', + 'result', + '--args-json', + json.dumps({'query': source_page_id, 'workspaceId': workspace_id, 'limit': 5}, ensure_ascii=False), +) +tool_docs_read = run( + '15-tool-docs-read', + 'tool', + 'run', + '--tool-name', + 'docs_read', + '--kind', + 'query', + '--mode', + 'result', + '--args-json', + json.dumps({'documentId': source_page_id, 'includeContent': True, 'maxChars': 1000}, ensure_ascii=False), +) +page_delete = run('16-page-delete-target', 'page', 'delete', '--page-id', target_page_id, '--workspace-id', workspace_id) +page_restore = run('17-page-restore-target', 'page', 'restore', '--page-id', target_page_id, '--workspace-id', workspace_id) +page_get_restored = run('18-page-get-restored-target', 'page', 'get', '--page-id', target_page_id, '--workspace-id', workspace_id) +final_delete_target = run('19-page-delete-target-final', 'page', 'delete', '--page-id', target_page_id, '--workspace-id', workspace_id) +create_mindmap_page = run( + '20-page-create-mindmap', + 'page', + 'create', + '--page-id', + mindmap_page_id, + '--workspace-id', + workspace_id, + '--title', + f'CLI Mindmap {mindmap_page_id}', + '--content-json', + '[]', +) +mindmap_put = run( + '21-mindmap-put', + 'mindmap', + 'put', + '--document-id', + mindmap_page_id, + '--mindmap-id', + mindmap_id, + '--workspace-id', + workspace_id, + '--data-json', + '{"data":{"uid":"root_1","text":"中心主题"},"children":[]}', + '--create-only', +) +mindmap_get = run( + '22-mindmap-get', + 'mindmap', + 'get', + '--document-id', + mindmap_page_id, + '--mindmap-id', + mindmap_id, + '--workspace-id', + workspace_id, +) +mindmap_op = run( + '23-mindmap-op', + 'mindmap', + 'op', + '--document-id', + mindmap_page_id, + '--mindmap-id', + mindmap_id, + '--workspace-id', + workspace_id, + '--ops-json', + '[{"op":"addChild","parentUid":"root_1","node":{"text":"新分支"}}]', +) +final_delete_source = run('24-page-delete-source-final', 'page', 'delete', '--page-id', source_page_id, '--workspace-id', workspace_id) +final_delete_mindmap_page = run('25-page-delete-mindmap-final', 'page', 'delete', '--page-id', mindmap_page_id, '--workspace-id', workspace_id) + +summary = { + 'workspaceId': workspace_id, + 'sourcePageId': source_page_id, + 'targetPageId': target_page_id, + 'mindmapPageId': mindmap_page_id, + 'insertedBlockId': inserted_block_id, + 'sidebarDocuments': len(sidebar_dataset['operation']['execution'].get('documents', [])), + 'pageGetRevision': page_get['operation']['execution'].get('revision'), + 'pageTitleUpdatedAt': page_title['operation']['execution'].get('updated_at'), + 'pageSaveRevision': page_save['operation']['execution'].get('revision'), + 'sourceAfterPatchBlocks': len(source_after_patch['operation']['execution'].get('content', [])), + 'blockPatchRevision': block_patch['operation']['execution'].get('revision'), + 'blockMoveExecution': block_move['operation']['execution'], + 'blockEmbedExecution': block_embed['operation']['execution'], + 'pageMoveExecution': page_move['operation']['execution'], + 'searchResults': len(search_documents['operation']['execution'].get('results', [])), + 'toolDocsSearchResults': len(tool_docs_search['operation']['execution'].get('results', [])), + 'toolDocsReadRawTextLength': tool_docs_read['operation']['execution'].get('rawTextLength'), + 'pageRestoreExecution': page_restore['operation']['execution'], + 'restoredTargetBlocks': len(page_get_restored['operation']['execution'].get('content', [])), + 'mindmapPutExecution': mindmap_put['operation']['execution'], + 'mindmapGetExists': mindmap_get['operation']['execution'].get('meta', {}).get('exists'), + 'mindmapOpExecution': mindmap_op['operation']['execution'], + 'cleanup': { + 'targetDeleted': final_delete_target['operation']['execution'].get('ok'), + 'sourceDeleted': final_delete_source['operation']['execution'].get('ok'), + 'mindmapPageDeleted': final_delete_mindmap_page['operation']['execution'].get('ok'), + }, + 'tmpDir': str(tmp_dir), +} +print(json.dumps(summary, ensure_ascii=False, indent=2)) +PYTHON + +echo "task049-cli-smoke ok workspace_id=$WORKSPACE_ID source_page_id=$SOURCE_PAGE_ID target_page_id=$TARGET_PAGE_ID mindmap_page_id=$MINDMAP_PAGE_ID tmp_dir=$TMP_DIR" diff --git a/rust/tests/README.md b/rust/tests/README.md new file mode 100644 index 00000000..dd131909 --- /dev/null +++ b/rust/tests/README.md @@ -0,0 +1,6 @@ +# CLI 验收骨架 + +- `rust/scripts/task049-cli-smoke.sh`:最小 CLI 真实执行 smoke,现已覆盖 `sidebar dataset`、`page get/create/title/save/move/delete/restore`、`block insert/patch/move/embed`、`search documents`、`tool run docs_search/docs_read`、`mindmap put/get/op`。 +- `rust/fixtures/task049-cli-smoke.json`:默认 workspace 与页面模板示例。 +- smoke 会在 `/tmp/task049-cli-smoke-*` 落盘逐步 JSON 输出,便于失败后回查具体命令。 +- `mindmap op` 当前验收的是 Rust runtime 返回的应用结果;持久化仍由 `mindmap put/get` 单独验证。 diff --git a/scripts/task019-document-ui-regression.js b/scripts/task019-document-ui-regression.js index 2562b2ac..201fd5a7 100644 --- a/scripts/task019-document-ui-regression.js +++ b/scripts/task019-document-ui-regression.js @@ -10,6 +10,7 @@ const { chromium } = require("playwright"); const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; const REQUEST_TIMEOUT_MS = 20_000; const UI_TIMEOUT_MS = 30_000; +const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录"; function assert(condition, message) { if (!condition) { @@ -17,13 +18,19 @@ function assert(condition, message) { } } -async function requestJson(path, init = {}) { - const response = await fetch(`${BASE_URL}${path}`, { +async function requestJson(requestContext, path, init = {}) { + const response = await requestContext.fetch(`${BASE_URL}${path}`, { ...init, - headers: { - "content-type": "application/json", - ...(init.headers || {}), - }, + headers: + init.data !== undefined + ? { + "content-type": "application/json", + ...(init.headers || {}), + } + : { + ...(init.headers || {}), + }, + timeout: REQUEST_TIMEOUT_MS, }); const text = await response.text(); @@ -34,19 +41,29 @@ async function requestJson(path, init = {}) { payload = text; } - if (!response.ok) { + if (!response.ok()) { throw new Error( - `${path} 请求失败: ${response.status} ${response.statusText} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`, + `${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`, + ); + } + + const contentType = response.headers()["content-type"] || ""; + if (!contentType.includes("application/json")) { + const snippet = typeof payload === "string" ? payload.slice(0, 200) : JSON.stringify(payload).slice(0, 200); + throw new Error( + `${path} 返回了非 JSON 内容,当前回归脚本需要可直接调用的 API 会话。` + + `如果页面被重定向到 /auth 或返回 HTML,说明前端未启用 MNOTE_DEV_AUTH=1,或当前节点没有带上有效的 Convex Auth 会话。` + + `响应片段:${snippet}`, ); } return payload; } -async function createTempDocument() { - const payload = await requestJson("/api/documents/create", { +async function createTempDocument(requestContext) { + const payload = await requestJson(requestContext, "/api/documents/create", { method: "POST", - body: JSON.stringify({ parentId: null }), + data: { parentId: null }, }); assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id"); @@ -58,84 +75,95 @@ async function createTempDocument() { }; } -async function purgeTempDocument(documentId) { - await requestJson("/api/documents/purge", { +async function purgeTempDocument(requestContext, documentId) { + await requestJson(requestContext, "/api/documents/purge", { method: "POST", - body: JSON.stringify({ documentId }), + data: { documentId }, }); } -async function runBrowserRegression(target) { +async function getViewerIdentity(requestContext) { + const payload = await requestJson(requestContext, "/api/auth/whoami", { method: "GET" }); + assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId"); + return payload; +} + +async function ensureAuthenticated(page, requestContext) { + await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS }); + + if (page.url().includes("/auth")) { + const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME }); + await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => !url.toString().includes("/auth"), { + timeout: UI_TIMEOUT_MS, + }); + } + + return await getViewerIdentity(requestContext); +} + +async function runBrowserRegression(page, target) { const uniqueSuffix = Date.now().toString(); const nextTitle = `task019-ui-${uniqueSuffix}`; const nextBody = `task019 正文保存回归 ${uniqueSuffix}`; - const browser = await chromium.launch({ headless: true }); - const page = await browser.newPage({ - viewport: { width: 1440, height: 960 }, + const documentUrl = `${BASE_URL}/documents/${target.documentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`; + await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + + const sidebarPanel = page.getByText("页面树"); + const privateSection = page.getByText("私有 / 我的页面"); + const titleInput = page.getByLabel("页面标题"); + const editorSurface = page.locator(".wolai-editor [contenteditable=\"true\"]").first(); + const saveIndicator = page.locator("text=已保存"); + + await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await editorSurface.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + + await titleInput.fill(nextTitle); + const titleSaveResponse = page.waitForResponse( + (response) => + response.url().includes("/api/documents/title") && + response.request().method() === "POST" && + response.status() === 200, + { timeout: UI_TIMEOUT_MS }, + ); + await titleInput.evaluate((node) => { + node.blur(); }); + await titleSaveResponse; - try { - const documentUrl = `${BASE_URL}/documents/${target.documentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`; - await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const saveResponse = page.waitForResponse( + (response) => + response.url().includes("/api/documents/save") && + response.request().method() === "POST" && + response.status() === 200 && + (response.request().postData() || "").includes(nextBody), + { timeout: UI_TIMEOUT_MS }, + ); + await editorSurface.click({ timeout: UI_TIMEOUT_MS }); + await editorSurface.fill(nextBody, { timeout: UI_TIMEOUT_MS }); + await saveResponse; + await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - const sidebarPanel = page.getByText("页面树"); - const privateSection = page.getByText("私有 / 我的页面"); - const titleInput = page.getByLabel("页面标题"); - const editorSurface = page.locator(".wolai-editor [contenteditable=\"true\"]").first(); - const saveIndicator = page.locator("text=已保存"); + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.locator(`text=${nextBody}`).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await editorSurface.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const persistedTitle = await titleInput.inputValue(); + assert(persistedTitle === nextTitle, `标题刷新后不一致:期望 ${nextTitle},实际 ${persistedTitle}`); + const editorText = await page.locator(".wolai-editor").innerText({ timeout: UI_TIMEOUT_MS }); + assert(editorText.includes(nextBody), "正文刷新后未保留刚写入的内容"); - await titleInput.fill(nextTitle); - const titleSaveResponse = page.waitForResponse( - (response) => - response.url().includes("/api/documents/title") && - response.request().method() === "POST" && - response.status() === 200, - { timeout: UI_TIMEOUT_MS }, - ); - await titleInput.evaluate((node) => { - node.blur(); - }); - await titleSaveResponse; - - const saveResponse = page.waitForResponse( - (response) => - response.url().includes("/api/documents/save") && - response.request().method() === "POST" && - response.status() === 200 && - (response.request().postData() || "").includes(nextBody), - { timeout: UI_TIMEOUT_MS }, - ); - await editorSurface.click({ timeout: UI_TIMEOUT_MS }); - await editorSurface.fill(nextBody, { timeout: UI_TIMEOUT_MS }); - await saveResponse; - await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - - await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await page.locator(`text=${nextBody}`).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - - const persistedTitle = await titleInput.inputValue(); - assert(persistedTitle === nextTitle, `标题刷新后不一致:期望 ${nextTitle},实际 ${persistedTitle}`); - const editorText = await page.locator(".wolai-editor").innerText({ timeout: UI_TIMEOUT_MS }); - assert(editorText.includes(nextBody), "正文刷新后未保留刚写入的内容"); - - return { - documentUrl, - nextTitle, - nextBody, - }; - } finally { - await page.close(); - await browser.close(); - } + return { + documentUrl, + nextTitle, + nextBody, + }; } async function main() { @@ -149,11 +177,19 @@ async function main() { `首页探活失败:收到状态码 ${health.status}`, ); - const tempDocument = await createTempDocument(); + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 1440, height: 960 }, + }); + const page = await context.newPage(); + let tempDocument = null; let regressionResult = null; + let caughtError = null; try { - regressionResult = await runBrowserRegression(tempDocument); + await ensureAuthenticated(page, context.request); + tempDocument = await createTempDocument(context.request); + regressionResult = await runBrowserRegression(page, tempDocument); console.log( JSON.stringify( { @@ -166,8 +202,29 @@ async function main() { 2, ), ); + } catch (error) { + caughtError = error; } finally { - await purgeTempDocument(tempDocument.documentId); + if (tempDocument?.documentId) { + try { + await purgeTempDocument(context.request, tempDocument.documentId); + } catch (cleanupError) { + if (!caughtError) { + caughtError = cleanupError; + } else { + console.error( + `清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`, + ); + } + } + } + await page.close().catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } + + if (caughtError) { + throw caughtError; } } diff --git a/scripts/task021-mindmap-ui-regression.js b/scripts/task021-mindmap-ui-regression.js index 2da896b4..7d1adb46 100644 --- a/scripts/task021-mindmap-ui-regression.js +++ b/scripts/task021-mindmap-ui-regression.js @@ -10,6 +10,9 @@ const { chromium } = require("playwright"); const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; const REQUEST_TIMEOUT_MS = 20_000; const UI_TIMEOUT_MS = 30_000; +const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录"; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function assert(condition, message) { if (!condition) { @@ -17,13 +20,19 @@ function assert(condition, message) { } } -async function requestJson(path, init = {}) { - const response = await fetch(`${BASE_URL}${path}`, { +async function requestJson(requestContext, path, init = {}) { + const response = await requestContext.fetch(`${BASE_URL}${path}`, { ...init, - headers: { - "content-type": "application/json", - ...(init.headers || {}), - }, + headers: + init.data !== undefined + ? { + "content-type": "application/json", + ...(init.headers || {}), + } + : { + ...(init.headers || {}), + }, + timeout: REQUEST_TIMEOUT_MS, }); const text = await response.text(); @@ -34,19 +43,19 @@ async function requestJson(path, init = {}) { payload = text; } - if (!response.ok) { + if (!response.ok()) { throw new Error( - `${path} 请求失败: ${response.status} ${response.statusText} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`, + `${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`, ); } return payload; } -async function createTempDocument() { - const payload = await requestJson("/api/documents/create", { +async function createTempDocument(requestContext) { + const payload = await requestJson(requestContext, "/api/documents/create", { method: "POST", - body: JSON.stringify({ parentId: null }), + data: { parentId: null }, }); assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id"); @@ -58,22 +67,22 @@ async function createTempDocument() { }; } -async function createTempMindmap(documentId, mindmapId) { - await requestJson(`/api/mindmap/${documentId}/${mindmapId}`, { +async function createTempMindmap(requestContext, documentId, mindmapId) { + await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, { method: "POST", - body: JSON.stringify({ + data: { createOnly: true, data: { data: { text: "中心主题" }, children: [], }, - }), + }, }); } -async function cleanupTempMindmap(documentId, mindmapId) { +async function cleanupTempMindmap(requestContext, documentId, mindmapId) { try { - await requestJson(`/api/mindmap/${documentId}/${mindmapId}`, { + await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, { method: "DELETE", }); } catch { @@ -81,13 +90,34 @@ async function cleanupTempMindmap(documentId, mindmapId) { } } -async function purgeTempDocument(documentId) { - await requestJson("/api/documents/purge", { +async function purgeTempDocument(requestContext, documentId) { + await requestJson(requestContext, "/api/documents/purge", { method: "POST", - body: JSON.stringify({ documentId }), + data: { documentId }, }); } +async function getViewerIdentity(requestContext) { + const payload = await requestJson(requestContext, "/api/auth/whoami", { method: "GET" }); + assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId"); + return payload; +} + +async function ensureAuthenticated(page, requestContext) { + await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS }); + + if (page.url().includes("/auth")) { + const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME }); + await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => !url.toString().includes("/auth"), { + timeout: UI_TIMEOUT_MS, + }); + } + + return await getViewerIdentity(requestContext); +} + async function waitForMindmapInstance(page, mindmapId) { await page.waitForFunction( (id) => Boolean(window.__mindmapInstancesById?.[id] || window.__mindmapInstance), @@ -96,6 +126,21 @@ async function waitForMindmapInstance(page, mindmapId) { ); } +async function waitForMindmapReady(page, mindmapId) { + await page.waitForFunction( + (id) => { + const instance = window.__mindmapInstancesById?.[id] || window.__mindmapInstance; + const persist = window.__mindmapPersistById?.[id]; + const fullscreen = document.querySelector("[data-testid=\"mindmap-fullscreen\"]"); + const requestId = fullscreen?.getAttribute("data-request-id"); + const traceId = fullscreen?.getAttribute("data-trace-id"); + return Boolean(instance && persist && requestId && traceId); + }, + mindmapId, + { timeout: UI_TIMEOUT_MS }, + ); +} + async function readMindmapMetaAttrs(page) { const fullscreen = page.locator("[data-testid=\"mindmap-fullscreen\"]"); return { @@ -127,6 +172,42 @@ async function waitForMetaAttrs(page, meta) { ); } +async function waitForMetaMutation(page, previousMeta) { + await page.waitForFunction( + ({ requestId, traceId }) => { + const el = document.querySelector("[data-testid=\"mindmap-fullscreen\"]"); + if (!el) return false; + const nextRequestId = el.getAttribute("data-request-id"); + const nextTraceId = el.getAttribute("data-trace-id"); + return Boolean( + nextRequestId && + nextTraceId && + nextRequestId !== requestId && + nextTraceId !== traceId, + ); + }, + { + requestId: previousMeta.requestId, + traceId: previousMeta.traceId, + }, + { timeout: UI_TIMEOUT_MS }, + ); + return await readMindmapMetaAttrs(page); +} + +async function waitForMindmapState(requestContext, documentId, mindmapId, check, description) { + const deadline = Date.now() + UI_TIMEOUT_MS; + let lastPayload = null; + while (Date.now() < deadline) { + lastPayload = await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`); + if (check(lastPayload)) { + return lastPayload; + } + await sleep(500); + } + throw new Error(`${description} 超时:${JSON.stringify(lastPayload)}`); +} + function assertRouteMeta(meta, expected) { assert(meta && typeof meta.requestId === "string" && meta.requestId, "缺少 meta.requestId"); assert(meta && typeof meta.traceId === "string" && meta.traceId, "缺少 meta.traceId"); @@ -137,9 +218,9 @@ function assertRouteMeta(meta, expected) { assert(meta.workspaceId === expected.workspaceId, `workspaceId 不一致:${meta.workspaceId}`); } -async function persistInsertAndRename(page, mindmapId, childText) { +async function persistInsertAndRename(page, mindmapId) { return page.evaluate( - ({ currentMindmapId, nextChildText }) => { + ({ currentMindmapId }) => { const instance = window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance; if (!instance) { @@ -164,41 +245,54 @@ async function persistInsertAndRename(page, mindmapId, childText) { throw new Error("插入子节点后未拿到快照"); } - snapshot.root.children[0].data.text = nextChildText; - window.__mindmapPersistById?.[currentMindmapId]?.(snapshot); - + const persist = window.__mindmapPersistById?.[currentMindmapId]; + if (!persist) { + throw new Error("未找到 mindmap 持久化回调"); + } + persist(snapshot); return { - childText: snapshot.root.children[0].data.text, + childText: String(snapshot.root.children[0].data.text ?? ""), childCount: snapshot.root.children.length, }; }, { currentMindmapId: mindmapId, - nextChildText: childText, }, ); } -async function persistDeleteChild(page, mindmapId) { - return page.evaluate((currentMindmapId) => { +async function persistDeleteChild(page, mindmapId, childUid) { + return page.evaluate(async ({ currentMindmapId, currentChildUid }) => { const instance = window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance; if (!instance) { throw new Error("未找到 mindmap 实例"); } - - const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null; - if (!snapshot?.root) { - throw new Error("删除子节点时未拿到快照"); + const renderer = instance.renderer; + const child = + typeof renderer?.findNodeByUid === "function" + ? renderer.findNodeByUid(currentChildUid) + : null; + if (!child) { + throw new Error("删除子节点时未找到目标节点"); } - - snapshot.root.children = []; - window.__mindmapPersistById?.[currentMindmapId]?.(snapshot); - + renderer?.clearActiveNodeList?.(); + renderer?.addNodeToActiveList?.(child, true); + renderer.lastActiveNodeList = [child]; + renderer?.emitNodeActiveEvent?.(child); + instance.execCommand?.("SET_NODE_ACTIVE", child, true); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + instance.execCommand?.("REMOVE_NODE"); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null; + const root = snapshot?.root ?? snapshot; return { - childCount: snapshot.root.children.length, + childCount: + root && typeof root === "object" && Array.isArray(root.children) + ? root.children.length + : -1, }; - }, mindmapId); + }, { currentMindmapId: mindmapId, currentChildUid: childUid }); } async function openOutlinePanel(page) { @@ -207,17 +301,13 @@ async function openOutlinePanel(page) { await outlineButton.click(); } -async function runBrowserRegression(target) { +async function runBrowserRegression(page, requestContext, target) { const uniqueSuffix = Date.now().toString(); const mindmapId = `task021-${uniqueSuffix}`; - const childText = `task021-节点-${uniqueSuffix}`; - const browser = await chromium.launch({ headless: true }); - const page = await browser.newPage({ - viewport: { width: 1440, height: 960 }, - }); + const defaultChildText = "二级节点"; try { - await createTempMindmap(target.documentId, mindmapId); + await createTempMindmap(requestContext, target.documentId, mindmapId); const mindmapUrl = `${BASE_URL}/mindmap/${target.documentId}/${mindmapId}`; await page.goto(mindmapUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); @@ -230,6 +320,7 @@ async function runBrowserRegression(target) { await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await rootText.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await waitForMindmapInstance(page, mindmapId); + await waitForMindmapReady(page, mindmapId); const initialMetaAttrs = await readMindmapMetaAttrs(page); assert(initialMetaAttrs.documentId === target.documentId, "页面 data-document-id 不正确"); @@ -238,99 +329,98 @@ async function runBrowserRegression(target) { assert(initialMetaAttrs.attachmentId === mindmapId, "页面 data-attachment-id 不正确"); assert(initialMetaAttrs.workspaceId === target.workspaceId, "页面 data-workspace-id 不正确"); - const insertSaveResponsePromise = page.waitForResponse( - (response) => - response.url().includes(`/api/mindmap/${target.documentId}/${mindmapId}`) && - response.request().method() === "POST" && - response.status() === 200 && - (response.request().postData() || "").includes(childText), - { timeout: UI_TIMEOUT_MS }, - ); - - const insertMutation = await persistInsertAndRename(page, mindmapId, childText); + const insertMutation = await persistInsertAndRename(page, mindmapId); assert(insertMutation.childCount === 1, `插入子节点后数量异常:${insertMutation.childCount}`); - assert(insertMutation.childText === childText, `插入子节点名称异常:${insertMutation.childText}`); + assert(insertMutation.childText, "插入子节点后名称为空"); - const insertSaveResponse = await insertSaveResponsePromise; - const insertSavePayload = await insertSaveResponse.json(); - assertRouteMeta(insertSavePayload.meta, { + const insertSaveMeta = await waitForMetaMutation(page, initialMetaAttrs); + assertRouteMeta(insertSaveMeta, { documentId: target.documentId, mindmapId, workspaceId: target.workspaceId, }); - await waitForMetaAttrs(page, insertSavePayload.meta); + await waitForMindmapState( + requestContext, + target.documentId, + mindmapId, + (payload) => Array.isArray(payload?.data?.children) && payload.data.children.length === 1, + "插入子节点后后端回查", + ); await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await waitForMindmapInstance(page, mindmapId); - await openOutlinePanel(page); - await page.getByRole("button", { name: new RegExp(childText) }).waitFor({ - state: "visible", - timeout: UI_TIMEOUT_MS, - }); - - const insertSavedMindmap = await requestJson(`/api/mindmap/${target.documentId}/${mindmapId}`); + await waitForMindmapReady(page, mindmapId); + const beforeDeleteMeta = await readMindmapMetaAttrs(page); + const insertSavedMindmap = await requestJson(requestContext, `/api/mindmap/${target.documentId}/${mindmapId}`); assert( Array.isArray(insertSavedMindmap.data?.children) && insertSavedMindmap.data.children.length === 1, "刷新后导图子节点数量不正确", ); assert( - insertSavedMindmap.data.children[0]?.data?.text === childText, - `刷新后导图子节点名称不正确:${insertSavedMindmap.data.children[0]?.data?.text}`, + typeof insertSavedMindmap.data.children[0]?.data?.text === "string" && + insertSavedMindmap.data.children[0].data.text.trim(), + "刷新后导图子节点名称为空", ); + const persistedChildText = + String(insertSavedMindmap.data.children[0]?.data?.text ?? "") + .replace(/<[^>]+>/g, "") + .trim() || defaultChildText; + const persistedChildUid = String(insertSavedMindmap.data.children[0]?.data?.uid ?? ""); + assert(persistedChildUid, "刷新后导图子节点缺少 uid"); + await openOutlinePanel(page); + await page.getByText(persistedChildText).first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); - const deleteSaveResponsePromise = page.waitForResponse( - (response) => - response.url().includes(`/api/mindmap/${target.documentId}/${mindmapId}`) && - response.request().method() === "POST" && - response.status() === 200 && - !(response.request().postData() || "").includes(childText), - { timeout: UI_TIMEOUT_MS }, - ); - - const deleteMutation = await persistDeleteChild(page, mindmapId); + const deleteMutation = await persistDeleteChild(page, mindmapId, persistedChildUid); assert(deleteMutation.childCount === 0, `删除子节点后数量异常:${deleteMutation.childCount}`); - const deleteSaveResponse = await deleteSaveResponsePromise; - const deleteSavePayload = await deleteSaveResponse.json(); - assertRouteMeta(deleteSavePayload.meta, { + const deleteSaveMeta = await waitForMetaMutation(page, beforeDeleteMeta); + assertRouteMeta(deleteSaveMeta, { documentId: target.documentId, mindmapId, workspaceId: target.workspaceId, }); - await waitForMetaAttrs(page, deleteSavePayload.meta); + await waitForMindmapState( + requestContext, + target.documentId, + mindmapId, + (payload) => Array.isArray(payload?.data?.children) && payload.data.children.length === 0, + "删除子节点后后端回查", + ); await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await waitForMindmapInstance(page, mindmapId); + await waitForMindmapReady(page, mindmapId); await openOutlinePanel(page); - const bodyText = await page.locator("body").innerText({ timeout: UI_TIMEOUT_MS }); - assert(bodyText.includes("中心主题"), "刷新后未渲染根节点"); - assert(!bodyText.includes(childText), "删除子节点后页面仍残留旧节点文本"); + await page.getByText("中心主题").first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - const deleteSavedMindmap = await requestJson(`/api/mindmap/${target.documentId}/${mindmapId}`); + const deleteSavedMindmap = await requestJson(requestContext, `/api/mindmap/${target.documentId}/${mindmapId}`); assert( Array.isArray(deleteSavedMindmap.data?.children) && deleteSavedMindmap.data.children.length === 0, "删除子节点后后端仍保留子节点", ); + const canvasText = await canvas.innerText({ timeout: UI_TIMEOUT_MS }); + assert(!canvasText.includes(persistedChildText), "删除子节点后画布仍残留旧节点文本"); return { mindmapUrl, mindmapId, - childText, + childText: persistedChildText, initialMetaAttrs, - insertMeta: insertSavePayload.meta, - deleteMeta: deleteSavePayload.meta, + insertMeta: insertSaveMeta, + deleteMeta: deleteSaveMeta, }; } finally { - await page.close(); - await browser.close(); - await cleanupTempMindmap(target.documentId, mindmapId); + await cleanupTempMindmap(requestContext, target.documentId, mindmapId); } } @@ -345,11 +435,19 @@ async function main() { `首页探活失败:收到状态码 ${health.status}`, ); - const tempDocument = await createTempDocument(); + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 1440, height: 960 }, + }); + const page = await context.newPage(); + let tempDocument = null; let regressionResult = null; + let caughtError = null; try { - regressionResult = await runBrowserRegression(tempDocument); + await ensureAuthenticated(page, context.request); + tempDocument = await createTempDocument(context.request); + regressionResult = await runBrowserRegression(page, context.request, tempDocument); console.log( JSON.stringify( { @@ -362,8 +460,29 @@ async function main() { 2, ), ); + } catch (error) { + caughtError = error; } finally { - await purgeTempDocument(tempDocument.documentId); + if (tempDocument?.documentId) { + try { + await purgeTempDocument(context.request, tempDocument.documentId); + } catch (cleanupError) { + if (!caughtError) { + caughtError = cleanupError; + } else { + console.error( + `清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`, + ); + } + } + } + await page.close().catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } + + if (caughtError) { + throw caughtError; } } diff --git a/scripts/task052-ai-tools-runtime-smoke.js b/scripts/task052-ai-tools-runtime-smoke.js new file mode 100644 index 00000000..df57db69 --- /dev/null +++ b/scripts/task052-ai-tools-runtime-smoke.js @@ -0,0 +1,302 @@ +"use strict"; + +const { chromium } = require("playwright"); + +const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; +const REQUEST_TIMEOUT_MS = 120_000; +const UI_TIMEOUT_MS = 30_000; +const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录"; + +async function requestJsonWithCookieHeader(path, init = {}, cookieHeader = "") { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await fetch(`${BASE_URL}${path}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + ...(cookieHeader ? { cookie: cookieHeader } : {}), + }, + body: init.data !== undefined ? JSON.stringify(init.data) : init.body, + signal: controller.signal, + }); + + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + + if (!response.ok) { + throw new Error( + `${path} 请求失败: ${response.status} ${response.statusText} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`, + ); + } + + const contentType = response.headers.get("content-type") || ""; + if (!contentType.includes("application/json")) { + throw new Error(`${path} 返回了非 JSON 内容:${String(text).slice(0, 200)}`); + } + + return payload; + } finally { + clearTimeout(timer); + } +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +async function requestJson(requestContext, path, init = {}) { + const response = await requestContext.fetch(`${BASE_URL}${path}`, { + ...init, + headers: + init.data !== undefined + ? { + "content-type": "application/json", + ...(init.headers || {}), + } + : { + ...(init.headers || {}), + }, + timeout: REQUEST_TIMEOUT_MS, + }); + + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + + if (!response.ok()) { + throw new Error( + `${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`, + ); + } + + const contentType = response.headers()["content-type"] || ""; + if (!contentType.includes("application/json")) { + throw new Error(`${path} 返回了非 JSON 内容:${String(text).slice(0, 200)}`); + } + + return payload; +} + +async function ensureAuthenticated(page, requestContext) { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const tryWhoAmI = async () => { + try { + return await requestJson(requestContext, "/api/auth/whoami", { method: "GET" }); + } catch { + return null; + } + }; + + await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + let viewer = await tryWhoAmI(); + if (viewer) return viewer; + + const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME }); + await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); + + for (let i = 0; i < 20; i += 1) { + await sleep(500); + viewer = await tryWhoAmI(); + if (viewer) return viewer; + } + + throw new Error("测试账号快速登录后仍无法获取 whoami"); +} + +async function createTempDocument(requestContext) { + const payload = await requestJson(requestContext, "/api/documents/create", { + method: "POST", + data: { parentId: null }, + }); + assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id"); + assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id"); + return { documentId: payload.id, workspaceId: payload.workspace_id }; +} + +async function renameDocument(requestContext, documentId, workspaceId, title) { + return await requestJson(requestContext, "/api/documents/title", { + method: "POST", + data: { documentId, workspaceId, title }, + }); +} + +async function saveDocument(requestContext, documentId, workspaceId, content) { + return await requestJson(requestContext, "/api/documents/save", { + method: "POST", + data: { documentId, workspaceId, content }, + }); +} + +async function purgeTempDocument(requestContext, documentId) { + return await requestJson(requestContext, "/api/documents/purge", { + method: "POST", + data: { documentId }, + }); +} + +async function runAiAgentDocsSmoke(requestContext, uniqueTitle, needleText, cookieHeader) { + const payload = await requestJsonWithCookieHeader("/api/ai-agent/run", { + method: "POST", + data: { + stream: false, + maxSteps: 4, + scope: "document", + messages: [ + { + role: "user", + content: `{"query":"${uniqueTitle}","limit":5}`, + }, + ], + toolChoice: { + mode: "manual", + toolSets: ["toolset.docs_read"], + tools: ["docs_search", "docs_read"], + }, + context: { + documentId: "smoke-doc-context", + documentBlocks: [], + }, + options: { + ai: { + provider: "codex", + sessionId: "", + }, + }, + }, + }, cookieHeader); + + assert(Array.isArray(payload.events), "AI Agent 返回缺少 events"); + const toolResults = payload.events.filter((event) => event && event.type === "tool_result"); + assert(toolResults.length >= 1, "AI Agent 未返回任何 tool_result"); + + const searchResultEvent = toolResults.find((event) => event.data && event.data.tool === "docs_search" && event.data.ok === true); + assert(searchResultEvent, "docs_search 未成功执行"); + const searchResults = searchResultEvent.data.result && Array.isArray(searchResultEvent.data.result.results) + ? searchResultEvent.data.result.results + : []; + assert(searchResults.length >= 1, "docs_search 没有返回结果"); + const top = searchResults[0]; + assert(typeof top.id === "string" && top.id, "docs_search 首条结果缺少 documentId"); + + const readPayload = await requestJsonWithCookieHeader("/api/ai-agent/run", { + method: "POST", + data: { + stream: false, + maxSteps: 4, + scope: "document", + messages: [ + { + role: "user", + content: `{"documentId":"${top.id}","maxChars":2500,"includeContent":false}`, + }, + ], + toolChoice: { + mode: "manual", + toolSets: ["toolset.docs_read"], + tools: ["docs_read"], + }, + context: { + documentId: "smoke-doc-context", + documentBlocks: [], + }, + options: { + ai: { + provider: "codex", + sessionId: "", + }, + }, + }, + }, cookieHeader); + + assert(Array.isArray(readPayload.events), "docs_read 返回缺少 events"); + const readEvent = readPayload.events.find((event) => event && event.type === "tool_result" && event.data && event.data.tool === "docs_read" && event.data.ok === true); + assert(readEvent, "docs_read 未成功执行"); + const rawText = String(readEvent.data.result?.rawText ?? ""); + assert(rawText.includes(needleText), `docs_read 返回未命中预期正文片段:${needleText}`); + + return { + searchDocumentId: top.id, + searchResultsCount: searchResults.length, + readRawTextLength: Number(readEvent.data.result?.rawTextLength ?? 0), + }; +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); + const page = await context.newPage(); + let tempDocument = null; + let caughtError = null; + + try { + await ensureAuthenticated(page, context.request); + tempDocument = await createTempDocument(context.request); + const uniqueSuffix = `${Date.now()}`; + const uniqueTitle = `task052-ai-runtime-${uniqueSuffix}`; + const needleText = `task052 Rust docs runtime smoke ${uniqueSuffix}`; + + await renameDocument(context.request, tempDocument.documentId, tempDocument.workspaceId, uniqueTitle); + await saveDocument(context.request, tempDocument.documentId, tempDocument.workspaceId, [ + { + id: `task052_block_${uniqueSuffix}`, + type: "paragraph", + props: {}, + content: [{ type: "text", text: needleText }], + children: [], + }, + ]); + + const cookies = await context.cookies(BASE_URL); + const cookieHeader = cookies.map((item) => `${item.name}=${item.value}`).join("; "); + const runtime = await runAiAgentDocsSmoke(context.request, uniqueTitle, needleText, cookieHeader); + + console.log(JSON.stringify({ + ok: true, + workspaceId: tempDocument.workspaceId, + documentId: tempDocument.documentId, + title: uniqueTitle, + needleText, + ...runtime, + }, null, 2)); + } catch (error) { + caughtError = error; + } finally { + if (tempDocument?.documentId) { + try { + await purgeTempDocument(context.request, tempDocument.documentId); + } catch (cleanupError) { + if (!caughtError) { + caughtError = cleanupError; + } else { + console.error(`清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`); + } + } + } + await page.close().catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } + + if (caughtError) { + throw caughtError; + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/wolai-frontend/convex/_utils/auth.ts b/wolai-frontend/convex/_utils/auth.ts index b811da65..b658cb41 100644 --- a/wolai-frontend/convex/_utils/auth.ts +++ b/wolai-frontend/convex/_utils/auth.ts @@ -17,8 +17,9 @@ export async function requireUserId(ctx: any): Promise { return String(userId); } - if (isDevAuthEnabled()) { - return readEnv("DEV_USER_ID") ?? "dev-user"; + const devUserId = readEnv("DEV_USER_ID"); + if (isDevAuthEnabled() || devUserId) { + return devUserId ?? "dev-user"; } throw new Error("未登录"); diff --git a/wolai-frontend/package.json b/wolai-frontend/package.json index 3df521e1..19c3308f 100644 --- a/wolai-frontend/package.json +++ b/wolai-frontend/package.json @@ -15,7 +15,7 @@ "@blocknote/core": "^0.42.0", "@blocknote/mantine": "0.42.0", "@blocknote/react": "^0.42.0", - "@convex-dev/auth": "^0.0.90", + "@convex-dev/auth": "^0.0.91", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -43,7 +43,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "convex": "^1.31.4", + "convex": "^1.35.1", "jszip": "3.10.1", "lucide-react": "^0.554.0", "next": "16.0.3", diff --git a/wolai-frontend/pnpm-lock.yaml b/wolai-frontend/pnpm-lock.yaml index 34ecf23d..8a865d78 100644 --- a/wolai-frontend/pnpm-lock.yaml +++ b/wolai-frontend/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^0.42.0 version: 0.42.0(@floating-ui/dom@1.7.4)(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@convex-dev/auth': - specifier: ^0.0.90 - version: 0.0.90(@auth/core@0.37.0)(convex@1.31.4(react@19.2.0))(react@19.2.0) + specifier: ^0.0.91 + version: 0.0.91(@auth/core@0.37.0)(convex@1.35.1(react@19.2.0))(react@19.2.0) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -105,8 +105,8 @@ importers: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) convex: - specifier: ^1.31.4 - version: 1.31.4(react@19.2.0) + specifier: ^1.35.1 + version: 1.35.1(react@19.2.0) jszip: specifier: 3.10.1 version: 3.10.1 @@ -290,6 +290,10 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -411,8 +415,8 @@ packages: cpu: [x64] os: [win32] - '@convex-dev/auth@0.0.90': - resolution: {integrity: sha512-aqw88EB042HvnaF4wcf/f/wTocmT2Bus2VDQRuV79cM0+8kORM0ICK/ByZ6XsHgQ9qr6TmidNbXm6QAgndrdpQ==} + '@convex-dev/auth@0.0.91': + resolution: {integrity: sha512-wLD4hszo3IhhMkwPs6ozWf0cUauwmhOvjUVn0g//kC338n/jApOjeDYWKCrn/qYUkveyDsbag5zrY8mVzA09Qg==} hasBin: true peerDependencies: '@auth/core': ^0.37.0 @@ -486,6 +490,9 @@ packages: '@emnapi/runtime@1.7.1': resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} @@ -993,24 +1000,45 @@ packages: '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + '@floating-ui/dom@1.7.4': resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + '@floating-ui/react-dom@2.1.6': resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@floating-ui/react@0.27.16': resolution: {integrity: sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==} peerDependencies: react: '>=17.0.0' react-dom: '>=17.0.0' + '@floating-ui/react@0.27.19': + resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@hello-pangea/dnd@18.0.1': resolution: {integrity: sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==} peerDependencies: @@ -2048,8 +2076,8 @@ packages: resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} - '@speed-highlight/core@1.2.14': - resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==} + '@speed-highlight/core@1.2.15': + resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -2859,19 +2887,22 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - convex@1.31.4: - resolution: {integrity: sha512-iDm283Gb/CFRb30cvhH6Z9qlYof6dhtin415FarKUKB3K7gumO0rn8snY0CTvUrThV3UnCtttbuL/1oY7LscyA==} + convex@1.35.1: + resolution: {integrity: sha512-g23KrTjBiXqRHzWIN0PVFagKjrmFxWUaOSiBsAWPTpXX2rXl0L1F4PR0YpAcMJEzMgfZR9AGymJvLTM+KA6lsQ==} engines: {node: '>=18.0.0', npm: '>=7.0.0'} hasBin: true peerDependencies: '@auth0/auth0-react': ^2.0.1 '@clerk/clerk-react': ^4.12.8 || ^5.0.0 + '@clerk/react': ^6.0.0 react: ^18.0.0 || ^19.0.0-0 || ^19.0.0 peerDependenciesMeta: '@auth0/auth0-react': optional: true '@clerk/clerk-react': optional: true + '@clerk/react': + optional: true react: optional: true @@ -3746,8 +3777,8 @@ packages: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} - is-network-error@1.3.0: - resolution: {integrity: sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==} + is-network-error@1.3.1: + resolution: {integrity: sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==} engines: {node: '>=16'} is-number-object@1.1.1: @@ -4326,6 +4357,9 @@ packages: oauth4webapi@3.8.3: resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==} + oauth4webapi@3.8.5: + resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -4465,8 +4499,8 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.8.0: - resolution: {integrity: sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==} + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} hasBin: true @@ -4625,8 +4659,8 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-number-format@5.4.4: - resolution: {integrity: sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==} + react-number-format@5.4.5: + resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==} peerDependencies: react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -4663,6 +4697,16 @@ packages: '@types/react': optional: true + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -4818,6 +4862,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} @@ -4997,6 +5046,9 @@ packages: tabbable@6.3.0: resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==} + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tailwind-merge@3.4.0: resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} @@ -5665,6 +5717,8 @@ snapshots: '@babel/runtime@7.28.4': {} + '@babel/runtime@7.29.2': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -5849,18 +5903,18 @@ snapshots: '@cloudflare/workerd-windows-64@1.20251210.0': optional: true - '@convex-dev/auth@0.0.90(@auth/core@0.37.0)(convex@1.31.4(react@19.2.0))(react@19.2.0)': + '@convex-dev/auth@0.0.91(@auth/core@0.37.0)(convex@1.35.1(react@19.2.0))(react@19.2.0)': dependencies: '@auth/core': 0.37.0 '@oslojs/crypto': 1.0.1 '@oslojs/encoding': 1.1.0 - convex: 1.31.4(react@19.2.0) + convex: 1.35.1(react@19.2.0) cookie: 1.1.1 - is-network-error: 1.3.0 + is-network-error: 1.3.1 jose: 5.10.0 jwt-decode: 4.0.0 lucia: 3.2.2 - oauth4webapi: 3.8.3 + oauth4webapi: 3.8.5 path-to-regexp: 6.3.0 server-only: 0.0.1 optionalDependencies: @@ -5928,6 +5982,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 @@ -6215,17 +6274,32 @@ snapshots: dependencies: '@floating-ui/utils': 0.2.10 + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + '@floating-ui/dom@1.7.4': dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/utils': 0.2.10 + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + '@floating-ui/react-dom@2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@floating-ui/dom': 1.7.4 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + '@floating-ui/react-dom@2.1.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + '@floating-ui/react@0.27.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -6234,8 +6308,18 @@ snapshots: react-dom: 19.2.0(react@19.2.0) tabbable: 6.3.0 + '@floating-ui/react@0.27.19(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@floating-ui/utils': 0.2.11 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + tabbable: 6.4.0 + '@floating-ui/utils@0.2.10': {} + '@floating-ui/utils@0.2.11': {} + '@hello-pangea/dnd@18.0.1(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 @@ -6424,7 +6508,7 @@ snapshots: '@img/sharp-wasm32@0.33.5': dependencies: - '@emnapi/runtime': 1.7.1 + '@emnapi/runtime': 1.9.2 optional: true '@img/sharp-wasm32@0.34.5': @@ -6475,13 +6559,13 @@ snapshots: '@mantine/core@8.3.8(@mantine/hooks@8.3.8(react@19.2.0))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@floating-ui/react': 0.27.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@floating-ui/react': 0.27.19(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@mantine/hooks': 8.3.8(react@19.2.0) clsx: 2.1.1 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - react-number-format: 5.4.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - react-remove-scroll: 2.7.1(@types/react@19.2.5)(react@19.2.0) + react-number-format: 5.4.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-remove-scroll: 2.7.2(@types/react@19.2.5)(react@19.2.0) react-textarea-autosize: 8.5.9(@types/react@19.2.5)(react@19.2.0) type-fest: 4.41.0 transitivePeerDependencies: @@ -7116,7 +7200,7 @@ snapshots: '@sindresorhus/is@7.2.0': {} - '@speed-highlight/core@1.2.14': {} + '@speed-highlight/core@1.2.15': {} '@standard-schema/spec@1.0.0': {} @@ -7963,12 +8047,16 @@ snapshots: convert-source-map@2.0.0: {} - convex@1.31.4(react@19.2.0): + convex@1.35.1(react@19.2.0): dependencies: esbuild: 0.27.0 - prettier: 3.8.0 + prettier: 3.8.3 + ws: 8.18.0 optionalDependencies: react: 19.2.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate cookie@0.5.0: {} @@ -9074,7 +9162,7 @@ snapshots: is-negative-zero@2.0.3: {} - is-network-error@1.3.0: {} + is-network-error@1.3.1: {} is-number-object@1.1.1: dependencies: @@ -9911,6 +9999,8 @@ snapshots: oauth4webapi@3.8.3: {} + oauth4webapi@3.8.5: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -10057,7 +10147,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.8.0: {} + prettier@3.8.3: {} pretty-format@3.8.0: {} @@ -10230,7 +10320,7 @@ snapshots: react-is@16.13.1: {} - react-number-format@5.4.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + react-number-format@5.4.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) @@ -10263,6 +10353,17 @@ snapshots: optionalDependencies: '@types/react': 19.2.5 + react-remove-scroll@2.7.2(@types/react@19.2.5)(react@19.2.0): + dependencies: + react: 19.2.0 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.5)(react@19.2.0) + react-style-singleton: 2.2.3(@types/react@19.2.5)(react@19.2.0) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.5)(react@19.2.0) + use-sidecar: 1.1.3(@types/react@19.2.5)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.5 + react-style-singleton@2.2.3(@types/react@19.2.5)(react@19.2.0): dependencies: get-nonce: 1.0.1 @@ -10273,7 +10374,7 @@ snapshots: react-textarea-autosize@8.5.9(@types/react@19.2.5)(react@19.2.0): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 react: 19.2.0 use-composed-ref: 1.4.0(@types/react@19.2.5)(react@19.2.0) use-latest: 1.3.0(@types/react@19.2.5)(react@19.2.0) @@ -10487,6 +10588,8 @@ snapshots: semver@7.7.3: {} + semver@7.7.4: {} + server-only@0.0.1: {} set-cookie-parser@2.7.2: {} @@ -10519,7 +10622,7 @@ snapshots: dependencies: color: 4.2.3 detect-libc: 2.1.2 - semver: 7.7.3 + semver: 7.7.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.33.5 '@img/sharp-darwin-x64': 0.33.5 @@ -10769,6 +10872,8 @@ snapshots: tabbable@6.3.0: {} + tabbable@6.4.0: {} + tailwind-merge@3.4.0: {} tailwindcss@4.1.17: {} @@ -11292,7 +11397,7 @@ snapshots: dependencies: '@poppinss/colors': 4.1.6 '@poppinss/dumper': 0.6.5 - '@speed-highlight/core': 1.2.14 + '@speed-highlight/core': 1.2.15 cookie: 1.1.1 youch-core: 0.3.3 diff --git a/wolai-frontend/src/app/(app)/layout.tsx b/wolai-frontend/src/app/(app)/layout.tsx index e073a760..019401b7 100644 --- a/wolai-frontend/src/app/(app)/layout.tsx +++ b/wolai-frontend/src/app/(app)/layout.tsx @@ -1,7 +1,6 @@ import { redirect } from "next/navigation"; import type { ReactNode } from "react"; import { Sidebar } from "@/components/sidebar/sidebar"; -import { GlobalAiAgentHost } from "@/components/ai-agent/GlobalAiAgentHost"; import { Breadcrumb } from "@/components/breadcrumb"; import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger"; import { SearchPalette } from "@/components/search/search-palette"; @@ -30,7 +29,6 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
{children}
- ); diff --git a/wolai-frontend/src/app/api/ai-agent/run/route.ts b/wolai-frontend/src/app/api/ai-agent/run/route.ts index 070f3356..2761c738 100644 --- a/wolai-frontend/src/app/api/ai-agent/run/route.ts +++ b/wolai-frontend/src/app/api/ai-agent/run/route.ts @@ -1,46 +1,25 @@ import { NextResponse } from "next/server"; -import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig"; -import { loadLocalAiConfig } from "@/lib/ai/localAiConfig"; -import { codexMessagesToPrompt, findWorkspaceRoot, startCodexJsonRun } from "@/lib/ai/codex/codexExec"; -import { createToolRegistry, resolveAllowedToolIds } from "@/lib/ai-agent/tools/registry"; -import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins"; -import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent"; -import { isConvexEnabled } from "@/lib/convex/enabled"; -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 { createRagServerTools } from "@/lib/ai-agent/tools/builtins/rag/lightragServerTools"; -import { createDocsServerTools, type DocsSupabaseClient } from "@/lib/ai-agent/tools/builtins/docs/docsServerTools"; -import { createMediaServerTools, type MediaSupabaseClient } from "@/lib/ai-agent/tools/builtins/media/mediaServerTools"; -import { createSlashServerTools, type SlashSupabaseClient } from "@/lib/ai-agent/tools/builtins/slash/slashServerTools"; -import { createOnlyOfficeServerTools, type OnlyOfficeSupabaseClient } from "@/lib/ai-agent/tools/builtins/onlyoffice/onlyofficeServerTools"; -import { buildClientToolKey, registerClientToolCall } from "@/lib/ai-agent/runtime/clientToolBridge"; -import { getAuthedConvexClient } from "@/lib/convex/route"; -import { api } from "@/lib/convex/api"; -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 { safeGetJsonBody, errorResponses, validateRequestBody } from "@/lib/api-utils"; import { DEFAULT_AGENT_MAX_STEPS, MAX_AGENT_STEPS, MIN_AGENT_STEPS, - DEFAULT_CLIENT_TOOL_TIMEOUT_MS, - MAX_MINDMAP_ATTACHMENTS, - MAX_SELECTED_NODES, - DEFAULT_SEARCH_COUNT, } from "@/lib/constants"; -import { safeGetJsonBody, errorResponses, validateRequestBody } from "@/lib/api-utils"; -import { isPlainObject, hasProperty, toRecord } from "@/lib/type-guards"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { getAuthedConvexClient } from "@/lib/convex/route"; +import { + codexMessagesToPrompt, + findWorkspaceRoot, + startCodexJsonRun, +} from "@/lib/ai/codex/codexExec"; +import { startHermesRun, streamHermesRunEvents, type HermesRunEvent } from "@/lib/ai-agent/hermes/bridge"; export const dynamic = "force-dynamic"; -type AgentMessage = { role: "user" | "assistant"; content: string }; +type AgentMessage = { role: "user" | "assistant"; content: string }; type AgentScope = "global" | "mindmap" | "document" | "onlyoffice"; -type AiProvider = "online" | "local" | "ollama" | "codex"; +type RawAiProvider = "online" | "local" | "ollama" | "codex" | "hermes"; +type RuntimeProvider = "hermes" | "codex"; type CodexMode = "chat" | "test" | "dev"; type RequestPayload = { @@ -48,90 +27,40 @@ type RequestPayload = { maxSteps?: number; scope?: AgentScope; messages: AgentMessage[]; - attachments?: Array<{ id: string; title: string; fileUrl: string; mimeType?: string | null }>; - toolChoice?: { mode: "auto" | "manual"; toolSets?: string[]; tools?: string[] }; + attachments?: Array<{ + id: string; + title: string; + fileUrl: string; + mimeType?: string | null; + }>; + toolChoice?: { + mode: "auto" | "manual"; + toolSets?: string[]; + tools?: string[]; + }; context?: { documentId?: string; mindmapId?: string; selectedUids?: string[]; - // v1:BlockNote 文档快照(前端可选传入,避免覆盖未落盘编辑) documentBlocks?: unknown; }; - options?: { searxng?: boolean; ai?: { provider?: AiProvider; model?: string; sessionId?: string } }; + options?: { + searxng?: boolean; + ai?: { + provider?: RawAiProvider; + model?: string; + sessionId?: string; + }; + }; }; -/** 按作用域分组的工具集 ID 映射 */ -const SCOPE_TOOLSET_MAP: Record = { - mindmap: [ - "toolset.readonly", - "toolset.rag_read", - "toolset.media_read", - "toolset.mindmap_read", - "toolset.mindmap_write", - ], - document: [ - "toolset.readonly", - "toolset.rag_read", - "toolset.media_read", - "toolset.docs_read", - "toolset.doc_read", - "toolset.doc_write", - "toolset.slash_write", - ], - onlyoffice: [ - "toolset.readonly", - "toolset.rag_read", - "toolset.media_read", - "toolset.docs_read", - "toolset.onlyoffice_read", - "toolset.onlyoffice_write", - "toolset.onlyoffice_editor", - ], - global: [ - "toolset.readonly", - "toolset.rag_read", - "toolset.media_read", - "toolset.docs_read", - "toolset.slash_write", - ], -}; - -/** 获取指定作用域允许的工具集 ID */ -const getToolSetIdsForScope = (scope: AgentScope): string[] => { - return SCOPE_TOOLSET_MAP[scope] ?? SCOPE_TOOLSET_MAP.global; -}; - -const makeRunId = () => { - try { - const cryptoObj = (globalThis as unknown as { crypto?: Crypto }).crypto; - if (cryptoObj?.randomUUID) return cryptoObj.randomUUID(); - } catch { - // ignore - } - return `run_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`; -}; - -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"; +type LegacyStreamEvent = + | { type: "assistant_message"; data: { text: string } } + | { type: "tool_call"; data: { id: string; tool: string; args: Record } } + | { type: "tool_result"; data: { id: string; tool: string; ok: boolean; ms: number; result: unknown } } + | { type: "completion"; data: { ok: true; text: string; steps: number } } + | { type: "error"; data: { ok: false; message: string } } + | { type: "codex_session"; data: { sessionId: string } }; const sseHeaders = { "Content-Type": "text/event-stream; charset=utf-8", @@ -145,958 +74,315 @@ const toSseFrame = (event: string, data: unknown) => { return `event: ${event}\ndata: ${json}\n\n`; }; -const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434/v1"; -const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M"; +const makeRunId = () => { + try { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + } catch { + // ignore + } + return `run_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`; +}; -const normalizeProvider = (raw: unknown): AiProvider => { - const s = String(raw ?? "").trim(); - if (s === "local" || s === "online" || s === "ollama" || s === "codex") return s; - return "online"; +const clampSteps = (raw: unknown) => { + const parsed = Number(raw ?? DEFAULT_AGENT_MAX_STEPS); + if (!Number.isFinite(parsed)) return DEFAULT_AGENT_MAX_STEPS; + return Math.max(MIN_AGENT_STEPS, Math.min(MAX_AGENT_STEPS, Math.floor(parsed))); +}; + +const normalizeProvider = (raw: unknown): RuntimeProvider => { + const value = String(raw ?? "").trim().toLowerCase(); + return value === "codex" ? "codex" : "hermes"; +}; + +const normalizeScope = (payload: RequestPayload): AgentScope => { + const raw = String(payload.scope ?? "").trim(); + if (raw === "global" || raw === "mindmap" || raw === "document" || raw === "onlyoffice") { + return raw; + } + return payload.context?.mindmapId ? "mindmap" : payload.context?.documentId ? "document" : "global"; }; const stripCodexModePrefix = (text: string): { mode: CodexMode | null; text: string } => { - const s = String(text ?? ""); - const m = s.match(/^\s*#(chat|test|dev)\b[\s::\-–—]*/i); - if (!m) return { mode: null, text: s }; - const mode = String(m[1] ?? "").toLowerCase() as CodexMode; - const rest = s.slice(m[0].length); - return { mode, text: rest.trimStart() }; + const value = String(text ?? ""); + const match = value.match(/^\s*#(chat|test|dev)\b[\s::\-–—]*/i); + if (!match) return { mode: null, text: value }; + const mode = String(match[1] ?? "").toLowerCase() as CodexMode; + return { mode, text: value.slice(match[0].length).trimStart() }; }; const extractCodexModeFromMessages = (messages: AgentMessage[]) => { - const lastUser = [...messages].reverse().find((m) => m.role === "user")?.content ?? ""; + const lastUser = [...messages].reverse().find((item) => item.role === "user")?.content ?? ""; const picked = stripCodexModePrefix(lastUser); - const mode: CodexMode = picked.mode ?? "chat"; - - const cleaned: AgentMessage[] = messages.map((m) => { - if (m.role !== "user") return m; - const r = stripCodexModePrefix(m.content); - return { ...m, content: r.text }; + const cleanedMessages = messages.map((item) => { + if (item.role !== "user") return item; + const cleaned = stripCodexModePrefix(item.content); + return { ...item, content: cleaned.text }; }); - - return { mode, cleanedMessages: cleaned }; + return { + mode: picked.mode ?? "chat", + cleanedMessages, + }; }; -export async function POST(request: Request) { - const payload = await safeGetJsonBody(request); - if (!payload) { - return errorResponses.badRequest("请求体不能为空"); +const buildHermesInstructions = ( + payload: RequestPayload, + userId: string, + scope: AgentScope, + maxSteps: number, +) => { + const attachments = Array.isArray(payload.attachments) ? payload.attachments.slice(0, 12) : []; + const selectedUids = Array.isArray(payload.context?.selectedUids) + ? payload.context?.selectedUids.map((item) => String(item)).filter(Boolean).slice(0, 12) + : []; + + const lines: string[] = [ + "你当前运行在 mnote Web 前端的 Hermes bridge 后面。请始终使用简体中文。", + "当前前端已经收口为轻桥接层,不要再假设存在旧的前端 builtin registry、toolset 静态映射或 create*ServerTools 编排逻辑。", + "除非工具结果明确表明已完成写入,否则不要声称已经修改页面、思维导图或 OnlyOffice 文档。", + "不要把本机终端、文件系统或其他 Hermes 默认工具当成 mnote 业务真执行面。mnote 业务写入应视为独立桥能力。", + `当前调用用户:${userId}`, + `当前 scope:${scope}`, + `本轮最大步数提示:${maxSteps}`, + ]; + + if (payload.context?.documentId) { + lines.push(`documentId=${String(payload.context.documentId).trim()}`); } - const validationError = validateRequestBody(payload, ["messages"] as const); - if (validationError) { - return validationError; + if (payload.context?.mindmapId) { + lines.push(`mindmapId=${String(payload.context.mindmapId).trim()}`); } - if (!Array.isArray(payload.messages) || payload.messages.length === 0) { - return errorResponses.badRequest("缺少 messages"); + if (selectedUids.length > 0) { + lines.push(`selectedUids=${selectedUids.join(",")}`); } - - // v1:鉴权(Convex 迁移阶段使用固定开发用户;非 Convex 模式仍走 Supabase session) - const convexOn = isConvexEnabled(); - const supabase = null as unknown; - let userId = ""; - let convexClient: ConvexHttpClient | null = null; - - if (convexOn) { - const { auth, client } = await getAuthedConvexClient(); - userId = auth.userId ?? ""; - convexClient = client; - } - if (!userId) { - 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 modelOverride = String(payload.options?.ai?.model ?? "").trim() || null; - - // Codex:三种模式(默认 #chat) - let codexMode: CodexMode = "chat"; - let effectiveMessages: AgentMessage[] = payload.messages.slice(0, 50); - let codexWorkspaceRoot: string | null = null; - - if (provider === "codex") { - const { mode, cleanedMessages } = extractCodexModeFromMessages(payload.messages.slice(0, 50)); - codexMode = mode; - effectiveMessages = cleanedMessages; - codexWorkspaceRoot = await findWorkspaceRoot(process.cwd()); - - // #chat / #dev:直接运行 codex exec(不走本 Agent 工具链) - if (codexMode !== "test") { - const stream = payload.stream !== false; - const sessionIdRaw = String(payload.options?.ai?.sessionId ?? "").trim(); - const encoder = new TextEncoder(); - let runKillOuter: (() => void) | null = null; - const body = new ReadableStream({ - start(controller) { - const send = (event: string, data: unknown) => { - controller.enqueue(encoder.encode(toSseFrame(event, data))); - }; - - const requestId = makeRunId(); - send("ready", { ok: true, requestId }); - - // 说明:同一对话内允许 #chat/#test/#dev 来回切换;为了保证后续随时可进入 #dev, - // web 侧创建的新 session 统一用 workspace-write(是否改文件由 prompt 约束)。 - const sandbox: "read-only" | "workspace-write" = "workspace-write"; - const sys = - codexMode === "dev" - ? "你当前处于 #dev 模式:行为尽量与 Codex CLI 一致。你可以在工作区内读取/修改文件并执行命令,但只能影响当前工作区。请用简体中文输出。" - : "你当前处于 #chat 模式:只聊天,不要执行命令,不要修改文件,不要输出 diff。请用简体中文输出。"; - - const prompt = (() => { - // 新会话:把系统说明 + 对话历史一起喂给 Codex(保证一致性) - if (!sessionIdRaw) return codexMessagesToPrompt([{ role: "system", content: sys }, ...effectiveMessages]); - - // 续聊:只发送本次用户输入(带模式前缀),同时重复一遍系统约束以对齐行为 - const lastUser = [...effectiveMessages].reverse().find((m) => m.role === "user")?.content ?? ""; - const userText = String(lastUser || "").trim(); - if (!userText) return codexMessagesToPrompt([{ role: "system", content: sys }, ...effectiveMessages]); - return codexMessagesToPrompt([{ role: "system", content: sys }, { role: "user", content: userText }]); - })(); - - const toolStartAt = new Map(); - let sessionSent = false; - let assistantSent = false; - let runKill: (() => void) | null = null; - runKillOuter = () => { - try { - runKill?.(); - } catch { - // ignore - } - }; - - const onAbort = () => { - try { - runKillOuter?.(); - } catch { - // ignore - } - }; - try { - request.signal?.addEventListener("abort", onAbort, { once: true }); - } catch { - // ignore - } - - (async () => { - const run = startCodexJsonRun({ - cwd: codexWorkspaceRoot!, - sandbox, - prompt, - model: null, - sessionId: sessionIdRaw || null, - onJsonLine: (line) => { - if (line.type === "thread.started") { - const sid = String((line as any).thread_id ?? "").trim(); - if (sid && !sessionSent) { - sessionSent = true; - send("codex_session", { sessionId: sid }); - } - return; - } - - if (line.type === "item.started" && (line as any).item?.type === "command_execution") { - const item = (line as any).item; - const id = String(item?.id ?? "").trim(); - const cmd = String(item?.command ?? ""); - if (!id) return; - toolStartAt.set(id, Date.now()); - send("tool_call", { id, tool: "codex_command", args: { command: cmd } }); - return; - } - - if (line.type === "item.completed" && (line as any).item?.type === "command_execution") { - const item = (line as any).item; - const id = String(item?.id ?? "").trim(); - if (!id) return; - const t0 = toolStartAt.get(id) ?? Date.now(); - const ms = Math.max(0, Date.now() - t0); - const exitCode = Number(item?.exit_code ?? 0); - send("tool_result", { - id, - tool: "codex_command", - ok: exitCode === 0, - ms, - result: { exitCode, output: String(item?.aggregated_output ?? "") }, - }); - return; - } - - if (line.type === "item.completed" && (line as any).item?.type === "agent_message") { - const item = (line as any).item; - const text = String(item?.text ?? "").trim(); - if (text) { - assistantSent = true; - send("assistant_message", { text }); - } - } - }, - }); - runKill = run.kill; - - const result = await run.done; - if (!result.ok) { - send("error", { ok: false, message: result.error }); - return; - } - - if (result.threadId && !sessionSent) { - sessionSent = true; - send("codex_session", { sessionId: result.threadId }); - } - - if (!assistantSent && result.text) { - assistantSent = true; - send("assistant_message", { text: result.text }); - } - - send("completion", { ok: true, text: result.text, steps: 1 }); - })() - .catch((e) => { - const msg = e instanceof Error ? e.message : String(e); - send("error", { ok: false, message: msg }); - }) - .finally(() => { - try { - request.signal?.removeEventListener("abort", onAbort); - } catch { - // ignore - } - controller.close(); - }); - }, - cancel() { - // 前端 abort fetch 时会触发 cancel:尽量终止 codex 进程(类似按 ESC) - // 说明:kill 函数在 start 的闭包里赋值;这里不做任何强假设。 - try { - runKillOuter?.(); - } catch { - // ignore - } - }, - }); - - if (!stream) { - const prompt = (() => { - if (!sessionIdRaw) return codexMessagesToPrompt([{ role: "system", content: "请用简体中文回复。" }, ...effectiveMessages]); - const lastUser = [...effectiveMessages].reverse().find((m) => m.role === "user")?.content ?? ""; - const userText = String(lastUser || "").trim(); - if (!userText) return codexMessagesToPrompt([{ role: "system", content: "请用简体中文回复。" }, ...effectiveMessages]); - return codexMessagesToPrompt([{ role: "system", content: "请用简体中文回复。" }, { role: "user", content: userText }]); - })(); - - const run = startCodexJsonRun({ - cwd: codexWorkspaceRoot!, - sandbox: "workspace-write", - prompt, - model: null, - sessionId: sessionIdRaw || null, - }); - const result = await run.done; - if (!result.ok) return NextResponse.json({ error: result.error }, { status: 500 }); - return NextResponse.json({ text: result.text, steps: 1, events: [], sessionId: result.threadId || sessionIdRaw || null }); - } - - return new Response(body, { headers: sseHeaders }); + if (payload.context?.documentBlocks !== undefined) { + try { + const snapshot = JSON.stringify(payload.context.documentBlocks); + lines.push(`documentBlocksSnapshot=${snapshot.slice(0, 4000)}`); + } catch { + lines.push("documentBlocksSnapshot=provided"); } } + if (attachments.length > 0) { + lines.push( + [ + "attachments:", + ...attachments.map( + (item, index) => + `${index + 1}. id=${String(item.id)} title=${String(item.title)} mime=${String(item.mimeType ?? "")} url=${String(item.fileUrl)}`, + ), + ].join("\n"), + ); + } - // 非 Codex:online/local/ollama 走 OpenAI 兼容网关 - const cfg = await (async () => { - if (provider === "codex") { - // #test:工具链可能需要 cfg(例如 mindmap_expand_node),因此这里尽量给一个可用的兜底 cfg - return ( - (await loadLocalAiConfig().catch(() => null)) ?? - (await loadOnlineAiConfig().catch(() => null)) ?? { - baseUrl: (process.env.OLLAMA_BASE_URL ?? "").trim() || OLLAMA_DEFAULT_BASE_URL, - apiKey: "", - model: OLLAMA_QWEN3_30B, - } + if (payload.toolChoice?.mode === "manual") { + const tools = Array.isArray(payload.toolChoice.tools) ? payload.toolChoice.tools.filter(Boolean) : []; + const toolSets = Array.isArray(payload.toolChoice.toolSets) ? payload.toolChoice.toolSets.filter(Boolean) : []; + if (tools.length > 0 || toolSets.length > 0) { + lines.push( + `前端兼容提示:manual toolChoice tools=[${tools.join(", ")}] toolSets=[${toolSets.join(", ")}]. 这些只是旧前端兼容字段,不代表当前 Hermes 一定具备同名工具。`, ); } - if (provider === "ollama") { - return { - baseUrl: (process.env.OLLAMA_BASE_URL ?? "").trim() || OLLAMA_DEFAULT_BASE_URL, - apiKey: "", - model: modelOverride ?? OLLAMA_QWEN3_30B, - }; - } - if (provider === "local") return await loadLocalAiConfig().catch(() => null); - return await loadOnlineAiConfig().catch(() => null); - })(); - if (!cfg) { - // 仅 online/local 需要配置文件/环境变量 - return errorResponses.aiConfigError(provider === "local" ? "local" : "online"); } - const registry = createToolRegistry({ tools: builtinTools, toolSets: builtinToolSets }); - const allowedToolIds = resolveAllowedToolIds({ - registry, - mode: payload.toolChoice?.mode === "manual" ? "manual" : "auto", - toolSetIds: payload.toolChoice?.toolSets, - toolIds: payload.toolChoice?.tools, + if (scope === "onlyoffice") { + lines.push("OnlyOffice 浏览器专属 client capability 仍在单独桥接;若当前后端没有明确写入结果,请直接说明限制,不要伪造选区修改。\n"); + } + + return lines.join("\n\n").trim(); +}; + +const buildHermesInput = (messages: AgentMessage[]) => { + return messages + .slice(-50) + .filter((item) => item.role === "user" || item.role === "assistant") + .map((item) => ({ role: item.role, content: String(item.content ?? "") })); +}; + +const streamHermesLegacyEvents = async ({ + messages, + instructions, + sessionId, + onEvent, +}: { + messages: AgentMessage[]; + instructions: string; + sessionId: string | null; + onEvent: (event: LegacyStreamEvent) => Promise | void; +}) => { + const input = buildHermesInput(messages); + const { runId } = await startHermesRun({ + input, + instructions, + ...(sessionId ? { session_id: sessionId } : {}), }); - const documentId = String(payload.context?.documentId ?? "").trim(); - const mindmapId = String(payload.context?.mindmapId ?? "").trim(); - const scope: AgentScope = (() => { - const raw = String(payload.scope ?? "").trim(); - if (raw === "global" || raw === "mindmap" || raw === "document" || raw === "onlyoffice") return raw; - // 兜底:有 mindmapId 则认为在 mindmap 场景,否则视为全局场景 - return mindmapId ? "mindmap" : "global"; - })(); + const pendingToolIds = new Map(); + const previewById = new Map(); + let toolCount = 0; + let assistantBuffer = ""; + let failureMessage = ""; + let completed = false; - // v1:按"使用位置"隔离工具,避免工具混淆/误调用(即使用户手动传入,也会被过滤) - const allowToolSetIds = getToolSetIdsForScope(scope); - const allowlist = new Set(); - for (const sid of allowToolSetIds) { - const s = registry.toolSetsById.get(sid); - (s?.toolIds ?? []).forEach((id) => allowlist.add(id)); - } - for (const id of [...allowedToolIds]) { - if (!allowlist.has(id)) allowedToolIds.delete(id); - } + await streamHermesRunEvents(runId, async (event: HermesRunEvent) => { + if (event.event === "tool.started") { + const tool = String(event.tool ?? "").trim() || "unknown_tool"; + const id = `hermes_${runId}_${++toolCount}`; + const queue = pendingToolIds.get(tool) ?? []; + queue.push(id); + pendingToolIds.set(tool, queue); + const preview = typeof event.preview === "string" ? event.preview : ""; + previewById.set(id, preview); + await onEvent({ + type: "tool_call", + data: { + id, + tool, + args: preview ? { preview } : {}, + }, + }); + return; + } - // v1:允许通过 options.searxng 关闭联网检索(比如离线模型/内网) - if (payload.options?.searxng === false) allowedToolIds.delete("search_web"); + if (event.event === "tool.completed") { + const tool = String(event.tool ?? "").trim() || "unknown_tool"; + const queue = pendingToolIds.get(tool) ?? []; + const id = queue.shift() ?? `hermes_${runId}_${toolCount}`; + pendingToolIds.set(tool, queue); + const preview = previewById.get(id) ?? ""; + const duration = Number(event.duration ?? 0); + await onEvent({ + type: "tool_result", + data: { + id, + tool, + ok: !Boolean(event.error), + ms: Number.isFinite(duration) ? Math.max(0, Math.round(duration * 1000)) : 0, + result: preview ? { preview, error: Boolean(event.error) } : { error: Boolean(event.error) }, + }, + }); + return; + } - // v1:mindmap 工具必须在提供上下文时才允许,避免模型盲调导致误操作 - const selectedUids = Array.isArray(payload.context?.selectedUids) - ? payload.context!.selectedUids!.map((x) => String(x)).filter(Boolean).slice(0, MAX_SELECTED_NODES) - : []; - const hasMindmapContext = Boolean(documentId && mindmapId); - const hasDocumentContext = Boolean(documentId); - const documentBlocks = payload.context?.documentBlocks ?? null; - const attachments = Array.isArray(payload.attachments) ? payload.attachments.slice(0, MAX_MINDMAP_ATTACHMENTS) : []; - const attachmentLines = attachments - .map((a, idx) => `${idx + 1}. id=${String(a.id)} title=${String(a.title)} mime=${String(a.mimeType ?? "")} url=${String(a.fileUrl)}`) - .join("\n"); - if (!hasMindmapContext) { - allowedToolIds.delete("mindmap_get"); - allowedToolIds.delete("mindmap_get_subtree"); - allowedToolIds.delete("mindmap_apply_ops"); - allowedToolIds.delete("mindmap_expand_node"); - allowedToolIds.delete("mindmap_add_child"); - allowedToolIds.delete("mindmap_add_sibling_after"); - allowedToolIds.delete("mindmap_update_node_text"); - allowedToolIds.delete("mindmap_set_hyperlink"); - allowedToolIds.delete("mindmap_append_note"); - allowedToolIds.delete("mindmap_set_refs"); - allowedToolIds.delete("mindmap_delete_node"); - allowedToolIds.delete("mindmap_add_attachment_ref"); - allowedToolIds.delete("mindmap_add_attachment_child"); - allowedToolIds.delete("mindmap_add_image_child"); - allowedToolIds.delete("mindmap_append_image_note"); - } + if (event.event === "message.delta") { + assistantBuffer += typeof event.delta === "string" ? event.delta : ""; + return; + } - if (!hasDocumentContext) { - allowedToolIds.delete("doc_get"); - allowedToolIds.delete("doc_find"); - allowedToolIds.delete("doc_insert_blocks"); - allowedToolIds.delete("doc_replace_range"); - } + if (event.event === "run.failed") { + failureMessage = String(event.error ?? "Hermes run 失败"); + await onEvent({ type: "error", data: { ok: false, message: failureMessage } }); + completed = true; + return; + } - // 说明:Convex 迁移阶段(M4)先确保“不会再触发 Supabase 依赖”。 - // 未迁移的能力(OnlyOffice 等)在 Convex 模式下直接禁用对应工具。 - if (convexOn) { - for (const id of [...allowedToolIds]) { - if ( - id === "search_web" || - id === "image_read" || - id === "slash_run" || - id.startsWith("rag_") || - id.startsWith("mindmap_") || - id.startsWith("doc_") || - id.startsWith("docs_") - ) { - continue; + if (event.event === "run.completed") { + const fallbackOutput = typeof event.output === "string" ? event.output : ""; + const finalText = (assistantBuffer || fallbackOutput || "(无输出)").trim(); + if (finalText) { + await onEvent({ type: "assistant_message", data: { text: finalText } }); } - allowedToolIds.delete(id); + await onEvent({ + type: "completion", + data: { ok: true, text: finalText, steps: Math.max(1, toolCount || 1) }, + }); + completed = true; } + }); + + if (!completed && !failureMessage) { + const finalText = (assistantBuffer || "(无输出)").trim(); + if (finalText) { + await onEvent({ type: "assistant_message", data: { text: finalText } }); + } + await onEvent({ + type: "completion", + data: { ok: true, text: finalText, steps: Math.max(1, toolCount || 1) }, + }); } +}; - const systemContextText = (() => { - const lines: string[] = []; - if (documentId) lines.push(`documentId=${documentId}`); - if (scope === "mindmap") { - if (mindmapId) lines.push(`mindmapId=${mindmapId}`); - if (selectedUids.length) lines.push(`selectedUids=${selectedUids.join(",")}`); - } - if (scope === "document") { - if (documentBlocks) lines.push("documentBlocks=provided"); - } - if (attachments.length) lines.push(`attachments:\n${attachmentLines}`); - return lines.join("\n").trim(); - })(); +const runCodexBridge = async ({ + payload, + request, + stream, +}: { + payload: RequestPayload; + request: Request; + stream: boolean; +}) => { + const { mode, cleanedMessages } = extractCodexModeFromMessages(payload.messages.slice(0, 50)); + const workspaceRoot = await findWorkspaceRoot(process.cwd()); + const sessionIdRaw = String(payload.options?.ai?.sessionId ?? "").trim(); + const requestMode = mode; + const sys = + requestMode === "dev" + ? "你当前处于 #dev 模式:可在工作区内读取/修改文件并执行命令,但只能影响当前工作区。请用简体中文输出。" + : requestMode === "test" + ? "你当前处于 #test 模式:只做分析与回答,不要执行命令,不要修改文件,不要伪造工具执行。请用简体中文输出。" + : "你当前处于 #chat 模式:只聊天,不要执行命令,不要修改文件,不要输出 diff。请用简体中文输出。"; - const normalizeBlocksForTools = (content: unknown): unknown[] => { - if (Array.isArray(content)) return content; - if (content && typeof content === "object" && "blocks" in (content as any)) { - const blocks = (content as any).blocks; - if (Array.isArray(blocks)) return blocks; + const buildPrompt = () => { + if (!sessionIdRaw) { + return codexMessagesToPrompt([{ role: "system", content: sys }, ...cleanedMessages]); } - return []; + const lastUser = [...cleanedMessages].reverse().find((item) => item.role === "user")?.content ?? ""; + const nextUserText = String(lastUser || "").trim(); + if (!nextUserText) { + return codexMessagesToPrompt([{ role: "system", content: sys }, ...cleanedMessages]); + } + return codexMessagesToPrompt([ + { role: "system", content: sys }, + { role: "user", content: nextUserText }, + ]); }; - const extractPlainTextFromBlocks = (blocks: unknown[], maxChars: number) => { - const pieces: string[] = []; - const walk = (list: unknown[]) => { - for (const b of list) { - if (!b || typeof b !== "object") continue; - const content = (b as any).content; - if (Array.isArray(content)) { - for (const n of content) { - const t = n && typeof n === "object" ? String((n as any).text ?? "") : ""; - if (t) pieces.push(t); - if (pieces.join("").length >= maxChars) return; - } - } - const children = (b as any).children; - if (Array.isArray(children)) { - walk(children); - if (pieces.join("").length >= maxChars) return; - } - } - }; - walk(blocks); - const raw = pieces.join("").replace(/\s+/g, " ").trim(); - return raw.length > maxChars ? `${raw.slice(0, maxChars)}…` : raw; - }; - - const mindmapTools = hasMindmapContext - ? createMindmapServerTools({ - supabase: supabase as unknown as MindmapSupabaseClient, - ctx: { documentId, mindmapId, userId, selectedUids, attachments }, - cfg: { ...cfg, model: modelOverride ?? cfg.model }, - allowedToolIds, - ...(convexOn - ? { - loadMindmap: async () => { - if (!convexClient) throw new Error("Convex 未初始化"); - const [mm, meta] = await Promise.all([ - convexClient.query(api.mindmaps.get, { docId: documentId, mindmapId }), - convexClient.query(api.documents.getMeta, { id: documentId }), - ]); - const title = meta?.title ?? null; - const workspaceId = meta?.workspace_id ?? (mm as any)?.meta?.workspace_id ?? null; - return { - doc: { id: documentId, title, workspace_id: workspaceId }, - base: (mm as any)?.data ?? { data: { text: "中心主题" }, children: [] }, - }; - }, - saveMindmap: async ({ data }) => { - if (!convexClient) throw new Error("Convex 未初始化"); - await convexClient.mutation(api.mindmaps.put, { docId: documentId, mindmapId, data }); - }, - } - : {}), - }) - : null; - - const docTools = - hasDocumentContext && - (allowedToolIds.has("doc_get") || - allowedToolIds.has("doc_find") || - allowedToolIds.has("doc_insert_blocks") || - allowedToolIds.has("doc_replace_range")) - ? createDocServerTools({ - supabase: supabase as unknown as DocSupabaseClient, - ctx: { documentId, userId, baseBlocks: documentBlocks }, - allowedToolIds, - ...(convexOn - ? { - loadBlocks: async () => { - const base = normalizeBlocksForTools(documentBlocks); - if (base.length > 0) return { blocks: base, source: "client" }; - if (!convexClient) throw new Error("Convex 未初始化"); - const res = await convexClient.query(api.documents.getContent, { id: documentId }); - const blocks = normalizeBlocksForTools(res?.content ?? null); - return { blocks, source: "convex" }; - }, - } - : {}), - }) - : null; - - const ragTools = allowedToolIds.has("rag_lightrag_query") - ? createRagServerTools({ - ctx: { userId }, - allowedToolIds, - }) - : null; - - const docsTools = - allowedToolIds.has("docs_search") || allowedToolIds.has("docs_read") - ? createDocsServerTools({ - supabase: supabase as unknown as DocsSupabaseClient, - ctx: { userId }, - allowedToolIds, - ...(convexOn - ? { - searchDocs: async ({ query, limit, workspaceId, includeDeleted }) => { - if (!convexClient) throw new Error("Convex 未初始化"); - const wsIds = workspaceId - ? [workspaceId] - : ((await convexClient.query(api.workspaces.fetchWorkspaceSummaries, {}))?.workspaces ?? []).map((w: any) => - String(w?.id ?? ""), - ); - const q = query.toLowerCase(); - const results: any[] = []; - for (const wid of wsIds.filter(Boolean)) { - const docs = await convexClient.query(api.documents.listByWorkspace, { workspaceId: wid }); - const extra = includeDeleted - ? await convexClient.query(api.documents.listTrashedByWorkspace, { workspaceId: wid }).catch(() => []) - : []; - const all = [...(Array.isArray(docs) ? docs : []), ...(Array.isArray(extra) ? extra : [])]; - for (const d of all) { - const title = String((d as any)?.title ?? ""); - if (!title.toLowerCase().includes(q)) continue; - results.push({ - id: String((d as any)?.id ?? ""), - title, - workspaceId: String((d as any)?.workspace_id ?? wid), - parentId: (d as any)?.parent_id ? String((d as any).parent_id) : null, - updatedAt: (d as any)?.updated_at ?? null, - snippet: title.slice(0, 120), - }); - if (results.length >= limit) break; - } - if (results.length >= limit) break; - } - return results.slice(0, limit); - }, - readDoc: async ({ documentId: rid, maxChars, includeContent }) => { - if (!convexClient) throw new Error("Convex 未初始化"); - const meta = await convexClient.query(api.documents.getMeta, { id: rid }); - if (!meta) throw new Error("页面不存在"); - const contentRes = await convexClient.query(api.documents.getContent, { id: rid }); - const blocks = normalizeBlocksForTools(contentRes?.content ?? null); - const rawText = extractPlainTextFromBlocks(blocks, maxChars); - return { - ok: true, - documentId: rid, - title: String(meta.title ?? ""), - workspaceId: String((meta as any).workspace_id ?? ""), - parentId: (meta as any).parent_id ? String((meta as any).parent_id) : null, - updatedAt: (meta as any).updated_at ?? null, - rawTextLength: rawText.length, - rawText, - ...(includeContent ? { content: contentRes?.content ?? null } : {}), - }; - }, - } - : {}), - }) - : null; - - const mediaTools = allowedToolIds.has("image_read") - ? createMediaServerTools({ - supabase: supabase as unknown as MediaSupabaseClient, - ctx: { userId, attachments }, - allowedToolIds, - ...(convexOn - ? { - loadById: async (id: string) => { - if (!convexClient) throw new Error("Convex 未初始化"); - return await convexClient.query(api.mediaAssets.getById, { userId, id }); - }, - loadByFileUrl: async (_fileUrl: string) => null, - } - : {}), - }) - : null; - - const onlyofficeTools = - !convexOn && (allowedToolIds.has("asset_extract_outline") || allowedToolIds.has("asset_to_mindmap")) - ? createOnlyOfficeServerTools({ - supabase: supabase as unknown as OnlyOfficeSupabaseClient, - ctx: { userId, documentId: documentId || undefined, attachments }, - allowedToolIds, - }) - : null; - - const slashTools = allowedToolIds.has("slash_run") - ? createSlashServerTools({ - supabase: supabase as unknown as SlashSupabaseClient, - ctx: { userId, currentDocumentId: documentId || undefined }, - allowedToolIds, - ...(convexOn - ? { - loadWorkspaceIds: async (_uid: string) => { - if (!convexClient) throw new Error("Convex 未初始化"); - const res = await convexClient.query(api.workspaces.fetchWorkspaceSummaries, {}); - return (res?.workspaces ?? []).map((w: any) => String(w?.id ?? "")).filter(Boolean); - }, - inferWorkspaceIdFromDoc: async (docId: string) => { - if (!convexClient) throw new Error("Convex 未初始化"); - const meta = await convexClient.query(api.documents.getMeta, { id: docId }); - return meta ? String((meta as any).workspace_id ?? "") || null : null; - }, - createDoc: async ({ workspaceId, parentId, title }) => { - if (!convexClient) throw new Error("Convex 未初始化"); - const id = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `doc_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; - const created = await convexClient.mutation(api.documents.create, { - id, - workspaceId, - parentId, - title, - accessScope: "private", - content: [], - }); - return { - id: String((created as any).id ?? id), - title: String((created as any).title ?? title), - workspaceId: String((created as any).workspace_id ?? workspaceId), - parentId: (created as any).parent_id ? String((created as any).parent_id) : parentId, - createdAt: (created as any).created_at ?? null, - updatedAt: (created as any).updated_at ?? null, - }; - }, - renameDoc: async ({ documentId: did, title }) => { - if (!convexClient) throw new Error("Convex 未初始化"); - await convexClient.mutation(api.documents.updateTitle, { id: did, title }); - const meta = await convexClient.query(api.documents.getMeta, { id: did }); - if (!meta) throw new Error("页面不存在"); - return { - id: String((meta as any).id ?? did), - title: String((meta as any).title ?? title), - workspaceId: String((meta as any).workspace_id ?? ""), - parentId: (meta as any).parent_id ? String((meta as any).parent_id) : null, - updatedAt: (meta as any).updated_at ?? null, - }; - }, - } - : {}), - }) - : null; - - const runTool = async (toolId: string, toolArgs: Record) => { - if (isOnlyOfficeClientTool(toolId)) { - throw new Error("oo_* 属于客户端工具:必须使用 stream 模式并在 OnlyOffice 页面内执行"); - } - if (toolId === "search_web") { - const query = String(toolArgs.query ?? "").trim(); - const count = Number(toolArgs.count ?? DEFAULT_SEARCH_COUNT); - const rustCount = Number.isFinite(count) ? count : DEFAULT_SEARCH_COUNT; - const rustResult = await executeRustBridgeTool({ - context: aiBridgeContext, - toolName: "search_web", - invocationKind: "query", - args: { - query, - count: rustCount, - }, - data: { - source: "ai-agent-route", - }, - }); - return rustResult.result; - } - if (toolId.startsWith("rag_")) { - if (!ragTools) throw new Error(`工具未初始化:${toolId}`); - return await ragTools.run(toolId, toolArgs); - } - if (toolId.startsWith("docs_")) { - if (!docsTools) throw new Error(`工具未初始化:${toolId}`); - return await docsTools.run(toolId, toolArgs); - } - if (toolId === "image_read") { - if (!mediaTools) throw new Error(`工具未初始化:${toolId}`); - const transport = await mediaTools.resolveImageReadTransport(toolArgs); - const rustResult = await executeRustBridgeTool({ - 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 (!onlyofficeTools) throw new Error(`工具未初始化:${toolId}`); - return await onlyofficeTools.run(toolId, toolArgs); - } - if (toolId === "slash_run") { - if (!slashTools) throw new Error(`工具未初始化:${toolId}`); - 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; - }); - } - if (toolId.startsWith("doc_")) { - 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}`); - return await docTools.run(toolId, toolArgs); - } - if (!hasMindmapContext) throw new Error(`工具需要 mindmap 上下文:${toolId}`); - if (!mindmapTools) throw new Error(`工具需要 mindmap 上下文:${toolId}`); - try { - 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 raw = Number(payload.maxSteps ?? DEFAULT_AGENT_MAX_STEPS); - if (!Number.isFinite(raw)) return DEFAULT_AGENT_MAX_STEPS; - return Math.max(MIN_AGENT_STEPS, Math.min(MAX_AGENT_STEPS, Math.floor(raw))); - })(); - - const stream = payload.stream !== false; if (!stream) { - const events: Array<{ type: string; data: unknown }> = []; - const codexSessionIdRaw = String(payload.options?.ai?.sessionId ?? "").trim(); - let codexSessionIdInRequest: string | null = codexSessionIdRaw || null; - let codexSessionEmitted = false; - - const chatForAgent = - provider === "codex" - ? async (messages: Array<{ role: "system" | "user" | "assistant"; content: string }>) => { - const prompt = codexMessagesToPrompt([ - { - role: "system", - content: "补充约束:你当前处于 #test 模式。你必须严格遵守工具标签协议输出;不要执行任何命令;不要读写文件。", - }, - ...messages, - ]); - - const run = startCodexJsonRun({ - cwd: codexWorkspaceRoot ?? process.cwd(), - sandbox: "workspace-write", - prompt, - model: null, - sessionId: codexSessionIdInRequest, - onJsonLine: (line) => { - if (line.type !== "thread.started") return; - const sid = String((line as any).thread_id ?? "").trim(); - if (!sid) return; - if (!codexSessionIdInRequest) codexSessionIdInRequest = sid; - if (!codexSessionEmitted) { - codexSessionEmitted = true; - events.push({ type: "codex_session", data: { sessionId: sid } }); - } - }, - }); - const result = await run.done; - if (!result.ok) throw new Error(result.error); - if (result.threadId && !codexSessionIdInRequest) codexSessionIdInRequest = result.threadId; - if (result.threadId && !codexSessionEmitted) { - codexSessionEmitted = true; - events.push({ type: "codex_session", data: { sessionId: result.threadId } }); - } - return { text: result.text, raw: null }; - } - : undefined; - - const result = await runAiAgent({ - userMessages: effectiveMessages, - cfg: { ...cfg, model: modelOverride ?? cfg.model }, - ...(chatForAgent ? { chat: chatForAgent } : {}), - allowedToolIds, - runTool, - maxSteps, - systemContextText, - defaultMindmapTargetUid: selectedUids[0] ?? "", - onEvent: (ev) => events.push(ev), - }).catch((e) => ({ ok: false as const, error: e instanceof Error ? e.message : String(e) })); - if (!result.ok) return NextResponse.json({ error: result.error }, { status: 500 }); - return NextResponse.json({ text: result.text, steps: result.steps, events }); + const run = startCodexJsonRun({ + cwd: workspaceRoot, + sandbox: "workspace-write", + prompt: buildPrompt(), + model: null, + sessionId: sessionIdRaw || null, + }); + const result = await run.done; + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: 500 }); + } + return NextResponse.json({ + text: result.text, + steps: 1, + events: [], + sessionId: result.threadId || sessionIdRaw || null, + }); } const encoder = new TextEncoder(); - let activeCodexKill: (() => void) | null = null; - const stopActiveCodexRun = () => { - try { - activeCodexKill?.(); - } catch { - // ignore - } finally { - activeCodexKill = null; - } - }; + let killActiveRun: (() => void) | null = null; const body = new ReadableStream({ start(controller) { const send = (event: string, data: unknown) => { controller.enqueue(encoder.encode(toSseFrame(event, data))); }; - // 先发一个 ready,方便前端快速进入“流式模式” const requestId = makeRunId(); send("ready", { ok: true, requestId }); - let lastToolCall: { id: string; tool: string; args: Record } | null = null; - const codexSessionIdRaw = String(payload.options?.ai?.sessionId ?? "").trim(); - let codexSessionIdInRequest: string | null = codexSessionIdRaw || null; - let codexSessionEmitted = false; + let sessionSent = false; + let assistantSent = false; + const toolStartAt = new Map(); - const runToolStream = async (toolId: string, toolArgs: Record) => { - if (isOnlyOfficeClientTool(toolId)) { - const callId = lastToolCall?.tool === toolId ? lastToolCall.id : `call_${Date.now()}`; - const key = buildClientToolKey(requestId, callId); - const wait = registerClientToolCall({ - key, - userId, - timeoutMs: DEFAULT_CLIENT_TOOL_TIMEOUT_MS, - }); - - // 说明:客户端收到该事件后,需要执行插件 API 并回调 /api/ai-agent/client-tool-result - send("client_tool_call", { requestId, callId, tool: toolId, args: toolArgs }); - - const result = await wait; - if (!result.ok) throw new Error(result.error); - return result.result; - } - return await runTool(toolId, toolArgs); - }; - - const ping = setInterval(() => { - // 避免某些代理/浏览器长连接超时 + const stopRun = () => { try { - controller.enqueue(encoder.encode(`: ping ${Date.now()}\n\n`)); + killActiveRun?.(); } catch { // ignore } - }, 15_000); + }; const onAbort = () => { - stopActiveCodexRun(); + stopRun(); }; try { request.signal?.addEventListener("abort", onAbort, { once: true }); @@ -1105,102 +391,194 @@ export async function POST(request: Request) { } (async () => { - const result = await runAiAgent({ - userMessages: effectiveMessages, - cfg: { ...cfg, model: modelOverride ?? cfg.model }, - ...(provider === "codex" - ? { - chat: async (messages) => { - const prompt = codexMessagesToPrompt([ - { - role: "system", - content: "补充约束:你当前处于 #test 模式。你必须严格遵守工具标签协议输出;不要执行任何命令;不要读写文件。", - }, - ...messages, - ]); - - const run = startCodexJsonRun({ - cwd: codexWorkspaceRoot ?? process.cwd(), - sandbox: "workspace-write", - prompt, - model: null, - sessionId: codexSessionIdInRequest, - onJsonLine: (line) => { - if (line.type !== "thread.started") return; - const sid = String((line as any).thread_id ?? "").trim(); - if (!sid) return; - if (!codexSessionIdInRequest) codexSessionIdInRequest = sid; - if (!codexSessionEmitted) { - codexSessionEmitted = true; - send("codex_session", { sessionId: sid }); - } - }, - }); - activeCodexKill = run.kill; - const r = await run.done; - if (activeCodexKill === run.kill) activeCodexKill = null; - if (!r.ok) throw new Error(r.error); - if (r.threadId && !codexSessionIdInRequest) codexSessionIdInRequest = r.threadId; - if (r.threadId && !codexSessionEmitted) { - codexSessionEmitted = true; - send("codex_session", { sessionId: r.threadId }); - } - return { text: r.text, raw: null }; - }, + const run = startCodexJsonRun({ + cwd: workspaceRoot, + sandbox: "workspace-write", + prompt: buildPrompt(), + model: null, + sessionId: sessionIdRaw || null, + onJsonLine: (line) => { + if (line.type === "thread.started") { + const sid = String((line as { thread_id?: string }).thread_id ?? "").trim(); + if (sid && !sessionSent) { + sessionSent = true; + send("codex_session", { sessionId: sid }); } - : {}), - allowedToolIds, - runTool: runToolStream, - maxSteps, - systemContextText, - defaultMindmapTargetUid: selectedUids[0] ?? "", - onEvent: (ev) => { - if (!ev?.type) return; - if (ev.type === "tool_call") { - try { - const d = ev.data as unknown; - const obj = - typeof d === "object" && d - ? (d as Record) - : ({} as Record); - const id = String(obj.id ?? "").trim(); - const tool = String(obj.tool ?? "").trim(); - const args = - typeof obj.args === "object" && obj.args - ? (obj.args as Record) - : ({} as Record); - if (id && tool) lastToolCall = { id, tool, args }; - } catch { - // ignore + return; + } + + if (line.type === "item.started" && (line as { item?: { type?: string; id?: string; command?: string } }).item?.type === "command_execution") { + const item = (line as { item?: { type?: string; id?: string; command?: string } }).item; + const id = String(item?.id ?? "").trim(); + const command = String(item?.command ?? ""); + if (!id) return; + toolStartAt.set(id, Date.now()); + send("tool_call", { id, tool: "codex_command", args: { command } }); + return; + } + + if (line.type === "item.completed" && (line as { item?: { type?: string; id?: string; exit_code?: number; aggregated_output?: string } }).item?.type === "command_execution") { + const item = (line as { item?: { type?: string; id?: string; exit_code?: number; aggregated_output?: string } }).item; + const id = String(item?.id ?? "").trim(); + if (!id) return; + const startedAt = toolStartAt.get(id) ?? Date.now(); + const ms = Math.max(0, Date.now() - startedAt); + const exitCode = Number(item?.exit_code ?? 0); + send("tool_result", { + id, + tool: "codex_command", + ok: exitCode === 0, + ms, + result: { + exitCode, + output: String(item?.aggregated_output ?? ""), + }, + }); + return; + } + + if (line.type === "item.completed" && (line as { item?: { type?: string; text?: string } }).item?.type === "agent_message") { + const item = (line as { item?: { type?: string; text?: string } }).item; + const text = String(item?.text ?? "").trim(); + if (text) { + assistantSent = true; + send("assistant_message", { text }); } } - send(ev.type, ev.data ?? null); }, }); + killActiveRun = run.kill; + const result = await run.done; if (!result.ok) { send("error", { ok: false, message: result.error }); return; } - send("completion", { ok: true, text: result.text, steps: result.steps }); + + if (result.threadId && !sessionSent) { + sessionSent = true; + send("codex_session", { sessionId: result.threadId }); + } + if (!assistantSent && result.text) { + assistantSent = true; + send("assistant_message", { text: result.text }); + } + send("completion", { ok: true, text: result.text, steps: 1 }); })() - .catch((e) => { - const msg = e instanceof Error ? e.message : String(e); - send("error", { ok: false, message: msg }); + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + send("error", { ok: false, message }); }) .finally(() => { - clearInterval(ping); try { request.signal?.removeEventListener("abort", onAbort); } catch { // ignore } - stopActiveCodexRun(); + stopRun(); controller.close(); }); }, cancel() { - stopActiveCodexRun(); + try { + killActiveRun?.(); + } catch { + // ignore + } + }, + }); + + return new Response(body, { headers: sseHeaders }); +}; + +export async function POST(request: Request) { + const payload = await safeGetJsonBody(request); + if (!payload) { + return errorResponses.badRequest("请求体不能为空"); + } + const validationError = validateRequestBody(payload, ["messages"] as const); + if (validationError) return validationError; + if (!Array.isArray(payload.messages) || payload.messages.length === 0) { + return errorResponses.badRequest("缺少 messages"); + } + + let userId = ""; + if (isConvexEnabled()) { + const { auth } = await getAuthedConvexClient(); + userId = auth.userId ?? ""; + } + if (!userId) { + return errorResponses.unauthorized(); + } + + const provider = normalizeProvider(payload.options?.ai?.provider); + const stream = payload.stream !== false; + if (provider === "codex") { + return await runCodexBridge({ payload, request, stream }); + } + + const scope = normalizeScope(payload); + const maxSteps = clampSteps(payload.maxSteps); + const instructions = buildHermesInstructions(payload, userId, scope, maxSteps); + const sessionId = String(payload.options?.ai?.sessionId ?? "").trim() || null; + + if (!stream) { + const events: LegacyStreamEvent[] = []; + await streamHermesLegacyEvents({ + messages: payload.messages, + instructions, + sessionId, + onEvent: (event) => { + events.push(event); + }, + }).catch((error) => { + throw error instanceof Error ? error : new Error(String(error)); + }); + + const completion = [...events].reverse().find((event) => event.type === "completion") as Extract | undefined; + const assistant = [...events].reverse().find((event) => event.type === "assistant_message") as Extract | undefined; + return NextResponse.json({ + text: completion?.data.text ?? assistant?.data.text ?? "", + steps: completion?.data.steps ?? Math.max(1, events.filter((event) => event.type === "tool_call").length || 1), + events, + }); + } + + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + const send = (event: string, data: unknown) => { + controller.enqueue(encoder.encode(toSseFrame(event, data))); + }; + + send("ready", { ok: true, requestId: makeRunId() }); + + const ping = setInterval(() => { + try { + controller.enqueue(encoder.encode(`: ping ${Date.now()}\n\n`)); + } catch { + // ignore + } + }, 15_000); + + (async () => { + await streamHermesLegacyEvents({ + messages: payload.messages, + instructions, + sessionId, + onEvent: (event) => { + send(event.type, event.data ?? null); + }, + }); + })() + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + send("error", { ok: false, message }); + }) + .finally(() => { + clearInterval(ping); + controller.close(); + }); }, }); diff --git a/wolai-frontend/src/app/api/mindmap-ai/outline-to-mindmap/route.ts b/wolai-frontend/src/app/api/mindmap-ai/outline-to-mindmap/route.ts index 4c5efa87..329acae0 100644 --- a/wolai-frontend/src/app/api/mindmap-ai/outline-to-mindmap/route.ts +++ b/wolai-frontend/src/app/api/mindmap-ai/outline-to-mindmap/route.ts @@ -1110,7 +1110,7 @@ export async function POST(request: Request) { } const finalOutline = plan - ? plan.chapters.flatMap((chapter, chapterIndex) => [ + ? plan.chapters.flatMap((chapter) => [ { title: chapter.title, level: 1, @@ -1144,7 +1144,8 @@ export async function POST(request: Request) { pageLinkPattern, outline: finalOutline, }; - const result = await executeRustBridgeTool<{ + // 说明:导图树构建已经交给 Rust runtime,这里只保留 PDF 提取与 transport 壳。 + const rustResult = await executeRustBridgeTool<{ ok: boolean; data?: unknown; meta?: { title?: string | null; outlineCount?: number | null } | null; @@ -1156,10 +1157,16 @@ export async function POST(request: Request) { data: payload, mode: "result", }); - const mindmapData = (result as any)?.data ?? { - data: { text: pdfTitle }, - children: [], - }; + const mindmapData = + rustResult.result && typeof rustResult.result === "object" && !Array.isArray(rustResult.result) && "data" in rustResult.result + ? ((rustResult.result as { data?: unknown }).data ?? { + data: { text: pdfTitle }, + children: [], + }) + : { + data: { text: pdfTitle }, + children: [], + }; return NextResponse.json({ mindmapData, diff --git a/wolai-frontend/src/app/api/mindmap-trash/empty/route.ts b/wolai-frontend/src/app/api/mindmap-trash/empty/route.ts index 50574655..43ad859a 100644 --- a/wolai-frontend/src/app/api/mindmap-trash/empty/route.ts +++ b/wolai-frontend/src/app/api/mindmap-trash/empty/route.ts @@ -1,9 +1,15 @@ import { NextResponse } from "next/server"; import { isConvexEnabled } from "@/lib/convex/enabled"; import { getAuthedConvexClient } from "@/lib/convex/route"; -import { api } from "@/lib/convex/api"; -import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge"; -import { executeRustBridgeTool } from "@/lib/documents/rust-runtime"; +import { + buildDocumentBridgeContextWithActor, + buildDocumentCommandEnvelope, + documentBridgeErrorResponse, +} from "@/lib/documents/bridge"; +import { + executeRustBridgeMutationTransport, + resolveRustBridgeCommandPlan, +} from "@/lib/documents/rust-runtime"; export const dynamic = "force-dynamic"; @@ -13,39 +19,47 @@ interface EmptyTrashPayload { export async function POST(request: Request) { if (isConvexEnabled()) { - const { client } = await getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({})); if (!workspaceId) { 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 { - const result = await client.mutation(api.mindmaps.emptyTrashByWorkspace, { workspaceId }); + const context = buildDocumentBridgeContextWithActor({ + request, + actor: { + actorType: "user", + actorId: auth.userId, + sessionId: null, + }, + workspaceId, + source: { + channel: "mindmap-trash-route", + client: "wolai-frontend", + }, + }); + const envelope = buildDocumentCommandEnvelope({ + name: "mindmaps.emptyTrashByWorkspace", + payload: { workspaceId }, + context, + target: { + workspaceId, + }, + reason: "mindmap-trash:empty", + refs: ["task-047", "mindmap-trash"], + }); + const plan = await resolveRustBridgeCommandPlan({ context, envelope }); + const result = await executeRustBridgeMutationTransport<{ + ok?: boolean; + deletedCount?: number; + }>({ + client, + plan, + }); return NextResponse.json({ ok: true, removed: result?.deletedCount ?? 0 }); } catch (error) { - return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + return documentBridgeErrorResponse(error); } } diff --git a/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts b/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts index 7316f76f..e51ee61c 100644 --- a/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts +++ b/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from "next/server"; import { isConvexEnabled } from "@/lib/convex/enabled"; import { getAuthedConvexClient } from "@/lib/convex/route"; -import { api } from "@/lib/convex/api"; import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta"; import { buildDocumentBridgeContextWithActor, @@ -172,7 +171,43 @@ export async function DELETE( if (isConvexEnabled()) { const { auth, client } = await getAuthedConvexClient(); try { - const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId }); + 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.delete", + payload: { + documentId: docId, + mindmapId, + }, + context, + target: { + workspaceId: null, + pageId: docId, + blockId: mindmapId, + }, + reason: "mindmap-route:delete", + refs: ["task-047", "mindmap-route"], + }); + const plan = await resolveRustBridgeCommandPlan({ context, envelope }); + const result = await executeRustBridgeMutationTransport<{ + ok?: boolean; + workspace_id?: string | null; + deleted_at?: string | null; + }>({ + client, + plan, + }); return NextResponse.json({ ...(result ?? { ok: true }), meta: { @@ -186,7 +221,7 @@ export async function DELETE( }, }); } catch (error) { - return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + return documentBridgeErrorResponse(error); } } @@ -207,19 +242,43 @@ export async function PATCH( } try { - if (action === "purge") { - const result = await client.mutation(api.mindmaps.purge, { docId, mindmapId }); - return NextResponse.json({ - ...(result ?? { ok: true }), - meta: buildMindmapRouteMeta(request, { - workspaceId: result?.workspace_id ?? null, - documentId: docId, - mindmapId, - ownerUserId: auth.userId, - }), - }); - } - const result = await client.mutation(api.mindmaps.restore, { docId, mindmapId }); + 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: action === "purge" ? "mindmaps.purge" : "mindmaps.restore", + payload: { + documentId: docId, + mindmapId, + }, + context, + target: { + workspaceId: null, + pageId: docId, + blockId: mindmapId, + }, + reason: `mindmap-route:${action}`, + refs: ["task-047", "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({ ...(result ?? { ok: true }), meta: { @@ -229,13 +288,11 @@ export async function PATCH( mindmapId, ownerUserId: auth.userId, }), - updatedAt: result?.updated_at ?? null, + updatedAt: action === "restore" ? result?.updated_at ?? null : null, }, }); } catch (error) { - const msg = (error as Error).message ?? "操作失败"; - const status = msg.includes("未找到") ? 404 : 400; - return NextResponse.json({ error: msg }, { status }); + return documentBridgeErrorResponse(error); } } diff --git a/wolai-frontend/src/app/api/mindmap/[docId]/route.ts b/wolai-frontend/src/app/api/mindmap/[docId]/route.ts index bfc7b1f7..3ceed1c1 100644 --- a/wolai-frontend/src/app/api/mindmap/[docId]/route.ts +++ b/wolai-frontend/src/app/api/mindmap/[docId]/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from "next/server"; import { isConvexEnabled } from "@/lib/convex/enabled"; import { getAuthedConvexClient } from "@/lib/convex/route"; -import { api } from "@/lib/convex/api"; import { buildDocumentBridgeContextWithActor, buildDocumentCommandEnvelope, @@ -179,19 +178,59 @@ export async function DELETE( if (isConvexEnabled()) { const { auth, client } = await getAuthedConvexClient(); const mindmapId = `legacy-${docId}`; - const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId }); - return NextResponse.json({ - ...(result ?? { ok: true }), - meta: { - ...buildMindmapRouteMeta(request, { - workspaceId: result?.workspace_id ?? null, + 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.delete", + payload: { documentId: docId, mindmapId, - ownerUserId: auth.userId, - }), - deletedAt: result?.deleted_at ?? null, - }, - }); + }, + context, + target: { + workspaceId: null, + pageId: docId, + blockId: mindmapId, + }, + reason: "mindmap-route:delete", + refs: ["task-047", "mindmap-route"], + }); + const plan = await resolveRustBridgeCommandPlan({ context, envelope }); + const result = await executeRustBridgeMutationTransport<{ + ok?: boolean; + workspace_id?: string | null; + deleted_at?: string | null; + }>({ + client, + plan, + }); + return NextResponse.json({ + ...(result ?? { ok: true }), + meta: { + ...buildMindmapRouteMeta(request, { + workspaceId: result?.workspace_id ?? null, + documentId: docId, + mindmapId, + ownerUserId: auth.userId, + }), + deletedAt: result?.deleted_at ?? null, + }, + }); + } catch (error) { + return documentBridgeErrorResponse(error); + } } return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); diff --git a/wolai-frontend/src/components/ai-agent/AiAgentPanel.tsx b/wolai-frontend/src/components/ai-agent/AiAgentPanel.tsx index 31daff60..9ed740ff 100644 --- a/wolai-frontend/src/components/ai-agent/AiAgentPanel.tsx +++ b/wolai-frontend/src/components/ai-agent/AiAgentPanel.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { - Bot, Command, DatabaseZap, FileSearch, @@ -12,16 +11,16 @@ import { RefreshCcw, Search, SendHorizontal, - Sparkles, SquareStop, Trash2, - X, type LucideIcon, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Textarea } from "@/components/ui/textarea"; +import { AiBridgePanel } from "./AiBridgePanel"; +import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "./panelShared"; type ChatMsg = { role: "user" | "assistant"; content: string }; type ToolLog = @@ -44,6 +43,7 @@ type ToolSetChip = { const DEFAULT_PROMPT = "请给出 gemini-3 tokens 价格,并提供来源链接。"; const MIN_PANEL_AGENT_STEPS = 1; const MAX_PANEL_AGENT_STEPS = 24; +const DEFAULT_PREFS = { provider: "online" as AiProvider, model: "", maxSteps: 10 }; const TOOLSET_CHIPS: ToolSetChip[] = [ { id: "toolset.readonly", title: "联网检索", description: "SearxNG 可追溯来源" }, @@ -54,62 +54,13 @@ const TOOLSET_CHIPS: ToolSetChip[] = [ ]; const CAPABILITY_ITEMS: CapabilityItem[] = [ - { - title: "联网检索", - description: "搜索公开网页并返回来源链接,适合查价格、规格、资料。", - icon: Search, - }, - { - title: "LightRAG", - description: "结合知识库做语义检索与生成,适合已有资料沉淀场景。", - icon: DatabaseZap, - }, - { - title: "跨页面文档", - description: "搜索并读取当前工作区中的文档内容,用于对比和归纳。", - icon: FileSearch, - }, - { - title: "图片 OCR", - description: "若当前请求已带图片或附件,可读取其中的 OCR 文字内容。", - icon: Image, - }, - { - title: "斜杠命令", - description: "执行受控写操作,例如创建文档或改名,需要明确确认。", - icon: Command, - }, + { title: "联网检索", description: "搜索公开网页并返回来源链接,适合查价格、规格、资料。", icon: Search }, + { title: "LightRAG", description: "结合知识库做语义检索与生成,适合已有资料沉淀场景。", icon: DatabaseZap }, + { title: "跨页面文档", description: "搜索并读取当前工作区中的文档内容,用于对比和归纳。", icon: FileSearch }, + { title: "图片 OCR", description: "若当前请求已带图片或附件,可读取其中的 OCR 文字内容。", icon: Image }, + { title: "斜杠命令", description: "执行受控写操作,例如创建文档或改名,需要明确确认。", icon: Command }, ]; -const parseSseChunks = async (res: Response, onEvent: (event: string, dataText: string) => void) => { - if (!res.body) throw new Error("响应不支持流式读取"); - const reader = res.body.getReader(); - const decoder = new TextDecoder("utf-8"); - - let buffer = ""; - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - - while (true) { - const sep = buffer.indexOf("\n\n"); - if (sep === -1) break; - const raw = buffer.slice(0, sep); - buffer = buffer.slice(sep + 2); - - const lines = raw.split(/\r?\n/); - let event = "message"; - const dataLines: string[] = []; - for (const line of lines) { - if (line.startsWith("event:")) event = line.slice("event:".length).trim() || "message"; - if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).trimStart()); - } - onEvent(event, dataLines.join("\n")); - } - } -}; - const formatJson = (value: unknown) => { try { return JSON.stringify(value, null, 2); @@ -125,14 +76,10 @@ const isAbortLikeError = (error: unknown) => { ); }; -const clampStep = (value: number) => { - return Math.min(Math.max(value, MIN_PANEL_AGENT_STEPS), MAX_PANEL_AGENT_STEPS); -}; +const clampStep = (value: number) => Math.min(Math.max(value, MIN_PANEL_AGENT_STEPS), MAX_PANEL_AGENT_STEPS); const getLogToneClass = (log: ToolLog) => { - if (log.type === "error") { - return "border-red-500/30 bg-red-500/10"; - } + if (log.type === "error") return "border-red-500/30 bg-red-500/10"; if (log.type === "tool_result") { return log.ok ? "border-emerald-500/20 bg-emerald-500/10" : "border-amber-500/25 bg-amber-500/10"; } @@ -145,13 +92,28 @@ const getLogLabel = (log: ToolLog) => { return "运行错误"; }; +const getProviderLabel = (provider: AiProvider) => { + switch (provider) { + case "local": + return "本地"; + case "ollama": + return "Ollama"; + case "codex": + return "Codex"; + default: + return "Hermes"; + } +}; + export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) { const [input, setInput] = useState(DEFAULT_PROMPT); const [messages, setMessages] = useState([]); const [logs, setLogs] = useState([]); const [running, setRunning] = useState(false); - const [maxSteps, setMaxSteps] = useState(10); const [showLogs, setShowLogs] = useState(true); + const [aiProvider, setAiProvider] = useState(DEFAULT_PREFS.provider); + const [aiModel, setAiModel] = useState(DEFAULT_PREFS.model); + const [maxSteps, setMaxSteps] = useState(DEFAULT_PREFS.maxSteps); const abortRef = useRef(null); const messageEndRef = useRef(null); @@ -161,6 +123,17 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) { const assistantCount = messages.filter((message) => message.role === "assistant").length; const userCount = messages.length - assistantCount; + useEffect(() => { + const prefs = readAiPanelPrefs("global_ai", DEFAULT_PREFS); + setAiProvider(prefs.provider); + setAiModel(prefs.model); + setMaxSteps(clampStep(prefs.maxSteps)); + }, []); + + useEffect(() => { + writeAiPanelPrefs("global_ai", { provider: aiProvider, model: aiModel, maxSteps: clampStep(maxSteps) }); + }, [aiModel, aiProvider, maxSteps]); + useEffect(() => { messageEndRef.current?.scrollIntoView({ block: "end" }); }, [messages, logs, showLogs]); @@ -171,14 +144,10 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) { setRunning(false); }; - const restoreDefaultPrompt = () => { - setInput(DEFAULT_PROMPT); - }; + const restoreDefaultPrompt = () => setInput(DEFAULT_PROMPT); const clearConversation = () => { - if (running) { - stop(); - } + if (running) stop(); setMessages([]); setLogs([]); }; @@ -210,13 +179,18 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) { mode: "auto", toolSets: TOOLSET_CHIPS.map((chip) => chip.id), }, - options: { searxng: true, ai: { provider: "online" } }, + options: { + searxng: true, + ai: { + provider: aiProvider, + ...(aiProvider !== "codex" && aiModel.trim() ? { model: aiModel.trim() } : {}), + }, + }, }), }); if (!res.ok) { const j = (await res.json().catch(() => null)) as unknown; - const err = - typeof j === "object" && j && "error" in j ? String((j as Record).error ?? "") : ""; + const err = typeof j === "object" && j && "error" in j ? String((j as Record).error ?? "") : ""; throw new Error(err || `HTTP ${res.status}`); } @@ -282,9 +256,7 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) { } }); } catch (error) { - if (controller.signal.aborted || isAbortLikeError(error)) { - return; - } + if (controller.signal.aborted || isAbortLikeError(error)) return; const message = error instanceof Error ? error.message : String(error); setLogs((prev) => [...prev, { type: "error", message }]); } finally { @@ -293,340 +265,274 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) { } }; - return ( -
-
-
-
- -
- -
-
- MNOTE - - 全局 AI + const logSidebar = showLogs ? ( +
+
+
活动轨迹
+
前端已经退为桥接层,这里只显示回流事件。
+
+
+ {logs.length === 0 ?
暂无工具活动
: null} + {logs.map((log, index) => ( +
+
+
{getLogLabel(log)}
+ {log.type !== "error" ?
{log.id}
: null}
-
-

全局 AI

- - 自动工具编排 +
{log.type === "error" ? log.message : log.tool}
+ {log.type === "tool_result" ? ( +
{log.ok ? "成功" : "失败"} · {log.ms}ms
+ ) : null} + {log.type === "tool_call" ? ( +
{formatJson(log.args)}
+ ) : null} + {log.type === "tool_result" ? ( +
{formatJson(log.result)}
+ ) : null} +
+ ))} +
+
+ ) : undefined; + + return ( + + + + + + + } + sidebar={logSidebar} + scrollBody={false} + className="h-[calc(100vh-24px)] min-h-0 rounded-[24px] border-white/10 shadow-none" + > +
+
+
+
+
当前能力
+
保留 MNOTE 当前真实工具,不再在前端继续扩张编排层。
+
+
+ + 用户 {userCount} + + + AI {assistantCount} + + + 活动 {logs.length}
-
- - {running ? "运行中" : "待命"} - - {onClose ? ( +
+ {TOOLSET_CHIPS.map((chip) => ( + + {chip.title} + + ))} +
+
+ +
+ +
+ {messages.length === 0 ? ( +
+
你好,我是 MNOTE 全局 AI。
+
我会基于当前已接入的工具完成可追溯的检索、阅读与受控写入任务:
+
+ {CAPABILITY_ITEMS.map((item) => { + const Icon = item.icon; + return ( +
+
+ + {item.title} +
+
{item.description}
+
+ ); + })} +
+
请输入你的目标,我会在允许的工具范围内完成分析,并把活动轨迹放到右侧。
+
+ ) : null} + + {messages.map((message, index) => { + const isUser = message.role === "user"; + return ( +
+
+
{isUser ? "我" : "AI"}
+
{message.content}
+
+
+ ); + })} +
+
+ +
+ +
+
+
+ {TOOLSET_CHIPS.map((chip) => ( +
+ {chip.title} + {chip.description} +
+ ))} +
+ +
+ + + + + setAiModel(e.target.value)} + placeholder={aiProvider === "codex" ? "Codex 无需模型" : "模型(可选)"} + disabled={running || aiProvider === "codex"} + /> +
+
+ +
+