feat: 提交 task-045 至 task-058 收口产物
- 收口 rust final closure checklist,推进页面/块系统/Mindmap/CLI/AI tools 到最终 cutover 状态 - 按 ai-frontend-simplification-plan-v1 接入 Hermes bridge,合并 AI 面板并清理旧前端编排残留 - 补充 harness 任务与进度记录,加入 CLI smoke 夹具/脚本,并修正文档页 bridge SSR 自请求回退逻辑
This commit is contained in:
@@ -55,5 +55,9 @@ design
|
|||||||
/rust/target/
|
/rust/target/
|
||||||
|
|
||||||
# Harness 本地运行状态/调试产物
|
# Harness 本地运行状态/调试产物
|
||||||
|
/.harness-active
|
||||||
/.harness-stop-counter
|
/.harness-stop-counter
|
||||||
/harness-tasks.json.bak
|
/harness-tasks.json.bak
|
||||||
|
|
||||||
|
# pnpm 本地缓存
|
||||||
|
/.pnpm-store/
|
||||||
|
|||||||
@@ -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] 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] 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-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
|
||||||
|
|||||||
+997
-2
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ name: mnote-convex
|
|||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
# 说明:官方自托管镜像。需要时可将 :latest 固定为特定版本。
|
# 说明:官方自托管镜像。需要时可将 :latest 固定为特定版本。
|
||||||
image: ghcr.io/get-convex/convex-backend:latest
|
image: ghcr.io/get-convex/convex-backend@sha256:2143ad479a997802e74ac52f5f1ce3d6e75309b1965e36069d1c939166e210eb
|
||||||
stop_grace_period: 10s
|
stop_grace_period: 10s
|
||||||
stop_signal: SIGINT
|
stop_signal: SIGINT
|
||||||
ports:
|
ports:
|
||||||
@@ -19,7 +19,7 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
dashboard:
|
dashboard:
|
||||||
image: ghcr.io/get-convex/convex-dashboard:latest
|
image: ghcr.io/get-convex/convex-dashboard@sha256:a1fa8df3d320d1691c74bb9df7bb2c536bdef91ff3f5307b717dba97eec793d1
|
||||||
stop_grace_period: 10s
|
stop_grace_period: 10s
|
||||||
stop_signal: SIGINT
|
stop_signal: SIGINT
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
Generated
+3
@@ -670,8 +670,11 @@ dependencies = [
|
|||||||
name = "mnote-cli"
|
name = "mnote-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"bridge-runtime",
|
||||||
"clap",
|
"clap",
|
||||||
"core-protocol",
|
"core-protocol",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"storage-convex-bridge",
|
"storage-convex-bridge",
|
||||||
|
|||||||
@@ -153,6 +153,52 @@ struct MindmapOutlineToolPayload {
|
|||||||
outline: Vec<MindmapOutlineItemPayload>,
|
outline: Vec<MindmapOutlineItemPayload>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DocsSearchDatasetPayload {
|
||||||
|
workspace_id: String,
|
||||||
|
documents: Vec<index_fts::SearchDocumentRecord>,
|
||||||
|
mindmaps: Vec<index_fts::SearchMindmapRecord>,
|
||||||
|
tables: Vec<index_fts::SearchTableRecord>,
|
||||||
|
table_rows: Vec<index_fts::SearchTableRowRecord>,
|
||||||
|
assets: Vec<index_fts::SearchAssetRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DocsSearchToolPayload {
|
||||||
|
query: String,
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
limit: Option<u32>,
|
||||||
|
include_deleted: Option<bool>,
|
||||||
|
page_id: Option<String>,
|
||||||
|
title_only: Option<bool>,
|
||||||
|
exact: Option<bool>,
|
||||||
|
include_ocr: Option<bool>,
|
||||||
|
time_range: Option<String>,
|
||||||
|
time_field: Option<String>,
|
||||||
|
custom_range_from: Option<String>,
|
||||||
|
custom_range_to: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
datasets: Vec<DocsSearchDatasetPayload>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DocsReadToolPayload {
|
||||||
|
document_id: String,
|
||||||
|
max_chars: Option<u32>,
|
||||||
|
include_content: Option<bool>,
|
||||||
|
title: Option<String>,
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
parent_id: Option<String>,
|
||||||
|
updated_at: Option<String>,
|
||||||
|
raw_text_length: Option<u64>,
|
||||||
|
raw_text: Option<String>,
|
||||||
|
content: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, PartialEq)]
|
#[derive(Debug, Serialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RuntimeSuccess {
|
pub struct RuntimeSuccess {
|
||||||
@@ -388,6 +434,33 @@ struct MindmapPutCommandPayload {
|
|||||||
create_only: Option<bool>,
|
create_only: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct DocumentCreateCommandPayload {
|
struct DocumentCreateCommandPayload {
|
||||||
@@ -427,6 +500,25 @@ struct DocumentDuplicateCommandPayload {
|
|||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct DocumentCopyTreeItemPayload {
|
struct DocumentCopyTreeItemPayload {
|
||||||
@@ -1038,6 +1130,119 @@ fn execute_tool_result(
|
|||||||
"results": results,
|
"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::<String>::new();
|
||||||
|
let mut results = Vec::<Value>::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" => {
|
"doc_insert_blocks" => {
|
||||||
let blocks = normalize_blocks_from_value(&data);
|
let blocks = normalize_blocks_from_value(&data);
|
||||||
let specs = parse_insert_specs(&args)?;
|
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_") {
|
if invocation.tool.starts_with("onlyoffice_") {
|
||||||
let description = match invocation.tool.as_str() {
|
let description = match invocation.tool.as_str() {
|
||||||
"onlyoffice_session_resolve" => "解析 OnlyOffice 的附件、页面与用户上下文,生成稳定 session/asset 边界",
|
"onlyoffice_session_resolve" => "解析 OnlyOffice 的附件、页面与用户上下文,生成稳定 session/asset 边界",
|
||||||
@@ -2374,6 +2617,16 @@ fn strip_html_tags(value: &str) -> String {
|
|||||||
result.trim().to_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::<String>();
|
||||||
|
if value.chars().count() > max_chars {
|
||||||
|
format!("{taken}…")
|
||||||
|
} else {
|
||||||
|
taken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn walk_mindmap_summaries(root: &MindmapTreeNode, max_nodes: usize) -> Vec<RuntimeMindmapSummary> {
|
fn walk_mindmap_summaries(root: &MindmapTreeNode, max_nodes: usize) -> Vec<RuntimeMindmapSummary> {
|
||||||
let mut list = Vec::new();
|
let mut list = Vec::new();
|
||||||
let mut queue = VecDeque::from([(root, None::<String>, 0usize)]);
|
let mut queue = VecDeque::from([(root, None::<String>, 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" => {
|
"documents.create" => {
|
||||||
let payload: DocumentCreateCommandPayload =
|
let payload: DocumentCreateCommandPayload =
|
||||||
parse_payload(command_wire.payload.clone())?;
|
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" => {
|
"documents.copy_tree" => {
|
||||||
let payload: DocumentCopyTreeCommandPayload =
|
let payload: DocumentCopyTreeCommandPayload =
|
||||||
parse_payload(command_wire.payload.clone())?;
|
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]
|
#[test]
|
||||||
fn doc_get_tool_plan_uses_documents_content_query() {
|
fn doc_get_tool_plan_uses_documents_content_query() {
|
||||||
let plan = execute_runtime_input(RuntimeInput::Tool {
|
let plan = execute_runtime_input(RuntimeInput::Tool {
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ pub use tool::{
|
|||||||
default_tool_registry, invocation_kind_label, tool_effect_label, tool_mode_label,
|
default_tool_registry, invocation_kind_label, tool_effect_label, tool_mode_label,
|
||||||
InvocationKind, ToolEffect, ToolExecutionMode, ToolInvocation, ToolRegistry, ToolSetSpec,
|
InvocationKind, ToolEffect, ToolExecutionMode, ToolInvocation, ToolRegistry, ToolSetSpec,
|
||||||
ToolSpec, BRIDGE_TOOL_COMMAND_GET, BRIDGE_TOOL_REQUEST_GET, BRIDGE_TOOL_TRACE_GET,
|
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,
|
DOCS_TOOL_READ, DOCS_TOOL_SEARCH, DOCS_TOOLSET_READ, DOC_TOOL_FIND, DOC_TOOL_GET,
|
||||||
DOC_TOOLSET_READ, DOC_TOOLSET_WRITE, INDEX_TOOL_REBUILD, MINDMAP_TOOL_APPLY_OPS,
|
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_EMPTY_TRASH, MINDMAP_TOOL_EXPAND_NODE, MINDMAP_TOOL_GET,
|
||||||
MINDMAP_TOOL_GET_SUBTREE,
|
MINDMAP_TOOL_GET_SUBTREE,
|
||||||
MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT, MINDMAP_TOOLSET_READ,
|
MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT, MINDMAP_TOOLSET_READ,
|
||||||
@@ -81,6 +82,8 @@ mod tests {
|
|||||||
"search_web",
|
"search_web",
|
||||||
"doc_get",
|
"doc_get",
|
||||||
"doc_find",
|
"doc_find",
|
||||||
|
"docs_search",
|
||||||
|
"docs_read",
|
||||||
"image_read",
|
"image_read",
|
||||||
"doc_insert_blocks",
|
"doc_insert_blocks",
|
||||||
"doc_replace_range",
|
"doc_replace_range",
|
||||||
@@ -140,6 +143,11 @@ mod tests {
|
|||||||
.expect("slash toolset should exist");
|
.expect("slash toolset should exist");
|
||||||
assert!(slash.write_toolset);
|
assert!(slash.write_toolset);
|
||||||
assert_eq!(slash.tool_names, &["slash_run"]);
|
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
|
let doc_write = registry
|
||||||
.toolset("toolset.doc_write")
|
.toolset("toolset.doc_write")
|
||||||
.expect("doc_write toolset should exist");
|
.expect("doc_write toolset should exist");
|
||||||
|
|||||||
@@ -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}}}"#,
|
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 {
|
pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec {
|
||||||
name: "image_read",
|
name: "image_read",
|
||||||
display_name: "读取图片",
|
display_name: "读取图片",
|
||||||
@@ -381,6 +406,15 @@ pub const DOC_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
|||||||
tool_names: &["doc_get", "doc_find"],
|
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 {
|
pub const READONLY_TOOLSET: ToolSetSpec = ToolSetSpec {
|
||||||
id: "toolset.readonly",
|
id: "toolset.readonly",
|
||||||
display_name: "只读工具",
|
display_name: "只读工具",
|
||||||
@@ -470,6 +504,7 @@ pub const DOC_TOOL_REGISTRY: ToolRegistry = ToolRegistry::new(
|
|||||||
READONLY_TOOLSET,
|
READONLY_TOOLSET,
|
||||||
MEDIA_TOOLSET,
|
MEDIA_TOOLSET,
|
||||||
SLASH_TOOLSET_WRITE,
|
SLASH_TOOLSET_WRITE,
|
||||||
|
DOCS_TOOLSET_READ,
|
||||||
DOC_TOOLSET_READ,
|
DOC_TOOLSET_READ,
|
||||||
DOC_TOOLSET_WRITE,
|
DOC_TOOLSET_WRITE,
|
||||||
MINDMAP_TOOLSET_READ,
|
MINDMAP_TOOLSET_READ,
|
||||||
@@ -482,6 +517,8 @@ pub const DOC_TOOL_REGISTRY: ToolRegistry = ToolRegistry::new(
|
|||||||
SEARCH_WEB_TOOL,
|
SEARCH_WEB_TOOL,
|
||||||
DOC_TOOL_GET,
|
DOC_TOOL_GET,
|
||||||
DOC_TOOL_FIND,
|
DOC_TOOL_FIND,
|
||||||
|
DOCS_TOOL_SEARCH,
|
||||||
|
DOCS_TOOL_READ,
|
||||||
IMAGE_READ_TOOL,
|
IMAGE_READ_TOOL,
|
||||||
DOC_TOOL_INSERT_BLOCKS,
|
DOC_TOOL_INSERT_BLOCKS,
|
||||||
DOC_TOOL_REPLACE_RANGE,
|
DOC_TOOL_REPLACE_RANGE,
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ license.workspace = true
|
|||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64 = "0.22"
|
||||||
|
bridge-runtime = { path = "../bridge-runtime" }
|
||||||
clap = { version = "4.5.38", features = ["derive"] }
|
clap = { version = "4.5.38", features = ["derive"] }
|
||||||
core-protocol = { path = "../core-protocol" }
|
core-protocol = { path = "../core-protocol" }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
storage-convex-bridge = { path = "../storage-convex-bridge" }
|
storage-convex-bridge = { path = "../storage-convex-bridge" }
|
||||||
|
|||||||
+1508
-17
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,17 @@
|
|||||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||||
use core_protocol::{InvocationKind, ToolExecutionMode};
|
use core_protocol::{InvocationKind, ToolExecutionMode};
|
||||||
use mnote_cli::{
|
use mnote_cli::{
|
||||||
plan_block_insert, plan_block_patch, plan_mindmap_get, plan_mindmap_op, plan_mindmap_put,
|
execute_output, plan_block_embed, plan_block_insert, plan_block_move, plan_block_patch,
|
||||||
plan_page_get, plan_page_save, plan_page_title, plan_search_blocks,
|
plan_mindmap_get, plan_mindmap_op, plan_mindmap_put, plan_page_create, plan_page_delete,
|
||||||
plan_search_documents, plan_sidebar_dataset, plan_tool_run, render_plain_output, CliContext,
|
plan_page_get, plan_page_move, plan_page_restore, plan_page_save, plan_page_title,
|
||||||
CliError, CliJsonOutput,
|
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)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(name = "mnote-cli")]
|
#[command(name = "mnote-cli")]
|
||||||
#[command(about = "Phase 2 最小 CLI 协议冻结器", long_about = None)]
|
#[command(about = "Phase 8 最小真实执行 CLI", long_about = None)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
global: GlobalArgs,
|
global: GlobalArgs,
|
||||||
@@ -23,6 +25,9 @@ struct GlobalArgs {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
execute: bool,
|
||||||
|
|
||||||
#[arg(long, default_value = "cli_user")]
|
#[arg(long, default_value = "cli_user")]
|
||||||
actor_id: String,
|
actor_id: String,
|
||||||
|
|
||||||
@@ -66,6 +71,10 @@ enum PageAction {
|
|||||||
Get(PageGetArgs),
|
Get(PageGetArgs),
|
||||||
Title(PageTitleArgs),
|
Title(PageTitleArgs),
|
||||||
Save(PageSaveArgs),
|
Save(PageSaveArgs),
|
||||||
|
Create(PageCreateCliArgs),
|
||||||
|
Move(PageMoveCliArgs),
|
||||||
|
Delete(PageDeleteCliArgs),
|
||||||
|
Restore(PageRestoreCliArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
@@ -107,6 +116,60 @@ struct PageSaveArgs {
|
|||||||
conflict_detection_key: Option<String>,
|
conflict_detection_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct PageCreateCliArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
page_id: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
workspace_id: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
parent_id: Option<String>,
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
parent_id: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
sort_order: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct PageDeleteCliArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
page_id: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct PageRestoreCliArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
page_id: String,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
struct BlockCommand {
|
struct BlockCommand {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -117,6 +180,8 @@ struct BlockCommand {
|
|||||||
enum BlockAction {
|
enum BlockAction {
|
||||||
Insert(BlockInsertArgs),
|
Insert(BlockInsertArgs),
|
||||||
Patch(BlockPatchArgs),
|
Patch(BlockPatchArgs),
|
||||||
|
Move(BlockMoveCliArgs),
|
||||||
|
Embed(BlockEmbedCliArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
@@ -161,6 +226,33 @@ struct BlockPatchArgs {
|
|||||||
conflict_detection_key: Option<String>,
|
conflict_detection_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
struct MindmapCommand {
|
struct MindmapCommand {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -349,6 +441,40 @@ fn main() {
|
|||||||
&args.content_json,
|
&args.content_json,
|
||||||
args.conflict_detection_key.as_deref(),
|
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 {
|
Commands::Block(command) => match command.action {
|
||||||
BlockAction::Insert(args) => plan_block_insert(
|
BlockAction::Insert(args) => plan_block_insert(
|
||||||
@@ -369,6 +495,23 @@ fn main() {
|
|||||||
&args.snapshot_json,
|
&args.snapshot_json,
|
||||||
args.conflict_detection_key.as_deref(),
|
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 {
|
Commands::Mindmap(command) => match command.action {
|
||||||
MindmapAction::Get(args) => plan_mindmap_get(
|
MindmapAction::Get(args) => plan_mindmap_get(
|
||||||
@@ -421,7 +564,14 @@ fn main() {
|
|||||||
&args.args_json,
|
&args.args_json,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
.and_then(|output| {
|
||||||
|
if cli.global.execute {
|
||||||
|
execute_output(&output, &context)
|
||||||
|
} else {
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(output) => emit_success(&output, cli.global.json),
|
Ok(output) => emit_success(&output, cli.global.json),
|
||||||
|
|||||||
@@ -35,12 +35,19 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
|
|||||||
"documents.restore" => "documents:restore",
|
"documents.restore" => "documents:restore",
|
||||||
"documents.duplicate" => "documents:duplicateWithMindmaps",
|
"documents.duplicate" => "documents:duplicateWithMindmaps",
|
||||||
"documents.copy_tree" => "documents:copyTree",
|
"documents.copy_tree" => "documents:copyTree",
|
||||||
|
"documents.template" => "documents:setTemplate",
|
||||||
|
"documents.emptyTrashByWorkspace" => "documents:emptyTrashByWorkspace",
|
||||||
|
"documents.purge" => "documents:purge",
|
||||||
"blocks.patch" => "documents:updateContent",
|
"blocks.patch" => "documents:updateContent",
|
||||||
"documents.save" => "documents:updateContent",
|
"documents.save" => "documents:updateContent",
|
||||||
"documents.title.update" => "documents:updateTitle",
|
"documents.title.update" => "documents:updateTitle",
|
||||||
"documents.stats.update" => "documents:updateStats",
|
"documents.stats.update" => "documents:updateStats",
|
||||||
"documents.options.update" => "documents:updateOptions",
|
"documents.options.update" => "documents:updateOptions",
|
||||||
"mindmaps.put" => "mindmaps:put",
|
"mindmaps.put" => "mindmaps:put",
|
||||||
|
"mindmaps.delete" => "mindmaps:softDelete",
|
||||||
|
"mindmaps.restore" => "mindmaps:restore",
|
||||||
|
"mindmaps.purge" => "mindmaps:purge",
|
||||||
|
"mindmaps.emptyTrashByWorkspace" => "mindmaps:emptyTrashByWorkspace",
|
||||||
"insert_block" => "blocks:insert",
|
"insert_block" => "blocks:insert",
|
||||||
"update_block" => "blocks:update",
|
"update_block" => "blocks:update",
|
||||||
"move_block" => "blocks:move",
|
"move_block" => "blocks:move",
|
||||||
|
|||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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` 单独验证。
|
||||||
@@ -10,6 +10,7 @@ const { chromium } = require("playwright");
|
|||||||
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||||
const REQUEST_TIMEOUT_MS = 20_000;
|
const REQUEST_TIMEOUT_MS = 20_000;
|
||||||
const UI_TIMEOUT_MS = 30_000;
|
const UI_TIMEOUT_MS = 30_000;
|
||||||
|
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
|
||||||
|
|
||||||
function assert(condition, message) {
|
function assert(condition, message) {
|
||||||
if (!condition) {
|
if (!condition) {
|
||||||
@@ -17,13 +18,19 @@ function assert(condition, message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestJson(path, init = {}) {
|
async function requestJson(requestContext, path, init = {}) {
|
||||||
const response = await fetch(`${BASE_URL}${path}`, {
|
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers:
|
||||||
"content-type": "application/json",
|
init.data !== undefined
|
||||||
...(init.headers || {}),
|
? {
|
||||||
},
|
"content-type": "application/json",
|
||||||
|
...(init.headers || {}),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...(init.headers || {}),
|
||||||
|
},
|
||||||
|
timeout: REQUEST_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
@@ -34,19 +41,29 @@ async function requestJson(path, init = {}) {
|
|||||||
payload = text;
|
payload = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok()) {
|
||||||
throw new Error(
|
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;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createTempDocument() {
|
async function createTempDocument(requestContext) {
|
||||||
const payload = await requestJson("/api/documents/create", {
|
const payload = await requestJson(requestContext, "/api/documents/create", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ parentId: null }),
|
data: { parentId: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
|
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
|
||||||
@@ -58,84 +75,95 @@ async function createTempDocument() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function purgeTempDocument(documentId) {
|
async function purgeTempDocument(requestContext, documentId) {
|
||||||
await requestJson("/api/documents/purge", {
|
await requestJson(requestContext, "/api/documents/purge", {
|
||||||
method: "POST",
|
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 uniqueSuffix = Date.now().toString();
|
||||||
const nextTitle = `task019-ui-${uniqueSuffix}`;
|
const nextTitle = `task019-ui-${uniqueSuffix}`;
|
||||||
const nextBody = `task019 正文保存回归 ${uniqueSuffix}`;
|
const nextBody = `task019 正文保存回归 ${uniqueSuffix}`;
|
||||||
const browser = await chromium.launch({ headless: true });
|
const documentUrl = `${BASE_URL}/documents/${target.documentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||||
const page = await browser.newPage({
|
await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
viewport: { width: 1440, height: 960 },
|
|
||||||
|
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 saveResponse = page.waitForResponse(
|
||||||
const documentUrl = `${BASE_URL}/documents/${target.documentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
(response) =>
|
||||||
await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
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("页面树");
|
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
const privateSection = page.getByText("私有 / 我的页面");
|
await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
const titleInput = page.getByLabel("页面标题");
|
await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
const editorSurface = page.locator(".wolai-editor [contenteditable=\"true\"]").first();
|
await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
const saveIndicator = page.locator("text=已保存");
|
await page.locator(`text=${nextBody}`).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
|
||||||
await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
const persistedTitle = await titleInput.inputValue();
|
||||||
await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
assert(persistedTitle === nextTitle, `标题刷新后不一致:期望 ${nextTitle},实际 ${persistedTitle}`);
|
||||||
await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
const editorText = await page.locator(".wolai-editor").innerText({ timeout: UI_TIMEOUT_MS });
|
||||||
await editorSurface.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
assert(editorText.includes(nextBody), "正文刷新后未保留刚写入的内容");
|
||||||
await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
||||||
|
|
||||||
await titleInput.fill(nextTitle);
|
return {
|
||||||
const titleSaveResponse = page.waitForResponse(
|
documentUrl,
|
||||||
(response) =>
|
nextTitle,
|
||||||
response.url().includes("/api/documents/title") &&
|
nextBody,
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -149,11 +177,19 @@ async function main() {
|
|||||||
`首页探活失败:收到状态码 ${health.status}`,
|
`首页探活失败:收到状态码 ${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 regressionResult = null;
|
||||||
|
let caughtError = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
regressionResult = await runBrowserRegression(tempDocument);
|
await ensureAuthenticated(page, context.request);
|
||||||
|
tempDocument = await createTempDocument(context.request);
|
||||||
|
regressionResult = await runBrowserRegression(page, tempDocument);
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
@@ -166,8 +202,29 @@ async function main() {
|
|||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
caughtError = error;
|
||||||
} finally {
|
} 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const { chromium } = require("playwright");
|
|||||||
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||||
const REQUEST_TIMEOUT_MS = 20_000;
|
const REQUEST_TIMEOUT_MS = 20_000;
|
||||||
const UI_TIMEOUT_MS = 30_000;
|
const UI_TIMEOUT_MS = 30_000;
|
||||||
|
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
function assert(condition, message) {
|
function assert(condition, message) {
|
||||||
if (!condition) {
|
if (!condition) {
|
||||||
@@ -17,13 +20,19 @@ function assert(condition, message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestJson(path, init = {}) {
|
async function requestJson(requestContext, path, init = {}) {
|
||||||
const response = await fetch(`${BASE_URL}${path}`, {
|
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers:
|
||||||
"content-type": "application/json",
|
init.data !== undefined
|
||||||
...(init.headers || {}),
|
? {
|
||||||
},
|
"content-type": "application/json",
|
||||||
|
...(init.headers || {}),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...(init.headers || {}),
|
||||||
|
},
|
||||||
|
timeout: REQUEST_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
@@ -34,19 +43,19 @@ async function requestJson(path, init = {}) {
|
|||||||
payload = text;
|
payload = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok()) {
|
||||||
throw new Error(
|
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;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createTempDocument() {
|
async function createTempDocument(requestContext) {
|
||||||
const payload = await requestJson("/api/documents/create", {
|
const payload = await requestJson(requestContext, "/api/documents/create", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ parentId: null }),
|
data: { parentId: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
|
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
|
||||||
@@ -58,22 +67,22 @@ async function createTempDocument() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createTempMindmap(documentId, mindmapId) {
|
async function createTempMindmap(requestContext, documentId, mindmapId) {
|
||||||
await requestJson(`/api/mindmap/${documentId}/${mindmapId}`, {
|
await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
data: {
|
||||||
createOnly: true,
|
createOnly: true,
|
||||||
data: {
|
data: {
|
||||||
data: { text: "中心主题" },
|
data: { text: "中心主题" },
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupTempMindmap(documentId, mindmapId) {
|
async function cleanupTempMindmap(requestContext, documentId, mindmapId) {
|
||||||
try {
|
try {
|
||||||
await requestJson(`/api/mindmap/${documentId}/${mindmapId}`, {
|
await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -81,13 +90,34 @@ async function cleanupTempMindmap(documentId, mindmapId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function purgeTempDocument(documentId) {
|
async function purgeTempDocument(requestContext, documentId) {
|
||||||
await requestJson("/api/documents/purge", {
|
await requestJson(requestContext, "/api/documents/purge", {
|
||||||
method: "POST",
|
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) {
|
async function waitForMindmapInstance(page, mindmapId) {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(id) => Boolean(window.__mindmapInstancesById?.[id] || window.__mindmapInstance),
|
(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) {
|
async function readMindmapMetaAttrs(page) {
|
||||||
const fullscreen = page.locator("[data-testid=\"mindmap-fullscreen\"]");
|
const fullscreen = page.locator("[data-testid=\"mindmap-fullscreen\"]");
|
||||||
return {
|
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) {
|
function assertRouteMeta(meta, expected) {
|
||||||
assert(meta && typeof meta.requestId === "string" && meta.requestId, "缺少 meta.requestId");
|
assert(meta && typeof meta.requestId === "string" && meta.requestId, "缺少 meta.requestId");
|
||||||
assert(meta && typeof meta.traceId === "string" && meta.traceId, "缺少 meta.traceId");
|
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}`);
|
assert(meta.workspaceId === expected.workspaceId, `workspaceId 不一致:${meta.workspaceId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistInsertAndRename(page, mindmapId, childText) {
|
async function persistInsertAndRename(page, mindmapId) {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
({ currentMindmapId, nextChildText }) => {
|
({ currentMindmapId }) => {
|
||||||
const instance =
|
const instance =
|
||||||
window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance;
|
window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance;
|
||||||
if (!instance) {
|
if (!instance) {
|
||||||
@@ -164,41 +245,54 @@ async function persistInsertAndRename(page, mindmapId, childText) {
|
|||||||
throw new Error("插入子节点后未拿到快照");
|
throw new Error("插入子节点后未拿到快照");
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot.root.children[0].data.text = nextChildText;
|
const persist = window.__mindmapPersistById?.[currentMindmapId];
|
||||||
window.__mindmapPersistById?.[currentMindmapId]?.(snapshot);
|
if (!persist) {
|
||||||
|
throw new Error("未找到 mindmap 持久化回调");
|
||||||
|
}
|
||||||
|
persist(snapshot);
|
||||||
return {
|
return {
|
||||||
childText: snapshot.root.children[0].data.text,
|
childText: String(snapshot.root.children[0].data.text ?? ""),
|
||||||
childCount: snapshot.root.children.length,
|
childCount: snapshot.root.children.length,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
currentMindmapId: mindmapId,
|
currentMindmapId: mindmapId,
|
||||||
nextChildText: childText,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistDeleteChild(page, mindmapId) {
|
async function persistDeleteChild(page, mindmapId, childUid) {
|
||||||
return page.evaluate((currentMindmapId) => {
|
return page.evaluate(async ({ currentMindmapId, currentChildUid }) => {
|
||||||
const instance =
|
const instance =
|
||||||
window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance;
|
window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance;
|
||||||
if (!instance) {
|
if (!instance) {
|
||||||
throw new Error("未找到 mindmap 实例");
|
throw new Error("未找到 mindmap 实例");
|
||||||
}
|
}
|
||||||
|
const renderer = instance.renderer;
|
||||||
const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null;
|
const child =
|
||||||
if (!snapshot?.root) {
|
typeof renderer?.findNodeByUid === "function"
|
||||||
throw new Error("删除子节点时未拿到快照");
|
? renderer.findNodeByUid(currentChildUid)
|
||||||
|
: null;
|
||||||
|
if (!child) {
|
||||||
|
throw new Error("删除子节点时未找到目标节点");
|
||||||
}
|
}
|
||||||
|
renderer?.clearActiveNodeList?.();
|
||||||
snapshot.root.children = [];
|
renderer?.addNodeToActiveList?.(child, true);
|
||||||
window.__mindmapPersistById?.[currentMindmapId]?.(snapshot);
|
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 {
|
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) {
|
async function openOutlinePanel(page) {
|
||||||
@@ -207,17 +301,13 @@ async function openOutlinePanel(page) {
|
|||||||
await outlineButton.click();
|
await outlineButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runBrowserRegression(target) {
|
async function runBrowserRegression(page, requestContext, target) {
|
||||||
const uniqueSuffix = Date.now().toString();
|
const uniqueSuffix = Date.now().toString();
|
||||||
const mindmapId = `task021-${uniqueSuffix}`;
|
const mindmapId = `task021-${uniqueSuffix}`;
|
||||||
const childText = `task021-节点-${uniqueSuffix}`;
|
const defaultChildText = "二级节点";
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const page = await browser.newPage({
|
|
||||||
viewport: { width: 1440, height: 960 },
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createTempMindmap(target.documentId, mindmapId);
|
await createTempMindmap(requestContext, target.documentId, mindmapId);
|
||||||
|
|
||||||
const mindmapUrl = `${BASE_URL}/mindmap/${target.documentId}/${mindmapId}`;
|
const mindmapUrl = `${BASE_URL}/mindmap/${target.documentId}/${mindmapId}`;
|
||||||
await page.goto(mindmapUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
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 canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
await rootText.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await rootText.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
await waitForMindmapInstance(page, mindmapId);
|
await waitForMindmapInstance(page, mindmapId);
|
||||||
|
await waitForMindmapReady(page, mindmapId);
|
||||||
|
|
||||||
const initialMetaAttrs = await readMindmapMetaAttrs(page);
|
const initialMetaAttrs = await readMindmapMetaAttrs(page);
|
||||||
assert(initialMetaAttrs.documentId === target.documentId, "页面 data-document-id 不正确");
|
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.attachmentId === mindmapId, "页面 data-attachment-id 不正确");
|
||||||
assert(initialMetaAttrs.workspaceId === target.workspaceId, "页面 data-workspace-id 不正确");
|
assert(initialMetaAttrs.workspaceId === target.workspaceId, "页面 data-workspace-id 不正确");
|
||||||
|
|
||||||
const insertSaveResponsePromise = page.waitForResponse(
|
const insertMutation = await persistInsertAndRename(page, mindmapId);
|
||||||
(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);
|
|
||||||
assert(insertMutation.childCount === 1, `插入子节点后数量异常:${insertMutation.childCount}`);
|
assert(insertMutation.childCount === 1, `插入子节点后数量异常:${insertMutation.childCount}`);
|
||||||
assert(insertMutation.childText === childText, `插入子节点名称异常:${insertMutation.childText}`);
|
assert(insertMutation.childText, "插入子节点后名称为空");
|
||||||
|
|
||||||
const insertSaveResponse = await insertSaveResponsePromise;
|
const insertSaveMeta = await waitForMetaMutation(page, initialMetaAttrs);
|
||||||
const insertSavePayload = await insertSaveResponse.json();
|
assertRouteMeta(insertSaveMeta, {
|
||||||
assertRouteMeta(insertSavePayload.meta, {
|
|
||||||
documentId: target.documentId,
|
documentId: target.documentId,
|
||||||
mindmapId,
|
mindmapId,
|
||||||
workspaceId: target.workspaceId,
|
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 page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
await waitForMindmapInstance(page, mindmapId);
|
await waitForMindmapInstance(page, mindmapId);
|
||||||
await openOutlinePanel(page);
|
await waitForMindmapReady(page, mindmapId);
|
||||||
await page.getByRole("button", { name: new RegExp(childText) }).waitFor({
|
const beforeDeleteMeta = await readMindmapMetaAttrs(page);
|
||||||
state: "visible",
|
const insertSavedMindmap = await requestJson(requestContext, `/api/mindmap/${target.documentId}/${mindmapId}`);
|
||||||
timeout: UI_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const insertSavedMindmap = await requestJson(`/api/mindmap/${target.documentId}/${mindmapId}`);
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(insertSavedMindmap.data?.children) &&
|
Array.isArray(insertSavedMindmap.data?.children) &&
|
||||||
insertSavedMindmap.data.children.length === 1,
|
insertSavedMindmap.data.children.length === 1,
|
||||||
"刷新后导图子节点数量不正确",
|
"刷新后导图子节点数量不正确",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
insertSavedMindmap.data.children[0]?.data?.text === childText,
|
typeof insertSavedMindmap.data.children[0]?.data?.text === "string" &&
|
||||||
`刷新后导图子节点名称不正确:${insertSavedMindmap.data.children[0]?.data?.text}`,
|
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(
|
const deleteMutation = await persistDeleteChild(page, mindmapId, persistedChildUid);
|
||||||
(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);
|
|
||||||
assert(deleteMutation.childCount === 0, `删除子节点后数量异常:${deleteMutation.childCount}`);
|
assert(deleteMutation.childCount === 0, `删除子节点后数量异常:${deleteMutation.childCount}`);
|
||||||
|
|
||||||
const deleteSaveResponse = await deleteSaveResponsePromise;
|
const deleteSaveMeta = await waitForMetaMutation(page, beforeDeleteMeta);
|
||||||
const deleteSavePayload = await deleteSaveResponse.json();
|
assertRouteMeta(deleteSaveMeta, {
|
||||||
assertRouteMeta(deleteSavePayload.meta, {
|
|
||||||
documentId: target.documentId,
|
documentId: target.documentId,
|
||||||
mindmapId,
|
mindmapId,
|
||||||
workspaceId: target.workspaceId,
|
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 page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
await waitForMindmapInstance(page, mindmapId);
|
await waitForMindmapInstance(page, mindmapId);
|
||||||
|
await waitForMindmapReady(page, mindmapId);
|
||||||
await openOutlinePanel(page);
|
await openOutlinePanel(page);
|
||||||
|
|
||||||
const bodyText = await page.locator("body").innerText({ timeout: UI_TIMEOUT_MS });
|
await page.getByText("中心主题").first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
assert(bodyText.includes("中心主题"), "刷新后未渲染根节点");
|
|
||||||
assert(!bodyText.includes(childText), "删除子节点后页面仍残留旧节点文本");
|
|
||||||
|
|
||||||
const deleteSavedMindmap = await requestJson(`/api/mindmap/${target.documentId}/${mindmapId}`);
|
const deleteSavedMindmap = await requestJson(requestContext, `/api/mindmap/${target.documentId}/${mindmapId}`);
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(deleteSavedMindmap.data?.children) &&
|
Array.isArray(deleteSavedMindmap.data?.children) &&
|
||||||
deleteSavedMindmap.data.children.length === 0,
|
deleteSavedMindmap.data.children.length === 0,
|
||||||
"删除子节点后后端仍保留子节点",
|
"删除子节点后后端仍保留子节点",
|
||||||
);
|
);
|
||||||
|
const canvasText = await canvas.innerText({ timeout: UI_TIMEOUT_MS });
|
||||||
|
assert(!canvasText.includes(persistedChildText), "删除子节点后画布仍残留旧节点文本");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mindmapUrl,
|
mindmapUrl,
|
||||||
mindmapId,
|
mindmapId,
|
||||||
childText,
|
childText: persistedChildText,
|
||||||
initialMetaAttrs,
|
initialMetaAttrs,
|
||||||
insertMeta: insertSavePayload.meta,
|
insertMeta: insertSaveMeta,
|
||||||
deleteMeta: deleteSavePayload.meta,
|
deleteMeta: deleteSaveMeta,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await page.close();
|
await cleanupTempMindmap(requestContext, target.documentId, mindmapId);
|
||||||
await browser.close();
|
|
||||||
await cleanupTempMindmap(target.documentId, mindmapId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,11 +435,19 @@ async function main() {
|
|||||||
`首页探活失败:收到状态码 ${health.status}`,
|
`首页探活失败:收到状态码 ${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 regressionResult = null;
|
||||||
|
let caughtError = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
regressionResult = await runBrowserRegression(tempDocument);
|
await ensureAuthenticated(page, context.request);
|
||||||
|
tempDocument = await createTempDocument(context.request);
|
||||||
|
regressionResult = await runBrowserRegression(page, context.request, tempDocument);
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
@@ -362,8 +460,29 @@ async function main() {
|
|||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
caughtError = error;
|
||||||
} finally {
|
} 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: `<docs_search>{"query":"${uniqueTitle}","limit":5}</docs_search>`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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: `<docs_read>{"documentId":"${top.id}","maxChars":2500,"includeContent":false}</docs_read>`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -17,8 +17,9 @@ export async function requireUserId(ctx: any): Promise<string> {
|
|||||||
return String(userId);
|
return String(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDevAuthEnabled()) {
|
const devUserId = readEnv("DEV_USER_ID");
|
||||||
return readEnv("DEV_USER_ID") ?? "dev-user";
|
if (isDevAuthEnabled() || devUserId) {
|
||||||
|
return devUserId ?? "dev-user";
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("未登录");
|
throw new Error("未登录");
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"@blocknote/core": "^0.42.0",
|
"@blocknote/core": "^0.42.0",
|
||||||
"@blocknote/mantine": "0.42.0",
|
"@blocknote/mantine": "0.42.0",
|
||||||
"@blocknote/react": "^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/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"convex": "^1.31.4",
|
"convex": "^1.35.1",
|
||||||
"jszip": "3.10.1",
|
"jszip": "3.10.1",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
|
|||||||
Generated
+138
-33
@@ -21,8 +21,8 @@ importers:
|
|||||||
specifier: ^0.42.0
|
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)
|
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':
|
'@convex-dev/auth':
|
||||||
specifier: ^0.0.90
|
specifier: ^0.0.91
|
||||||
version: 0.0.90(@auth/core@0.37.0)(convex@1.31.4(react@19.2.0))(react@19.2.0)
|
version: 0.0.91(@auth/core@0.37.0)(convex@1.35.1(react@19.2.0))(react@19.2.0)
|
||||||
'@dnd-kit/core':
|
'@dnd-kit/core':
|
||||||
specifier: ^6.3.1
|
specifier: ^6.3.1
|
||||||
version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
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
|
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)
|
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:
|
convex:
|
||||||
specifier: ^1.31.4
|
specifier: ^1.35.1
|
||||||
version: 1.31.4(react@19.2.0)
|
version: 1.35.1(react@19.2.0)
|
||||||
jszip:
|
jszip:
|
||||||
specifier: 3.10.1
|
specifier: 3.10.1
|
||||||
version: 3.10.1
|
version: 3.10.1
|
||||||
@@ -290,6 +290,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
|
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
|
||||||
engines: {node: '>=6.9.0'}
|
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':
|
'@babel/template@7.27.2':
|
||||||
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -411,8 +415,8 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@convex-dev/auth@0.0.90':
|
'@convex-dev/auth@0.0.91':
|
||||||
resolution: {integrity: sha512-aqw88EB042HvnaF4wcf/f/wTocmT2Bus2VDQRuV79cM0+8kORM0ICK/ByZ6XsHgQ9qr6TmidNbXm6QAgndrdpQ==}
|
resolution: {integrity: sha512-wLD4hszo3IhhMkwPs6ozWf0cUauwmhOvjUVn0g//kC338n/jApOjeDYWKCrn/qYUkveyDsbag5zrY8mVzA09Qg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@auth/core': ^0.37.0
|
'@auth/core': ^0.37.0
|
||||||
@@ -486,6 +490,9 @@ packages:
|
|||||||
'@emnapi/runtime@1.7.1':
|
'@emnapi/runtime@1.7.1':
|
||||||
resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
|
resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.9.2':
|
||||||
|
resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
|
||||||
|
|
||||||
'@emnapi/wasi-threads@1.1.0':
|
'@emnapi/wasi-threads@1.1.0':
|
||||||
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
|
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
|
||||||
|
|
||||||
@@ -993,24 +1000,45 @@ packages:
|
|||||||
'@floating-ui/core@1.7.3':
|
'@floating-ui/core@1.7.3':
|
||||||
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
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':
|
'@floating-ui/dom@1.7.4':
|
||||||
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
|
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
|
||||||
|
|
||||||
|
'@floating-ui/dom@1.7.6':
|
||||||
|
resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
|
||||||
|
|
||||||
'@floating-ui/react-dom@2.1.6':
|
'@floating-ui/react-dom@2.1.6':
|
||||||
resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==}
|
resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=16.8.0'
|
react: '>=16.8.0'
|
||||||
react-dom: '>=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':
|
'@floating-ui/react@0.27.16':
|
||||||
resolution: {integrity: sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==}
|
resolution: {integrity: sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=17.0.0'
|
react: '>=17.0.0'
|
||||||
react-dom: '>=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':
|
'@floating-ui/utils@0.2.10':
|
||||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||||
|
|
||||||
|
'@floating-ui/utils@0.2.11':
|
||||||
|
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
|
||||||
|
|
||||||
'@hello-pangea/dnd@18.0.1':
|
'@hello-pangea/dnd@18.0.1':
|
||||||
resolution: {integrity: sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==}
|
resolution: {integrity: sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2048,8 +2076,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
|
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@speed-highlight/core@1.2.14':
|
'@speed-highlight/core@1.2.15':
|
||||||
resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==}
|
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
|
||||||
|
|
||||||
'@standard-schema/spec@1.0.0':
|
'@standard-schema/spec@1.0.0':
|
||||||
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||||
@@ -2859,19 +2887,22 @@ packages:
|
|||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
convex@1.31.4:
|
convex@1.35.1:
|
||||||
resolution: {integrity: sha512-iDm283Gb/CFRb30cvhH6Z9qlYof6dhtin415FarKUKB3K7gumO0rn8snY0CTvUrThV3UnCtttbuL/1oY7LscyA==}
|
resolution: {integrity: sha512-g23KrTjBiXqRHzWIN0PVFagKjrmFxWUaOSiBsAWPTpXX2rXl0L1F4PR0YpAcMJEzMgfZR9AGymJvLTM+KA6lsQ==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=7.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=7.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@auth0/auth0-react': ^2.0.1
|
'@auth0/auth0-react': ^2.0.1
|
||||||
'@clerk/clerk-react': ^4.12.8 || ^5.0.0
|
'@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
|
react: ^18.0.0 || ^19.0.0-0 || ^19.0.0
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
'@auth0/auth0-react':
|
'@auth0/auth0-react':
|
||||||
optional: true
|
optional: true
|
||||||
'@clerk/clerk-react':
|
'@clerk/clerk-react':
|
||||||
optional: true
|
optional: true
|
||||||
|
'@clerk/react':
|
||||||
|
optional: true
|
||||||
react:
|
react:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -3746,8 +3777,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
|
resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
is-network-error@1.3.0:
|
is-network-error@1.3.1:
|
||||||
resolution: {integrity: sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==}
|
resolution: {integrity: sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
is-number-object@1.1.1:
|
is-number-object@1.1.1:
|
||||||
@@ -4326,6 +4357,9 @@ packages:
|
|||||||
oauth4webapi@3.8.3:
|
oauth4webapi@3.8.3:
|
||||||
resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==}
|
resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==}
|
||||||
|
|
||||||
|
oauth4webapi@3.8.5:
|
||||||
|
resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -4465,8 +4499,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
prettier@3.8.0:
|
prettier@3.8.3:
|
||||||
resolution: {integrity: sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==}
|
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -4625,8 +4659,8 @@ packages:
|
|||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
react-number-format@5.4.4:
|
react-number-format@5.4.5:
|
||||||
resolution: {integrity: sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==}
|
resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
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
|
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':
|
'@types/react':
|
||||||
optional: true
|
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:
|
react-style-singleton@2.2.3:
|
||||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -4818,6 +4862,11 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
semver@7.7.4:
|
||||||
|
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
server-only@0.0.1:
|
server-only@0.0.1:
|
||||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||||
|
|
||||||
@@ -4997,6 +5046,9 @@ packages:
|
|||||||
tabbable@6.3.0:
|
tabbable@6.3.0:
|
||||||
resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==}
|
resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==}
|
||||||
|
|
||||||
|
tabbable@6.4.0:
|
||||||
|
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
|
||||||
|
|
||||||
tailwind-merge@3.4.0:
|
tailwind-merge@3.4.0:
|
||||||
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
|
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
|
||||||
|
|
||||||
@@ -5665,6 +5717,8 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/runtime@7.28.4': {}
|
'@babel/runtime@7.28.4': {}
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.2': {}
|
||||||
|
|
||||||
'@babel/template@7.27.2':
|
'@babel/template@7.27.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.27.1
|
'@babel/code-frame': 7.27.1
|
||||||
@@ -5849,18 +5903,18 @@ snapshots:
|
|||||||
'@cloudflare/workerd-windows-64@1.20251210.0':
|
'@cloudflare/workerd-windows-64@1.20251210.0':
|
||||||
optional: true
|
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:
|
dependencies:
|
||||||
'@auth/core': 0.37.0
|
'@auth/core': 0.37.0
|
||||||
'@oslojs/crypto': 1.0.1
|
'@oslojs/crypto': 1.0.1
|
||||||
'@oslojs/encoding': 1.1.0
|
'@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
|
cookie: 1.1.1
|
||||||
is-network-error: 1.3.0
|
is-network-error: 1.3.1
|
||||||
jose: 5.10.0
|
jose: 5.10.0
|
||||||
jwt-decode: 4.0.0
|
jwt-decode: 4.0.0
|
||||||
lucia: 3.2.2
|
lucia: 3.2.2
|
||||||
oauth4webapi: 3.8.3
|
oauth4webapi: 3.8.5
|
||||||
path-to-regexp: 6.3.0
|
path-to-regexp: 6.3.0
|
||||||
server-only: 0.0.1
|
server-only: 0.0.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -5928,6 +5982,11 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.9.2':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@emnapi/wasi-threads@1.1.0':
|
'@emnapi/wasi-threads@1.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -6215,17 +6274,32 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/utils': 0.2.10
|
'@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':
|
'@floating-ui/dom@1.7.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/core': 1.7.3
|
'@floating-ui/core': 1.7.3
|
||||||
'@floating-ui/utils': 0.2.10
|
'@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)':
|
'@floating-ui/react-dom@2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/dom': 1.7.4
|
'@floating-ui/dom': 1.7.4
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
react-dom: 19.2.0(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)':
|
'@floating-ui/react@0.27.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
'@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)
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
tabbable: 6.3.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.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)':
|
'@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:
|
dependencies:
|
||||||
'@babel/runtime': 7.28.4
|
'@babel/runtime': 7.28.4
|
||||||
@@ -6424,7 +6508,7 @@ snapshots:
|
|||||||
|
|
||||||
'@img/sharp-wasm32@0.33.5':
|
'@img/sharp-wasm32@0.33.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/runtime': 1.7.1
|
'@emnapi/runtime': 1.9.2
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/sharp-wasm32@0.34.5':
|
'@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)':
|
'@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:
|
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)
|
'@mantine/hooks': 8.3.8(react@19.2.0)
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
react-dom: 19.2.0(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-number-format: 5.4.5(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-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)
|
react-textarea-autosize: 8.5.9(@types/react@19.2.5)(react@19.2.0)
|
||||||
type-fest: 4.41.0
|
type-fest: 4.41.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -7116,7 +7200,7 @@ snapshots:
|
|||||||
|
|
||||||
'@sindresorhus/is@7.2.0': {}
|
'@sindresorhus/is@7.2.0': {}
|
||||||
|
|
||||||
'@speed-highlight/core@1.2.14': {}
|
'@speed-highlight/core@1.2.15': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.0.0': {}
|
'@standard-schema/spec@1.0.0': {}
|
||||||
|
|
||||||
@@ -7963,12 +8047,16 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
convex@1.31.4(react@19.2.0):
|
convex@1.35.1(react@19.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.27.0
|
esbuild: 0.27.0
|
||||||
prettier: 3.8.0
|
prettier: 3.8.3
|
||||||
|
ws: 8.18.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
cookie@0.5.0: {}
|
cookie@0.5.0: {}
|
||||||
|
|
||||||
@@ -9074,7 +9162,7 @@ snapshots:
|
|||||||
|
|
||||||
is-negative-zero@2.0.3: {}
|
is-negative-zero@2.0.3: {}
|
||||||
|
|
||||||
is-network-error@1.3.0: {}
|
is-network-error@1.3.1: {}
|
||||||
|
|
||||||
is-number-object@1.1.1:
|
is-number-object@1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9911,6 +9999,8 @@ snapshots:
|
|||||||
|
|
||||||
oauth4webapi@3.8.3: {}
|
oauth4webapi@3.8.3: {}
|
||||||
|
|
||||||
|
oauth4webapi@3.8.5: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
@@ -10057,7 +10147,7 @@ snapshots:
|
|||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
prettier@3.8.0: {}
|
prettier@3.8.3: {}
|
||||||
|
|
||||||
pretty-format@3.8.0: {}
|
pretty-format@3.8.0: {}
|
||||||
|
|
||||||
@@ -10230,7 +10320,7 @@ snapshots:
|
|||||||
|
|
||||||
react-is@16.13.1: {}
|
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:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
react-dom: 19.2.0(react@19.2.0)
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
@@ -10263,6 +10353,17 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.5
|
'@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):
|
react-style-singleton@2.2.3(@types/react@19.2.5)(react@19.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
get-nonce: 1.0.1
|
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):
|
react-textarea-autosize@8.5.9(@types/react@19.2.5)(react@19.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.28.4
|
'@babel/runtime': 7.29.2
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
use-composed-ref: 1.4.0(@types/react@19.2.5)(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)
|
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.3: {}
|
||||||
|
|
||||||
|
semver@7.7.4: {}
|
||||||
|
|
||||||
server-only@0.0.1: {}
|
server-only@0.0.1: {}
|
||||||
|
|
||||||
set-cookie-parser@2.7.2: {}
|
set-cookie-parser@2.7.2: {}
|
||||||
@@ -10519,7 +10622,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
color: 4.2.3
|
color: 4.2.3
|
||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
semver: 7.7.3
|
semver: 7.7.4
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-darwin-arm64': 0.33.5
|
'@img/sharp-darwin-arm64': 0.33.5
|
||||||
'@img/sharp-darwin-x64': 0.33.5
|
'@img/sharp-darwin-x64': 0.33.5
|
||||||
@@ -10769,6 +10872,8 @@ snapshots:
|
|||||||
|
|
||||||
tabbable@6.3.0: {}
|
tabbable@6.3.0: {}
|
||||||
|
|
||||||
|
tabbable@6.4.0: {}
|
||||||
|
|
||||||
tailwind-merge@3.4.0: {}
|
tailwind-merge@3.4.0: {}
|
||||||
|
|
||||||
tailwindcss@4.1.17: {}
|
tailwindcss@4.1.17: {}
|
||||||
@@ -11292,7 +11397,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@poppinss/colors': 4.1.6
|
'@poppinss/colors': 4.1.6
|
||||||
'@poppinss/dumper': 0.6.5
|
'@poppinss/dumper': 0.6.5
|
||||||
'@speed-highlight/core': 1.2.14
|
'@speed-highlight/core': 1.2.15
|
||||||
cookie: 1.1.1
|
cookie: 1.1.1
|
||||||
youch-core: 0.3.3
|
youch-core: 0.3.3
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Sidebar } from "@/components/sidebar/sidebar";
|
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||||
import { GlobalAiAgentHost } from "@/components/ai-agent/GlobalAiAgentHost";
|
|
||||||
import { Breadcrumb } from "@/components/breadcrumb";
|
import { Breadcrumb } from "@/components/breadcrumb";
|
||||||
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
|
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
|
||||||
import { SearchPalette } from "@/components/search/search-palette";
|
import { SearchPalette } from "@/components/search/search-palette";
|
||||||
@@ -30,7 +29,6 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
</header>
|
</header>
|
||||||
<main className="flex-1 overflow-hidden bg-white">{children}</main>
|
<main className="flex-1 overflow-hidden bg-white">{children}</main>
|
||||||
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
|
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
|
||||||
<GlobalAiAgentHost />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1110,7 +1110,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const finalOutline = plan
|
const finalOutline = plan
|
||||||
? plan.chapters.flatMap((chapter, chapterIndex) => [
|
? plan.chapters.flatMap((chapter) => [
|
||||||
{
|
{
|
||||||
title: chapter.title,
|
title: chapter.title,
|
||||||
level: 1,
|
level: 1,
|
||||||
@@ -1144,7 +1144,8 @@ export async function POST(request: Request) {
|
|||||||
pageLinkPattern,
|
pageLinkPattern,
|
||||||
outline: finalOutline,
|
outline: finalOutline,
|
||||||
};
|
};
|
||||||
const result = await executeRustBridgeTool<{
|
// 说明:导图树构建已经交给 Rust runtime,这里只保留 PDF 提取与 transport 壳。
|
||||||
|
const rustResult = await executeRustBridgeTool<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
data?: unknown;
|
data?: unknown;
|
||||||
meta?: { title?: string | null; outlineCount?: number | null } | null;
|
meta?: { title?: string | null; outlineCount?: number | null } | null;
|
||||||
@@ -1156,10 +1157,16 @@ export async function POST(request: Request) {
|
|||||||
data: payload,
|
data: payload,
|
||||||
mode: "result",
|
mode: "result",
|
||||||
});
|
});
|
||||||
const mindmapData = (result as any)?.data ?? {
|
const mindmapData =
|
||||||
data: { text: pdfTitle },
|
rustResult.result && typeof rustResult.result === "object" && !Array.isArray(rustResult.result) && "data" in rustResult.result
|
||||||
children: [],
|
? ((rustResult.result as { data?: unknown }).data ?? {
|
||||||
};
|
data: { text: pdfTitle },
|
||||||
|
children: [],
|
||||||
|
})
|
||||||
|
: {
|
||||||
|
data: { text: pdfTitle },
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
mindmapData,
|
mindmapData,
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import { api } from "@/lib/convex/api";
|
import {
|
||||||
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
|
buildDocumentBridgeContextWithActor,
|
||||||
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
|
buildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -13,39 +19,47 @@ interface EmptyTrashPayload {
|
|||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
const { client } = await getAuthedConvexClient();
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
|
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = buildDocumentBridgeContextWithActor({
|
|
||||||
request,
|
|
||||||
actor: {
|
|
||||||
actorType: "service",
|
|
||||||
actorId: "mindmap-trash-empty",
|
|
||||||
sessionId: null,
|
|
||||||
},
|
|
||||||
workspaceId,
|
|
||||||
source: {
|
|
||||||
channel: "mindmap-trash-route",
|
|
||||||
client: "wolai-frontend",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await executeRustBridgeTool({
|
|
||||||
context,
|
|
||||||
toolName: "mindmap_empty_trash",
|
|
||||||
invocationKind: "command",
|
|
||||||
args: { workspaceId },
|
|
||||||
data: { workspaceId, source: "mindmap-trash-empty" },
|
|
||||||
mode: "result",
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await client.mutation(api.mindmaps.emptyTrashByWorkspace, { workspaceId });
|
const 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 });
|
return NextResponse.json({ ok: true, removed: result?.deletedCount ?? 0 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
return documentBridgeErrorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import { api } from "@/lib/convex/api";
|
|
||||||
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
|
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
|
||||||
import {
|
import {
|
||||||
buildDocumentBridgeContextWithActor,
|
buildDocumentBridgeContextWithActor,
|
||||||
@@ -172,7 +171,43 @@ export async function DELETE(
|
|||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
try {
|
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({
|
return NextResponse.json({
|
||||||
...(result ?? { ok: true }),
|
...(result ?? { ok: true }),
|
||||||
meta: {
|
meta: {
|
||||||
@@ -186,7 +221,7 @@ export async function DELETE(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
return documentBridgeErrorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,19 +242,43 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (action === "purge") {
|
const context = buildDocumentBridgeContextWithActor({
|
||||||
const result = await client.mutation(api.mindmaps.purge, { docId, mindmapId });
|
request,
|
||||||
return NextResponse.json({
|
actor: {
|
||||||
...(result ?? { ok: true }),
|
actorType: "user",
|
||||||
meta: buildMindmapRouteMeta(request, {
|
actorId: auth.userId,
|
||||||
workspaceId: result?.workspace_id ?? null,
|
sessionId: null,
|
||||||
documentId: docId,
|
},
|
||||||
mindmapId,
|
workspaceId: null,
|
||||||
ownerUserId: auth.userId,
|
source: {
|
||||||
}),
|
channel: "mindmap-route",
|
||||||
});
|
client: "wolai-frontend",
|
||||||
}
|
},
|
||||||
const result = await client.mutation(api.mindmaps.restore, { docId, mindmapId });
|
});
|
||||||
|
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({
|
return NextResponse.json({
|
||||||
...(result ?? { ok: true }),
|
...(result ?? { ok: true }),
|
||||||
meta: {
|
meta: {
|
||||||
@@ -229,13 +288,11 @@ export async function PATCH(
|
|||||||
mindmapId,
|
mindmapId,
|
||||||
ownerUserId: auth.userId,
|
ownerUserId: auth.userId,
|
||||||
}),
|
}),
|
||||||
updatedAt: result?.updated_at ?? null,
|
updatedAt: action === "restore" ? result?.updated_at ?? null : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = (error as Error).message ?? "操作失败";
|
return documentBridgeErrorResponse(error);
|
||||||
const status = msg.includes("未找到") ? 404 : 400;
|
|
||||||
return NextResponse.json({ error: msg }, { status });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import { api } from "@/lib/convex/api";
|
|
||||||
import {
|
import {
|
||||||
buildDocumentBridgeContextWithActor,
|
buildDocumentBridgeContextWithActor,
|
||||||
buildDocumentCommandEnvelope,
|
buildDocumentCommandEnvelope,
|
||||||
@@ -179,19 +178,59 @@ export async function DELETE(
|
|||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
const mindmapId = `legacy-${docId}`;
|
const mindmapId = `legacy-${docId}`;
|
||||||
const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId });
|
try {
|
||||||
return NextResponse.json({
|
const context = buildDocumentBridgeContextWithActor({
|
||||||
...(result ?? { ok: true }),
|
request,
|
||||||
meta: {
|
actor: {
|
||||||
...buildMindmapRouteMeta(request, {
|
actorType: "user",
|
||||||
workspaceId: result?.workspace_id ?? null,
|
actorId: auth.userId,
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
workspaceId: null,
|
||||||
|
source: {
|
||||||
|
channel: "mindmap-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "mindmaps.delete",
|
||||||
|
payload: {
|
||||||
documentId: docId,
|
documentId: docId,
|
||||||
mindmapId,
|
mindmapId,
|
||||||
ownerUserId: auth.userId,
|
},
|
||||||
}),
|
context,
|
||||||
deletedAt: result?.deleted_at ?? null,
|
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 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Bot,
|
|
||||||
Command,
|
Command,
|
||||||
DatabaseZap,
|
DatabaseZap,
|
||||||
FileSearch,
|
FileSearch,
|
||||||
@@ -12,16 +11,16 @@ import {
|
|||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Search,
|
Search,
|
||||||
SendHorizontal,
|
SendHorizontal,
|
||||||
Sparkles,
|
|
||||||
SquareStop,
|
SquareStop,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 ChatMsg = { role: "user" | "assistant"; content: string };
|
||||||
type ToolLog =
|
type ToolLog =
|
||||||
@@ -44,6 +43,7 @@ type ToolSetChip = {
|
|||||||
const DEFAULT_PROMPT = "请给出 gemini-3 tokens 价格,并提供来源链接。";
|
const DEFAULT_PROMPT = "请给出 gemini-3 tokens 价格,并提供来源链接。";
|
||||||
const MIN_PANEL_AGENT_STEPS = 1;
|
const MIN_PANEL_AGENT_STEPS = 1;
|
||||||
const MAX_PANEL_AGENT_STEPS = 24;
|
const MAX_PANEL_AGENT_STEPS = 24;
|
||||||
|
const DEFAULT_PREFS = { provider: "online" as AiProvider, model: "", maxSteps: 10 };
|
||||||
|
|
||||||
const TOOLSET_CHIPS: ToolSetChip[] = [
|
const TOOLSET_CHIPS: ToolSetChip[] = [
|
||||||
{ id: "toolset.readonly", title: "联网检索", description: "SearxNG 可追溯来源" },
|
{ id: "toolset.readonly", title: "联网检索", description: "SearxNG 可追溯来源" },
|
||||||
@@ -54,62 +54,13 @@ const TOOLSET_CHIPS: ToolSetChip[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const CAPABILITY_ITEMS: CapabilityItem[] = [
|
const CAPABILITY_ITEMS: CapabilityItem[] = [
|
||||||
{
|
{ title: "联网检索", description: "搜索公开网页并返回来源链接,适合查价格、规格、资料。", icon: Search },
|
||||||
title: "联网检索",
|
{ title: "LightRAG", description: "结合知识库做语义检索与生成,适合已有资料沉淀场景。", icon: DatabaseZap },
|
||||||
description: "搜索公开网页并返回来源链接,适合查价格、规格、资料。",
|
{ title: "跨页面文档", description: "搜索并读取当前工作区中的文档内容,用于对比和归纳。", icon: FileSearch },
|
||||||
icon: Search,
|
{ title: "图片 OCR", description: "若当前请求已带图片或附件,可读取其中的 OCR 文字内容。", icon: Image },
|
||||||
},
|
{ title: "斜杠命令", description: "执行受控写操作,例如创建文档或改名,需要明确确认。", icon: Command },
|
||||||
{
|
|
||||||
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) => {
|
const formatJson = (value: unknown) => {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(value, null, 2);
|
||||||
@@ -125,14 +76,10 @@ const isAbortLikeError = (error: unknown) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clampStep = (value: number) => {
|
const clampStep = (value: number) => Math.min(Math.max(value, MIN_PANEL_AGENT_STEPS), MAX_PANEL_AGENT_STEPS);
|
||||||
return Math.min(Math.max(value, MIN_PANEL_AGENT_STEPS), MAX_PANEL_AGENT_STEPS);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLogToneClass = (log: ToolLog) => {
|
const getLogToneClass = (log: ToolLog) => {
|
||||||
if (log.type === "error") {
|
if (log.type === "error") return "border-red-500/30 bg-red-500/10";
|
||||||
return "border-red-500/30 bg-red-500/10";
|
|
||||||
}
|
|
||||||
if (log.type === "tool_result") {
|
if (log.type === "tool_result") {
|
||||||
return log.ok ? "border-emerald-500/20 bg-emerald-500/10" : "border-amber-500/25 bg-amber-500/10";
|
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 "运行错误";
|
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 } = {}) {
|
export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
|
||||||
const [input, setInput] = useState(DEFAULT_PROMPT);
|
const [input, setInput] = useState(DEFAULT_PROMPT);
|
||||||
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
||||||
const [logs, setLogs] = useState<ToolLog[]>([]);
|
const [logs, setLogs] = useState<ToolLog[]>([]);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [maxSteps, setMaxSteps] = useState(10);
|
|
||||||
const [showLogs, setShowLogs] = useState(true);
|
const [showLogs, setShowLogs] = useState(true);
|
||||||
|
const [aiProvider, setAiProvider] = useState<AiProvider>(DEFAULT_PREFS.provider);
|
||||||
|
const [aiModel, setAiModel] = useState(DEFAULT_PREFS.model);
|
||||||
|
const [maxSteps, setMaxSteps] = useState(DEFAULT_PREFS.maxSteps);
|
||||||
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const messageEndRef = useRef<HTMLDivElement | null>(null);
|
const messageEndRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -161,6 +123,17 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
|
|||||||
const assistantCount = messages.filter((message) => message.role === "assistant").length;
|
const assistantCount = messages.filter((message) => message.role === "assistant").length;
|
||||||
const userCount = messages.length - assistantCount;
|
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(() => {
|
useEffect(() => {
|
||||||
messageEndRef.current?.scrollIntoView({ block: "end" });
|
messageEndRef.current?.scrollIntoView({ block: "end" });
|
||||||
}, [messages, logs, showLogs]);
|
}, [messages, logs, showLogs]);
|
||||||
@@ -171,14 +144,10 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
|
|||||||
setRunning(false);
|
setRunning(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreDefaultPrompt = () => {
|
const restoreDefaultPrompt = () => setInput(DEFAULT_PROMPT);
|
||||||
setInput(DEFAULT_PROMPT);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearConversation = () => {
|
const clearConversation = () => {
|
||||||
if (running) {
|
if (running) stop();
|
||||||
stop();
|
|
||||||
}
|
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setLogs([]);
|
setLogs([]);
|
||||||
};
|
};
|
||||||
@@ -210,13 +179,18 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
|
|||||||
mode: "auto",
|
mode: "auto",
|
||||||
toolSets: TOOLSET_CHIPS.map((chip) => chip.id),
|
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) {
|
if (!res.ok) {
|
||||||
const j = (await res.json().catch(() => null)) as unknown;
|
const j = (await res.json().catch(() => null)) as unknown;
|
||||||
const err =
|
const err = typeof j === "object" && j && "error" in j ? String((j as Record<string, unknown>).error ?? "") : "";
|
||||||
typeof j === "object" && j && "error" in j ? String((j as Record<string, unknown>).error ?? "") : "";
|
|
||||||
throw new Error(err || `HTTP ${res.status}`);
|
throw new Error(err || `HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,9 +256,7 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted || isAbortLikeError(error)) {
|
if (controller.signal.aborted || isAbortLikeError(error)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
setLogs((prev) => [...prev, { type: "error", message }]);
|
setLogs((prev) => [...prev, { type: "error", message }]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -293,340 +265,274 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const logSidebar = showLogs ? (
|
||||||
<section className="flex h-[calc(100vh-112px)] min-h-[720px] w-full overflow-hidden rounded-[28px] border border-white/10 bg-[#070b14] text-white shadow-[0_24px_80px_rgba(0,0,0,0.45)]">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="border-b border-white/8 px-4 py-4">
|
||||||
<header className="flex items-center gap-4 border-b border-white/8 px-5 py-4">
|
<div className="text-sm font-medium text-white">活动轨迹</div>
|
||||||
<div className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-sky-400/30 bg-sky-400/10 text-sky-100">
|
<div className="mt-1 text-xs text-white/45">前端已经退为桥接层,这里只显示回流事件。</div>
|
||||||
<Bot className="h-5 w-5" />
|
</div>
|
||||||
</div>
|
<div className="flex min-h-0 flex-1 flex-col gap-3 px-4 py-4">
|
||||||
|
{logs.length === 0 ? <div className="text-sm text-white/45">暂无工具活动</div> : null}
|
||||||
<div className="min-w-0">
|
{logs.map((log, index) => (
|
||||||
<div className="flex items-center gap-2">
|
<article key={`${log.type}-${index}`} className={`rounded-2xl border p-3 ${getLogToneClass(log)}`}>
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-sky-200/70">MNOTE</span>
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="h-1 w-1 rounded-full bg-white/25" />
|
<div className="text-xs font-medium tracking-wide text-white/82">{getLogLabel(log)}</div>
|
||||||
<span className="text-sm text-white/60">全局 AI</span>
|
{log.type !== "error" ? <div className="text-[11px] text-white/40">{log.id}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
<div className="mt-2 text-sm font-medium text-white">{log.type === "error" ? log.message : log.tool}</div>
|
||||||
<h1 className="text-lg font-semibold tracking-tight text-white">全局 AI</h1>
|
{log.type === "tool_result" ? (
|
||||||
<Badge className="border-sky-400/25 bg-sky-400/10 text-sky-100 hover:bg-sky-400/10" variant="outline">
|
<div className="mt-1 text-xs text-white/45">{log.ok ? "成功" : "失败"} · {log.ms}ms</div>
|
||||||
自动工具编排
|
) : null}
|
||||||
|
{log.type === "tool_call" ? (
|
||||||
|
<pre className="mt-3 whitespace-pre-wrap text-xs leading-6 text-white/70">{formatJson(log.args)}</pre>
|
||||||
|
) : null}
|
||||||
|
{log.type === "tool_result" ? (
|
||||||
|
<pre className="mt-3 whitespace-pre-wrap text-xs leading-6 text-white/70">{formatJson(log.result)}</pre>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AiBridgePanel
|
||||||
|
title="全局 AI"
|
||||||
|
subtitle="实验入口"
|
||||||
|
status={`${running ? "运行中" : "待命"} · ${getProviderLabel(aiProvider)}`}
|
||||||
|
onClose={onClose}
|
||||||
|
secondaryActions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="切换工具活动面板"
|
||||||
|
title={showLogs ? "隐藏工具活动面板" : "显示工具活动面板"}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
|
onClick={() => setShowLogs((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{showLogs ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="恢复示例问题"
|
||||||
|
title="恢复示例问题"
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
|
onClick={restoreDefaultPrompt}
|
||||||
|
>
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="清空对话"
|
||||||
|
title="清空对话"
|
||||||
|
disabled={!canClear}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
|
onClick={clearConversation}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="停止运行"
|
||||||
|
title="停止本轮运行"
|
||||||
|
disabled={!running}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
|
onClick={stop}
|
||||||
|
>
|
||||||
|
<SquareStop className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
sidebar={logSidebar}
|
||||||
|
scrollBody={false}
|
||||||
|
className="h-[calc(100vh-24px)] min-h-0 rounded-[24px] border-white/10 shadow-none"
|
||||||
|
>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="border-b border-white/8 px-5 py-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-[0.22em] text-white/45">当前能力</div>
|
||||||
|
<div className="mt-2 text-sm text-white/65">保留 MNOTE 当前真实工具,不再在前端继续扩张编排层。</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
||||||
|
用户 {userCount}
|
||||||
|
</Badge>
|
||||||
|
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
||||||
|
AI {assistantCount}
|
||||||
|
</Badge>
|
||||||
|
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
||||||
|
活动 {logs.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
{TOOLSET_CHIPS.map((chip) => (
|
||||||
{running ? "运行中" : "待命"}
|
<Badge
|
||||||
</Badge>
|
key={chip.id}
|
||||||
{onClose ? (
|
variant="outline"
|
||||||
|
className="rounded-full border-white/10 bg-white/[0.03] px-3 py-1.5 text-white/78 hover:bg-white/[0.03]"
|
||||||
|
title={chip.description}
|
||||||
|
>
|
||||||
|
{chip.title}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="mx-auto flex max-w-4xl flex-col gap-4 px-5 py-5">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="max-w-3xl rounded-[24px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_10px_40px_rgba(0,0,0,0.24)]">
|
||||||
|
<div className="text-base font-semibold text-white">你好,我是 MNOTE 全局 AI。</div>
|
||||||
|
<div className="mt-2 text-sm leading-7 text-white/72">我会基于当前已接入的工具完成可追溯的检索、阅读与受控写入任务:</div>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
{CAPABILITY_ITEMS.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<div key={item.title} className="rounded-2xl border border-white/8 bg-black/15 p-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-white">
|
||||||
|
<Icon className="h-4 w-4 text-sky-200/80" />
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs leading-6 text-white/58">{item.description}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-white/70">请输入你的目标,我会在允许的工具范围内完成分析,并把活动轨迹放到右侧。</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{messages.map((message, index) => {
|
||||||
|
const isUser = message.role === "user";
|
||||||
|
return (
|
||||||
|
<article key={`${message.role}-${index}`} className={`flex gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
|
||||||
|
<div
|
||||||
|
className={`max-w-[min(760px,92%)] rounded-[22px] border px-4 py-3 text-sm leading-7 shadow-[0_10px_30px_rgba(0,0,0,0.16)] ${
|
||||||
|
isUser ? "border-sky-400/25 bg-sky-500/15 text-sky-50" : "border-white/10 bg-white/[0.04] text-white/90"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`mb-2 text-xs ${isUser ? "text-sky-100/70" : "text-white/45"}`}>{isUser ? "我" : "AI"}</div>
|
||||||
|
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div ref={messageEndRef} />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-white/8 px-5 py-4">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{TOOLSET_CHIPS.map((chip) => (
|
||||||
|
<div key={`${chip.id}-summary`} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5">
|
||||||
|
<span className="text-xs font-medium text-white/82">{chip.title}</span>
|
||||||
|
<span className="text-xs text-white/42">{chip.description}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs text-white/68">
|
||||||
|
最大步数
|
||||||
|
<input
|
||||||
|
className="w-14 rounded-md border border-white/10 bg-black/20 px-2 py-1 text-right text-white outline-none"
|
||||||
|
type="number"
|
||||||
|
min={MIN_PANEL_AGENT_STEPS}
|
||||||
|
max={MAX_PANEL_AGENT_STEPS}
|
||||||
|
step={1}
|
||||||
|
value={maxSteps}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
if (!Number.isFinite(value)) return;
|
||||||
|
setMaxSteps(clampStep(Math.floor(value)));
|
||||||
|
}}
|
||||||
|
disabled={running}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-full border border-white/10 bg-white/[0.03] px-3 text-xs text-white outline-none"
|
||||||
|
value={aiProvider}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = String(e.target.value || "").trim();
|
||||||
|
if (value === "online" || value === "local" || value === "ollama" || value === "codex") {
|
||||||
|
setAiProvider(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAiProvider("online");
|
||||||
|
}}
|
||||||
|
disabled={running}
|
||||||
|
>
|
||||||
|
<option value="online">Hermes</option>
|
||||||
|
<option value="local">本地</option>
|
||||||
|
<option value="ollama">Ollama</option>
|
||||||
|
<option value="codex">Codex</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="h-9 w-44 rounded-full border border-white/10 bg-white/[0.03] px-3 text-xs text-white outline-none placeholder:text-white/30"
|
||||||
|
value={aiModel}
|
||||||
|
onChange={(e) => setAiModel(e.target.value)}
|
||||||
|
placeholder={aiProvider === "codex" ? "Codex 无需模型" : "模型(可选)"}
|
||||||
|
disabled={running || aiProvider === "codex"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="输入问题,Enter 发送,Shift+Enter 换行"
|
||||||
|
className="min-h-[140px] rounded-[24px] border-white/10 bg-white/[0.03] px-4 py-4 pb-16 pr-40 text-sm leading-7 text-white placeholder:text-white/30"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (canSend) void send();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute bottom-4 left-4 text-xs text-white/38">Enter 发送,Shift+Enter 换行</div>
|
||||||
|
|
||||||
|
{running ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="secondary"
|
||||||
size="icon-sm"
|
className="absolute bottom-4 right-16 rounded-xl border border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.12]"
|
||||||
aria-label="关闭全局 AI"
|
onClick={stop}
|
||||||
title="关闭"
|
|
||||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
停止
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
disabled={!canSend}
|
||||||
size="icon-sm"
|
aria-label="发送消息"
|
||||||
aria-label="切换工具活动面板"
|
title="发送"
|
||||||
title={showLogs ? "隐藏工具活动面板" : "显示工具活动面板"}
|
className="absolute bottom-4 right-4 h-10 w-10 rounded-full bg-sky-500 p-0 text-white hover:bg-sky-400"
|
||||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
onClick={() => void send()}
|
||||||
onClick={() => setShowLogs((prev) => !prev)}
|
|
||||||
>
|
>
|
||||||
{showLogs ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
|
<SendHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label="恢复示例问题"
|
|
||||||
title="恢复示例问题"
|
|
||||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
|
||||||
onClick={restoreDefaultPrompt}
|
|
||||||
>
|
|
||||||
<RefreshCcw className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label="清空对话"
|
|
||||||
title="清空对话"
|
|
||||||
disabled={!canClear}
|
|
||||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
|
||||||
onClick={clearConversation}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label="停止运行"
|
|
||||||
title="停止本轮运行"
|
|
||||||
disabled={!running}
|
|
||||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
|
||||||
onClick={stop}
|
|
||||||
>
|
|
||||||
<SquareStop className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px]">
|
|
||||||
<div className="flex min-h-0 min-w-0 flex-col bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.08),_transparent_36%),linear-gradient(180deg,_rgba(255,255,255,0.02),_rgba(255,255,255,0.01))]">
|
|
||||||
<div className="border-b border-white/8 px-5 py-4">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.22em] text-white/45">
|
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
|
||||||
当前能力
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-sm text-white/65">保留 MNOTE 当前真实工具,不伪装未实现的会话历史和附件能力。</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
|
||||||
用户 {userCount}
|
|
||||||
</Badge>
|
|
||||||
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
|
||||||
AI {assistantCount}
|
|
||||||
</Badge>
|
|
||||||
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
|
||||||
活动 {logs.length}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
{TOOLSET_CHIPS.map((chip) => (
|
|
||||||
<Badge
|
|
||||||
key={chip.id}
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-full border-white/10 bg-white/[0.03] px-3 py-1.5 text-white/78 hover:bg-white/[0.03]"
|
|
||||||
title={chip.description}
|
|
||||||
>
|
|
||||||
{chip.title}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
|
||||||
<div className="mx-auto flex max-w-4xl flex-col gap-4 px-5 py-5">
|
|
||||||
{messages.length === 0 ? (
|
|
||||||
<div className="max-w-3xl rounded-[24px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_10px_40px_rgba(0,0,0,0.24)]">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="mt-1 inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.06] text-white/85">
|
|
||||||
<Bot className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-base font-semibold text-white">你好,我是 MNOTE 全局 AI。</div>
|
|
||||||
<div className="mt-2 text-sm leading-7 text-white/72">我会基于当前已接入的工具完成可追溯的检索、阅读与受控写入任务:</div>
|
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
|
||||||
{CAPABILITY_ITEMS.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<div key={item.title} className="rounded-2xl border border-white/8 bg-black/15 p-3">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-white">
|
|
||||||
<Icon className="h-4 w-4 text-sky-200/80" />
|
|
||||||
{item.title}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs leading-6 text-white/58">{item.description}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 text-sm text-white/70">请输入你的目标,我会在允许的工具范围内自动完成分析,并把活动轨迹展示在右侧。</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{messages.map((message, index) => {
|
|
||||||
const isUser = message.role === "user";
|
|
||||||
return (
|
|
||||||
<article key={`${message.role}-${index}`} className={`flex gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
|
|
||||||
{!isUser ? (
|
|
||||||
<div className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.06] text-sm font-semibold text-white/88">
|
|
||||||
AI
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div
|
|
||||||
className={`max-w-[min(760px,92%)] rounded-[22px] border px-4 py-3 text-sm leading-7 shadow-[0_10px_30px_rgba(0,0,0,0.16)] ${
|
|
||||||
isUser
|
|
||||||
? "border-sky-400/25 bg-sky-500/15 text-sky-50"
|
|
||||||
: "border-white/10 bg-white/[0.04] text-white/90"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className={`mb-2 text-xs ${isUser ? "text-sky-100/70" : "text-white/45"}`}>{isUser ? "我" : "AI"}</div>
|
|
||||||
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
|
||||||
</div>
|
|
||||||
{isUser ? (
|
|
||||||
<div className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-sky-400/30 bg-sky-500/18 text-sm font-semibold text-sky-50">
|
|
||||||
我
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div ref={messageEndRef} />
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<div className="border-t border-white/8 px-5 py-4">
|
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{TOOLSET_CHIPS.map((chip) => (
|
|
||||||
<div
|
|
||||||
key={`${chip.id}-summary`}
|
|
||||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5"
|
|
||||||
>
|
|
||||||
<span className="text-xs font-medium text-white/82">{chip.title}</span>
|
|
||||||
<span className="text-xs text-white/42">{chip.description}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<label className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs text-white/68">
|
|
||||||
最大步数
|
|
||||||
<input
|
|
||||||
className="w-14 rounded-md border border-white/10 bg-black/20 px-2 py-1 text-right text-white outline-none"
|
|
||||||
type="number"
|
|
||||||
min={MIN_PANEL_AGENT_STEPS}
|
|
||||||
max={MAX_PANEL_AGENT_STEPS}
|
|
||||||
step={1}
|
|
||||||
value={maxSteps}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = Number(e.target.value);
|
|
||||||
if (!Number.isFinite(value)) return;
|
|
||||||
setMaxSteps(clampStep(Math.floor(value)));
|
|
||||||
}}
|
|
||||||
disabled={running}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<Badge className="border-white/10 bg-white/[0.04] px-3 py-1.5 text-white/70 hover:bg-white/[0.04]" variant="outline">
|
|
||||||
{running ? "状态:生成中…" : "状态:等待输入"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<Textarea
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
placeholder="输入问题,Enter 发送,Shift+Enter 换行"
|
|
||||||
className="min-h-[140px] rounded-[24px] border-white/10 bg-white/[0.03] px-4 py-4 pb-16 pr-40 text-sm leading-7 text-white placeholder:text-white/30"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (canSend) void send();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="pointer-events-none absolute bottom-4 left-4 text-xs text-white/38">Enter 发送,Shift+Enter 换行</div>
|
|
||||||
|
|
||||||
{running ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
className="absolute bottom-4 right-16 rounded-xl border border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.12]"
|
|
||||||
onClick={stop}
|
|
||||||
>
|
|
||||||
停止
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={!canSend}
|
|
||||||
aria-label="发送消息"
|
|
||||||
title="发送"
|
|
||||||
className="absolute bottom-4 right-4 h-10 w-10 rounded-full bg-sky-500 p-0 text-white hover:bg-sky-400"
|
|
||||||
onClick={() => void send()}
|
|
||||||
>
|
|
||||||
<SendHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showLogs ? (
|
|
||||||
<aside className="flex min-h-0 min-w-0 flex-col border-t border-white/8 bg-white/[0.02] xl:border-l xl:border-t-0">
|
|
||||||
<div className="border-b border-white/8 px-4 py-4">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-white">本轮活动</div>
|
|
||||||
<div className="mt-1 text-xs text-white/48">{running ? "AI 正在调度工具与生成回答" : "等待发起下一轮任务"}</div>
|
|
||||||
</div>
|
|
||||||
<Badge className="border-white/10 bg-white/[0.04] text-white/72 hover:bg-white/[0.04]" variant="outline">
|
|
||||||
{logs.length}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
|
||||||
<div className="space-y-3 p-4">
|
|
||||||
{logs.length === 0 ? (
|
|
||||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm leading-7 text-white/45">
|
|
||||||
还没有工具活动。发送问题后,这里会展示工具请求、执行结果和错误信息。
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{logs.map((log, index) => (
|
|
||||||
<div key={`${log.type}-${index}`} className={`rounded-2xl border p-3 ${getLogToneClass(log)}`}>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="text-xs uppercase tracking-[0.18em] text-white/42">{getLogLabel(log)}</div>
|
|
||||||
{"id" in log ? <div className="text-xs text-white/35">{log.id || "no-id"}</div> : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{log.type === "error" ? (
|
|
||||||
<div className="mt-3 whitespace-pre-wrap text-sm leading-7 text-red-100/90">{log.message}</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{log.type === "tool_call" ? (
|
|
||||||
<>
|
|
||||||
<div className="mt-3 flex items-center gap-2 text-sm font-medium text-white">
|
|
||||||
<Search className="h-4 w-4 text-sky-200/80" />
|
|
||||||
{log.tool}
|
|
||||||
</div>
|
|
||||||
<pre className="mt-3 overflow-auto rounded-xl border border-white/10 bg-black/20 p-3 text-xs leading-6 text-white/70">
|
|
||||||
{formatJson(log.args)}
|
|
||||||
</pre>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{log.type === "tool_result" ? (
|
|
||||||
<>
|
|
||||||
<div className="mt-3 flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-white">
|
|
||||||
<Search className="h-4 w-4 text-emerald-200/80" />
|
|
||||||
{log.tool}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-white/45">{log.ms}ms</div>
|
|
||||||
</div>
|
|
||||||
<pre className="mt-3 overflow-auto rounded-xl border border-white/10 bg-black/20 p-3 text-xs leading-6 text-white/70">
|
|
||||||
{formatJson(log.result)}
|
|
||||||
</pre>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</aside>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</AiBridgePanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Sparkles } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type AiBridgePanelProps = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
status?: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
primaryAction?: ReactNode;
|
||||||
|
secondaryActions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
sidebar?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
scrollBody?: boolean;
|
||||||
|
scrollSidebar?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 通用 AI 面板壳:统一视觉和双栏结构,具体状态由各场景 adapter 提供。
|
||||||
|
export function AiBridgePanel({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
status,
|
||||||
|
onClose,
|
||||||
|
primaryAction,
|
||||||
|
secondaryActions,
|
||||||
|
children,
|
||||||
|
sidebar,
|
||||||
|
className,
|
||||||
|
scrollBody = true,
|
||||||
|
scrollSidebar = true,
|
||||||
|
}: AiBridgePanelProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
"flex h-[calc(100vh-112px)] min-h-[640px] w-full overflow-hidden rounded-[28px] border border-white/10 bg-[#070b14] text-white shadow-[0_24px_80px_rgba(0,0,0,0.45)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<header className="flex items-center gap-4 border-b border-white/8 px-5 py-4">
|
||||||
|
<div className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-sky-400/30 bg-sky-400/10 text-sky-100">
|
||||||
|
<Sparkles className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-sky-200/70">MNOTE</span>
|
||||||
|
<span className="h-1 w-1 rounded-full bg-white/25" />
|
||||||
|
<span className="text-sm text-white/60">{subtitle}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight text-white">{title}</h1>
|
||||||
|
{status ? (
|
||||||
|
<span className="rounded-full border border-sky-400/25 bg-sky-400/10 px-3 py-1 text-xs text-sky-100">
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{secondaryActions}
|
||||||
|
{primaryAction}
|
||||||
|
{onClose ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="关闭"
|
||||||
|
title="关闭"
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={cn("grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px]", !sidebar && "xl:grid-cols-1")}>
|
||||||
|
<div className="flex min-h-0 min-w-0 flex-col bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.08),_transparent_36%),linear-gradient(180deg,_rgba(255,255,255,0.02),_rgba(255,255,255,0.01))]">
|
||||||
|
{scrollBody ? <ScrollArea className="min-h-0 flex-1">{children}</ScrollArea> : <div className="min-h-0 flex-1">{children}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sidebar ? (
|
||||||
|
<aside className="flex min-h-0 min-w-0 flex-col border-t border-white/8 bg-white/[0.02] xl:border-l xl:border-t-0">
|
||||||
|
{scrollSidebar ? (
|
||||||
|
<ScrollArea className="min-h-0 flex-1">{sidebar}</ScrollArea>
|
||||||
|
) : (
|
||||||
|
<div className="min-h-0 flex-1">{sidebar}</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export type AiProvider = "online" | "local" | "ollama" | "codex";
|
||||||
|
|
||||||
|
export type AiPanelPrefs = {
|
||||||
|
provider: AiProvider;
|
||||||
|
model: string;
|
||||||
|
maxSteps: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
|
||||||
|
if (raw.trimStart().startsWith(":")) continue;
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readAiPanelPrefs = (
|
||||||
|
storageKeyPrefix: string,
|
||||||
|
defaults: AiPanelPrefs,
|
||||||
|
): AiPanelPrefs => {
|
||||||
|
if (typeof window === "undefined") return defaults;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const providerRaw = (window.localStorage.getItem(`${storageKeyPrefix}_provider`) || "").trim();
|
||||||
|
const model = window.localStorage.getItem(`${storageKeyPrefix}_model`) || defaults.model;
|
||||||
|
const stepsRaw = window.localStorage.getItem(`${storageKeyPrefix}_max_steps`) || "";
|
||||||
|
const parsedSteps = Number(stepsRaw);
|
||||||
|
|
||||||
|
const provider: AiProvider =
|
||||||
|
providerRaw === "online" || providerRaw === "local" || providerRaw === "ollama" || providerRaw === "codex"
|
||||||
|
? providerRaw
|
||||||
|
: defaults.provider;
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
maxSteps: Number.isFinite(parsedSteps) ? Math.floor(parsedSteps) : defaults.maxSteps,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeAiPanelPrefs = (storageKeyPrefix: string, prefs: AiPanelPrefs) => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(`${storageKeyPrefix}_provider`, prefs.provider);
|
||||||
|
window.localStorage.setItem(`${storageKeyPrefix}_model`, prefs.model);
|
||||||
|
window.localStorage.setItem(`${storageKeyPrefix}_max_steps`, String(prefs.maxSteps));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -26,8 +26,6 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
|
|||||||
const showInspector = usePageLayoutStore((state) => state.showInspector);
|
const showInspector = usePageLayoutStore((state) => state.showInspector);
|
||||||
const toggleInspector = usePageLayoutStore((state) => state.toggleInspector);
|
const toggleInspector = usePageLayoutStore((state) => state.toggleInspector);
|
||||||
const backendStatus = useBackendHealth();
|
const backendStatus = useBackendHealth();
|
||||||
const globalAgentOpen = useAiAgentUiStore((s) => s.globalAgentOpen);
|
|
||||||
const toggleGlobalAgentOpen = useAiAgentUiStore((s) => s.toggleGlobalAgentOpen);
|
|
||||||
const documentAgentAvailable = useAiAgentUiStore((s) => s.documentAgentAvailable);
|
const documentAgentAvailable = useAiAgentUiStore((s) => s.documentAgentAvailable);
|
||||||
const toggleDocumentAgentOpen = useAiAgentUiStore((s) => s.toggleDocumentAgentOpen);
|
const toggleDocumentAgentOpen = useAiAgentUiStore((s) => s.toggleDocumentAgentOpen);
|
||||||
const isStarred = useQuery(
|
const isStarred = useQuery(
|
||||||
@@ -88,28 +86,16 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-3 py-1 text-sm transition-colors",
|
"rounded-full px-3 py-1 text-sm transition-colors",
|
||||||
globalAgentOpen
|
documentAgentAvailable
|
||||||
? "bg-[#2563eb] text-white hover:bg-[#1d4ed8]"
|
? "bg-[#2563eb] text-white hover:bg-[#1d4ed8]"
|
||||||
: "hover:bg-wolai-bg-hover hover:text-wolai-text-primary",
|
: "hover:bg-wolai-bg-hover hover:text-wolai-text-primary",
|
||||||
)}
|
)}
|
||||||
onClick={() => toggleGlobalAgentOpen()}
|
|
||||||
title="打开全局 AI"
|
|
||||||
>
|
|
||||||
<Sparkles className="mr-1 inline h-4 w-4" />
|
|
||||||
全局AI
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"hover:bg-wolai-bg-hover hover:text-wolai-text-primary rounded-full px-3 py-1 text-sm transition-colors",
|
|
||||||
!documentAgentAvailable && "cursor-not-allowed opacity-50",
|
|
||||||
)}
|
|
||||||
onClick={() => toggleDocumentAgentOpen()}
|
onClick={() => toggleDocumentAgentOpen()}
|
||||||
disabled={!documentAgentAvailable}
|
disabled={!documentAgentAvailable}
|
||||||
title={documentAgentAvailable ? "打开页面 AI" : "仅在页面编辑区可用"}
|
title={documentAgentAvailable ? "打开页面 AI" : "仅在页面编辑区可用"}
|
||||||
>
|
>
|
||||||
<Sparkles className="mr-1 inline h-4 w-4" />
|
<Sparkles className="mr-1 inline h-4 w-4" />
|
||||||
AI
|
页面AI
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -7,19 +7,15 @@ import { useEditorBridgeStore } from "@/store/editor-bridge";
|
|||||||
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
|
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
|
||||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AiBridgePanel } from "@/components/ai-agent/AiBridgePanel";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "@/components/ui/sheet";
|
|
||||||
import { clamp, MIN_AGENT_STEPS, MAX_AGENT_STEPS } from "@/lib/constants";
|
import { clamp, MIN_AGENT_STEPS, MAX_AGENT_STEPS } from "@/lib/constants";
|
||||||
|
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
|
||||||
|
|
||||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||||
type AiProvider = "online" | "local" | "ollama" | "codex";
|
|
||||||
type CodexMode = "chat" | "test" | "dev";
|
type CodexMode = "chat" | "test" | "dev";
|
||||||
|
|
||||||
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
|
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
|
||||||
@@ -116,41 +112,6 @@ const normalizeSessions = (sessions: ChatSession[]) => {
|
|||||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
};
|
};
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 注释/心跳:以 ":" 开头
|
|
||||||
if (raw.trimStart().startsWith(":")) continue;
|
|
||||||
|
|
||||||
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 safeJsonStringify = (value: unknown) => {
|
const safeJsonStringify = (value: unknown) => {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
@@ -177,7 +138,6 @@ export function DocumentAiAgentPanel({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [networkOn, setNetworkOn] = useState(() => !flightMode);
|
const [networkOn, setNetworkOn] = useState(() => !flightMode);
|
||||||
const [toolAuto, setToolAuto] = useState(true);
|
const [toolAuto, setToolAuto] = useState(true);
|
||||||
const [toolPickerOpen, setToolPickerOpen] = useState(false);
|
|
||||||
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
|
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
|
||||||
const [maxSteps, setMaxSteps] = useState<number>(10);
|
const [maxSteps, setMaxSteps] = useState<number>(10);
|
||||||
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
|
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
|
||||||
@@ -212,29 +172,18 @@ export function DocumentAiAgentPanel({
|
|||||||
}, [setAvailable, setOpen]);
|
}, [setAvailable, setOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
const prefs = readAiPanelPrefs("doc_ai", { provider: "online", model: "", maxSteps: 10 });
|
||||||
const stepsRaw = window.localStorage.getItem("doc_ai_max_steps") || "";
|
setMaxSteps(clamp(prefs.maxSteps, MIN_AGENT_STEPS, MAX_AGENT_STEPS));
|
||||||
const p = (window.localStorage.getItem("doc_ai_provider") || "").trim();
|
setAiProvider(prefs.provider);
|
||||||
const m = window.localStorage.getItem("doc_ai_model") || "";
|
setAiModel(prefs.model);
|
||||||
const parsed = Number(stepsRaw);
|
|
||||||
if (Number.isFinite(parsed) && parsed >= MIN_AGENT_STEPS) {
|
|
||||||
setMaxSteps(clamp(Math.floor(parsed), MIN_AGENT_STEPS, MAX_AGENT_STEPS));
|
|
||||||
}
|
|
||||||
if (p === "local" || p === "online" || p === "ollama" || p === "codex") setAiProvider(p);
|
|
||||||
if (typeof m === "string") setAiModel(m);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
writeAiPanelPrefs("doc_ai", {
|
||||||
window.localStorage.setItem("doc_ai_max_steps", String(maxSteps));
|
provider: aiProvider,
|
||||||
window.localStorage.setItem("doc_ai_provider", aiProvider);
|
model: aiModel,
|
||||||
window.localStorage.setItem("doc_ai_model", aiModel);
|
maxSteps: clamp(maxSteps, MIN_AGENT_STEPS, MAX_AGENT_STEPS),
|
||||||
} catch {
|
});
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, [aiModel, aiProvider, maxSteps]);
|
}, [aiModel, aiProvider, maxSteps]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -676,73 +625,89 @@ export function DocumentAiAgentPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={setOpen}>
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
<SheetContent side="right" showCloseButton={false} className="p-0">
|
<SheetContent
|
||||||
<SheetHeader className="border-b">
|
side="right"
|
||||||
<SheetTitle className="flex items-center justify-between gap-2">
|
showCloseButton={false}
|
||||||
<span className="flex items-center gap-2">
|
className="w-[min(1400px,calc(100vw-24px))] max-w-none border-l-0 bg-transparent p-3 shadow-none sm:max-w-none"
|
||||||
|
>
|
||||||
|
<SheetTitle className="sr-only">页面 AI</SheetTitle>
|
||||||
|
<AiBridgePanel
|
||||||
|
title={pageTitle}
|
||||||
|
subtitle="页面 AI"
|
||||||
|
status={loading ? "运行中" : "待命"}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
scrollBody={false}
|
||||||
|
className="h-[calc(100vh-24px)] min-h-0 rounded-[24px] border-white/10 shadow-none"
|
||||||
|
secondaryActions={
|
||||||
|
<>
|
||||||
{page === "chat" ? (
|
{page === "chat" ? (
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4 text-white/75" />
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon-sm"
|
||||||
onClick={() => setPage("chat")}
|
onClick={() => setPage("chat")}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
title="返回对话"
|
title="返回对话"
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{pageTitle}
|
<Button
|
||||||
</span>
|
variant="ghost"
|
||||||
<div className="flex items-center gap-1">
|
size="icon-sm"
|
||||||
<Button variant="ghost" size="icon" onClick={startNewSession} disabled={loading} title="新建会话">
|
onClick={startNewSession}
|
||||||
|
disabled={loading}
|
||||||
|
title="新建会话"
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon-sm"
|
||||||
onClick={() => setPage("tools")}
|
onClick={() => setPage("tools")}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
title="工具(代替 MCP)"
|
title="工具(代替 MCP)"
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
>
|
>
|
||||||
<Wrench className="h-4 w-4" />
|
<Wrench className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon-sm"
|
||||||
onClick={() => setPage("history")}
|
onClick={() => setPage("history")}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
title="历史"
|
title="历史"
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
>
|
>
|
||||||
<History className="h-4 w-4" />
|
<History className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon-sm"
|
||||||
onClick={() => setPage("account")}
|
onClick={() => setPage("account")}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
title="账户/模型"
|
title="账户/模型"
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
>
|
>
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon-sm"
|
||||||
onClick={() => setPage("settings")}
|
onClick={() => setPage("settings")}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
title="设置"
|
title="设置"
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onClick={() => setOpen(false)} title="关闭">
|
</>
|
||||||
<X className="h-4 w-4" />
|
}
|
||||||
</Button>
|
>
|
||||||
</div>
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
|
||||||
<div className={page === "chat" ? "" : "hidden"}>
|
<div className={page === "chat" ? "" : "hidden"}>
|
||||||
<div className="hidden">
|
<div className="hidden">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -766,17 +731,6 @@ export function DocumentAiAgentPanel({
|
|||||||
<Settings2 className="h-3.5 w-3.5" />
|
<Settings2 className="h-3.5 w-3.5" />
|
||||||
{toolAuto ? "自动工具" : "手动工具"}
|
{toolAuto ? "自动工具" : "手动工具"}
|
||||||
</button>
|
</button>
|
||||||
{!toolAuto && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-1 rounded border bg-white px-2 py-1"
|
|
||||||
onClick={() => setToolPickerOpen((v) => !v)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<Settings2 className="h-3.5 w-3.5" />
|
|
||||||
选择工具
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1446,6 +1400,7 @@ export function DocumentAiAgentPanel({
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</AiBridgePanel>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkles, User, Wrench, X, Paperclip } from "lucide-react";
|
import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkles, User, Wrench, X, Paperclip } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AiBridgePanel } from "@/components/ai-agent/AiBridgePanel";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||||
|
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
|
||||||
|
|
||||||
type AgentAssetItem = {
|
type AgentAssetItem = {
|
||||||
kind: "media" | "local-mindmap" | "test-pdf";
|
kind: "media" | "local-mindmap" | "test-pdf";
|
||||||
@@ -18,7 +20,6 @@ type AgentAssetItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||||
type AiProvider = "online" | "local" | "ollama" | "codex";
|
|
||||||
type CodexMode = "chat" | "test" | "dev";
|
type CodexMode = "chat" | "test" | "dev";
|
||||||
|
|
||||||
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
|
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
|
||||||
@@ -183,7 +184,6 @@ export function MindmapAiAgentPanel({
|
|||||||
const [networkOn, setNetworkOn] = useState(() => !flightMode);
|
const [networkOn, setNetworkOn] = useState(() => !flightMode);
|
||||||
const [toolAuto, setToolAuto] = useState(true);
|
const [toolAuto, setToolAuto] = useState(true);
|
||||||
// 兼容旧 UI(已隐藏),避免影响已有交互与回滚风险
|
// 兼容旧 UI(已隐藏),避免影响已有交互与回滚风险
|
||||||
const [toolPickerOpen, setToolPickerOpen] = useState(false);
|
|
||||||
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
|
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
|
||||||
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
|
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
|
||||||
const [aiModel, setAiModel] = useState<string>("");
|
const [aiModel, setAiModel] = useState<string>("");
|
||||||
@@ -210,29 +210,18 @@ export function MindmapAiAgentPanel({
|
|||||||
}, [flightMode]);
|
}, [flightMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
const prefs = readAiPanelPrefs("mindmap_ai", { provider: "online", model: "", maxSteps: 10 });
|
||||||
const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim();
|
setAiProvider(prefs.provider);
|
||||||
const m = window.localStorage.getItem("mindmap_ai_model") || "";
|
setAiModel(prefs.model);
|
||||||
const stepsRaw = window.localStorage.getItem("mindmap_ai_max_steps") || "";
|
setMaxSteps(Math.max(1, Math.min(24, Math.floor(prefs.maxSteps))));
|
||||||
if (p === "local" || p === "online" || p === "ollama" || p === "codex") setAiProvider(p);
|
|
||||||
if (typeof m === "string") setAiModel(m);
|
|
||||||
const parsed = Number(stepsRaw);
|
|
||||||
if (Number.isFinite(parsed) && parsed >= 1) {
|
|
||||||
setMaxSteps(Math.max(1, Math.min(24, Math.floor(parsed))));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
writeAiPanelPrefs("mindmap_ai", {
|
||||||
window.localStorage.setItem("mindmap_ai_provider", aiProvider);
|
provider: aiProvider,
|
||||||
window.localStorage.setItem("mindmap_ai_model", aiModel);
|
model: aiModel,
|
||||||
window.localStorage.setItem("mindmap_ai_max_steps", String(maxSteps));
|
maxSteps: Math.max(1, Math.min(24, Math.floor(maxSteps))),
|
||||||
} catch {
|
});
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, [aiProvider, aiModel, maxSteps]);
|
}, [aiProvider, aiModel, maxSteps]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -369,38 +358,6 @@ export function MindmapAiAgentPanel({
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 注释/心跳:以 ":" 开头
|
|
||||||
if (raw.trimStart().startsWith(":")) continue;
|
|
||||||
|
|
||||||
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 selectedNodes = useMemo(() => {
|
const selectedNodes = useMemo(() => {
|
||||||
const list = Array.isArray(activeNodes) ? activeNodes : [];
|
const list = Array.isArray(activeNodes) ? activeNodes : [];
|
||||||
|
|
||||||
@@ -562,13 +519,6 @@ export function MindmapAiAgentPanel({
|
|||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleTool = (tool: ToolName) => {
|
|
||||||
setSelectedTools((prev) => {
|
|
||||||
if (prev.includes(tool)) return prev.filter((t) => t !== tool);
|
|
||||||
return [...prev, tool];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadFiles = async (files: FileList | null) => {
|
const uploadFiles = async (files: FileList | null) => {
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
if (!workspaceId || !documentId) {
|
if (!workspaceId || !documentId) {
|
||||||
@@ -979,42 +929,82 @@ export function MindmapAiAgentPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<AiBridgePanel
|
||||||
<div className="border-b p-3">
|
title={pageTitle}
|
||||||
<div className="flex items-center justify-between gap-2">
|
subtitle="思维导图 AI"
|
||||||
<div className="flex items-center gap-2">
|
status={loading ? "运行中" : "待命"}
|
||||||
{page === "chat" ? (
|
onClose={onClose}
|
||||||
<Sparkles className="h-4 w-4" />
|
scrollBody={false}
|
||||||
) : (
|
className="h-full min-h-0 rounded-[24px] border-white/10 shadow-none"
|
||||||
<Button variant="ghost" size="icon" onClick={() => setPage("chat")} disabled={loading} title="返回对话">
|
secondaryActions={
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<>
|
||||||
</Button>
|
{page === "chat" ? (
|
||||||
)}
|
<Sparkles className="h-4 w-4 text-white/75" />
|
||||||
<span className="text-sm font-medium">{pageTitle}</span>
|
) : (
|
||||||
</div>
|
<Button
|
||||||
<div className="flex items-center gap-1">
|
variant="ghost"
|
||||||
<Button variant="ghost" size="icon" onClick={startNewSession} disabled={loading} title="新建会话">
|
size="icon-sm"
|
||||||
<Plus className="h-4 w-4" />
|
onClick={() => setPage("chat")}
|
||||||
|
disabled={loading}
|
||||||
|
title="返回对话"
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onClick={() => setPage("tools")} disabled={loading} title="工具(代替 MCP)">
|
)}
|
||||||
<Wrench className="h-4 w-4" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
<Button variant="ghost" size="icon" onClick={() => setPage("history")} disabled={loading} title="历史">
|
size="icon-sm"
|
||||||
<History className="h-4 w-4" />
|
onClick={startNewSession}
|
||||||
</Button>
|
disabled={loading}
|
||||||
<Button variant="ghost" size="icon" onClick={() => setPage("account")} disabled={loading} title="账户/模型">
|
title="新建会话"
|
||||||
<User className="h-4 w-4" />
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
</Button>
|
>
|
||||||
<Button variant="ghost" size="icon" onClick={() => setPage("settings")} disabled={loading} title="设置">
|
<Plus className="h-4 w-4" />
|
||||||
<Settings className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
<Button variant="ghost" size="icon" onClick={() => onClose?.()} disabled={loading} title="关闭">
|
variant="ghost"
|
||||||
<X className="h-4 w-4" />
|
size="icon-sm"
|
||||||
</Button>
|
onClick={() => setPage("tools")}
|
||||||
</div>
|
disabled={loading}
|
||||||
</div>
|
title="工具(代替 MCP)"
|
||||||
</div>
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
|
>
|
||||||
|
<Wrench className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => setPage("history")}
|
||||||
|
disabled={loading}
|
||||||
|
title="历史"
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
|
>
|
||||||
|
<History className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => setPage("account")}
|
||||||
|
disabled={loading}
|
||||||
|
title="账户/模型"
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
|
>
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => setPage("settings")}
|
||||||
|
disabled={loading}
|
||||||
|
title="设置"
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
{page === "chat" ? (
|
{page === "chat" ? (
|
||||||
<>
|
<>
|
||||||
@@ -1435,374 +1425,6 @@ export function MindmapAiAgentPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{false ? (
|
</AiBridgePanel>
|
||||||
<div className="hidden">
|
|
||||||
<div className="flex items-center justify-between gap-2 pb-3">
|
|
||||||
<div
|
|
||||||
className="text-xs text-gray-500"
|
|
||||||
title={
|
|
||||||
selectedNodes.length
|
|
||||||
? selectedNodes
|
|
||||||
.map((n) => (n.text ? `${n.text}(${n.uid})` : n.uid))
|
|
||||||
.slice(0, 3)
|
|
||||||
.join(",")
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{selectedNodes.length
|
|
||||||
? `选中节点:${selectedNodes[0]?.text || selectedNodes[0]?.uid}${selectedNodes.length > 1 ? `(+${selectedNodes.length - 1})` : ""}`
|
|
||||||
: "未选中节点(将以整图为上下文)"}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs ${networkOn ? "border-blue-200 bg-blue-50 text-blue-700" : "border-gray-200 text-gray-500"}`}
|
|
||||||
title="联网检索(SearxNG)"
|
|
||||||
onClick={() => setNetworkOn((v) => !v)}
|
|
||||||
>
|
|
||||||
<Network className="h-3 w-3" />
|
|
||||||
联网
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs ${toolPickerOpen ? "border-gray-300 bg-gray-50 text-gray-700" : "border-gray-200 text-gray-600"}`}
|
|
||||||
title="选择允许使用的工具"
|
|
||||||
onClick={() => setToolPickerOpen((v) => !v)}
|
|
||||||
>
|
|
||||||
<Settings2 className="h-3 w-3" />
|
|
||||||
工具
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{toolPickerOpen && (
|
|
||||||
<div className="mb-3 rounded-md border border-gray-200 bg-white p-2">
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<div className="text-xs font-medium text-gray-700">工具选择</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`rounded px-2 py-1 text-xs ${toolAuto ? "bg-blue-500 text-white" : "border border-gray-200 text-gray-600"}`}
|
|
||||||
onClick={() => setToolAuto((v) => !v)}
|
|
||||||
title="自动:AI 自行选择;手动:仅允许勾选工具"
|
|
||||||
>
|
|
||||||
{toolAuto ? "自动" : "手动"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{DEFAULT_TOOLS.map((t) => (
|
|
||||||
<label key={t} className={`flex cursor-pointer items-center gap-2 text-xs ${toolAuto ? "opacity-50" : ""}`}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
disabled={toolAuto}
|
|
||||||
checked={selectedTools.includes(t)}
|
|
||||||
onChange={() => toggleTool(t)}
|
|
||||||
/>
|
|
||||||
<span className="text-gray-700">{TOOL_LABEL[t]}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-[11px] text-gray-400">
|
|
||||||
提示:如果你希望 AI 一定要“写入导图”,请在需求中明确说“请写入并保存”。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto rounded-md border border-gray-200 bg-white p-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{messages.map((m, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className={`whitespace-pre-wrap rounded-md px-2 py-2 text-sm ${m.role === "user" ? "bg-gray-50 text-gray-900" : "bg-white text-gray-800"}`}
|
|
||||||
>
|
|
||||||
<div className="mb-1 text-[11px] text-gray-400">{m.role === "user" ? "你" : "AI"}</div>
|
|
||||||
<div>{m.content}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{attachments.length > 0 && (
|
|
||||||
<div className="mt-2 flex flex-wrap gap-1">
|
|
||||||
{attachments.map((a) => (
|
|
||||||
<span
|
|
||||||
key={a.id}
|
|
||||||
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2 py-1 text-[11px] text-gray-700"
|
|
||||||
title={a.fileUrl}
|
|
||||||
>
|
|
||||||
@{a.title}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-gray-400 hover:text-gray-700"
|
|
||||||
onClick={() => setAttachments((prev) => prev.filter((x) => x.id !== a.id))}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-2 rounded-md border border-gray-200 bg-white px-2 py-2 text-xs text-gray-700">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<div className="text-gray-500">AI:</div>
|
|
||||||
<label className="inline-flex cursor-pointer items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="mindmap-ai-provider"
|
|
||||||
data-testid="mindmap-ai-provider-online"
|
|
||||||
checked={aiProvider === "online"}
|
|
||||||
onChange={() => setAiProvider("online")}
|
|
||||||
/>
|
|
||||||
在线
|
|
||||||
</label>
|
|
||||||
<label className="inline-flex cursor-pointer items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="mindmap-ai-provider"
|
|
||||||
data-testid="mindmap-ai-provider-local"
|
|
||||||
checked={aiProvider === "local"}
|
|
||||||
onChange={() => setAiProvider("local")}
|
|
||||||
/>
|
|
||||||
本地
|
|
||||||
</label>
|
|
||||||
<label className="inline-flex cursor-pointer items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="mindmap-ai-provider"
|
|
||||||
checked={aiProvider === "ollama"}
|
|
||||||
onChange={() => setAiProvider("ollama")}
|
|
||||||
/>
|
|
||||||
Ollama
|
|
||||||
</label>
|
|
||||||
<label className="inline-flex cursor-pointer items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="mindmap-ai-provider"
|
|
||||||
checked={aiProvider === "codex"}
|
|
||||||
onChange={() => setAiProvider("codex")}
|
|
||||||
/>
|
|
||||||
Codex
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="text-gray-500">模型:</div>
|
|
||||||
{aiProvider === "codex" ? (
|
|
||||||
<div className="text-[11px] text-gray-500">
|
|
||||||
消息开头加 <code className="rounded bg-gray-100 px-1 py-0.5">#chat</code> /{" "}
|
|
||||||
<code className="rounded bg-gray-100 px-1 py-0.5">#test</code> /{" "}
|
|
||||||
<code className="rounded bg-gray-100 px-1 py-0.5">#dev</code>(默认 <code className="rounded bg-gray-100 px-1 py-0.5">#chat</code>)。
|
|
||||||
</div>
|
|
||||||
) : aiProvider === "online" ? (
|
|
||||||
<select
|
|
||||||
data-testid="mindmap-ai-model-select"
|
|
||||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
|
||||||
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
|
|
||||||
onChange={(e) => setAiModel(e.target.value)}
|
|
||||||
>
|
|
||||||
{ONLINE_MODELS.map((m) => (
|
|
||||||
<option key={m || "__default__"} value={m}>
|
|
||||||
{m ? m : "默认(ai.md)"}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : aiProvider === "ollama" ? (
|
|
||||||
<select
|
|
||||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
|
||||||
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
|
|
||||||
onChange={(e) => setAiModel(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">默认:{OLLAMA_QWEN3_30B}</option>
|
|
||||||
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
data-testid="mindmap-ai-model-input"
|
|
||||||
className="w-[220px] rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
|
||||||
value={aiModel}
|
|
||||||
onChange={(e) => setAiModel(e.target.value)}
|
|
||||||
placeholder="默认(ai.local.md/环境变量)"
|
|
||||||
list="mindmap-local-model-suggestions-bottom"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<datalist id="mindmap-local-model-suggestions-bottom">
|
|
||||||
<option value={OLLAMA_QWEN3_30B} />
|
|
||||||
</datalist>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="text-gray-500">最大步数:</div>
|
|
||||||
<input
|
|
||||||
className="w-[72px] rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={24}
|
|
||||||
step={1}
|
|
||||||
value={maxSteps}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = Number(e.target.value);
|
|
||||||
if (!Number.isFinite(v)) return;
|
|
||||||
setMaxSteps(Math.max(1, Math.min(24, Math.floor(v))));
|
|
||||||
}}
|
|
||||||
title="工具调用/推理最大步数(上限 24)"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{aiProvider === "local" ? (
|
|
||||||
<div className="mt-1 text-[11px] text-gray-400">
|
|
||||||
本地 AI 需配置 `LOCAL_AI_BASE_URL`/`LOCAL_AI_MODEL` 或 `ai.local.md` / `ai-local.md`
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative mt-2">
|
|
||||||
{mentionOpen && filteredAssets.length > 0 && (
|
|
||||||
<div className="absolute bottom-[calc(100%+8px)] left-0 right-0 z-30 max-h-56 overflow-auto rounded-md border border-gray-200 bg-white shadow">
|
|
||||||
{filteredAssets.map((a) => (
|
|
||||||
<button
|
|
||||||
key={a.id}
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50"
|
|
||||||
onClick={() => insertMention(a)}
|
|
||||||
>
|
|
||||||
<span className="truncate text-gray-800">{a.title}</span>
|
|
||||||
<span className="shrink-0 text-[11px] text-gray-400">{a.kind}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
data-testid="mindmap-ai-input"
|
|
||||||
className="w-full resize-none rounded-md border border-gray-200 bg-white p-2 text-sm outline-none focus:border-blue-300"
|
|
||||||
rows={4}
|
|
||||||
value={input}
|
|
||||||
placeholder="输入你的需求。使用 @ 选择文件(PDF/附件/本地导图),例如:总结 @卤化反应原理_1-9.pdf 并写入导图(章->节->要点)。"
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
setInput(v);
|
|
||||||
updateMentionState(v, e.target.selectionStart ?? v.length);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
// 焦点在 AI 输入框时,不应触发导图 Enter/Tab 快捷键
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setMentionOpen(false);
|
|
||||||
setToolPickerOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
// Enter 发送;Shift+Enter 换行
|
|
||||||
const isComposing = Boolean((e.nativeEvent as unknown as { isComposing?: boolean })?.isComposing);
|
|
||||||
if (!e.shiftKey && !isComposing) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!loading) void send();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
const el = e.currentTarget;
|
|
||||||
updateMentionState(el.value, el.selectionStart ?? el.value.length);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-2 flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
title="上传文件到当前页面附件"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<Paperclip className="h-3 w-3" />
|
|
||||||
上传
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
void uploadFiles(e.target.files);
|
|
||||||
}}
|
|
||||||
accept="*/*"
|
|
||||||
/>
|
|
||||||
<div className="text-[11px] text-gray-400">Enter 发送 · Shift+Enter 换行</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
|
||||||
disabled={!loading}
|
|
||||||
onClick={() => abortRef.current?.abort()}
|
|
||||||
title={aiProvider === "codex" ? "暂停本次执行(类似 ESC)" : "停止本次执行"}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
{aiProvider === "codex" ? "暂停" : "停止"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
disabled={loading || !input.trim()}
|
|
||||||
onClick={() => void send()}
|
|
||||||
>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
{loading ? "执行中..." : "发送"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details className="mt-2 rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-600">
|
|
||||||
<summary className="cursor-pointer select-none">工具日志(可折叠)</summary>
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
{toolLogs.length === 0 ? <div className="text-gray-400">暂无工具日志</div> : null}
|
|
||||||
{toolLogs.map((l, idx) => {
|
|
||||||
if (l.type === "error") {
|
|
||||||
return (
|
|
||||||
<div key={idx} className="rounded border border-red-200 bg-red-50 p-2 text-red-700">
|
|
||||||
错误:{l.message}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (l.type === "info") {
|
|
||||||
return (
|
|
||||||
<div key={idx} className="rounded border bg-gray-50 p-2 text-gray-600">
|
|
||||||
{l.message}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (l.type === "tool_call") {
|
|
||||||
return (
|
|
||||||
<div key={idx} className="rounded border p-2">
|
|
||||||
<div className="text-[11px] text-gray-400">tool_call · {l.id}</div>
|
|
||||||
<div className="font-medium text-gray-800">{l.tool}</div>
|
|
||||||
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap">{JSON.stringify(l.args, null, 2)}</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div key={idx} className="rounded border p-2">
|
|
||||||
<div className="text-[11px] text-gray-400">
|
|
||||||
tool_result · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
|
|
||||||
</div>
|
|
||||||
<div className="font-medium text-gray-800">{l.tool}</div>
|
|
||||||
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap">{JSON.stringify(l.result, null, 2)}</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
{debug ? (
|
|
||||||
<details className="mt-2 rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-600">
|
|
||||||
<summary className="cursor-pointer select-none">调试信息(SSE 事件)</summary>
|
|
||||||
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap">{debug}</pre>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Bot, Settings, Wrench, X } from "lucide-react";
|
import { Bot, Settings, Wrench } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AiBridgePanel } from "@/components/ai-agent/AiBridgePanel";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { clamp } from "@/lib/constants";
|
import { clamp } from "@/lib/constants";
|
||||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||||
|
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
|
||||||
|
|
||||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||||
type AiProvider = "online" | "local" | "ollama" | "codex";
|
|
||||||
|
|
||||||
type ToolLog =
|
type ToolLog =
|
||||||
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
|
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
|
||||||
@@ -44,37 +45,6 @@ const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
|
|||||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 注释/心跳:以 ":" 开头
|
|
||||||
if (raw.trimStart().startsWith(":")) continue;
|
|
||||||
|
|
||||||
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 blobToDataUrl = (blob: Blob) =>
|
const blobToDataUrl = (blob: Blob) =>
|
||||||
new Promise<string>((resolve, reject) => {
|
new Promise<string>((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -133,27 +103,18 @@ export function OnlyOfficeAiAgentPanel({
|
|||||||
}, [flightMode]);
|
}, [flightMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
const prefs = readAiPanelPrefs("onlyoffice_ai", { provider: "online", model: "", maxSteps: 10 });
|
||||||
const stepsRaw = window.localStorage.getItem("onlyoffice_ai_max_steps") || "";
|
setAiProvider(prefs.provider);
|
||||||
const providerRaw = (window.localStorage.getItem("onlyoffice_ai_provider") || "").trim();
|
setAiModel(prefs.model);
|
||||||
const modelRaw = window.localStorage.getItem("onlyoffice_ai_model") || "";
|
setMaxSteps(clamp(Math.floor(prefs.maxSteps), 1, 24));
|
||||||
const parsed = Number(stepsRaw);
|
|
||||||
if (providerRaw === "online" || providerRaw === "local" || providerRaw === "ollama" || providerRaw === "codex") setAiProvider(providerRaw);
|
|
||||||
if (typeof modelRaw === "string") setAiModel(modelRaw);
|
|
||||||
if (Number.isFinite(parsed) && parsed >= 1) setMaxSteps(clamp(Math.floor(parsed), 1, 24));
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
writeAiPanelPrefs("onlyoffice_ai", {
|
||||||
window.localStorage.setItem("onlyoffice_ai_provider", aiProvider);
|
provider: aiProvider,
|
||||||
window.localStorage.setItem("onlyoffice_ai_model", aiModel);
|
model: aiModel,
|
||||||
window.localStorage.setItem("onlyoffice_ai_max_steps", String(maxSteps));
|
maxSteps: clamp(Math.floor(maxSteps), 1, 24),
|
||||||
} catch {
|
});
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, [aiProvider, aiModel, maxSteps]);
|
}, [aiProvider, aiModel, maxSteps]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -458,41 +419,43 @@ export function OnlyOfficeAiAgentPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{open ? (
|
{open ? (
|
||||||
<div className="fixed right-0 top-0 z-[70] h-screen w-[420px] border-l bg-background shadow-xl">
|
<div className="fixed inset-y-0 right-0 z-[70] w-[min(960px,calc(100vw-24px))] p-3">
|
||||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
<AiBridgePanel
|
||||||
<div className="text-sm font-semibold">OnlyOffice AI</div>
|
title={page === "chat" ? "OnlyOffice AI" : page === "tools" ? "OnlyOffice 工具" : "OnlyOffice 设置"}
|
||||||
<Button variant="ghost" size="icon" onClick={() => setOpen(false)}>
|
subtitle="OnlyOffice AI"
|
||||||
<X className="h-4 w-4" />
|
status={loading ? "运行中" : "待命"}
|
||||||
</Button>
|
onClose={() => setOpen(false)}
|
||||||
</div>
|
scrollBody={false}
|
||||||
|
className="h-[calc(100vh-24px)] min-h-0 rounded-[24px] border-white/10 shadow-none"
|
||||||
<div className="flex gap-1 border-b px-2 py-2">
|
secondaryActions={
|
||||||
<Button
|
<>
|
||||||
variant={page === "chat" ? "default" : "secondary"}
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => setPage("chat")}
|
size="icon-sm"
|
||||||
>
|
onClick={() => setPage("chat")}
|
||||||
<Bot className="mr-2 h-4 w-4" />
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
对话
|
>
|
||||||
</Button>
|
<Bot className="h-4 w-4" />
|
||||||
<Button
|
</Button>
|
||||||
variant={page === "tools" ? "default" : "secondary"}
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => setPage("tools")}
|
size="icon-sm"
|
||||||
>
|
onClick={() => setPage("tools")}
|
||||||
<Wrench className="mr-2 h-4 w-4" />
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
工具
|
>
|
||||||
</Button>
|
<Wrench className="h-4 w-4" />
|
||||||
<Button
|
</Button>
|
||||||
variant={page === "settings" ? "default" : "secondary"}
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => setPage("settings")}
|
size="icon-sm"
|
||||||
>
|
onClick={() => setPage("settings")}
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||||
设置
|
>
|
||||||
</Button>
|
<Settings className="h-4 w-4" />
|
||||||
</div>
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
{page === "tools" ? (
|
{page === "tools" ? (
|
||||||
<div className="space-y-3 p-3 text-sm">
|
<div className="space-y-3 p-3 text-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -687,6 +650,7 @@ export function OnlyOfficeAiAgentPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</AiBridgePanel>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { isPlainObject } from "@/lib/type-guards";
|
||||||
|
|
||||||
|
export type HermesBridgeConfig = {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HermesRunRequest = {
|
||||||
|
input: Array<{ role: string; content: string }> | string;
|
||||||
|
instructions?: string;
|
||||||
|
conversation_history?: Array<{ role: string; content: string }>;
|
||||||
|
session_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HermesRunStarted = {
|
||||||
|
runId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HermesRunEvent =
|
||||||
|
| { event: "tool.started"; tool: string; preview?: string | null }
|
||||||
|
| { event: "tool.completed"; tool: string; duration?: number; error?: boolean }
|
||||||
|
| { event: "message.delta"; delta: string }
|
||||||
|
| { event: "run.completed"; output?: string; usage?: Record<string, unknown> }
|
||||||
|
| { event: "run.failed"; error?: string }
|
||||||
|
| { event: string; [key: string]: unknown };
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL = "http://127.0.0.1:8642";
|
||||||
|
|
||||||
|
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
|
||||||
|
|
||||||
|
export const readHermesBridgeConfig = (): HermesBridgeConfig => ({
|
||||||
|
baseUrl: trimTrailingSlash((process.env.MNOTE_HERMES_API_BASE_URL || "").trim() || DEFAULT_BASE_URL),
|
||||||
|
apiKey: (process.env.MNOTE_HERMES_API_KEY || "").trim() || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buildHermesHeaders = (config: HermesBridgeConfig, init?: HeadersInit) => {
|
||||||
|
const headers = new Headers(init);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
if (config.apiKey) {
|
||||||
|
headers.set("Authorization", `Bearer ${config.apiKey}`);
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startHermesRun = async (payload: HermesRunRequest): Promise<HermesRunStarted> => {
|
||||||
|
const config = readHermesBridgeConfig();
|
||||||
|
const response = await fetch(`${config.baseUrl}/v1/runs`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: buildHermesHeaders(config),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => "");
|
||||||
|
throw new Error(text || `Hermes run 启动失败:HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await response.json().catch(() => null)) as unknown;
|
||||||
|
const runId = isPlainObject(json) ? String(json.run_id ?? "").trim() : "";
|
||||||
|
if (!runId) throw new Error("Hermes run 响应缺少 run_id");
|
||||||
|
return { runId };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const streamHermesRunEvents = async (
|
||||||
|
runId: string,
|
||||||
|
onEvent: (event: HermesRunEvent) => Promise<void> | void,
|
||||||
|
) => {
|
||||||
|
const config = readHermesBridgeConfig();
|
||||||
|
const response = await fetch(`${config.baseUrl}/v1/runs/${encodeURIComponent(runId)}/events`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: buildHermesHeaders(config),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => "");
|
||||||
|
throw new Error(text || `Hermes 事件流连接失败:HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
if (!response.body) throw new Error("Hermes 事件流不支持 body");
|
||||||
|
|
||||||
|
const reader = response.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);
|
||||||
|
|
||||||
|
if (raw.trimStart().startsWith(":")) continue;
|
||||||
|
|
||||||
|
const dataLines = raw
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => line.startsWith("data:"))
|
||||||
|
.map((line) => line.slice("data:".length).trimStart());
|
||||||
|
if (dataLines.length === 0) continue;
|
||||||
|
|
||||||
|
let parsed: unknown = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(dataLines.join("\n"));
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isPlainObject(parsed)) continue;
|
||||||
|
await onEvent(parsed as HermesRunEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { runAiAgent } from "./runAgent";
|
||||||
|
import type { OpenAiCompatibleChatMessage } from "@/lib/ai/openaiCompatibleChat";
|
||||||
|
|
||||||
|
describe("runAiAgent", () => {
|
||||||
|
it("按顺序执行 docs_search 和 docs_read 工具链", async () => {
|
||||||
|
const events: Array<{ type: string; data: unknown }> = [];
|
||||||
|
const chatCalls: OpenAiCompatibleChatMessage[][] = [];
|
||||||
|
const runTool = vi.fn(async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||||
|
if (toolId === "docs_search") {
|
||||||
|
expect(toolArgs).toEqual({
|
||||||
|
query: "Rust runtime 收口",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
query: "Rust runtime 收口",
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: "page_1",
|
||||||
|
title: "Rust 文档",
|
||||||
|
snippet: "这里记录 rust runtime 收口",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
source: "convex",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolId === "docs_read") {
|
||||||
|
expect(toolArgs).toEqual({
|
||||||
|
documentId: "page_1",
|
||||||
|
maxChars: 200,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
documentId: "page_1",
|
||||||
|
title: "Rust 文档",
|
||||||
|
rawText: "这里记录 rust runtime 收口",
|
||||||
|
rawTextLength: 24,
|
||||||
|
source: "convex",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`未知工具: ${toolId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
let chatStep = 0;
|
||||||
|
const chat = vi.fn(async (messages: OpenAiCompatibleChatMessage[]) => {
|
||||||
|
chatCalls.push(messages);
|
||||||
|
chatStep += 1;
|
||||||
|
|
||||||
|
if (chatStep === 1) {
|
||||||
|
return {
|
||||||
|
text: '<docs_search>{"query":"Rust runtime 收口","limit":5}</docs_search>',
|
||||||
|
raw: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chatStep === 2) {
|
||||||
|
expect(messages.at(-1)?.content).toContain('<tool_result tool="docs_search">');
|
||||||
|
expect(messages.at(-1)?.content).toContain('"id":"page_1"');
|
||||||
|
return {
|
||||||
|
text: '<docs_read>{"documentId":"page_1","maxChars":200}</docs_read>',
|
||||||
|
raw: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messages.at(-1)?.content).toContain('<tool_result tool="docs_read">');
|
||||||
|
expect(messages.at(-1)?.content).toContain("rust runtime 收口");
|
||||||
|
return {
|
||||||
|
text: "已找到目标文档并读取原文。",
|
||||||
|
raw: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runAiAgent({
|
||||||
|
userMessages: [{ role: "user", content: "请帮我查找 Rust runtime 收口的相关文档" }],
|
||||||
|
cfg: {
|
||||||
|
baseUrl: "http://127.0.0.1:11434/v1",
|
||||||
|
apiKey: "",
|
||||||
|
model: "test-model",
|
||||||
|
},
|
||||||
|
chat,
|
||||||
|
allowedToolIds: new Set(["docs_search", "docs_read"]),
|
||||||
|
runTool,
|
||||||
|
maxSteps: 4,
|
||||||
|
onEvent: (event) => events.push(event),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
text: "已找到目标文档并读取原文。",
|
||||||
|
steps: 3,
|
||||||
|
});
|
||||||
|
expect(chat).toHaveBeenCalledTimes(3);
|
||||||
|
expect(runTool).toHaveBeenCalledTimes(2);
|
||||||
|
expect(runTool).toHaveBeenNthCalledWith(1, "docs_search", {
|
||||||
|
query: "Rust runtime 收口",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
expect(runTool).toHaveBeenNthCalledWith(2, "docs_read", {
|
||||||
|
documentId: "page_1",
|
||||||
|
maxChars: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(chatCalls[0]?.[0]?.role).toBe("system");
|
||||||
|
expect(chatCalls[0]?.[1]?.content).toBe("请帮我查找 Rust runtime 收口的相关文档");
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "tool_call",
|
||||||
|
data: {
|
||||||
|
id: expect.any(String),
|
||||||
|
tool: "docs_search",
|
||||||
|
args: {
|
||||||
|
query: "Rust runtime 收口",
|
||||||
|
limit: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
data: {
|
||||||
|
id: expect.any(String),
|
||||||
|
tool: "docs_search",
|
||||||
|
ok: true,
|
||||||
|
ms: expect.any(Number),
|
||||||
|
result: {
|
||||||
|
query: "Rust runtime 收口",
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: "page_1",
|
||||||
|
title: "Rust 文档",
|
||||||
|
snippet: "这里记录 rust runtime 收口",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
source: "convex",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool_call",
|
||||||
|
data: {
|
||||||
|
id: expect.any(String),
|
||||||
|
tool: "docs_read",
|
||||||
|
args: {
|
||||||
|
documentId: "page_1",
|
||||||
|
maxChars: 200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
data: {
|
||||||
|
id: expect.any(String),
|
||||||
|
tool: "docs_read",
|
||||||
|
ok: true,
|
||||||
|
ms: expect.any(Number),
|
||||||
|
result: {
|
||||||
|
documentId: "page_1",
|
||||||
|
title: "Rust 文档",
|
||||||
|
rawText: "这里记录 rust runtime 收口",
|
||||||
|
rawTextLength: 24,
|
||||||
|
source: "convex",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assistant_message",
|
||||||
|
data: {
|
||||||
|
text: "已找到目标文档并读取原文。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// 遗留兼容:runAiAgent 已退出 /api/ai-agent/run 主链,保留此文件仅用于历史测试与渐进清理。
|
||||||
import type { OpenAiCompatibleChatMessage, OpenAiCompatibleChatOptions } from "@/lib/ai/openaiCompatibleChat";
|
import type { OpenAiCompatibleChatMessage, OpenAiCompatibleChatOptions } from "@/lib/ai/openaiCompatibleChat";
|
||||||
import { openAiCompatibleChat } from "@/lib/ai/openaiCompatibleChat";
|
import { openAiCompatibleChat } from "@/lib/ai/openaiCompatibleChat";
|
||||||
import { parseToolTagCalls, formatToolResultTag } from "../protocol/toolTagProtocol";
|
import { parseToolTagCalls, formatToolResultTag } from "../protocol/toolTagProtocol";
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ export const createDocsServerTools = (args: {
|
|||||||
supabase?: DocsSupabaseClient;
|
supabase?: DocsSupabaseClient;
|
||||||
ctx: DocsToolContext;
|
ctx: DocsToolContext;
|
||||||
allowedToolIds: Set<string>;
|
allowedToolIds: Set<string>;
|
||||||
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
|
// 说明:该文件现在主要服务于非 Convex 模式或兼容兜底;Convex 主链下 docs_* 已改由 Rust runtime 产出结果。
|
||||||
|
// 说明:如果提供该能力,则走本地兼容 transport,不依赖 Supabase。
|
||||||
searchDocs?: (args: {
|
searchDocs?: (args: {
|
||||||
userId: string;
|
userId: string;
|
||||||
query: string;
|
query: string;
|
||||||
|
|||||||
@@ -446,10 +446,10 @@ export const createMindmapServerTools = (args: {
|
|||||||
invocationKind: "command",
|
invocationKind: "command",
|
||||||
toolArgs: withMindmapIds({ ops: normalized, reason }),
|
toolArgs: withMindmapIds({ ops: normalized, reason }),
|
||||||
data: base,
|
data: base,
|
||||||
target: mindmapTarget(doc),
|
target: mindmapTarget(loaded.doc),
|
||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
await persistResultData(doc, result);
|
await persistResultData(loaded.doc, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, normalized);
|
const { data: nextData, applied, errors } = applyMindmapOps(base, normalized);
|
||||||
@@ -460,7 +460,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
opCount: normalized.length,
|
opCount: normalized.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await persistMindmap(doc, nextData);
|
await persistMindmap(loaded.doc, nextData);
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { applyMindmapOps, ensureMindmapUids, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
|
import { ensureMindmapUids, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
|
||||||
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
||||||
|
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
|
||||||
|
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
type SupabaseRouteClient = {
|
type SupabaseRouteClient = {
|
||||||
from: (table: string) => any;
|
from: (table: string) => any;
|
||||||
@@ -229,6 +231,21 @@ export const createOnlyOfficeServerTools = (args: {
|
|||||||
ctx: OnlyOfficeToolContext;
|
ctx: OnlyOfficeToolContext;
|
||||||
allowedToolIds: Set<string>;
|
allowedToolIds: Set<string>;
|
||||||
}) => {
|
}) => {
|
||||||
|
const buildRustContext = () =>
|
||||||
|
buildDocumentBridgeContextWithActor({
|
||||||
|
request: new Request("http://localhost"),
|
||||||
|
actor: {
|
||||||
|
actorType: "service",
|
||||||
|
actorId: "onlyoffice-asset-to-mindmap",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
workspaceId: null,
|
||||||
|
source: {
|
||||||
|
channel: "onlyoffice-asset-to-mindmap",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const asset_extract_outline = async (toolArgs: Record<string, unknown>) => {
|
const asset_extract_outline = async (toolArgs: Record<string, unknown>) => {
|
||||||
const assetId = String(toolArgs.assetId ?? "").trim();
|
const assetId = String(toolArgs.assetId ?? "").trim();
|
||||||
const attachmentRef = String(toolArgs.attachmentRef ?? "").trim();
|
const attachmentRef = String(toolArgs.attachmentRef ?? "").trim();
|
||||||
@@ -308,11 +325,13 @@ export const createOnlyOfficeServerTools = (args: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const asset_to_mindmap = async (toolArgs: Record<string, unknown>) => {
|
const asset_to_mindmap = async (toolArgs: Record<string, unknown>) => {
|
||||||
|
// 说明:附件大纲提取仍在 TS/MinerU 侧,真正的导图写入由 Rust mindmap_apply_ops 负责。
|
||||||
const mindmapId = String(toolArgs.mindmapId ?? "").trim();
|
const mindmapId = String(toolArgs.mindmapId ?? "").trim();
|
||||||
if (!mindmapId) throw new Error("缺少 mindmapId");
|
if (!mindmapId) throw new Error("缺少 mindmapId");
|
||||||
const parentUidArg = String(toolArgs.parentUid ?? "").trim();
|
const parentUidArg = String(toolArgs.parentUid ?? "").trim();
|
||||||
const maxItemsRaw = Number(toolArgs.maxItems ?? 120);
|
const maxItemsRaw = Number(toolArgs.maxItems ?? 120);
|
||||||
const maxItems = Number.isFinite(maxItemsRaw) ? Math.max(10, Math.min(600, Math.floor(maxItemsRaw))) : 120;
|
const maxItems = Number.isFinite(maxItemsRaw) ? Math.max(10, Math.min(600, Math.floor(maxItemsRaw))) : 120;
|
||||||
|
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||||
|
|
||||||
const documentId = String(args.ctx.documentId ?? "").trim();
|
const documentId = String(args.ctx.documentId ?? "").trim();
|
||||||
if (!documentId) throw new Error("缺少 documentId 上下文(OnlyOffice 工具需要落盘到指定文档)");
|
if (!documentId) throw new Error("缺少 documentId 上下文(OnlyOffice 工具需要落盘到指定文档)");
|
||||||
@@ -356,8 +375,39 @@ export const createOnlyOfficeServerTools = (args: {
|
|||||||
if (!findNodeByUid(base, parentUid)) throw new Error("未找到 parentUid 对应节点");
|
if (!findNodeByUid(base, parentUid)) throw new Error("未找到 parentUid 对应节点");
|
||||||
|
|
||||||
const ops = buildOutlineOps({ parentUid, items, attachment });
|
const ops = buildOutlineOps({ parentUid, items, attachment });
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, ops);
|
|
||||||
await writeMindmapLocal(documentId, mindmapId, nextData, "OnlyOffice 生成导图");
|
const rustContext = buildRustContext();
|
||||||
|
const rustResult = await executeRustBridgeTool<{
|
||||||
|
ok: boolean;
|
||||||
|
applied?: number;
|
||||||
|
errors?: string[];
|
||||||
|
data?: unknown;
|
||||||
|
meta?: { reason?: string | null } | null;
|
||||||
|
}>({
|
||||||
|
context: rustContext,
|
||||||
|
toolName: "mindmap_apply_ops",
|
||||||
|
invocationKind: "command",
|
||||||
|
args: {
|
||||||
|
ops,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
data: base,
|
||||||
|
target: {
|
||||||
|
pageId: documentId,
|
||||||
|
workspaceId: null,
|
||||||
|
blockId: mindmapId,
|
||||||
|
},
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultData = rustResult.result && typeof rustResult.result === "object" ? (rustResult.result as Record<string, unknown>).data : null;
|
||||||
|
const nextData = resultData && typeof resultData === "object" && !Array.isArray(resultData) ? (resultData as MindmapTreeNode) : base;
|
||||||
|
await writeMindmapLocal(documentId, mindmapId, nextData, "OnlyOffice 生成导图(Rust)");
|
||||||
|
|
||||||
|
const applied = Number((rustResult.result as Record<string, unknown> | undefined)?.applied ?? ops.length) || 0;
|
||||||
|
const errors = Array.isArray((rustResult.result as Record<string, unknown> | undefined)?.errors)
|
||||||
|
? ((rustResult.result as Record<string, unknown>).errors as string[])
|
||||||
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -371,6 +421,8 @@ export const createOnlyOfficeServerTools = (args: {
|
|||||||
fileName: attachment.title,
|
fileName: attachment.title,
|
||||||
items: items.length,
|
items: items.length,
|
||||||
strategy: String(outlineResult.strategy ?? ""),
|
strategy: String(outlineResult.strategy ?? ""),
|
||||||
|
rustOwner: "mindmap_apply_ops",
|
||||||
|
rustReason: reason,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// 遗留兼容:builtin 声明仍保留给历史兼容与文档对照,前端主链不再从这里继续扩展 Hermes 前置编排。
|
||||||
import type { AiAgentTool, AiAgentToolSet } from "../types";
|
import type { AiAgentTool, AiAgentToolSet } from "../types";
|
||||||
|
|
||||||
export const builtinTools: AiAgentTool[] = [
|
export const builtinTools: AiAgentTool[] = [
|
||||||
@@ -409,6 +410,18 @@ export type BuiltinRustCutoverBinding = {
|
|||||||
* 完整的一一对应矩阵见 `design/ai-tool-cutover-matrix.md`。
|
* 完整的一一对应矩阵见 `design/ai-tool-cutover-matrix.md`。
|
||||||
*/
|
*/
|
||||||
export const builtinRustCutoverBindings: Record<string, BuiltinRustCutoverBinding> = {
|
export const builtinRustCutoverBindings: Record<string, BuiltinRustCutoverBinding> = {
|
||||||
|
docs_search: {
|
||||||
|
rustToolsetId: "toolset.docs_read",
|
||||||
|
rustToolName: "docs_search",
|
||||||
|
status: "rust",
|
||||||
|
note: "跨页文档搜索已切到 Rust runtime,TS 仅负责拉取搜索数据集 transport。",
|
||||||
|
},
|
||||||
|
docs_read: {
|
||||||
|
rustToolsetId: "toolset.docs_read",
|
||||||
|
rustToolName: "docs_read",
|
||||||
|
status: "rust",
|
||||||
|
note: "跨页文档读取结果已由 Rust runtime 统一裁剪与归一化,TS 仅负责读取目标文档 transport。",
|
||||||
|
},
|
||||||
search_web: {
|
search_web: {
|
||||||
rustToolsetId: "toolset.readonly",
|
rustToolsetId: "toolset.readonly",
|
||||||
rustToolName: "search_web",
|
rustToolName: "search_web",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// 遗留兼容:当前 AI 主链已转到 Hermes bridge,这里的 registry 仅保留给历史测试/兼容调用,不再作为前端主编排入口。
|
||||||
import type { AiAgentTool, AiAgentToolSet, ToolPermissions } from "./types";
|
import type { AiAgentTool, AiAgentToolSet, ToolPermissions } from "./types";
|
||||||
|
|
||||||
export type ToolRegistry = {
|
export type ToolRegistry = {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { api } from "@/lib/convex/api";
|
import { api } from "@/lib/convex/api";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +17,7 @@ import {
|
|||||||
resolveRustBridgeCommandPlan,
|
resolveRustBridgeCommandPlan,
|
||||||
resolveRustBridgeQueryPlan,
|
resolveRustBridgeQueryPlan,
|
||||||
} from "@/lib/documents/rust-runtime";
|
} from "@/lib/documents/rust-runtime";
|
||||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
import { extractBlocksFromContent } from "@/lib/document-content";
|
||||||
|
|
||||||
type BlockLike = {
|
type BlockLike = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -91,20 +90,6 @@ function replaceBlockInTree(blocks: BlockLike[], blockId: string, nextBlock: unk
|
|||||||
return { ok: true, nextBlocks: nextTop };
|
return { ok: true, nextBlocks: nextTop };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReferenceBlock(sourceDocumentId: string, blockId: string): BlockLike {
|
|
||||||
return {
|
|
||||||
id: randomUUID(),
|
|
||||||
type: "blockReference",
|
|
||||||
props: {
|
|
||||||
sourceDocumentId,
|
|
||||||
targetBlockId: blockId,
|
|
||||||
display: "embed",
|
|
||||||
},
|
|
||||||
content: [],
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildBridgeContext(request: Request, workspaceId: string | null): Promise<BridgeContext> {
|
async function buildBridgeContext(request: Request, workspaceId: string | null): Promise<BridgeContext> {
|
||||||
return await buildDocumentBridgeContext({ request, workspaceId });
|
return await buildDocumentBridgeContext({ request, workspaceId });
|
||||||
}
|
}
|
||||||
@@ -183,10 +168,6 @@ export async function executeBlockPatchBridgeCommand(input: {
|
|||||||
client,
|
client,
|
||||||
plan,
|
plan,
|
||||||
});
|
});
|
||||||
await client.mutation(api.documents.updateContent, {
|
|
||||||
id: input.sourceDocumentId,
|
|
||||||
content: composeContentWithBlocks(doc.content, replaced.nextBlocks as never),
|
|
||||||
});
|
|
||||||
await recordBridgeCommandArtifacts({ context, envelope });
|
await recordBridgeCommandArtifacts({ context, envelope });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await recordBridgeCommandFailureArtifacts({
|
await recordBridgeCommandFailureArtifacts({
|
||||||
@@ -220,9 +201,6 @@ export async function executeBlockMoveBridgeCommand(input: {
|
|||||||
const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
|
const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
|
||||||
const removedRes = removeBlockSubtree(sourceBlocks, input.blockId);
|
const removedRes = removeBlockSubtree(sourceBlocks, input.blockId);
|
||||||
if (!removedRes.removed) throw new Error("源块不存在或无权限");
|
if (!removedRes.removed) throw new Error("源块不存在或无权限");
|
||||||
const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
|
|
||||||
const nextSourceContent = composeContentWithBlocks(source.content, removedRes.nextBlocks as never);
|
|
||||||
const nextTargetContent = composeContentWithBlocks(target.content, [...targetBlocks, removedRes.removed] as never);
|
|
||||||
const context = await buildBridgeContext(input.request, null);
|
const context = await buildBridgeContext(input.request, null);
|
||||||
const envelope = buildDocumentCommandEnvelope({
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
name: "blocks.move",
|
name: "blocks.move",
|
||||||
@@ -240,8 +218,6 @@ export async function executeBlockMoveBridgeCommand(input: {
|
|||||||
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||||
try {
|
try {
|
||||||
await executeRustBridgeMutationTransport({ client, plan });
|
await executeRustBridgeMutationTransport({ client, plan });
|
||||||
await client.mutation(api.documents.updateContent, { id: input.sourceDocumentId, content: nextSourceContent });
|
|
||||||
await client.mutation(api.documents.updateContent, { id: input.targetDocumentId, content: nextTargetContent });
|
|
||||||
await recordBridgeCommandArtifacts({ context, envelope });
|
await recordBridgeCommandArtifacts({ context, envelope });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await recordBridgeCommandFailureArtifacts({
|
await recordBridgeCommandFailureArtifacts({
|
||||||
@@ -276,18 +252,14 @@ export async function executeBlockEmbedBridgeCommand(input: {
|
|||||||
const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
|
const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
|
||||||
const hit = findBlockInTree(sourceBlocks, input.blockId);
|
const hit = findBlockInTree(sourceBlocks, input.blockId);
|
||||||
if (!hit) throw new Error("源块不存在或无权限");
|
if (!hit) throw new Error("源块不存在或无权限");
|
||||||
const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
|
|
||||||
const anchorId = (targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id ?? null;
|
const anchorId = (targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id ?? null;
|
||||||
const anchorIndex =
|
const anchorIndex =
|
||||||
typeof anchorId === "string" && anchorId.trim()
|
typeof anchorId === "string" && anchorId.trim()
|
||||||
? targetBlocks.findIndex((block) => String(block.id ?? "") === anchorId)
|
? extractBlocksFromContent(target.content).findIndex((block) => String((block as BlockLike).id ?? "") === anchorId)
|
||||||
: -1;
|
: -1;
|
||||||
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : targetBlocks.length;
|
const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
|
||||||
const nextTargetBlocks = [
|
void targetBlocks;
|
||||||
...targetBlocks.slice(0, insertIndex),
|
void anchorIndex;
|
||||||
buildReferenceBlock(input.sourceDocumentId, input.blockId),
|
|
||||||
...targetBlocks.slice(insertIndex),
|
|
||||||
];
|
|
||||||
const context = await buildBridgeContext(input.request, null);
|
const context = await buildBridgeContext(input.request, null);
|
||||||
const envelope = buildDocumentCommandEnvelope({
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
name: "blocks.embed",
|
name: "blocks.embed",
|
||||||
@@ -305,10 +277,6 @@ export async function executeBlockEmbedBridgeCommand(input: {
|
|||||||
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||||
try {
|
try {
|
||||||
await executeRustBridgeMutationTransport({ client, plan });
|
await executeRustBridgeMutationTransport({ client, plan });
|
||||||
await client.mutation(api.documents.updateContent, {
|
|
||||||
id: input.targetDocumentId,
|
|
||||||
content: composeContentWithBlocks(target.content, nextTargetBlocks as never),
|
|
||||||
});
|
|
||||||
await recordBridgeCommandArtifacts({ context, envelope });
|
await recordBridgeCommandArtifacts({ context, envelope });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await recordBridgeCommandFailureArtifacts({
|
await recordBridgeCommandFailureArtifacts({
|
||||||
|
|||||||
@@ -12,16 +12,74 @@ type DocumentMetaResponse<T> = {
|
|||||||
meta: BridgeMeta;
|
meta: BridgeMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getServerRequestOrigin(headerList: Headers): string {
|
function getHeaderFirstValue(value: string | null): string {
|
||||||
const forwardedProto = headerList.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
return String(value || "")
|
||||||
const forwardedHost = headerList.get("x-forwarded-host")?.split(",")[0]?.trim();
|
.split(",")[0]
|
||||||
const host = forwardedHost || headerList.get("host");
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHostParts(host: string): { hostname: string; port: string } {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(`http://${host}`);
|
||||||
|
return {
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
hostname: host,
|
||||||
|
port: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalHostname(hostname: string): boolean {
|
||||||
|
const normalized = hostname.replace(/^\[(.*)\]$/, "$1").trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized === "localhost" ||
|
||||||
|
normalized === "127.0.0.1" ||
|
||||||
|
normalized === "::1" ||
|
||||||
|
normalized === "0.0.0.0" ||
|
||||||
|
normalized === "::"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildServerRequestOriginCandidates(headerList: Headers): string[] {
|
||||||
|
const forwardedProto = getHeaderFirstValue(headerList.get("x-forwarded-proto"));
|
||||||
|
const forwardedHost = getHeaderFirstValue(headerList.get("x-forwarded-host"));
|
||||||
|
const host = forwardedHost || getHeaderFirstValue(headerList.get("host"));
|
||||||
|
|
||||||
if (!host) {
|
if (!host) {
|
||||||
throw new Error("缺少 host 头,无法构造 bridge 请求地址");
|
throw new Error("缺少 host 头,无法构造 bridge 请求地址");
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${forwardedProto || "http"}://${host}`;
|
const { hostname, port } = parseHostParts(host);
|
||||||
|
const candidates = new Set<string>();
|
||||||
|
const addCandidate = (proto: string, candidateHost: string) => {
|
||||||
|
const normalizedProto = String(proto || "http").trim().toLowerCase() || "http";
|
||||||
|
const normalizedHost = String(candidateHost || "").trim();
|
||||||
|
if (!normalizedHost) return;
|
||||||
|
candidates.add(`${normalizedProto}://${normalizedHost}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 说明:优先保留浏览器实际请求的 origin,用于正常的同源自调用。
|
||||||
|
addCandidate(forwardedProto || "http", host);
|
||||||
|
|
||||||
|
// 说明:某些反代会把 x-forwarded-proto 设成 https,但本机 dev server 实际只监听 http。
|
||||||
|
// 这里补一个 http 候选,避免 SSR 自请求被错误协议直接打挂。
|
||||||
|
if ((forwardedProto || "").trim().toLowerCase() === "https") {
|
||||||
|
addCandidate("http", host);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 说明:localhost / 0.0.0.0 / ::1 这类本机地址在 Node 侧自调用时最容易踩解析差异,
|
||||||
|
// 统一补 127.0.0.1 与 localhost 两个稳定候选,避免 fetch 因回环地址选择失败。
|
||||||
|
if (isLocalHostname(hostname)) {
|
||||||
|
const portSuffix = port ? `:${port}` : "";
|
||||||
|
addCandidate("http", `127.0.0.1${portSuffix}`);
|
||||||
|
addCandidate("http", `localhost${portSuffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...candidates];
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
|
function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
|
||||||
@@ -31,19 +89,21 @@ function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchDocumentMetaResponse(url: URL, headersToSend: Headers): Promise<Response> {
|
||||||
|
return await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: headersToSend,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchDocumentMetaViaBridge<T>(input: {
|
export async function fetchDocumentMetaViaBridge<T>(input: {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
}): Promise<DocumentMetaResponse<T> | null> {
|
}): Promise<DocumentMetaResponse<T> | null> {
|
||||||
const headerList = await headers();
|
const headerList = await headers();
|
||||||
const requestHeaders = new Headers();
|
const requestHeaders = new Headers();
|
||||||
const origin = getServerRequestOrigin(headerList);
|
const originCandidates = buildServerRequestOriginCandidates(headerList);
|
||||||
const url = new URL("/api/documents/meta", origin);
|
|
||||||
|
|
||||||
url.searchParams.set("documentId", input.documentId);
|
|
||||||
if (input.workspaceId?.trim()) {
|
|
||||||
url.searchParams.set("workspaceId", input.workspaceId.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
copyHeaderIfPresent(requestHeaders, headerList, "cookie");
|
copyHeaderIfPresent(requestHeaders, headerList, "cookie");
|
||||||
copyHeaderIfPresent(requestHeaders, headerList, "authorization");
|
copyHeaderIfPresent(requestHeaders, headerList, "authorization");
|
||||||
@@ -54,11 +114,34 @@ export async function fetchDocumentMetaViaBridge<T>(input: {
|
|||||||
copyHeaderIfPresent(requestHeaders, headerList, "x-source-client");
|
copyHeaderIfPresent(requestHeaders, headerList, "x-source-client");
|
||||||
copyHeaderIfPresent(requestHeaders, headerList, "user-agent");
|
copyHeaderIfPresent(requestHeaders, headerList, "user-agent");
|
||||||
|
|
||||||
const response = await fetch(url, {
|
let response: Response | null = null;
|
||||||
method: "GET",
|
let lastError: unknown = null;
|
||||||
headers: requestHeaders,
|
const errorMessages: string[] = [];
|
||||||
cache: "no-store",
|
|
||||||
});
|
for (const origin of originCandidates) {
|
||||||
|
const url = new URL("/api/documents/meta", origin);
|
||||||
|
url.searchParams.set("documentId", input.documentId);
|
||||||
|
if (input.workspaceId?.trim()) {
|
||||||
|
url.searchParams.set("workspaceId", input.workspaceId.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetchDocumentMetaResponse(url, requestHeaders);
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
errorMessages.push(`${url.toString()} => ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
const message =
|
||||||
|
errorMessages.length > 0
|
||||||
|
? `bridge 自请求失败:${errorMessages.join(" | ")}`
|
||||||
|
: "bridge 自请求失败:未生成可用的请求地址";
|
||||||
|
throw new Error(message, { cause: lastError instanceof Error ? lastError : undefined });
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
|
|||||||
"documents.restore": "documents:restore",
|
"documents.restore": "documents:restore",
|
||||||
"documents.duplicate": "documents:duplicateWithMindmaps",
|
"documents.duplicate": "documents:duplicateWithMindmaps",
|
||||||
"documents.copy_tree": "documents:copyTree",
|
"documents.copy_tree": "documents:copyTree",
|
||||||
|
"documents.template": "documents:setTemplate",
|
||||||
|
"documents.emptyTrashByWorkspace": "documents:emptyTrashByWorkspace",
|
||||||
|
"documents.purge": "documents:purge",
|
||||||
"blocks.patch": "documents:updateContent",
|
"blocks.patch": "documents:updateContent",
|
||||||
"blocks.move": "documents:updateContent",
|
"blocks.move": "documents:updateContent",
|
||||||
"blocks.embed": "documents:updateContent",
|
"blocks.embed": "documents:updateContent",
|
||||||
@@ -130,6 +133,10 @@ const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
|
|||||||
"documents.stats.update": "documents:updateStats",
|
"documents.stats.update": "documents:updateStats",
|
||||||
"documents.options.update": "documents:updateOptions",
|
"documents.options.update": "documents:updateOptions",
|
||||||
"documents.save": "documents:updateContent",
|
"documents.save": "documents:updateContent",
|
||||||
|
"mindmaps.delete": "mindmaps:softDelete",
|
||||||
|
"mindmaps.restore": "mindmaps:restore",
|
||||||
|
"mindmaps.purge": "mindmaps:purge",
|
||||||
|
"mindmaps.emptyTrashByWorkspace": "mindmaps:emptyTrashByWorkspace",
|
||||||
"media.assets.replace_storage": "mediaAssets:replaceStorageFromUpload",
|
"media.assets.replace_storage": "mediaAssets:replaceStorageFromUpload",
|
||||||
} as const satisfies Record<string, string>;
|
} as const satisfies Record<string, string>;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
executeRustBridgeMutationTransport,
|
executeRustBridgeMutationTransport,
|
||||||
resolveRustBridgeCommandPlan,
|
resolveRustBridgeCommandPlan,
|
||||||
} from "@/lib/documents/rust-runtime";
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
|
||||||
|
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
||||||
import type { Json } from "@/types/supabase";
|
import type { Json } from "@/types/supabase";
|
||||||
|
|
||||||
export type DocumentCreatePayload = {
|
export type DocumentCreatePayload = {
|
||||||
@@ -64,6 +66,19 @@ export type DocumentCopyTreePayload = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DocumentTemplatePayload = {
|
||||||
|
documentId: string;
|
||||||
|
isTemplate: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentEmptyTrashPayload = {
|
||||||
|
workspaceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentPurgePayload = {
|
||||||
|
documentId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PageCommandExecutionResult<TResult> = {
|
export type PageCommandExecutionResult<TResult> = {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
traceId: string;
|
traceId: string;
|
||||||
@@ -103,52 +118,37 @@ async function buildRuntimeContext(request: Request, workspaceId: string | null)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recordLifecycleArtifacts<T>(input: {
|
|
||||||
context: BridgeContext;
|
|
||||||
envelope: CommandEnvelope<T>;
|
|
||||||
client: ConvexHttpClient;
|
|
||||||
}) {
|
|
||||||
await recordBridgeCommandArtifacts({
|
|
||||||
context: input.context,
|
|
||||||
envelope: input.envelope,
|
|
||||||
client: input.client,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function recordLifecycleFailureArtifacts<T>(input: {
|
|
||||||
context: BridgeContext;
|
|
||||||
envelope: CommandEnvelope<T>;
|
|
||||||
client: ConvexHttpClient;
|
|
||||||
error: unknown;
|
|
||||||
}) {
|
|
||||||
await recordBridgeCommandFailureArtifacts({
|
|
||||||
context: input.context,
|
|
||||||
envelope: input.envelope,
|
|
||||||
client: input.client,
|
|
||||||
error: input.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function executePageLifecycleBridgeCommand<TPayload, TResult>(input: {
|
export async function executePageLifecycleBridgeCommand<TPayload, TResult>(input: {
|
||||||
context: BridgeContext;
|
context: BridgeContext;
|
||||||
envelope: CommandEnvelope<TPayload>;
|
envelope: CommandEnvelope<TPayload>;
|
||||||
client?: ConvexHttpClient;
|
client?: ConvexHttpClient;
|
||||||
}): Promise<PageCommandExecutionResult<TResult>> {
|
}): Promise<PageCommandExecutionResult<TResult>> {
|
||||||
const client = input.client ?? (await getAuthedConvexClient()).client;
|
const client = input.client ?? (await getAuthedConvexClient()).client;
|
||||||
const plan = await resolveRustBridgeCommandPlan({
|
let result;
|
||||||
context: input.context,
|
try {
|
||||||
envelope: input.envelope,
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
});
|
context: input.context,
|
||||||
const result = await executeRustBridgeMutationTransport<TResult>({
|
envelope: input.envelope,
|
||||||
client,
|
});
|
||||||
plan,
|
result = await executeRustBridgeMutationTransport<TResult>({
|
||||||
});
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
|
||||||
await recordBridgeCommandArtifacts({
|
await recordBridgeCommandArtifacts({
|
||||||
context: input.context,
|
context: input.context,
|
||||||
envelope: input.envelope,
|
envelope: input.envelope,
|
||||||
client,
|
client,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await recordBridgeCommandFailureArtifacts({
|
||||||
|
context: input.context,
|
||||||
|
envelope: input.envelope,
|
||||||
|
client,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requestId: input.context.requestId,
|
requestId: input.context.requestId,
|
||||||
@@ -200,7 +200,7 @@ export async function executeDocumentCreateChildBridgeCommand(request: Request):
|
|||||||
const pageId = safeRandomId();
|
const pageId = safeRandomId();
|
||||||
const context = await buildRuntimeContext(request, workspaceId);
|
const context = await buildRuntimeContext(request, workspaceId);
|
||||||
const envelope = buildDocumentCommandEnvelope({
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
name: "documents.createChild",
|
name: "documents.create",
|
||||||
payload: {
|
payload: {
|
||||||
documentId: pageId,
|
documentId: pageId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -216,40 +216,23 @@ export async function executeDocumentCreateChildBridgeCommand(request: Request):
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let created;
|
const created = await executePageLifecycleBridgeCommand<DocumentCreatePayload, {
|
||||||
try {
|
id: string;
|
||||||
created = await client.mutation(api.documents.create, {
|
title?: string | null;
|
||||||
id: pageId,
|
}>({
|
||||||
workspaceId,
|
context,
|
||||||
parentId,
|
envelope,
|
||||||
title: resolvedTitle,
|
client,
|
||||||
accessScope,
|
});
|
||||||
content: contentPayload,
|
|
||||||
});
|
|
||||||
|
|
||||||
await recordLifecycleArtifacts({
|
|
||||||
context,
|
|
||||||
envelope,
|
|
||||||
client,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
await recordLifecycleFailureArtifacts({
|
|
||||||
context,
|
|
||||||
envelope,
|
|
||||||
client,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
pageId: created.id,
|
pageId: created.result.id,
|
||||||
title: created.title ?? resolvedTitle,
|
title: created.result.title ?? resolvedTitle,
|
||||||
meta: {
|
meta: {
|
||||||
requestId: context.requestId,
|
requestId: created.requestId,
|
||||||
traceId: context.traceId,
|
traceId: created.traceId,
|
||||||
commandId: envelope.commandId,
|
commandId: created.commandId,
|
||||||
commandName: envelope.name,
|
commandName: created.commandName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -304,12 +287,23 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
|
|||||||
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
|
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
|
||||||
const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id) ?? normalizeWorkspaceId((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
|
const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id) ?? normalizeWorkspaceId((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
|
||||||
const context = await buildRuntimeContext(request, workspaceId);
|
const context = await buildRuntimeContext(request, workspaceId);
|
||||||
|
const savePayload = buildDocumentSavePayload({
|
||||||
|
documentId: normalizedTargetId,
|
||||||
|
workspaceId,
|
||||||
|
revision:
|
||||||
|
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
|
||||||
|
? targetContent.revision
|
||||||
|
: null,
|
||||||
|
content: payload,
|
||||||
|
conflictDetectionKey:
|
||||||
|
typeof targetContent.conflict_detection_key === "string"
|
||||||
|
? targetContent.conflict_detection_key
|
||||||
|
: null,
|
||||||
|
blockCount: nextBlocks.length,
|
||||||
|
});
|
||||||
const envelope = buildDocumentCommandEnvelope({
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
name: "documents.embed",
|
name: "documents.save",
|
||||||
payload: {
|
payload: savePayload,
|
||||||
sourceId: normalizedSourceId,
|
|
||||||
targetId: normalizedTargetId,
|
|
||||||
},
|
|
||||||
context,
|
context,
|
||||||
target: {
|
target: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -317,34 +311,18 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
const result = await executeSaveBridgeCommand({
|
||||||
await client.mutation(api.documents.updateContent, {
|
context,
|
||||||
id: normalizedTargetId,
|
envelope,
|
||||||
content: payload,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
await recordLifecycleArtifacts({
|
|
||||||
context,
|
|
||||||
envelope,
|
|
||||||
client,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
await recordLifecycleFailureArtifacts({
|
|
||||||
context,
|
|
||||||
envelope,
|
|
||||||
client,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
meta: {
|
meta: {
|
||||||
requestId: context.requestId,
|
requestId: result.requestId,
|
||||||
traceId: context.traceId,
|
traceId: result.traceId,
|
||||||
commandId: envelope.commandId,
|
commandId: result.commandId,
|
||||||
commandName: envelope.name,
|
commandName: result.commandName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -384,34 +362,22 @@ export async function executeDocumentTemplateBridgeCommand(request: Request): Pr
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
const result = await executePageLifecycleBridgeCommand<DocumentTemplatePayload, {
|
||||||
await client.mutation(api.documents.setTemplate, {
|
ok?: boolean;
|
||||||
id: normalizedDocumentId,
|
updated_at?: string | null;
|
||||||
isTemplate: payload.isTemplate,
|
}>({
|
||||||
});
|
context,
|
||||||
|
envelope,
|
||||||
await recordLifecycleArtifacts({
|
client,
|
||||||
context,
|
});
|
||||||
envelope,
|
|
||||||
client,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
await recordLifecycleFailureArtifacts({
|
|
||||||
context,
|
|
||||||
envelope,
|
|
||||||
client,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
meta: {
|
meta: {
|
||||||
requestId: context.requestId,
|
requestId: result.requestId,
|
||||||
traceId: context.traceId,
|
traceId: result.traceId,
|
||||||
commandId: envelope.commandId,
|
commandId: result.commandId,
|
||||||
commandName: envelope.name,
|
commandName: result.commandName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -442,31 +408,23 @@ export async function executeDocumentEmptyTrashBridgeCommand(request: Request):
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
const result = await executePageLifecycleBridgeCommand<DocumentEmptyTrashPayload, {
|
||||||
await client.mutation(api.documents.emptyTrashByWorkspace, { workspaceId });
|
ok?: boolean;
|
||||||
|
deletedCount?: number;
|
||||||
await recordLifecycleArtifacts({
|
}>({
|
||||||
context,
|
context,
|
||||||
envelope,
|
envelope,
|
||||||
client,
|
client,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
await recordLifecycleFailureArtifacts({
|
|
||||||
context,
|
|
||||||
envelope,
|
|
||||||
client,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
removed: typeof result.result?.deletedCount === "number" ? result.result.deletedCount : 0,
|
||||||
meta: {
|
meta: {
|
||||||
requestId: context.requestId,
|
requestId: result.requestId,
|
||||||
traceId: context.traceId,
|
traceId: result.traceId,
|
||||||
commandId: envelope.commandId,
|
commandId: result.commandId,
|
||||||
commandName: envelope.name,
|
commandName: result.commandName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -501,31 +459,24 @@ export async function executeDocumentPurgeBridgeCommand(request: Request): Promi
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
const result = await executePageLifecycleBridgeCommand<DocumentPurgePayload, {
|
||||||
await client.mutation(api.documents.purge, { id: normalizedDocumentId });
|
ok?: boolean;
|
||||||
|
purged?: boolean;
|
||||||
await recordLifecycleArtifacts({
|
purged_at?: string | null;
|
||||||
context,
|
}>({
|
||||||
envelope,
|
context,
|
||||||
client,
|
envelope,
|
||||||
});
|
client,
|
||||||
} catch (error) {
|
});
|
||||||
await recordLifecycleFailureArtifacts({
|
|
||||||
context,
|
|
||||||
envelope,
|
|
||||||
client,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
purged: result.result?.purged ?? true,
|
||||||
meta: {
|
meta: {
|
||||||
requestId: context.requestId,
|
requestId: result.requestId,
|
||||||
traceId: context.traceId,
|
traceId: result.traceId,
|
||||||
commandId: envelope.commandId,
|
commandId: result.commandId,
|
||||||
commandName: envelope.name,
|
commandName: result.commandName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -606,6 +606,19 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
|
|||||||
newId: assertStringArg(input.plan.argsJson, "newId"),
|
newId: assertStringArg(input.plan.argsJson, "newId"),
|
||||||
title: readOptionalStringArg(input.plan.argsJson, "title"),
|
title: readOptionalStringArg(input.plan.argsJson, "title"),
|
||||||
});
|
});
|
||||||
|
case "documents:setTemplate":
|
||||||
|
return mutation(api.documents.setTemplate, {
|
||||||
|
id: assertStringArg(input.plan.argsJson, "id"),
|
||||||
|
isTemplate: Boolean(input.plan.argsJson.isTemplate),
|
||||||
|
});
|
||||||
|
case "documents:emptyTrashByWorkspace":
|
||||||
|
return mutation(api.documents.emptyTrashByWorkspace, {
|
||||||
|
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||||
|
});
|
||||||
|
case "documents:purge":
|
||||||
|
return mutation(api.documents.purge, {
|
||||||
|
id: assertStringArg(input.plan.argsJson, "id"),
|
||||||
|
});
|
||||||
case "documents:updateTitle":
|
case "documents:updateTitle":
|
||||||
return mutation(api.documents.updateTitle, {
|
return mutation(api.documents.updateTitle, {
|
||||||
id: assertStringArg(input.plan.argsJson, "id"),
|
id: assertStringArg(input.plan.argsJson, "id"),
|
||||||
@@ -628,6 +641,25 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
|
|||||||
? input.plan.argsJson.createOnly
|
? input.plan.argsJson.createOnly
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
case "mindmaps:softDelete":
|
||||||
|
return mutation(api.mindmaps.softDelete, {
|
||||||
|
docId: assertStringArg(input.plan.argsJson, "docId"),
|
||||||
|
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
|
||||||
|
});
|
||||||
|
case "mindmaps:restore":
|
||||||
|
return mutation(api.mindmaps.restore, {
|
||||||
|
docId: assertStringArg(input.plan.argsJson, "docId"),
|
||||||
|
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
|
||||||
|
});
|
||||||
|
case "mindmaps:purge":
|
||||||
|
return mutation(api.mindmaps.purge, {
|
||||||
|
docId: assertStringArg(input.plan.argsJson, "docId"),
|
||||||
|
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
|
||||||
|
});
|
||||||
|
case "mindmaps:emptyTrashByWorkspace":
|
||||||
|
return mutation(api.mindmaps.emptyTrashByWorkspace, {
|
||||||
|
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||||
|
});
|
||||||
default:
|
default:
|
||||||
throw new DocumentBridgeError(
|
throw new DocumentBridgeError(
|
||||||
`未注册的 Rust mutation transport: ${input.plan.functionName}`,
|
`未注册的 Rust mutation transport: ${input.plan.functionName}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user