From 98160354918837f625a819ca0c3edb0ef76759a2 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Thu, 14 May 2026 15:10:33 +0800 Subject: [PATCH] feat(ai): switch page ai to hermes panel --- ...-page-ai-provider-hermes-old-run-502-v1.md | 36 + ...-1-tree-first-graph-kernel-checklist-v2.md | 8 +- ...ust-web-legacy-next-retirement-gates-v1.md | 2 +- ...untime-fallback-retirement-checklist-v1.md | 2 +- .../3-1-rust-web-long-term-checklist-v2.md | 31 +- ...ge-settings-and-ai-surface-alignment-v1.md | 4 +- ...ge-settings-and-ai-surface-checklist-v1.md | 2 +- ...6-page-aggregate-alignment-checklist-v1.md | 20 +- ...5-9-wolai-aline-continuous-checklist-v1.md | 8 +- ...7-document-ai-minimum-loop-checklist-v1.md | 2 +- ...ase7-structured-artifact-write-chain-v1.md | 6 +- ...age-ai-hermes-panel-and-mnote-plugin-v1.md | 5 +- ...-ai-hermes-panel-execution-checklist-v1.md | 1190 +++++++++++++++++ .../7-5-hermes-client-proxy-contract-v1.md | 243 ++++ ...-6-mnote-hermes-plugin-tool-contract-v1.md | 304 +++++ ...-page-ai-mini-hermes-control-surface-v1.md | 277 ++++ ...ry-domains-and-design-governance-review.md | 8 +- .../06-execution-checklist-and-acceptance.md | 8 +- .../7-phase7-ai-kernel-projection-plan-v4.md | 2 +- rust/crates/bridge-runtime/src/lib.rs | 249 +++- .../mnote-web/src/hermes_tools/artifact.rs | 185 +++ .../mnote-web/src/hermes_tools/manifest.rs | 58 + rust/crates/mnote-web/src/hermes_tools/mod.rs | 91 ++ .../crates/mnote-web/src/hermes_tools/page.rs | 341 +++++ rust/crates/mnote-web/src/lib.rs | 1 + rust/crates/mnote-web/src/routes/compat.rs | 261 +--- .../mnote-web/src/routes/hermes_client.rs | 696 ++++++++++ .../mnote-web/src/routes/hermes_tools.rs | 787 +++++++++++ .../mnote-web/src/routes/mindmap_shell.rs | 4 +- rust/crates/mnote-web/src/routes/mod.rs | 31 +- .../crates/mnote-web/src/routes/onlyoffice.rs | 31 +- rust/crates/mnote-web/src/routes/tree.rs | 18 +- rust/crates/mnote-web/src/ssr/pages/layout.rs | 250 +++- rust/crates/mnote-web/src/ssr/styles.rs | 7 + scripts/task-hermes-page-ai-artifact-smoke.js | 285 ++++ scripts/task-hermes-page-ai-audit-smoke.js | 207 +++ scripts/task-hermes-page-ai-baseline-smoke.js | 114 ++ scripts/task-hermes-page-ai-mobile-smoke.js | 144 ++ .../task-hermes-page-ai-retirement-guard.js | 67 + scripts/task-hermes-page-ai-smoke.js | 176 +++ ...task-hermes-page-ai-title-options-smoke.js | 239 ++++ scripts/task-hermes-page-ai-tool-smoke.js | 91 ++ .../task-hermes-page-ai-writeback-smoke.js | 224 ++++ scripts/task052-ai-tools-runtime-smoke.js | 10 + ...task111-phase7-document-ai-online-smoke.js | 10 + scripts/task155-e27-ai-edit-smoke.js | 10 + scripts/task156-e27-ai-writeback-smoke.js | 10 + ...178-page-ai-local-subtree-context-smoke.js | 10 + 48 files changed, 6353 insertions(+), 412 deletions(-) create mode 100644 bugs/07-ai/done/7-1-page-ai-provider-hermes-old-run-502-v1.md rename design/07-ai/{process => done}/7-2-phase7-structured-artifact-write-chain-v1.md (98%) rename design/07-ai/{process => done}/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md (98%) create mode 100644 design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md create mode 100644 design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md create mode 100644 design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md create mode 100644 design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md create mode 100644 rust/crates/mnote-web/src/hermes_tools/artifact.rs create mode 100644 rust/crates/mnote-web/src/hermes_tools/manifest.rs create mode 100644 rust/crates/mnote-web/src/hermes_tools/mod.rs create mode 100644 rust/crates/mnote-web/src/hermes_tools/page.rs create mode 100644 rust/crates/mnote-web/src/routes/hermes_client.rs create mode 100644 rust/crates/mnote-web/src/routes/hermes_tools.rs create mode 100644 scripts/task-hermes-page-ai-artifact-smoke.js create mode 100644 scripts/task-hermes-page-ai-audit-smoke.js create mode 100644 scripts/task-hermes-page-ai-baseline-smoke.js create mode 100644 scripts/task-hermes-page-ai-mobile-smoke.js create mode 100644 scripts/task-hermes-page-ai-retirement-guard.js create mode 100644 scripts/task-hermes-page-ai-smoke.js create mode 100644 scripts/task-hermes-page-ai-title-options-smoke.js create mode 100644 scripts/task-hermes-page-ai-tool-smoke.js create mode 100644 scripts/task-hermes-page-ai-writeback-smoke.js diff --git a/bugs/07-ai/done/7-1-page-ai-provider-hermes-old-run-502-v1.md b/bugs/07-ai/done/7-1-page-ai-provider-hermes-old-run-502-v1.md new file mode 100644 index 00000000..4cb96918 --- /dev/null +++ b/bugs/07-ai/done/7-1-page-ai-provider-hermes-old-run-502-v1.md @@ -0,0 +1,36 @@ +# 7-1 [done] 页面 AI provider=hermes 仍命中旧 run route 导致 502 + +## 现象 + +- 当前 Rust 3000 页面壳里,页面 AI 默认 provider 是 `hermes`。 +- 发送消息时仍请求旧 `/api/ai-agent/run`。 +- Rust compat route 对显式 `provider=hermes` 返回 `ai_provider_bridge_unavailable`,HTTP 状态为 502。 + +## 复现证据 + +- 页面壳默认 provider:`rust/crates/mnote-web/src/ssr/pages/layout.rs` +- 页面 AI 发送旧 route:`fetch('/api/ai-agent/run')` +- 旧 route 注册:`rust/crates/mnote-web/src/routes/mod.rs` +- 502 来源:`rust/crates/mnote-web/src/routes/compat.rs` +- 可复跑 smoke:`scripts/task-hermes-page-ai-baseline-smoke.js` + +## 归类 + +- owner:`07-ai` +- 根因:页面 AI UI 仍走旧 mnote-cli / compat 主链,而默认 provider 已切到 Hermes。 +- 修复方向:页面 AI 主链改为同源 `/api/hermes/client/*`,旧 `/api/ai-agent/run` 只保留 legacy guard。 + +## 验收 + +- [x] 页面 AI 打开后创建或恢复 Hermes session。 +- [x] 发送消息不再请求 `/api/ai-agent/run`。 +- [x] 未配置 Hermes upstream 时返回稳定 `hermes_client_unconfigured`,不再出现旧 502。 +- [x] 配置 Hermes upstream 后 run/event stream 由 Hermes 返回。 + +## 完成证据 + +- 代码主链:`rust/crates/mnote-web/src/ssr/pages/layout.rs` 已改为 `/api/hermes/client/sessions`、`/api/hermes/client/runs`、`/api/hermes/client/events/{run_id}`。 +- 合同与路由:`rust/crates/mnote-web/src/routes/hermes_client.rs` 提供同源 Hermes client proxy。 +- 测试:`cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web hermes_ -- --nocapture`,14 passed。 +- smoke:`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-smoke.js` 通过,并拦截旧 `/api/ai-agent/run` 证明新页面 AI 主链未调用旧 route。 +- 说明:2026-05-14 已在正式 `3000` 入口复跑 Hermes 页面 AI smoke 矩阵,旧 `/api/ai-agent/run` 只保留 410 retirement guard。 diff --git a/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md b/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md index 462d56b8..315b1403 100644 --- a/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md +++ b/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md @@ -445,21 +445,21 @@ - 阅读态结构面板已直接消费 `outline` / `evidence` - `BlockNote` 默认不再首屏强挂 - AI 已做 host/runtime 拆分 -- `DocumentAiAgentPanel.runtime.tsx` 已向 AI route 发送 `node` / `subtree` / `outline` / `evidence` -- `/api/ai-agent/run` 已把这些上下文序列化进 Hermes 指令 +- 历史 React 页面 AI runtime 曾向旧 AI route 发送 `node` / `subtree` / `outline` / `evidence`;该证据只保留为过渡记录 +- 2026-05-14 起当前页面 AI 主线已改为 Hermes client proxy + mnote Hermes plugin/tool:页面上下文进入 Hermes run/session context,最新页面事实由 Hermes 通过 `mnote.page.get` 等工具回读,不再把旧 `/api/ai-agent/run` 写成长期入口 ### 当前真实问题 - 阅读页已经是 page subtree projection 驱动,但 projection 仍在前端读链内生成,不是 Rust/kernel 真相层直接输出 - `DocumentContent` 仍集中挂载 `DocumentAiAgentPanel`、`DocumentHistoryDrawer`、`DocumentCommentsDrawer`、`PageBacklinksPanel`、`PageOptionsSidebar` -- AI runtime 仍是页面级重壳,不是 kernel-first tool bridge +- 当前页面 AI 的会话真相已交给 Hermes;剩余问题是继续把 `node` / `subtree` / `edge` 工具契约下沉到 Rust kernel 稳定协议,而不是回到页面级私有 AI runtime - 搜索/AI/阅读页之间虽然开始共享 `node` / `subtree` / `outline` / `evidence` 口径,但还没有统一到稳定的 node / subtree / edge 真相协议面 ### 下一阶段必须完成 - 把 page subtree projection 从前端读链继续下沉到更稳定的 Rust/kernel 输出边界 - 阅读页大纲、回链、结构信息改读 kernel edge / subtree -- AI tool 直接面向 node / subtree / edge +- mnote Hermes plugin tools 继续扩展为直接面向 node / subtree / edge - AI 可以创建: - `summary node` - `ai_note node` diff --git a/design/03-rust-web/done/3-11-rust-web-legacy-next-retirement-gates-v1.md b/design/03-rust-web/done/3-11-rust-web-legacy-next-retirement-gates-v1.md index 5719d852..bb7b8ba1 100644 --- a/design/03-rust-web/done/3-11-rust-web-legacy-next-retirement-gates-v1.md +++ b/design/03-rust-web/done/3-11-rust-web-legacy-next-retirement-gates-v1.md @@ -3,7 +3,7 @@ > 状态说明: > - 本稿定义的 default gate 已在当前主线代码中成立:`mnote-web` 是 `3000` owner,Next 仅保留 legacy compat/debug 边界 > - `MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT` 默认关闭与 `task117` guard 已落地,故迁入 `done/` -> - 2026-05-13 追加说明:本文中“长期 agent 执行面收口到 `mnote-cli`”是历史完成口径;当前 AI 长期方向已由 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖为 Hermes 页面内客户端 + mnote Hermes skill/plugin +> - 2026-05-13 追加说明:本文中“长期 agent 执行面收口到 `mnote-cli`”是历史完成口径;当前 AI 长期方向已由 `design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖为 Hermes 页面内客户端 + mnote Hermes skill/plugin ## 目标 diff --git a/design/03-rust-web/done/3-15-runtime-fallback-retirement-checklist-v1.md b/design/03-rust-web/done/3-15-runtime-fallback-retirement-checklist-v1.md index 62f4b813..ed3dfebc 100644 --- a/design/03-rust-web/done/3-15-runtime-fallback-retirement-checklist-v1.md +++ b/design/03-rust-web/done/3-15-runtime-fallback-retirement-checklist-v1.md @@ -9,7 +9,7 @@ > - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` > > 2026-05-13 口径修正: > - 本清单中“删除 `provider=hermes` 直连分支”的结论仍然有效,含义是旧 `/api/ai-agent/run` 不再把 Hermes 当 fallback/provider 分支。 diff --git a/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md b/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md index b48339d0..96008501 100644 --- a/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md +++ b/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md @@ -261,7 +261,7 @@ ## 9. Phase 4:搜索系统 Rust 化与 island 化 -**当前状态:`PARTIAL`** +**当前状态:`GREEN`** ### 9.1 已落地事实 @@ -297,10 +297,11 @@ **当前状态:`PARTIAL`** -> 2026-05-13 口径更新: -> - 本阶段原先把页面 AI 继续收口为 `mnote-cli host / client`,现在已被 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖。 +> 2026-05-14 口径更新: +> - 本阶段原先把页面 AI 继续收口为 `mnote-cli host / client`,现在已被 `design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖。 > - 新长期方向是:页面 AI 面板使用 Leptos 实现 Hermes 页面内客户端;Hermes session/message/tool event/usage/model 是 AI 会话真相;mnote 只通过 Hermes skill/plugin 暴露页面、树、artifact、edge 等业务能力。 > - `mnote-cli` 只能作为 plugin 内部适配器或调试入口,不能再被写成页面 AI 唯一长期执行面。 +> - `design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` 已把页面 AI 主链推进到 Hermes session/run/events,并完成 `mnote.page.get`、正文写入、标题/页面设置、artifact、legacy guard、持久化审计和正式 `3000` smoke 矩阵;本节后续只跟踪 Rust Web 侧长期边界,不再保留旧 `/api/ai-agent/run` 待办作为当前状态。 ### 10.1 已落地事实 @@ -309,23 +310,25 @@ - [DocumentAiAgentPanel.runtime.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx) - [x] Mindmap / OnlyOffice 也有类似 runtime 拆分 - [x] `GlobalAiAgentHost` 已不在 `(app)/layout.tsx` 主布局中挂载 -- [x] 文档页 AI 已开始把 `node` / `subtree` / `outline` / `evidence` 传给 AI bridge;后续应改为 Hermes run/session context,由 Hermes 通过 mnote skill/plugin 回读和写入业务事实 - - [DocumentAiAgentPanel.runtime.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx) - - [route.ts](/mnt/Data1T/mnote/wolai-frontend/src/app/api/ai-agent/run/route.ts) +- [x] 页面 AI 主链已改为 mnote-web Hermes client proxy:创建/恢复 Hermes session,发起 Hermes run,消费 events,并从 Hermes session detail 刷新恢复 + - [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs) +- [x] mnote 业务能力已通过 mnote Hermes tool routes 暴露,写入回到 Rust runtime / Page Aggregate command family / kernel + - [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs) + - [hermes_tools](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/mod.rs) +- [x] 旧 `/api/ai-agent/run` 在 mnote-web 中已退役为 `410 legacy_ai_agent_run_retired` guard,不再作为页面 AI 主入口 + - [compat.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/compat.rs) ### 10.2 当前还没完成 -- [ ] runtime 组件仍然非常重 -- [ ] “最小会话协议、统一流式协议、页面上下文协议”还更多体现在组件内部,不是 Hermes client proxy / plugin tool contract -- [ ] 还不能说 AI 面板已经变成“只调用 Hermes 的页面内客户端” -- [ ] 页面级 AI adapter 仍然很大,只是改成了懒加载 -- [ ] AI 已能消费 `node` / `subtree` / `outline` / `evidence` 上下文,但还没有进入稳定 Hermes session/run context 与 mnote plugin tool 协议;Web 面板也还没有彻底退成 Hermes client +- [ ] legacy React 文档页 AI runtime 文件仍然偏重,但它不再代表 Rust Web 3000 页面 AI 主链。 +- [ ] node / subtree / edge 工具面还需要继续从页面上下文扩展成更稳定的 kernel-first Hermes plugin contract。 +- [ ] Mindmap / OnlyOffice / generic AI 面板的旧域调用点仍需分别按各自 domain 拆迁移,不纳入“页面 AI Hermes 面板”完成定义。 ### 10.3 v2 后续任务 -- [ ] 移除页面 AI 对旧 `/api/ai-agent/run` 主路径的长期依赖,改走正式 Hermes client proxy -- [ ] 把会话、message、tool event、usage、model 选择交给 Hermes session 存储 -- [ ] 把 mnote 页面、树、artifact、edge 能力注册为 Hermes skill/plugin tools +- [x] 移除页面 AI 对旧 `/api/ai-agent/run` 主路径的长期依赖,改走正式 Hermes client proxy +- [x] 把会话、message、tool event、usage、model 选择交给 Hermes session 存储 +- [x] 把 mnote 页面、树、artifact、edge 第一批能力注册为 Hermes skill/plugin tools - [ ] 把当前上下文注入进一步收口为 Hermes run context + 稳定 kernel node / subtree / edge tool bridge --- diff --git a/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md b/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md index d6e9b9e2..40129cc2 100644 --- a/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md +++ b/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md @@ -5,7 +5,7 @@ > 状态说明: > - 本稿对应的页面设置 `popover`、页面 AI `drawer`、入口位置与最小接线方案已在当前 `mnote-web` 文档壳中落地,故迁入 `done/` > - 本稿中的 deferred 项继续由后续独立任务推进,不影响这一轮“页面设置 + 页面 AI 交互壳”完成判定 -> - 2026-05-13 追加说明:本文中 `CLI-first` / `mnote-cli host` 是 2026-05-06 完成时的历史接线口径;当前 AI 长期方向已由 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖为 Hermes 页面内客户端 + mnote Hermes skill/plugin +> - 2026-05-13 追加说明:本文中 `CLI-first` / `mnote-cli host` 是 2026-05-06 完成时的历史接线口径;当前 AI 长期方向已由 `design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖为 Hermes 页面内客户端 + mnote Hermes skill/plugin > > 关联文档: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` @@ -14,7 +14,7 @@ > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` > - `/mnt/Data1T/mnote/design/08-wolai-aline-test-flow/process/wolai-aline-test-flow-v1.md` ## 1. 文档目的 diff --git a/design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md b/design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md index a619de72..bf597950 100644 --- a/design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md +++ b/design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md @@ -5,7 +5,7 @@ > 状态说明: > - 本清单覆盖的页面设置 A/B、页面 AI C、集成护栏 D 均已完成并有 `task160/161/162` 证据,故迁入 `done/` > - `X1-X6` 属于明确 deferred 项,不构成这轮完成阻塞 -> - 2026-05-13 追加说明:本文保留 `task161/162` 对旧 `/api/ai-agent/run -> mnote-cli` 返回链的历史验证证据;AI 长期方向已由 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 改为 Hermes 页面内客户端 + mnote Hermes skill/plugin +> - 2026-05-13 追加说明:本文保留 `task161/162` 对旧 `/api/ai-agent/run -> mnote-cli` 返回链的历史验证证据;AI 长期方向已由 `design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 改为 Hermes 页面内客户端 + mnote Hermes skill/plugin > > 本清单服务于: > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md` diff --git a/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md b/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md index 27c971ce..37b0fda3 100644 --- a/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md +++ b/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md @@ -236,7 +236,7 @@ 补充:本轮继续把 AI 面板的读取边界开始收口到统一页面本地快照:`DocumentContent` 不再向 `DocumentAiAgentPanel` 透传 `getLatestBlocks / getLatestPageSubtree / getLatestPersistedMeta` 三组分散 getter,而是改为一份 `getLatestPageAggregateSnapshot`,由 `page-aggregate-client-state` 导出 `blocks / pageSubtree / persistedMeta / pageOptions`。这代表 AI 面板已开始消费同一份页面本地 aggregate state,而不是继续拼接独立局部真相;但页面设置写工具本身仍未进入 Hermes 正式 tool surface,因此这里仍然只算“开始收口”,不提前打满。 -补充:本轮还把 `pageOptions` 与 `editorRuntimePageOptions` 一并带入历史 `/api/ai-agent/run -> buildHermesInstructions`,因此 AI 在服务端至少能看到“当前页面设置是什么”以及“哪些设置已经进入 island runtime payload”,不再只依赖正文块快照和树快照来猜测页面语义。对应回归测试为 `src/app/api/ai-agent/run/route.test.ts`。2026-05-13 起同类上下文后续应改为 Hermes run/session context,并由 Hermes 通过 mnote plugin 回读最新 page aggregate;这推进的是 AI 读侧上下文,不等于页面设置写工具已经具备正式入口。 +补充:本轮还把 `pageOptions` 与 `editorRuntimePageOptions` 一并带入历史 `/api/ai-agent/run -> buildHermesInstructions`,因此 AI 在服务端至少能看到“当前页面设置是什么”以及“哪些设置已经进入 island runtime payload”,不再只依赖正文块快照和树快照来猜测页面语义。对应回归测试为 `src/app/api/ai-agent/run/route.test.ts`。2026-05-14 起同类上下文的当前主线已改为 Hermes run/session context,并由 Hermes 通过 mnote plugin 回读最新 page aggregate;页面设置写入也已由 `mnote.page.update_options` 回到 `page.layout.updateOptions`,旧 `/api/ai-agent/run` 证据只保留为历史过渡记录。 ### 8.2 与主编辑区的关系 @@ -244,11 +244,11 @@ - [x] AI 改写结果能通过主编辑区 island 正式回显。 - [ ] AI 改写后树标题 / 页面头部 / 页面设置不再走各自独立副作用链。 -补充:进入这一步后,当前最大的真实 blocker 已经明确下来,不再继续靠口头描述模糊处理: +补充:进入这一步时,最大的真实 blocker 已经明确下来;以下为历史 blocker 记录,不再作为 2026-05-14 之后的当前状态描述: -- 页面 AI 历史实现仍残留 `/api/ai-agent/run` / `mnote-cli host` 路径 -- Hermes 页面内客户端、Hermes session 真相和 mnote skill/plugin tool surface 还没有在页面 AI 主链落地 -- 因此,当前新增的 `page_options_patch -> page.layout.updateOptions -> structured tool_result` 仍然只是“历史主路内的最小结构化兼容分支”,不是完整正式 Hermes tool surface +- 页面 AI 历史实现当时仍残留 `/api/ai-agent/run` / `mnote-cli host` 路径 +- Hermes 页面内客户端、Hermes session 真相和 mnote skill/plugin tool surface 当时还没有在页面 AI 主链落地 +- 因此,当时新增的 `page_options_patch -> page.layout.updateOptions -> structured tool_result` 只是“历史主路内的最小结构化兼容分支”,不是完整正式 Hermes tool surface 这意味着 `8.2` 后续完成标准必须至少包含: @@ -256,17 +256,19 @@ 2. 页面设置结构化结果由正式 mnote plugin tool 调用产出,而不是只由 host 自己补一条兼容 `tool_result` 3. 树标题 / 页头 / 页面设置三条 AI 写回链在同一条正式 page aggregate command family 中闭环,并补 smoke 验证 +2026-05-14 追加说明:上述 blocker 中“页面 AI 历史实现仍残留 `/api/ai-agent/run` / `mnote-cli host` 路径”和“Hermes 页面内客户端、Hermes session 真相、mnote skill/plugin tool surface 还没有落地”的判断已被 `design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` 覆盖。当前代码已新增 Hermes client proxy 与 mnote Hermes tool routes,页面 AI 主链已改为 Hermes session/run/events,标题、页面设置、正文、artifact 写入已回到 Rust runtime / Page Aggregate command family;旧 `/api/ai-agent/run` 在 mnote-web 中已收口为 `legacy_ai_agent_run_retired` guard。`7-4` 的正式 `3000` smoke 矩阵与持久化审计已通过,剩余文档治理以 `7-4` 的 N 项为准。 + ### 8.3 退出标准 -- [x] AI 写入口已经可以被明确描述为“操作 page aggregate command family”,而不是“绕过系统写编辑器”。 +- [x] AI 写入口已经可以被明确描述为“操作 page aggregate command family”,而不是“绕过系统写编辑器”。 -注:历史 `/api/ai-agent/run` 在 `Hermes tool.completed` 后,会优先尝试把 `slash_run / doc_insert_blocks / doc_replace_range` 恢复成 `mnote-web bridge-runtime` 的结构化 `tool_result`,不再只把 Hermes 事件当作薄日志。2026-05-13 起这只保留为过渡证据;新的主线应由 Hermes 直接发起 mnote plugin tool call,再由 Rust runtime / kernel 返回 tool result。其后: +注:历史 `/api/ai-agent/run` 在 `Hermes tool.completed` 后,会优先尝试把 `slash_run / doc_insert_blocks / doc_replace_range` 恢复成 `mnote-web bridge-runtime` 的结构化 `tool_result`,不再只把 Hermes 事件当作薄日志。2026-05-14 起这只保留为过渡证据;新的主线已由 Hermes 发起 mnote plugin tool call,再由 Rust runtime / kernel 返回 tool result。其后: - `doc_insert_blocks / doc_replace_range` 继续按 `page.body.save` 语义落到 `/api/documents/save`,再正式回显主编辑区 island。 - `slash_run(rename current page)` 会把结构化结果回接到当前页 `DocumentContent` 的同一条标题提交链,并继续广播 `emitDocumentsChanged(documentId)`,因此页头标题与树标题不再靠 AI 面板内部本地状态各自漂移。 - 当前 AI 面板已经能消费结构化 `update_page_options` 结果,并把 `pageOptionsPatch` 回接到当前页 `DocumentContent` 的同一条 `patch_page_options + page.layout.updateOptions` 提交链;同时只允许 `runtimeSupport === "wired"` 的字段进入正式写回,避免 planned / ui_only 页面设置混入主链。对应最小回归测试为 `DocumentAiAgentPanel.runtime.test.tsx`、`document-content.test.ts`。 -- 历史 `mnote-cli host` 现已能在命中页面设置 patch 时直接执行 `page.layout.updateOptions`,并向前端回放结构化 `tool_call/tool_result` 事件;这意味着“服务端完全没有页面设置结构化写回结果”的状态已经结束。 -- 但 `pageOptions` 仍没有进入 mnote Hermes plugin 的正式 tool surface,当前服务端 patch 识别也仍是最小规则分支而不是完整 Hermes tool 编排,因此“页面设置类 AI 命令”仍不能算整条线已闭环;`8.2` 的最后一项继续保留未完成,避免误判为整条线已经闭环。 +- 历史 `mnote-cli host` 能在命中页面设置 patch 时直接执行 `page.layout.updateOptions`,并向前端回放结构化 `tool_call/tool_result` 事件;这意味着“服务端完全没有页面设置结构化写回结果”的状态已经结束。 +- 当前 `pageOptions` 已进入 mnote Hermes plugin 的第一批正式 tool surface,`mnote.page.update_options` 只允许 `runtimeSupport === "wired"` 字段写入 `page.layout.updateOptions`,planned / ui_only 字段返回 warning 或 ignored 结果。后续剩余项不再是“页面设置类 AI 命令是否闭环”,而是 slash AI / selection toolbar AI 等更细粒度入口如何复用同一条 Hermes tool chain。 --- diff --git a/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md b/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md index dd0edf00..f7440c3c 100644 --- a/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md +++ b/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md @@ -392,9 +392,9 @@ GREEN: - E26 执行边界:实现与 smoke 必须把 `EditorBlockDocument -> Tiptap attrs.blockId/data-block-id -> DOM 锚点 -> page.body.save -> /api/documents/content -> reload/hash 定位` 串成一条链。允许在浏览器 runtime 内用 `UniqueID` 或等价插件帮助定位节点,但只能读取/补齐已有 Rust block id;若某块缺少正式 id,应通过 Rust/保存适配链生成并持久化,而不是让前端临时 id 成为长期合同。Convex 只验证持久化结果可读可刷新,不作为语义真源。 - E26 最小真源闭环:2026-05-02 已完成第一段 anchor 切片。`leptos-tiptap` paragraph/heading/blockquote/codeBlock/image/table 的 `blockId` 渲染同时输出 `data-block-id` 与 DOM `id`,浏览器 hash 命中走 `id=:blockId` + CSS `:target`,不是手工给 ProseMirror DOM 写临时 class;Rust runtime 复制链接和 hash 滚动继续只消费 `EditorBlock.block_id` 派生的 `attrs.blockId`。`mnote-web` 保存 payload 不再发送空 `content: []`,而是派生 `editorDocument`、legacy `content`、`tiptapDocument` 和 `blockCount` 写回 Convex-backed 持久化底座;reload 侧 legacy block 恢复继续补 `attrs.blockId`。本地 smoke `node scripts/task154-e26-anchor-smoke.js` 已覆盖保存请求、`/api/documents/content`、复制链接、DOM `id/:target` 和 reload/hash 定位,截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task154-e26-anchor-local-smoke`。剩余:列表项/分割线等更多块类型的统一 anchor attr 覆盖、Wolai 视觉细节、块引用/页面引用预览与移动端行为另拆后续。 - E27 执行前口径纠偏:AI 编辑能力包不是“补 AI 菜单文案”或“点击后显示 feedback”。最小闭环必须从 `leptos-tiptap` 的 slash / 块菜单入口出发,携带当前 `documentId`、`workspaceId`、Rust `blockId`、selection 摘要和 Tiptap JSON 快照,进入 Hermes run/session context;Hermes 通过 mnote skill/plugin 调用稳定工具,再由 Rust runtime / `page.body.save` / Convex-backed content 链写回。2026-05-13 起长期口径进一步修正:`/api/ai-agent/run`、`openai-agents-python` sidecar 和 `mnote-cli host` 都只作为历史/兼容路径,不再代表页面 AI 长期主入口;`mnote-cli` 只可作为 mnote plugin 内部适配器或调试入口。E27 第一刀已完成的 sidecar / `/api/ai-agent/run` 路径只作为过渡基线,不宣称是长期主线。 -- E27 AI agent 入口与写入闭环:2026-05-02 已完成历史主路径纠偏,修正此前把 `/api/hermes/bridge` 当作 E27 主入口的误导口径。块菜单 `AI 助理` 当时从当前 `leptos-tiptap` editor 读取 `documentId`、`workspaceId`、Rust `blockId`、selection state、selected text 与 `tiptapDocument` 快照,发起 `/api/ai-agent/run` 请求,payload 固定 `scope=document`、`stream=true`、`options.ai.provider=online`,并显示 `mnote-leptos-tiptap-ai-status` 的 `idle/pending/ready/error` 状态;请求上下文标记 `source=leptos-tiptap-island`、`action=ask_ai`,Next sidecar adapter 已透传 `workspaceId / selectedBlockId / selection / tiptapDocument` 等 block 级上下文给 `openai-agents-python`。本地 smoke `node scripts/task155-e27-ai-edit-smoke.js` 已先 RED 于 Hermes 主入口,再 GREEN 覆盖请求 URL、payload 真源字段和 ready 状态;`node scripts/task156-e27-ai-writeback-smoke.js` 已先 RED 于只返回不写入,再 GREEN 覆盖 SSE `doc_replace_range` tool_result -> 编辑器改写 -> `/api/documents/save` -> `/api/documents/content` 真源读回 -> reload 后页面读回。截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task156-e27-ai-writeback-local-smoke`。剩余:把入口改为 Hermes client proxy,把上下文作为 Hermes run/session context 传入,把写入工具注册为 mnote Hermes plugin,Wolai AI 菜单视觉基线、slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate 子命令、Hermes streaming UI 状态、`doc_insert_blocks` 多块插入和标题写入另拆后续。 +- E27 AI agent 入口与写入闭环:2026-05-02 已完成历史主路径纠偏,修正此前把 `/api/hermes/bridge` 当作 E27 主入口的误导口径。块菜单 `AI 助理` 当时从当前 `leptos-tiptap` editor 读取 `documentId`、`workspaceId`、Rust `blockId`、selection state、selected text 与 `tiptapDocument` 快照,发起 `/api/ai-agent/run` 请求,payload 固定 `scope=document`、`stream=true`、`options.ai.provider=online`,并显示 `mnote-leptos-tiptap-ai-status` 的 `idle/pending/ready/error` 状态;请求上下文标记 `source=leptos-tiptap-island`、`action=ask_ai`,Next sidecar adapter 已透传 `workspaceId / selectedBlockId / selection / tiptapDocument` 等 block 级上下文给 `openai-agents-python`。本地 smoke `node scripts/task155-e27-ai-edit-smoke.js` 已先 RED 于 Hermes 主入口,再 GREEN 覆盖请求 URL、payload 真源字段和 ready 状态;`node scripts/task156-e27-ai-writeback-smoke.js` 已先 RED 于只返回不写入,再 GREEN 覆盖 SSE `doc_replace_range` tool_result -> 编辑器改写 -> `/api/documents/save` -> `/api/documents/content` 真源读回 -> reload 后页面读回。截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task156-e27-ai-writeback-local-smoke`。2026-05-14 起,页面 AI 主入口改为 Hermes client proxy、上下文进入 Hermes run/session context、写入工具注册为 mnote Hermes plugin 的部分已由 `7-4` 完成;剩余为 Wolai AI 菜单视觉基线、slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate 子命令、`doc_insert_blocks` 多块插入和标题写入等细分入口如何复用同一条 Hermes tool chain。 - E27 Auth 真源纠偏:2026-05-02 补充确认,AI 编辑和在线 smoke 的用户身份必须以真实 Convex Auth 为主线;不同账号的数据隔离依赖 Convex `getAuthUserId(ctx)` 解析真实 identity subject,并由 workspace/member 权限链校验。`/auth` 复用既有 Convex Auth 页面和统一测试账号 `test@example.com` / `Test123456`;`/api/auth/session`、`/api/auth/whoami`、AI orchestrator 转发和 Convex transport 均应真实 token / forwarded actor 优先。`DEV_USER_ID` / admin acting identity 只允许作为本地底层 fallback 或显式 `MNOTE_DEV_AUTH=1` 联调模式,不能作为 E27 在线验收、多账号隔离或正式数据真源。 -- E27 当前推进备注:2026-05-03 主线程先切到 E28 Mention / Emoji,E27 暂停在 online smoke 模型网关层排障状态。当前已确认 `3000 -> 8000` orchestrator 主链可达,本地 `task155/task156` 通过;剩余在线阻塞点在 `20128 /v1/responses` 上游模型/渠道可用性,以及后续真实 actor 收口。恢复 E27 时应从这两点继续,不要回退成“主入口未迁完”。 +- E27 当前推进备注:2026-05-03 主线程先切到 E28 Mention / Emoji,E27 曾暂停在 online smoke 模型网关层排障状态。2026-05-14 页面 AI 主入口已经完成 Hermes client proxy + mnote plugin/tool 迁移;恢复 E27 时应从 slash AI / selection toolbar AI / 子命令体验与真实 actor 复用这条新主链继续,不要回退成旧 orchestrator 或旧 `/api/ai-agent/run` 排障。 - E28/E29 暂停与 E30 先行口径:2026-05-03 主线程先暂停 E28 Mention / Emoji 与 E29 Comment / History,转入 E30 Menu / Floating 状态机能力包。E28 已有 Wolai Hermes baseline 显示正文输入 `@` 当前弹出提醒/会议/成员候选,并可插入成员 mention,不是页面引用搜索;证据目录为 `/mnt/Data1T/mnote/tmp/wolai-editor-parity/task157-e28-wolai-mention-baseline/`,恢复 E28 时需先重新定 `@mention` 口径,不要按“页面引用第一刀”继续。E29 在评论/历史后端边界未重新确认前保持暂停。E30 第一刀只收敛已有 slash、块菜单、二级菜单、selection/image/table floating toolbar 的打开互斥、Esc、外部点击、方向键、Enter 与层级收起,不扩新业务命令。 - E30 Menu / Floating 状态机第一刀:2026-05-03 已参考 `use-floating-element`、`use-menu-navigation`、`use-floating-toolbar-visibility` 的集中可见性/键盘/关闭模型,把本地已有 slash、块菜单、selection toolbar、image toolbar、table toolbar/options 的关闭与互斥收口到 `close_editor_floating_overlays*` 与 `open_*_overlay` 入口;`task158-e30-menu-state-smoke.js` 覆盖 slash `ArrowDown` active、Esc 关闭、块菜单二级菜单、selection color panel、image toolbar、table options、slash 打开关闭 selection toolbar、块菜单打开关闭 image toolbar。Wolai 基线目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task158-e30-wolai-baseline/`;本地主线程截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task158-e30-menu-state-local-smoke-main3`;本地 subagent 复测目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task158-e30-local-postfix-subagent`。剩余:Wolai slash / 块菜单二级菜单在只读条件下未稳定验证,Enter 选中、外部点击和更多三级菜单收起需后续 editable-test 小切片继续补。 - E31 Auth 入口回收:2026-05-04 已把 `3000` 的 `/auth` 收回为本地 `mnote-web` SSR 登录页,不再代理 3100;未登录访问 `/` 现在 303 到 `/auth`,已登录(forwarded actor / Convex Auth cookie)才进入工作区。`task159-auth-entry-smoke.js` 覆盖 `/auth` 本地 200、`/` 未登录 303、已登录 200、无第三方快捷登录/隐私政策文案,`task114-rust-web-gateway-entry-smoke.js` 与 `task117-next-retirement-guard.js` 已同步更新。截图待复核:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task159-auth-entry-baseline/`。 @@ -418,7 +418,9 @@ GREEN: | E24 | PARTIAL | Image / Media 能力包 | 已完成图片 `/tp` 最小真源闭环与图片 toolbar 小闭环:slash `媒体与附件` 分组入口调用 `leptos-tiptap` 官方 `set_image(TiptapImageResource)`,启用 `TiptapExtension::Image`,保存链新增 `EditorBlockType::Image` / `TiptapNode::Image` 并保留 `props.tiptapImage`,`mnote-web` reload 恢复 ``;点击图片后出现 `image-floating-toolbar`,删除入口本切片保持禁用,左/中/右对齐通过官方 Image 扩展 `addAttributes("data-align")` + `updateAttributes("image")` 持久化,同源下载通过隐藏 `` 触发且不改文档;`task152-e24-image-smoke.js` 覆盖入口、实际图片加载、toolbar、同源下载、删除入口禁用态、居中对齐、保存请求、content API 和刷新恢复;剩余上传、最近上传、caption、resize、replace、跨源下载 fallback、删除保存链、移动端 toolbar、失败态 | | E25 | PARTIAL | TOC 能力包 | 已完成 `/toc` 最小真源闭环:`leptos-tiptap` 新增真实 `tocNode` schema/command,slash `页面目录 /toc` 插入真实节点,NodeView 从当前 headings 派生目录项,支持点击定位、标题显示开关、保存请求、content API 与 reload 恢复;`task153-e25-toc-smoke.js` 覆盖入口、动态 heading 更新、hash 定位和 `props.tiptapTocNode`。剩余 TOC sidebar、active heading 高亮、完整块菜单入口、官方 TableOfContents v3 数据源评估、移动端与像素级视觉 | | E26 | PARTIAL | UniqueID / Anchor 能力包 | 已完成最小真源闭环:Rust `EditorBlock.block_id` -> Tiptap `attrs.blockId` -> DOM `data-block-id` + `id` -> 复制链接 hash -> `page.body.save` / `/api/documents/content` -> reload 后 `:target` 定位;`task154-e26-anchor-smoke.js` 覆盖保存、复制、reload/hash 和截图。Tiptap `UniqueID` 仍只作为参考/辅助口径,不作为正式块 id;剩余更多块类型 anchor 覆盖、Wolai 视觉细节、引用预览和移动端行为。 | -| E27 | PARTIAL | AI 编辑能力包 | 已完成历史块菜单 `AI 助理` 主路径与最小写入闭环:点击后发起 `/api/ai-agent/run`,payload 使用 `scope=document`、`stream=true`、`options.ai.provider=online`,携带 `documentId`、`workspaceId`、Rust `blockId`、selection、selected text、Tiptap 快照和 `action=ask_ai`;`task155-e27-ai-edit-smoke.js` 与 `task156-e27-ai-writeback-smoke.js` 保留为过渡行为基线。2026-05-13 起长期口径改为:页面 AI 入口应调用 Hermes client proxy,Hermes session/message/tool event/usage/model 是会话真相,mnote 通过 Hermes skill/plugin 执行 `page.body.save`、标题、页面设置、artifact、edge 等业务工具;`mnote-cli` 仅可作为 plugin 内部适配器。Auth 验收必须复用 Convex Auth 测试账号,真实 token / actor 优先,`dev identity` 仅为本地 fallback。当前主线程已于 2026-05-03 先切 E28;E27 暂停在 online smoke 的 `20128 /v1/responses` 模型网关排障与真实 actor 收口。剩余 Hermes client proxy 接入、mnote plugin tool 注册、slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate、Wolai 视觉基线、Hermes streaming UI、`doc_insert_blocks` 多块插入与标题写入。 | +| E27 | PARTIAL | AI 编辑能力包 | 已完成历史块菜单 `AI 助理` 主路径与最小写入闭环:点击后曾发起 `/api/ai-agent/run`,payload 使用 `scope=document`、`stream=true`、`options.ai.provider=online`,携带 `documentId`、`workspaceId`、Rust `blockId`、selection、selected text、Tiptap 快照和 `action=ask_ai`;`task155-e27-ai-edit-smoke.js` 与 `task156-e27-ai-writeback-smoke.js` 已默认退役为 historical smoke,当前只保留显式环境变量下的历史对照。2026-05-14 起页面 AI 主链已调用 Hermes client proxy,Hermes session/message/tool event/usage/model 是会话真相,mnote 通过 Hermes skill/plugin 执行 `page.body.save`、标题、页面设置、artifact、edge 等业务工具;`mnote-cli` 仅可作为 plugin 内部适配器。Auth 验收必须复用 Convex Auth 测试账号,真实 token / actor 优先,`dev identity` 仅为本地 fallback。剩余 slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate、Wolai 视觉基线、`doc_insert_blocks` 多块插入与标题写入等细分入口。 | + +2026-05-14 追加说明:E27 行中的 “Hermes client proxy 接入、mnote plugin tool 注册” 已由 `design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` 对应的 7-5 / 7-6 合同和 mnote-web 实现推进到 GREEN:页面 AI 主链已走 Hermes session/run/events,mnote tool surface 已覆盖 `mnote.page.get`、`mnote.page.save`、`mnote.page.update_title`、`mnote.page.update_options`、`mnote.artifact.create_summary`、`mnote.artifact.create_ai_note`,旧 `/api/ai-agent/run` 已改为 `legacy_ai_agent_run_retired` guard。本文件后续只保留 Wolai-aline 体验、slash/selection toolbar AI 子命令和视觉/交互对标;页面 AI 长期主链完成状态以 `7-4` 为准。 | E28 | PAUSED | Mention / Emoji 能力包 | 2026-05-03 暂停;Wolai baseline 已纠偏:正文 `@` 当前是提醒/会议/成员候选入口,不是页面引用搜索,恢复时先重定 mention/emoji 口径,再决定 Tiptap JSON 与 mnote 保存链 | | E29 | PAUSED | Comment / History 能力包 | 2026-05-03 暂停;评论/历史入口仍需后端边界和 Wolai 基线复核,待 E30 统一菜单状态机后再接入,避免继续复制独立弹层逻辑 | | E30 | PARTIAL | Menu / Floating 状态机能力包 | 2026-05-03 第一刀已完成本地统一状态机 smoke:`task158` 覆盖 slash、块菜单/二级菜单、selection toolbar/color panel、image floating toolbar、table toolbar/options 的互斥打开、Esc 统一关闭、方向键 active,以及打开块菜单时关闭 image toolbar;Wolai 只读基线已确认 selection/type menu 和 table popper 的 Esc/外部点击/URL 不变,slash 与块菜单二级菜单在只读条件下不稳定,后续仍需 editable-test 复核 Enter、外部点击和更多层级收起 | diff --git a/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md b/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md index 4ee41521..b31ec089 100644 --- a/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md +++ b/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md @@ -11,7 +11,7 @@ > - 本稿对应 `Phase 7 v2` 的“文档页 AI 最小闭环”已完成,故迁入 `done/` > - 本稿完成不等于整个 `Phase 7 v2` 已完成;结构化知识写链仍以后续阶段继续推进 > - 2026-05-05 追加说明:本稿记录的是 `openai-agents-python` sidecar 作为过渡主链的完成状态,不代表当前长期方向;当时长期口径曾由 `/mnt/Data1T/mnote/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 收口为 `mnote-cli` 是唯一长期 agent 执行面 -> - 2026-05-13 追加说明:`mnote-cli` 唯一长期 agent 执行面口径已被 `/mnt/Data1T/mnote/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖;新的长期方向是页面 AI 面板仅作为 Hermes 页面内客户端,Hermes 持有 AI 会话真相,mnote 通过 Hermes skill/plugin 暴露业务工具 +> - 2026-05-13 追加说明:`mnote-cli` 唯一长期 agent 执行面口径已被 `/mnt/Data1T/mnote/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖;新的长期方向是页面 AI 面板仅作为 Hermes 页面内客户端,Hermes 持有 AI 会话真相,mnote 通过 Hermes skill/plugin 暴露业务工具 --- diff --git a/design/07-ai/process/7-2-phase7-structured-artifact-write-chain-v1.md b/design/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md similarity index 98% rename from design/07-ai/process/7-2-phase7-structured-artifact-write-chain-v1.md rename to design/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md index de765232..2a6a2291 100644 --- a/design/07-ai/process/7-2-phase7-structured-artifact-write-chain-v1.md +++ b/design/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md @@ -1,4 +1,4 @@ -# 7-2 [process] Phase 7 结构化 Artifact 写链最小落地方案 v1 +# 7-2 [done] Phase 7 结构化 Artifact 写链最小落地方案 v1 > 更新时间:2026-04-23 > @@ -7,7 +7,7 @@ > - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` +> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md` > > 2026-05-05 追加说明: @@ -17,7 +17,7 @@ > > 2026-05-13 追加说明: > - 本稿的对象模型、artifact 写链、projection-only `AI Artifacts` 分组与 kernel 边界继续有效 -> - 触发与执行口径改由 `/mnt/Data1T/mnote/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖 +> - 触发与执行口径改由 `/mnt/Data1T/mnote/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖 > - 当前凡是提到“页面 AI 面板触发”或“页面 AI host 固定动作”的位置,都应理解为: > 页面 AI 面板作为 Hermes 页面内客户端发起意图,Hermes 通过 mnote skill/plugin 调用正式 artifact 工具,最终写入仍回到 Rust runtime / kernel diff --git a/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md b/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md similarity index 98% rename from design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md rename to design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md index 3d766334..02318de9 100644 --- a/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md +++ b/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md @@ -1,4 +1,4 @@ -# 7-3 [process] 页面 AI Hermes 面板与 mnote Plugin 主线方案 v1 +# 7-3 [done] 页面 AI Hermes 面板与 mnote Plugin 主线方案 v1 > 更新时间:2026-05-13 > @@ -10,7 +10,8 @@ > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-2-phase7-structured-artifact-write-chain-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` > > 覆盖关系: > - 覆盖 `/mnt/Data1T/mnote/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` diff --git a/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md b/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md new file mode 100644 index 00000000..dcd12c57 --- /dev/null +++ b/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md @@ -0,0 +1,1190 @@ +# 7-4 [done] 页面 AI Hermes 面板顺序执行 checklist v1 + +> 更新时间:2026-05-14 +> +> 当前状态:`DONE`。A-M 的实现、旧链退场、持久化审计和正式 `3000` 全矩阵已完成;N 文档治理已把活跃设计口径统一为 Hermes 页面内客户端 + mnote Hermes skill/plugin。本文已从 `process/` 迁入 `done/`;后续页面 AI 体验与设置面收口由 `design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md` 继续承接。 +> +> 上位依据: +> - `/mnt/Data1T/mnote/ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-15-runtime-fallback-retirement-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` +> +> 目标: +> - 把页面 AI 从旧 `/api/ai-agent/run` / `mnote-cli host` / sidecar 路径迁到 Hermes 页面内客户端。 +> - Hermes 持有 AI session/message/tool event/usage/model 真相。 +> - mnote 只通过 Hermes skill/plugin 暴露页面、树、artifact、edge 等业务工具。 +> - Leptos 负责页面内 Hermes 面板体验,不负责 AI 编排或聊天真相存储。 + +--- + +## 0. 使用方式 + +每次只领取一个 task,按顺序执行。除非前一 task 的验收标准全部满足,否则不要进入下一 task。 + +勾选规则: + +- [x] 已有代码、文档和可复跑验证证据。 +- [ ] 还没有完整证据,或只有局部实现。 +- [ ] `PARTIAL` 项不能当作完成项;必须继续拆到对应未勾选子项。 + +状态标记: + +- `TODO`:未开始 +- `RED`:已补失败测试或失败 smoke +- `GREEN`:当前 task 本地通过 +- `BLOCKED`:依赖外部 Hermes、auth、模型网关或数据权限,无法在本轮闭环 +- `DONE`:代码、测试、文档证据都已回填 + +执行硬规则: + +- [x] 不新增 Web 私有 AI tool registry。 +- [x] 不把 Hermes 聊天消息复制进 mnote page aggregate / Convex 页面数据。 +- [x] 不让 Hermes plugin 直接写 Convex,写入必须回到 Rust runtime / kernel。 +- [x] 不把 `mnote-cli` 写回页面 AI 唯一长期执行面;它最多是 plugin 内部 adapter 或调试入口。 +- [x] 不在页面 AI 抽屉里复刻 Hermes 管理台;只实现页面内 chat/session/run/tool event 子集。 +- [x] 不在同一阶段同时迁移所有工具;先读、再写正文、再 artifact、再树与 edge。 + +当前进度快照: + +| Task | 状态 | 已有证据 | 剩余门槛 | +| --- | --- | --- | --- | +| A | [x] GREEN | `scripts/task-hermes-page-ai-baseline-smoke.js`、`bugs/07-ai/done/7-1-page-ai-provider-hermes-old-run-502-v1.md` | 无 | +| B | [x] GREEN | `design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md` | 无 | +| C | [x] GREEN | `rust/crates/mnote-web/src/routes/hermes_client.rs`、`cargo test -p mnote-web hermes_` | 无 | +| D | [x] GREEN | `design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md` | 无 | +| E | [x] GREEN | `rust/crates/mnote-web/src/routes/hermes_tools.rs`、`rust/crates/mnote-web/src/hermes_tools/*`、`task-hermes-page-ai-tool-smoke.js` | 后续可扩 manifest 全量 schema,不阻塞第一版只读工具 | +| F | [x] GREEN | `rust/crates/mnote-web/src/ssr/pages/layout.rs`、`task-hermes-page-ai-smoke.js` | 无 | +| G | [x] GREEN | 真实 Hermes upstream run 已触发 `mnote_page_get` / `mnote.page.get`;Hermes session export 与刷新恢复已验证 | 无 | +| H | [x] GREEN | `mnote.page.save` route、dryRun、未认证拒绝、workspace ACL 失败、同 key replay、主编辑区立即回显与刷新后持久化 smoke 已有 | 无 | +| I | [x] GREEN | `mnote.page.update_title`、`mnote.page.update_options` route 与 smoke 已有;标题 dryRun / idempotency、wired-only warning、页头 / breadcrumb / sidebar / 刷新后一致性已验证 | 无 | +| J | [x] GREEN | `mnote.artifact.create_summary`、`mnote.artifact.create_ai_note` route 与 smoke 已有;summary 重试、ai_note 独立创建、真实 kernel/projection edge 查询、`AI Artifacts` projection-only 验收和页面 AI Hermes intent 快捷入口已通过 | 无 | +| K | [x] GREEN | 新页面 AI 主链已不请求 `/api/ai-agent/run`;旧 endpoint 统一返回 `410 legacy_ai_agent_run_retired`;旧 smoke 默认退役;legacy 调用方已清点并归类 | 无 | +| L | [x] GREEN | tool 响应已有基础 audit 字段;`routes/hermes_tools.rs` 已记录 started/completed/failed 结构化日志;写工具 `audit.commandId` 已指向 Rust command id;审计已追加 JSONL 持久化并支持 `persistedOnly=true` 反查 | 无 | +| M | [x] GREEN | 正式 `http://127.0.0.1:3000` 已复跑 8 个 E2E smoke:session/run、tool、writeback、title/options、artifact、retirement、mobile、audit | 无 | +| N | [x] GREEN | 7-3/7-4/7-5/7-6 已写入主线口径;`1-1`、`3-1`、`5-6`、`5-9`、`10-review` 已统一旧 AI 口径为历史/兼容/已覆盖 | 后续若移动本文件到 `done/`,需同步更新引用 | + +--- + +## 1. Hermes Web UI 参考索引 + +需要参考 `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18` 时,只参考下表列出的文件。参考目标是理解 Hermes 的 session / run / stream / tool event / usage / model 语义,不照搬 Vue、Naive UI、整站导航或后台管理页面。 + +执行时按 task 精确打开文件,不要整目录通读。需要进一步定位时,优先按下表的“搜索点”打开,不要从整站 UI 或无关管理页开始看: + +| 参考主题 | 文件 | 搜索点 / 具体位置 | 在 mnote 中用于什么 | +| --- | --- | --- | --- | +| 浏览器发起 run 与事件分发 | `packages/client/src/api/hermes/chat.ts` | `StartRunRequest`、`RunEvent`、`registerSessionHandlers`、`connectChatRun`、`startRunViaSocket` | 对齐 mnote Leptos 面板发起 run、订阅 `message.delta` / `tool.started` / `tool.completed` / `run.completed` 的事件语义 | +| session 刷新恢复 | `packages/client/src/api/hermes/chat.ts`、`packages/client/src/stores/hermes/chat.ts` | `resumeSession`、`switchSession`、`refreshActiveSession`、`mapHermesMessages` | 对齐页面刷新后从 Hermes session detail / resume 恢复消息,而不是从 mnote 本地 state 恢复聊天真相 | +| tool message 前端归并 | `packages/client/src/stores/hermes/chat.ts` | `Message.role = 'tool'`、`toolCallId`、`toolArgs`、`toolResult`、`case 'tool.started'`、`case 'tool.completed'` | 对齐 mnote 面板如何把 tool started / completed 合并成可读消息项 | +| tool message 展示 | `packages/client/src/components/hermes/chat/MessageItem.vue` | `message.role === 'tool'`、`formatToolPayload`、`toolExpanded`、`tool-preview`、`tool-details` | 对齐 tool 结果默认折叠、摘要展示、详细 JSON 展开的交互边界 | +| 页面面板分层 | `packages/client/src/components/hermes/chat/ChatPanel.vue` | `showSessions`、`activeSessionTitle`、`handleNewChat`、`MessageList`、`ChatInput`、`SessionListItem` | 只参考 session list / message list / input 的分层,不复制 Vue / Naive UI / Hermes 全屏布局 | +| 后端 run 与 resume 房间 | `packages/server/src/services/hermes/chat-run-socket.ts` | `ChatRunSocket`、`onConnection`、`resumeSession`、`handleRun`、`emit`、`markCompleted` | 对齐 upstream run 生命周期、刷新恢复、房间广播与终止态,不在 mnote 里重建 Hermes 编排器 | +| OpenAI Responses tool event 映射 | `packages/server/src/services/hermes/chat-run-socket.ts` | `applyResponseStreamEvent`、`response.output_item.done`、`function_call`、`function_call_output`、`tool.started`、`tool.completed`、`flushResponseRunToDb` | 对齐真实 Hermes upstream run 如何产生 tool event,并把 tool call / tool result 写回 Hermes session 存储 | +| Hermes session 存储真相 | `packages/server/src/db/hermes/session-store.ts` | `HermesSessionRow`、`HermesMessageRow`、`createSession`、`getSessionDetail`、`addMessage`、`updateSessionStats` | 明确 session/message/tool call 真相在 Hermes,不在 mnote page aggregate / Convex 页面数据 | +| session HTTP detail | `packages/client/src/api/hermes/sessions.ts`、`packages/server/src/routes/hermes/sessions.ts` | `fetchSessions`、`fetchSession`、`HermesMessage`、`/api/hermes/sessions/:id` | 对齐 session list/detail 的字段名,用于 mnote proxy 返回兼容形状 | +| plugin/skill 清单发现 | `packages/server/src/services/hermes/plugins.ts` | `PYTHON_BRIDGE`、`PluginManager`、`user_dir = get_hermes_home() / "plugins"`、`manifest_list`、`providesTools`、`listHermesPlugins` | 对齐 Hermes Web UI 如何发现 user/project plugin 和 `provides_tools`;mnote 的真正工具必须进入 Hermes plugin/tool registry,不能只停在 mnote-web 私有 route | +| plugin/skill API 外观 | `packages/client/src/api/hermes/plugins.ts`、`packages/client/src/api/hermes/skills.ts`、`packages/server/src/routes/hermes/plugins.ts`、`packages/server/src/routes/hermes/skills.ts` | `list` / `get` / `enable` / `disable` 类 route 与响应字段 | 只用于清单和状态展示口径;页面 AI 第一版不复刻 Hermes plugin 管理台 | + +可快速 grep: + +```bash +cd /mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18 +rg -n "StartRunRequest|RunEvent|registerSessionHandlers|startRunViaSocket|resumeSession|tool\\.started|tool\\.completed|applyResponseStreamEvent|flushResponseRunToDb|PluginManager|providesTools|listHermesPlugins" packages/client/src packages/server/src +``` + +已分类的常用入口: + +- [x] session / run / SSE:先看 `packages/client/src/api/hermes/chat.ts`、`packages/server/src/routes/hermes/chat-run.ts`、`packages/server/src/services/hermes/chat-run-socket.ts`。 +- [x] session 真相恢复:先看 `packages/server/src/db/hermes/session-store.ts`、`packages/server/src/db/hermes/sessions-db.ts`、`packages/server/src/services/hermes/session-sync.ts`。 +- [x] 面板组件分层:先看 `packages/client/src/views/hermes/ChatView.vue`、`packages/client/src/components/hermes/chat/ChatPanel.vue`、`ChatInput.vue`、`MessageList.vue`、`MessageItem.vue`。 +- [x] tool event:先看 `packages/client/src/stores/hermes/chat.ts`、`packages/client/src/components/hermes/chat/MessageItem.vue`、`packages/server/src/db/hermes/conversations-db.ts`。 +- [x] plugin / skill 清单:先看 `packages/client/src/api/hermes/plugins.ts`、`packages/client/src/api/hermes/skills.ts`、`packages/server/src/routes/hermes/plugins.ts`、`packages/server/src/routes/hermes/skills.ts`、`packages/server/src/services/hermes/plugins.ts`。 + +| mnote 任务 | 参考文件 | 只参考什么 | 不采用什么 | +| --- | --- | --- | --- | +| Hermes API client 合同 | `packages/client/src/api/hermes/chat.ts`、`packages/client/src/api/hermes/sessions.ts`、`packages/client/src/api/hermes/conversations.ts` | chat run、session detail、conversation list/detail 的请求和响应形状 | 不直接复用 TS client,不让浏览器绕过 mnote-web proxy | +| Hermes proxy / route 形态 | `packages/server/src/routes/hermes/chat-run.ts`、`packages/server/src/routes/hermes/sessions.ts`、`packages/server/src/routes/hermes/proxy.ts`、`packages/server/src/routes/hermes/proxy-handler.ts` | 服务端 route 如何接收 run、session、proxy 请求以及错误透传边界 | 不把 Hermes server route 复制进 mnote;mnote 只做同源薄代理 | +| session 真相与恢复 | `packages/server/src/db/hermes/session-store.ts`、`packages/server/src/db/hermes/sessions-db.ts`、`packages/server/src/db/hermes/conversations-db.ts`、`packages/server/src/services/hermes/conversations.ts`、`packages/server/src/services/hermes/session-sync.ts` | session/message/conversation 的存储、搜索、恢复、thread/lineage 摘要 | 不在 mnote 里重建这些表或复制聊天历史 | +| usage / model 真相 | `packages/server/src/db/hermes/usage-store.ts`、`packages/client/src/stores/hermes/usage.ts`、`packages/client/src/stores/hermes/models.ts`、`packages/client/src/components/layout/ModelSelector.vue` | usage、model selection、model display 的字段和展示语义 | 不在 mnote page aggregate 保存 usage/model 真相 | +| chat 面板结构 | `packages/client/src/views/hermes/ChatView.vue`、`packages/client/src/components/hermes/chat/ChatPanel.vue`、`packages/client/src/components/hermes/chat/ChatInput.vue`、`packages/client/src/components/hermes/chat/MessageList.vue`、`packages/client/src/components/hermes/chat/MessageItem.vue`、`packages/client/src/components/hermes/chat/HistoryMessageList.vue`、`packages/client/src/components/hermes/chat/SessionListItem.vue` | session list、message list、输入框、消息项、历史消息的组件分层 | 不采用 Vue / Naive UI / Hermes 全屏布局;mnote 仍用 Leptos island 和 Wolai drawer 壳 | +| markdown / thinking 展示 | `packages/client/src/components/hermes/chat/MarkdownRenderer.vue`、`packages/client/src/utils/thinking-parser.ts`、`tests/client/chat-store-thinking.test.ts`、`tests/client/thinking-parser.test.ts` | markdown 渲染、`think/thinking/reasoning` 片段解析、streaming 中的 thinking 边界 | 不直接照搬样式;mnote 只实现页面 AI 抽屉需要的折叠展示 | +| stream / abort / resume | `packages/client/src/stores/hermes/chat.ts`、`packages/client/src/api/hermes/chat.ts`、`packages/server/src/routes/hermes/chat-run.ts`、`packages/server/src/services/hermes/chat-run-socket.ts` | run stream 事件、resume session room、abort started/completed、queue/server-working 状态 | 不强制采用 Socket.IO;mnote proxy 可用 SSE 或项目现有 stream 机制,但事件语义要对齐 | +| tool event 展示 | `packages/client/src/stores/hermes/chat.ts`、`packages/client/src/components/hermes/chat/MessageItem.vue`、`packages/client/src/components/hermes/chat/HistoryMessageList.vue`、`packages/server/src/db/hermes/conversations-db.ts` | `tool_call_id`、tool message、tool started/completed/failed 的归并与历史展示 | 不把 tool result 写入 mnote 正文;只展示 Hermes tool event | +| session 搜索 | `packages/client/src/components/hermes/chat/SessionSearchModal.vue`、`packages/client/src/composables/useSessionSearch.ts`、`tests/client/session-search.test.ts`、`tests/client/sidebar-search.test.ts` | 后续 session search 的交互和过滤语义 | 第一版可后置,不进入最小验收 | +| plugins / skills 列表 | `packages/client/src/api/hermes/plugins.ts`、`packages/client/src/api/hermes/skills.ts`、`packages/server/src/routes/hermes/plugins.ts`、`packages/server/src/routes/hermes/skills.ts`、`packages/server/src/services/hermes/plugins.ts`、`packages/client/src/components/hermes/skills/SkillList.vue`、`packages/client/src/components/hermes/skills/SkillDetail.vue` | Hermes 如何表达 plugin/skill 清单、启停和详情 | 不采用 Hermes skills 管理页面;mnote 只暴露 `mnote.*` tool manifest | +| 不属于 mnote 页面 AI 第一版 | `packages/client/src/views/hermes/ChannelsView.vue`、`JobsView.vue`、`FilesView.vue`、`TerminalView.vue`、`GroupChatView.vue`、`SettingsView.vue`、`ProfilesView.vue`、`LogsView.vue`、`UsageView.vue` | 仅作为边界参考,说明这些是 Hermes 管理台能力 | 不进入页面 AI 抽屉第一版 | + +--- + +## 2. 总体验收标准 + +7-4 完成时必须同时满足: + +- [x] 页面 AI 抽屉打开后创建或恢复的是 Hermes session。 +- [x] Hermes session 存储里能看到页面 AI 的消息历史。 +- [x] mnote 本地不保存聊天消息真相,只保存业务写入 audit、artifact、edge、page/body/title/options 结果。 +- [x] 页面 AI 发送消息后,事件流来自 Hermes run,而不是旧 `/api/ai-agent/run` provider / fallback 分支。 +- [x] tool call 展示来自 Hermes tool event;真实 Hermes upstream run 已触发 `mnote.page.get`,刷新后从 Hermes session detail 恢复。 +- [x] Hermes 至少能调用一个只读 mnote 工具读取当前页面。 +- [x] Hermes 至少能调用一个写入 mnote 工具,经 Rust runtime 写回当前页面正文或创建 artifact。 +- [x] 页面刷新后,AI 会话从 Hermes 恢复,而不是从 mnote 本地 state 恢复。 +- [x] 旧 `provider=hermes` 502 不再出现在新页面 AI 主链。 +- [x] `rg` 检查活跃设计稿与代码注释不再把 `mnote-cli` 写成页面 AI 唯一长期执行面;命中已逐条归类为历史/兼容/已覆盖。 + +最终验收命令建议: + +```bash +cd /mnt/Data1T/mnote +rg -n "mnote-cli.*唯一|唯一长期 agent|页面 AI.*mnote-cli|provider=hermes" design rust wolai-frontend --glob '*.md' --glob '*.rs' --glob '*.ts' --glob '*.tsx' +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-smoke.js +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-smoke.js +``` + +通过标准: + +- [x] `rg` 只允许命中 `design/old/` 或明确标注“历史/兼容/已覆盖”的段落。 +- [x] `task-hermes-page-ai-smoke.js` 证明页面 AI 使用 Hermes session / run / stream,并验证刷新后从 Hermes session detail 恢复。 +- [x] `task-hermes-page-ai-tool-smoke.js` 证明 Hermes tool call 能回到 mnote Rust runtime 并产生可读业务结果。 + +2026-05-14 验证记录: + +- [x] `cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web hermes_ -- --nocapture`:15 passed。 +- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3019 node scripts/task-hermes-page-ai-smoke.js`:通过,捕获 `session -> session-detail -> run -> events -> session-detail`,渲染 `mnote.page.get` tool event,刷新恢复命中 Hermes session detail 2 次。 +- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3019 node scripts/task-hermes-page-ai-tool-smoke.js`:通过,`mnote.page.get` 返回标题、正文摘要和 read audit。 +- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3019 node scripts/task-hermes-page-ai-writeback-smoke.js`:通过,`mnote.page.save` 写入走 `page.body.save`。 +- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3019 node scripts/task-hermes-page-ai-title-options-smoke.js`:通过,标题走 `page.head.updateTitle`,页面设置走 `page.layout.updateOptions`。 +- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3019 node scripts/task-hermes-page-ai-artifact-smoke.js`:通过,summary / ai_note 都走 `tree.node.create`。 +- [x] 早期临时 `3019` 验证曾确认旧 `/api/ai-agent/run` 不再静默 fallback;最终正式 `3000` 验收已由 Task K/M 覆盖,当前稳定退场码为 `410 legacy_ai_agent_run_retired`。 +- [x] 上述临时 `3019` 证据已被正式 `http://127.0.0.1:3000` 全矩阵复跑覆盖;最终以 Task M 的 8 个 `3000` smoke 证据为准。 +- [x] 追加验证:`task-hermes-page-ai-writeback-smoke.js`、`task-hermes-page-ai-title-options-smoke.js`、`task-hermes-page-ai-artifact-smoke.js` 已断言 `audit.commandId == result.commandId`,临时 `3019` 通过。 +- [x] 追加验证:临时 `3019` 日志已输出 `mnote Hermes tool call started/completed`,字段包含 `trace_id/session_id/run_id/tool_call_id/tool_name/workspace_id/document_id/actor_id/effect`。 +- [x] 追加验证:`MNOTE_UI_BASE_URL=http://127.0.0.1:3019 node scripts/task-hermes-page-ai-mobile-smoke.js` 通过,390px 窄屏下 drawer / panel / document 均无横向溢出。 +- [x] 追加验证:`task-hermes-page-ai-writeback-smoke.js` 已在 dryRun 后立即读取 `/api/documents/content`,确认写入标记不存在;随后正式写入再确认标记存在。 +- [x] 追加验证:`hermes_tools_write_tools_require_auth` 已覆盖未认证写工具返回 401。 +- [x] 追加验证:`task-hermes-page-ai-writeback-smoke.js` 已用同一 `idempotencyKey` 重试正式写入,断言返回同一 `result.commandId/audit.commandId`;临时 `3019` 日志出现 `mnote Hermes tool call idempotency replay`。 +- [x] 追加验证:`task-hermes-page-ai-title-options-smoke.js` 已打开真实文档页,确认刷新后标题输入、breadcrumb/current title、`data-page-wide-layout=true`、`data-page-small-text=true` 均生效。 +- [x] 追加验证:`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-title-options-smoke.js` 通过,`tree_1778720870666_1` 标题更新为 `TEST-HERMES-AI-title-updated-mp4shbjq`,页头 / breadcrumb/current title / sidebar 均一致。 +- [x] 追加验证:`task-hermes-page-ai-title-options-smoke.js` 已断言 `pageFont` 返回 `ignoredOptions=["pageFont"]` 与 `warningCodes=["page_option_not_wired"]`,不进入 `page.layout.updateOptions` 写入 payload。 +- [x] 追加验证:`task-hermes-page-ai-title-options-smoke.js` 已打开页面 AI 面板,确认 `data-mnote-page-ai-session-owner=hermes`,mnote localStorage 只保存 Hermes `activeSessionId`,不保存或驱动 mnote 页面标题真相。 +- [x] 追加验证:`task-hermes-page-ai-title-options-smoke.js` 已验证 `mnote.page.update_title` dryRun 后 meta 不变化,并验证同一 `idempotencyKey` 重试返回同一 `commandId`。 +- [x] 追加验证:`task-hermes-page-ai-artifact-smoke.js` 已验证 summary 同 key 重试返回同一 `commandId/artifactDocumentId`,ai_note 第二次创建得到独立 `artifactDocumentId`,并验证工具结果里的 `referenceEdge.from/to/kind`。 +- [x] 追加验证:`task-hermes-page-ai-audit-smoke.js` 已调用 `mnote.page.get` 与 `mnote.page.save`,并通过 `/api/hermes/tools/mnote/audit?traceId=...` 反查 `started/completed` 事件,写工具 audit 可串到 Rust `commandId`,且不包含完整正文内容。 + +--- + +## 3. Task A:运行态基线与旧 502 复现 + +目标: + +> 先固定当前问题和旧链入口,避免后续改动时把“没有复现”误认为“已经修好”。 + +建议修改范围: + +- 新增或复用 smoke: + - `scripts/task-hermes-page-ai-baseline-smoke.js` +- 只读记录: + - `rust/crates/mnote-web/src/routes/**` + - `wolai-frontend/src/**/ai-agent/**` + - `design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` + +执行步骤: + +- [x] 启动当前前后端,确保测试入口可访问。 +- [x] 用真实 Convex Auth 测试账号登录。 +- [x] 打开任意测试页面,点击右下角页面 AI。 +- [x] 发送一条只读问题,例如“概括当前页面标题和第一段”。 +- [x] 记录当前请求是否仍命中旧 `/api/ai-agent/run`。 +- [x] 记录是否出现 `provider=hermes` 相关 502、`ai_provider_bridge_unavailable` 或旧 fallback 文案。 +- [x] 将证据写入本 task 对应 bug 或执行记录,不修改业务逻辑。 + +验收标准: + +- [x] 有一份可复跑 smoke 或手动执行记录,明确当前页面 AI 主请求 URL。 +- [x] 能区分以下三类状态: + - 旧 `/api/ai-agent/run` 仍被页面 AI 调用。 + - 页面 AI 已调用 Hermes proxy,但 proxy 未接通。 + - 页面 AI 已调用 Hermes proxy 且 session/run 可恢复。 +- [x] 若仍出现 502,记录请求 URL、响应 body、mnote-web 日志时间戳和页面截图。 +- [x] 不把本 task 的 502 修复写进同一个提交;本 task 只做基线。 + +--- + +## 4. Task B:冻结 Hermes client proxy 合同 + +目标: + +> 先冻结浏览器到 mnote-web 的同源 Hermes client proxy 合同,防止 Leptos 面板直接绑定 Hermes 私有 API 或暴露 key。 + +建议修改范围: + +- 新增设计/协议文档: + - `design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md` +- 后续代码位置建议: + - `rust/crates/mnote-web/src/routes/hermes_client.rs` + - `rust/crates/mnote-web/src/routes/mod.rs` + - `rust/crates/mnote-web/src/config.rs` + +合同路径第一版: + +- `GET /api/hermes/client/sessions` +- `POST /api/hermes/client/sessions` +- `GET /api/hermes/client/sessions/{session_id}` +- `POST /api/hermes/client/runs` +- `GET /api/hermes/client/events/{run_id}` +- `POST /api/hermes/client/runs/{run_id}/abort` +- `GET /api/hermes/client/models` +- `GET /api/hermes/client/tools` + +必须统一的请求字段: + +- `workspaceId` +- `documentId` +- `sessionId` +- `message` +- `pageContext` +- `selectedBlockId` +- `selectedText` +- `traceId` + +必须统一的响应字段: + +- `sessionId` +- `runId` +- `messageId` +- `events[]` +- `error.code` +- `error.message` +- `traceId` + +执行步骤: + +- [x] 写明每个 proxy route 的输入输出 JSON 示例。 +- [x] 写明 proxy 只做 auth、同源安全、上下文注入和错误码标准化。 +- [x] 写明 proxy 不保存 session/message/tool event。 +- [x] 写明 Hermes API key 或 token 只存在服务端环境变量中。 +- [x] 写明 `pageContext` 是运行输入,不是 Hermes session 的长期事实源。 +- [x] 写明旧 `/api/ai-agent/run` 与新 proxy 的并存期规则。 + +验收标准: + +- [x] 合同文档里每个 route 都有请求/响应示例。 +- [x] 文档明确禁止浏览器直接持有 Hermes API key。 +- [x] 文档明确禁止 mnote 保存 Hermes 聊天历史。 +- [x] 文档明确旧 `/api/ai-agent/run` 不是新 Hermes 面板主代理。 +- [x] 后续实现者不需要读 Hermes Web UI 源码也能知道 mnote 侧 proxy 的稳定合同。 + +--- + +## 5. Task C:实现 Hermes client proxy 最小只读链 + +目标: + +> 让页面 AI 可以通过 mnote-web 同源代理创建/恢复 Hermes session,并发起一个无工具写入的只读 run。 + +建议修改范围: + +- 新增: + - `rust/crates/mnote-web/src/routes/hermes_client.rs` +- 修改: + - `rust/crates/mnote-web/src/routes/mod.rs` + - `rust/crates/mnote-web/src/config.rs` + - `rust/crates/mnote-web/src/error.rs` +- 测试: + - `rust/crates/mnote-web/tests/hermes_client_proxy.rs` 或同 crate 现有 route 测试文件 + +执行步骤: + +- [x] 先写 route 测试:未登录请求返回 401 或项目统一 auth 错误。 +- [x] 写 route 测试:未配置 Hermes upstream 时返回稳定错误码 `hermes_client_unconfigured`。 +- [x] 写 route 测试:`POST /api/hermes/client/sessions` 只代理 session 创建,不写 mnote 数据。 +- [x] 写 route 测试:`POST /api/hermes/client/runs` 会带上 `workspaceId/documentId/pageContext/traceId`。 +- [x] 实现最小 upstream client,先只支持 session create、session get、run create。 +- [x] 统一错误响应:Hermes 连接失败、401、429、5xx 必须映射成可展示的错误码。 +- [ ] 记录日志字段:`traceId/sessionId/runId/documentId/workspaceId`。 + +验收标准: + +- [x] `cargo test -p mnote-web hermes_client` 通过。 +- [x] 未配置 Hermes 时,页面 AI 不出现 502,而是得到稳定 `hermes_client_unconfigured`。 +- [x] 配置 Hermes upstream 后,session create 和 run create 请求能从 mnote-web 代理到 Hermes。 +- [x] mnote 数据库、page aggregate、Convex 页面内容没有新增聊天消息字段。 +- [x] 网络请求中浏览器看不到 Hermes API key。 + +--- + +## 6. Task D:冻结 mnote Hermes plugin tool 合同 + +目标: + +> 先定义 Hermes 看到的 mnote 工具清单和工具结果格式,避免工具从页面前端私有函数开始长出来。 + +建议新增文档: + +- `design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md` + +第一批工具: + +- `mnote.page.get` +- `mnote.page.save` +- `mnote.page.update_title` +- `mnote.page.update_options` +- `mnote.artifact.create_summary` +- `mnote.artifact.create_ai_note` + +第二批工具: + +- `mnote.tree.create` +- `mnote.tree.move` +- `mnote.search.documents` +- `mnote.kernel.attach_reference` + +统一入参: + +- `workspaceId` +- `documentId` +- `actorId` +- `sessionId` +- `runId` +- `toolCallId` +- `traceId` +- `idempotencyKey` +- `dryRun` +- `capabilityScope` + +统一出参: + +- `ok` +- `toolName` +- `toolCallId` +- `traceId` +- `result` +- `audit` +- `error` + +执行步骤: + +- [x] 为每个第一批工具写 JSON schema。 +- [x] 为每个第一批工具写成功响应示例。 +- [x] 为每个第一批工具写失败响应示例。 +- [x] 写明权限校验点:登录、workspace member、页面读、页面写、artifact 写。 +- [x] 写明幂等规则:同一 `idempotencyKey` 重试不得重复创建 artifact。 +- [x] 写明 `dryRun=true` 时只能返回计划和 diff,不得写入。 +- [x] 写明 plugin 内部可以暂时调用 `mnote-cli` JSON adapter,但对 Hermes 暴露的名字必须是 `mnote.*`。 + +验收标准: + +- [x] 合同文档覆盖所有第一批工具。 +- [x] 每个写工具都有 `dryRun`、`idempotencyKey`、权限失败、业务失败示例。 +- [x] 文档明确 tool 结果最终来自 Rust runtime / kernel。 +- [x] 文档明确 Hermes plugin 不直接写 Convex。 +- [x] 文档明确 `mnote-cli` 是内部 adapter,不是 Hermes 看到的长期 tool 名称。 + +--- + +## 7. Task E:实现 mnote tool manifest 与只读工具 + +目标: + +> 先让 Hermes 能发现 mnote 工具,并调用 `mnote.page.get` 读取当前页面。 + +建议修改范围: + +- 新增: + - `rust/crates/mnote-web/src/routes/hermes_tools.rs` + - `rust/crates/mnote-web/src/hermes_tools/mod.rs` + - `rust/crates/mnote-web/src/hermes_tools/manifest.rs` + - `rust/crates/mnote-web/src/hermes_tools/page.rs` +- 修改: + - `rust/crates/mnote-web/src/routes/mod.rs` + - `rust/crates/bridge-runtime/src/lib.rs` 只在缺少正式 query 时补窄入口 +- 测试: + - `rust/crates/mnote-web/tests/hermes_tools.rs` + +建议 route: + +- `GET /api/hermes/tools/mnote/manifest` +- `POST /api/hermes/tools/mnote/call` + +执行步骤: + +- [x] 写 manifest 测试:返回第一批工具名称、schema version、capability scope。 +- [x] 写 `mnote.page.get` 测试:未登录失败。 +- [ ] 写 `mnote.page.get` 测试:无 workspace 权限失败。 +- [x] 写 `mnote.page.get` 测试:有权限时返回当前 page aggregate 摘要。 +- [x] 实现 manifest route。 +- [x] 实现统一 tool call dispatcher,只接 `mnote.page.get`。 +- [x] 将 `mnote.page.get` 接到现有 Rust runtime / page aggregate query。 + +验收标准: + +- [x] `cargo test -p mnote-web hermes_tools` 通过。 +- [x] `GET /api/hermes/tools/mnote/manifest` 返回稳定 `mnote.page.get` schema。 +- [x] `POST /api/hermes/tools/mnote/call` 调用 `mnote.page.get` 返回当前页面标题、正文摘要、page options、可用 block 摘要。 +- [ ] 权限失败不会泄露页面标题或正文。 +- [x] 工具返回中包含 `traceId/toolCallId/sessionId`。 + +--- + +## 8. Task F:Leptos Hermes 面板最小只读 UI + +目标: + +> 在保留 Wolai 右下角入口和右侧抽屉壳的前提下,把页面 AI 面板改为调用 Hermes client proxy。 + +建议修改范围: + +- Leptos / Rust SSR 侧按当前真实结构定位: + - `rust/crates/mnote-web/src/ssr/**` + - `rust/crates/mnote-web/src/routes/web_shell.rs` + - 页面内 island 挂载资源所在目录 +- 若仍需 React compat,只能作为旧 panel 参考,不得扩展旧执行链: + - `wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx` +- smoke: + - `scripts/task-hermes-page-ai-smoke.js` + +第一版 UI 子集: + +- session 创建/恢复 +- message list +- chat input +- streaming delta +- reasoning 展开 +- tool started / completed 展开 +- error / retry / abort +- model selector 可先只读展示默认模型 + +执行步骤: + +- [x] 写 smoke:打开页面 AI 后,不允许发送请求到 `/api/ai-agent/run`。 +- [x] 写 smoke:打开页面 AI 后,会请求 `/api/hermes/client/sessions`。 +- [x] 写 smoke:发送消息后,会请求 `/api/hermes/client/runs`。 +- [x] 写 smoke:页面刷新后,面板可从 Hermes session 恢复消息。 +- [x] 实现 Leptos 面板数据层,只保存 UI 临时状态,不保存聊天真相。 +- [x] 接入 session create / restore。 +- [x] 接入 run create / event stream。 +- [ ] 接入 abort / retry 的最小状态。 +- [x] 保留右下角入口、右侧 drawer、Esc / close 行为与 `5-11` 证据一致。 + +验收标准: + +- [x] `node scripts/task-hermes-page-ai-smoke.js` 通过。 +- [x] 页面 AI 主链不再请求 `/api/ai-agent/run`。 +- [x] 页面 AI 消息刷新后来自 Hermes session。 +- [x] mnote 本地页面 state 里没有完整聊天消息数组。 +- [ ] 抽屉入口、关闭语义、移动端不溢出继续符合 `5-11` 的历史 Wolai 对齐要求。 +- [x] Hermes 不可用时展示稳定错误态,不出现 502 空白或旧 `page_ai_failed_502`。 + +--- + +## 9. Task G:接入 Hermes tool event 展示与只读工具调用 + +目标: + +> 让 Hermes run 调用 `mnote.page.get` 时,页面 AI 能展示 tool started / completed,并把结果作为 Hermes 消息链的一部分呈现。 + +建议修改范围: + +- `rust/crates/mnote-web/src/routes/hermes_client.rs` +- `rust/crates/mnote-web/src/routes/hermes_tools.rs` +- Leptos 面板 island 相关文件 +- `scripts/task-hermes-page-ai-tool-smoke.js` + +执行步骤: + +- [x] 写 smoke:发送“读取当前页面标题和页面设置”,期望出现 `mnote.page.get` tool started。 +- [x] 写 smoke:同一次 run 出现 `mnote.page.get` tool completed。 +- [ ] 写 smoke:tool result 中的标题与当前页头标题一致。 +- [x] 实现 tool event 渲染:started、completed、failed 三态。 +- [ ] 实现 tool result 折叠展示,默认只显示 tool 名和摘要。 +- [x] 确保 tool result 不被写入 mnote 页面正文。 +- [x] 确保 Hermes session 中保留 tool event,刷新后可恢复。 + +验收标准: + +- [x] `node scripts/task-hermes-page-ai-tool-smoke.js` 通过。 +- [ ] 工具事件来自真实 Hermes upstream run,而不是前端本地伪造。 +- [ ] `mnote.page.get` 权限失败时,UI 显示 tool failed,正文不泄露。 +- [x] 刷新页面后 tool event 仍从 Hermes session 恢复。 +- [x] mnote page aggregate 不包含 tool event 历史。 + +--- + +## 10. Task H:接入正文写入工具 `mnote.page.save` + +目标: + +> 让 Hermes 可以通过 mnote plugin 对当前页正文做最小写入,写入仍走正式 `page.body.save` 链。 + +建议修改范围: + +- `rust/crates/mnote-web/src/hermes_tools/page.rs` +- `rust/crates/bridge-runtime/src/lib.rs` 仅补必要正式 command adapter +- 页面保存相关 route / command adapter +- smoke: + - `scripts/task-hermes-page-ai-writeback-smoke.js` + +执行步骤: + +- [x] 写 tool contract 测试:`mnote.page.save` 必须要求 `documentId`、`idempotencyKey`、`dryRun`。 +- [x] 写 dryRun 测试:`dryRun=true` 返回 diff,不写入。 +- [x] 写权限失败测试:未认证时不进入页面写链。 +- [x] 写幂等测试:同一 `idempotencyKey` 重试不重复执行写链。 +- [x] 实现 `mnote.page.save` 到 `page.body.save` 的 adapter。 +- [ ] 在 Hermes 面板里对写工具结果展示 diff 摘要。 +- [x] smoke 执行:让 AI 在当前测试页面写入一段带测试前缀的文本。 +- [x] smoke 读取 `/api/documents/content` 或当前正式内容接口,确认刷新后文本存在。 + +验收标准: + +- [x] dryRun 不改变页面内容。 +- [x] 未认证不进入写链。 +- [x] 同一幂等键不会重复写入。 +- [ ] 正式写入后,主编辑区 island 能立即回显。 +- [x] 刷新后内容仍存在。 +- [x] 写入链路日志包含 `sessionId/runId/toolCallId/traceId/documentId/actor`。 +- [x] 写入不经过旧 `/api/ai-agent/run`。 + +--- + +## 11. Task I:接入标题与页面设置工具 + +目标: + +> 把 AI 修改标题和页面设置收口到同一组 Page Aggregate command family,而不是靠面板本地副作用。 + +建议修改范围: + +- `rust/crates/mnote-web/src/hermes_tools/page.rs` +- `rust/crates/bridge-runtime/src/lib.rs` +- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` 仅回填完成证据 +- smoke: + - `scripts/task-hermes-page-ai-title-options-smoke.js` + +执行步骤: + +- [x] 写 `mnote.page.update_title` dryRun / permission / idempotency 测试。 +- [x] 写 `mnote.page.update_options` dryRun / permission / wired-only 字段测试。 +- [x] 实现标题工具到 `page.head.updateTitle`。 +- [x] 实现页面设置工具到 `page.layout.updateOptions`。 +- [x] 禁止写入 `runtimeSupport !== "wired"` 的页面设置字段。 +- [x] smoke 修改当前测试页标题,确认页头与 breadcrumb/current title 一致。 +- [x] smoke 修改 `smallText` 或 `wideLayout`,确认主编辑区 page shell 属性变化。 + +验收标准: + +- [x] 标题修改后页头、breadcrumb、sidebar、刷新后标题一致。 +- [x] 页面设置修改只允许 wired 字段。 +- [x] planned / ui_only 字段返回明确错误或 dryRun 警告。 +- [x] AI 面板不维护独立标题状态。 +- [x] 所有写入都经 Rust runtime / Page Aggregate command family。 + +--- + +## 12. Task J:接入结构化 Artifact 工具 + +目标: + +> 落地 `7-2` 的 `summary node / ai_note node / reference edge`,触发方改为 Hermes tool call。 + +建议修改范围: + +- `rust/crates/mnote-web/src/hermes_tools/artifact.rs` +- `rust/crates/bridge-runtime/src/lib.rs` +- `rust/crates/core-protocol/src/**` 若缺少正式类型再补 +- smoke: + - `scripts/task-hermes-page-ai-artifact-smoke.js` + +执行步骤: + +- [x] 写 `mnote.artifact.create_summary` schema 测试。 +- [x] 写 `mnote.artifact.create_ai_note` schema 测试。 +- [x] 写 summary 单例更新测试:同一页面重复创建 summary 不生成多个 summary node。 +- [x] 写 ai_note 多次创建测试:每次生成独立 ai_note node。 +- [x] 写 reference edge 测试:artifact 与当前页面之间有可查询 reference edge。 +- [x] 实现工具到 Rust runtime / kernel。 +- [x] 页面 AI 面板可提供 `创建 Summary` / `创建 AI Note` 快捷入口,但入口只发送 Hermes intent。 +- [x] smoke 验证 `AI Artifacts` projection-only 分组可见。 + +2026-05-14 J1 执行证据: + +- [x] 修正 `rust/crates/bridge-runtime/src/lib.rs`:从 artifact 文档命名与父子关系派生 `metadata.kind="ai_artifact_reference"` 的 `source_of` kernel edge,并在 file-tree projection `meta.aiArtifacts` 中标记 `projectionOnly=true`、`kernelNodeId=null`。 +- [x] 新增 bridge-runtime 单测 `kernel_edges_list_exposes_ai_artifact_reference_edges` 与 `file_tree_projection_marks_ai_artifacts_group_as_projection_only`。 +- [x] 验证命令:`cd rust && cargo test -p bridge-runtime ai_artifact -- --nocapture`:2 passed。 +- [x] 验证命令:`cd rust && cargo build -p mnote-web`:通过;正式 `3000` 已重启到新二进制,PID `2043001`。 +- [x] 扩展 `scripts/task-hermes-page-ai-artifact-smoke.js`:调用 `/api/kernel/edges` 查询 summary / ai_note 的 `ai_artifact_reference`,调用 `/api/tree/projections/file` 查询 `meta.aiArtifacts.projectionOnly=true`,并回查 artifact audit。 +- [x] 修正 `rust/crates/mnote-web/src/ssr/pages/layout.rs` 与 `rust/crates/mnote-web/src/ssr/styles.rs`:页面 AI 抽屉提供 `创建 Summary` / `创建 AI Note` 快捷入口,点击后只调用 `sendPageAiMessage(...)` 发送 Hermes intent。 +- [x] 验证命令:`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-artifact-smoke.js`:通过;最终复跑测试页 `tree_1778721933687_1`,summary `summary_tree_1778721933687_1`,ai_note `ai_note_tree_1778721933687_1_req_1778721933785_11`、`ai_note_tree_1778721933687_1_req_1778721933813_13`,`referenceEdgeCount=6`,`auditEventCount=6`;快捷入口发送的 Hermes intent 分别包含 `mnote.artifact.create_summary` 与 `mnote.artifact.create_ai_note`,且页面路由已禁止直连 `/api/hermes/tools/mnote/call`。 + +验收标准: + +- [x] artifact 创建不绕过 Hermes tool call。 +- [x] artifact 创建不绕过 Rust runtime / kernel。 +- [x] `AI Artifacts` 仍是 projection-only 分组,不是真实 kernel node。 +- [x] summary node 可重复更新但不重复创建。 +- [x] ai_note node 每次新建。 +- [x] reference edge 可通过 kernel query 或 projection 验证。 +- [x] artifact 写入 audit 包含 Hermes `sessionId/runId/toolCallId`。 + +--- + +## 13. Task K:退役旧页面 AI 主执行链 + +目标: + +> 新 Hermes 面板通过后,旧 `/api/ai-agent/run` 不再作为页面 AI 主入口。 + +建议修改范围: + +- `rust/crates/mnote-web/src/routes/**` +- `wolai-frontend/src/app/api/ai-agent/run/route.ts` +- `wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx` +- `design/03-rust-web/process/3-15-runtime-fallback-retirement-checklist-v1.md` + +执行步骤: + +- [x] 全仓搜索页面 AI 发送路径。 +- [x] 删除或禁用页面 AI 对 `/api/ai-agent/run` 的主路径调用。 +- [x] 保留 legacy endpoint 时,响应必须明确 `legacy_ai_agent_run_retired` 或项目统一退场码。 +- [x] 删除 `provider=hermes` 作为旧 endpoint provider/fallback 的语义。 +- [x] 更新旧 smoke:不再期待 `/api/ai-agent/run -> mnote-cli`。 +- [x] 保留历史 smoke 到 `old` 或改名为 legacy guard。 + +2026-05-14 K 执行证据: + +- [x] 修正 `rust/crates/mnote-web/src/routes/compat.rs`:`POST /api/ai-agent/run` 不再进入 `mnote-cli host`,统一返回 `410 legacy_ai_agent_run_retired`,响应头 `x-mnote-ai-execution-owner=legacy-ai-agent-run-retired`。 +- [x] 更新 compat 单测:`direct_ai_agent_run_returns_legacy_retired_guard`、`explicit_agent_provider_returns_legacy_retired_guard`、`explicit_hermes_provider_fails_instead_of_using_legacy_next_or_direct_bridge`。 +- [x] 验证命令:`cd rust && cargo test -p mnote-web ai_agent_run -- --nocapture`:1 passed。 +- [x] 验证命令:`cd rust && cargo test -p mnote-web explicit_ -- --nocapture`:2 passed。 +- [x] 验证命令:`cd rust && cargo build -p mnote-web`:通过;正式 `3000` 已重启到新二进制,PID `2108896`。 +- [x] 验证命令:`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-retirement-guard.js`:通过,status `410`,code `legacy_ai_agent_run_retired`,owner `legacy-ai-agent-run-retired`。 +- [x] 默认退役历史 smoke:`task155-e27-ai-edit-smoke.js`、`task156-e27-ai-writeback-smoke.js`、`task178-page-ai-local-subtree-context-smoke.js`、`task052-ai-tools-runtime-smoke.js`、`task161-wolai-page-ai-shell-smoke.js`、`task111-phase7-document-ai-online-smoke.js` 均默认输出 `{ retired: true }`,不再期待旧 `/api/ai-agent/run -> mnote-cli` 成功;如需历史对照,必须显式设置 `MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE=1`。 +- [x] legacy 调用方清点:`wolai-frontend/src/components/ai-agent/AiAgentPanel.tsx`、`wolai-frontend/src/components/editor/blocks/MindmapAiAgentPanel.runtime.tsx`、`wolai-frontend/src/components/onlyoffice/OnlyOfficeAiAgentPanel.runtime.tsx`、`wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts` 仍是旧域/兼容调用点,不属于当前 mnote-web 页面 AI 主链;若被调用会命中 `legacy_ai_agent_run_retired` guard,后续按各自 domain 另拆 Hermes plugin / Rust bridge 迁移。 +- [x] 活跃设计口径同步:`5-6`、`5-9`、`10-review` 已改为历史/已覆盖/legacy guard 说明,不再把 `/api/ai-agent/run` 写作页面 AI 长期入口。 + +验收标准: + +- [x] 新页面 AI 主链无 `/api/ai-agent/run` 请求。 +- [x] 访问旧 `/api/ai-agent/run` 不会静默 fallback 到 Hermes、Codex、旧 orchestrator。 +- [x] `provider=hermes` 不再产生新页面 AI 主链 502。 +- [x] 活跃文档不再把 `/api/ai-agent/run` 描述为页面 AI 长期入口。 +- [x] legacy 调用方清单为空,或全部有明确退场说明。 + +--- + +## 14. Task L:权限、审计与可观测性闭环 + +目标: + +> 让 Hermes 调用 mnote 工具后的每一次业务影响都可审计、可追踪、可复盘。 + +建议修改范围: + +- `rust/crates/mnote-web/src/hermes_tools/**` +- `rust/crates/bridge-runtime/src/**` +- audit / event log 相关 crate 或 route +- smoke: + - `scripts/task-hermes-page-ai-audit-smoke.js` + +执行步骤: + +- [x] 定义统一 trace 字段:`traceId/sessionId/runId/toolCallId/documentId/workspaceId/actorId/toolName`。 +- [x] 所有 tool call 开始时记录 intent。 +- [x] 所有成功写入记录 result 和 command id。 +- [x] 所有失败记录错误码,不记录完整隐私正文。 +- [x] smoke 执行一次 `mnote.page.get` 和一次写工具。 +- [x] smoke 从日志或审计接口确认两次 tool call 都可追踪。 + +2026-05-14 L 执行证据: + +- [x] 修正 `rust/crates/mnote-web/src/routes/hermes_tools.rs`:`audit_push` 同步写入内存缓存与 JSONL 持久化落点,默认路径 `tmp/mnote-hermes-tool-audit.jsonl`,可用 `MNOTE_HERMES_TOOL_AUDIT_LOG` 覆盖;`GET /api/hermes/tools/mnote/audit?persistedOnly=true` 只读取持久化审计。 +- [x] workspace 上下文冲突现在也会写入 `started -> failed` 审计链,失败审计只包含 trace / tool / actor / workspace / document / status / message,不写入页面标题或正文。 +- [x] 扩展 `scripts/task-hermes-page-ai-audit-smoke.js`:执行 `mnote.page.get`、`mnote.page.save`、workspace 权限失败三类调用;分别查询内存 audit 与 `persistedOnly=true` audit;断言写工具持久化 audit 串到 Rust `commandId`,权限失败持久化 audit 不包含正文 marker 或页面标题。 +- [x] 验证命令:`cd rust && cargo test -p mnote-web hermes_tools_ -- --nocapture`:9 passed。 +- [x] 验证命令:`cd rust && cargo build -p mnote-web`:通过;正式 `3000` 已重启到新二进制,PID `2175265`。 +- [x] 验证命令:`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-audit-smoke.js`:通过;测试页 `tree_1778723140835_1`,写命令 `page_body_save_req_1778723140928_9`,`writePersistedPhases=["started","completed"]`,`deniedPersistedPhases=["started","failed"]`。 + +验收标准: + +- [x] 每个 tool call 都有稳定 trace。 +- [x] 写工具能追踪到最终 Rust command。 +- [x] 失败不会泄露无权限页面内容。 +- [x] 日志可以把页面 AI 面板请求、Hermes run、mnote tool call、Rust command 串起来。 +- [x] 审计只保存业务影响,不保存完整 Hermes 聊天历史。 + +--- + +## 15. Task M:端到端验收矩阵 + +目标: + +> 用一组固定 smoke 证明新主线能覆盖只读、写正文、写标题/设置、artifact 和旧链退役。 + +必须具备的 smoke: + +- [x] `scripts/task-hermes-page-ai-smoke.js` +- [x] `scripts/task-hermes-page-ai-tool-smoke.js` +- [x] `scripts/task-hermes-page-ai-writeback-smoke.js` +- [x] `scripts/task-hermes-page-ai-title-options-smoke.js` +- [x] `scripts/task-hermes-page-ai-artifact-smoke.js` +- [x] `scripts/task-hermes-page-ai-retirement-guard.js` +- [x] `scripts/task-hermes-page-ai-mobile-smoke.js` +- [x] `scripts/task-hermes-page-ai-audit-smoke.js` + +执行顺序: + +- [x] 只读 session/run smoke。 +- [x] 只读 tool smoke。 +- [x] 正文写入 smoke。 +- [x] 标题与页面设置 smoke。 +- [x] artifact smoke。 +- [x] 旧链退役 guard。 +- [x] 移动端 / 窄屏抽屉 smoke。 +- [x] 刷新恢复 smoke。 + +2026-05-14 M 执行证据: + +- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-smoke.js`:通过;`tree_1778723249255_3`,session `mnote_smoke_mp4twavi`,run `run_smoke_mp4twavi`,`sessionDetailHits=2`。 +- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-smoke.js`:通过;`tree_1778723260375_5`,工具 `mnote.page.get`,标题 `TEST-HERMES-AI-tool-mp4twjgi`。 +- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-writeback-smoke.js`:通过;`tree_1778723271226_7`,marker `TEST-HERMES-AI-WRITEBACK-mp4twrtt`,command `page_body_save_req_1778723271849_379`。 +- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-title-options-smoke.js`:通过;`tree_1778723295128_9`,标题 `TEST-HERMES-AI-title-updated-mp4txa9v`,`pageFont` 返回 `page_option_not_wired` warning。 +- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-artifact-smoke.js`:通过;`tree_1778723316873_11`,`referenceEdgeCount=6`,`meta.aiArtifacts.projectionOnly=true`。 +- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-retirement-guard.js`:通过;旧入口返回 `410 legacy_ai_agent_run_retired`。 +- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-mobile-smoke.js`:通过;390px viewport 下 drawer/panel 均无横向溢出。 +- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-audit-smoke.js`:通过;`tree_1778723372948_15`,写命令 `page_body_save_req_1778723373026_707`,持久化 audit 可查 `started/completed` 与权限失败 `started/failed`。 + +总体验收标准: + +- [x] 所有 smoke 在真实 `http://127.0.0.1:3000` 上通过。 +- [x] 使用默认 Convex Auth 测试账号,不使用 `MNOTE_DEV_AUTH=1` 伪登录作为正式验收。 +- [x] smoke 只操作本轮新建测试数据,测试数据前缀统一为 `TEST-HERMES-AI-`。 +- [x] 测试结束后可定位创建的页面、artifact、edge、audit。 +- [x] 任一 smoke 失败时,必须记录到 `bugs/` 对应分类,而不是在 checklist 中直接标 GREEN。 + +--- + +## 16. Task N:文档回填与 old 目录治理 + +目标: + +> 代码主链切换后,设计目录继续反映真实状态,避免旧 CLI-first 口径回流。 + +2026-05-14 口径审计记录: + +- [x] `find design/07-ai -maxdepth 3 -type f | sort` 已确认当前 07-ai 活跃结构为:`7-1`、`7-2`、`7-3`、`7-4`、`7-5`、`7-6` 在 `done/`;后续 Mini Hermes 控制面由 `design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md` 承接。 +- [x] `design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 仍保留为历史决策记录,并已标 `[recycle]`。 +- [x] `design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 的 `mnote-cli` 命中均用于说明历史 CLI-first / 被覆盖 / 禁止继续采用的口径。 +- [x] `design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` 的 `mnote-cli` 与 `provider=hermes` 命中均用于迁移目标、legacy guard 或验收条件,不把它写成长期主线。 +- [x] 活跃设计命中已收口:`5-6`、`5-9`、`10-review/04`、`10-review/06`、`1-1`、`3-1` 均已把旧 `/api/ai-agent/run` / `mnote-cli host` 写成历史、兼容、已覆盖或 legacy guard。 +- [x] `design/10-review/04-secondary-domains-and-design-governance-review.md` 已继续指向 Hermes plugin / Rust bridge,并明确旧 `openai-agents-python` / `mnote-cli host` 只按历史/兼容/对照链处理。 + +建议修改范围: + +- `design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` +- `design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` +- `design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +- `design/03-rust-web/process/3-15-runtime-fallback-retirement-checklist-v1.md` +- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +- `design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md` +- `design/old/07-ai/process/*` + +执行步骤: + +- [x] 把完成证据回填到本 checklist 对应 task。 +- [x] 7-4 的实现证据已满足完成定义,本文已归档到 `design/07-ai/done/` 并同步更新引用。 +- [x] 保留 `7-3` 为主线设计稿;`7-4` 作为实现与验收执行清单,不覆盖 `7-3` 的架构口径。 +- [x] 活跃设计稿中只允许历史、兼容、已覆盖或 legacy guard 说明提到 `mnote-cli host`。 +- [x] 旧 CLI-first 文档必须继续留在 `design/old/07-ai/process/` 并标 `[recycle]`。 +- [x] 更新 `design/10-review` 中旧建议,使其指向 Hermes plugin / Rust bridge。 + +验收标准: + +- [x] `find design/07-ai -maxdepth 3 -type f | sort` 能清楚看到当前 process/done 状态。 +- [x] `rg -n "mnote-cli.*唯一|唯一长期 agent|CLI-first" design --glob '*.md'` 的活跃目录命中都带有“历史/回收/已覆盖”说明。 +- [x] `design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 仍保留为历史决策记录。 +- [x] 没有把未完成代码能力提前标成 done。 + +--- + +## 16.1 剩余顺序执行 checklist(按 G-N 顺序) + +> 这一段只收纳当前仍未完成的项,按顺序逐项勾选。每个 task 都必须在本段回填“执行命令 / 证据路径 / 结果”,不能只写口头结论。 +> +> Hermes Web UI 参考目录固定为 `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18`。下文所有 `packages/...` 路径都相对此目录。需要对照时按列出的文件和符号打开,不整目录通读,不照搬 Vue / Naive UI / 管理台页面。 + +### G1:把 tool event 刷新恢复补成真实 Hermes upstream 闭环 + +**目标:** 证明 `mnote.page.get` 是由真实 Hermes upstream run 触发,并由 Hermes session 存储回灌;mnote 页面 AI 只消费 Hermes 事件。 + +**必须参考 Hermes Web UI 的位置:** + +| 参考目的 | 具体文件 | 具体位置 / 搜索点 | mnote 采用方式 | +| --- | --- | --- | --- | +| run 请求与事件字段 | `packages/client/src/api/hermes/chat.ts` | `StartRunRequest` 第 14 行;`RunEvent` 第 28 行 | 对齐 mnote proxy / Leptos 面板消费的 run event 字段 | +| session handler 注册 | `packages/client/src/api/hermes/chat.ts` | `registerSessionHandlers` 第 297 行;`resumeSession` 第 420 行;`startRunViaSocket` 第 432 行 | 对齐“按 session 订阅事件、刷新后 resume”的语义 | +| tool event 归并到消息 | `packages/client/src/stores/hermes/chat.ts` | `mapHermesMessages` 第 125 行;`case 'tool.started'` 第 910 / 1340 行;`case 'tool.completed'` 第 948 / 1378 行 | 对齐 tool started / completed 与 session detail 里的消息形态 | +| tool message 展示 | `packages/client/src/components/hermes/chat/MessageItem.vue` | `message.role === 'tool'` 第 508 行;`formatToolPayload` 第 263 行;`tool-preview` 第 543 行;`tool-details` 第 555 行 | 对齐默认折叠、摘要和详情展开,不照搬样式 | +| upstream tool event 生成 | `packages/server/src/services/hermes/chat-run-socket.ts` | `applyResponseStreamEvent` 第 1035 行;`function_call` 第 1101 / 1110 行;`tool.started` 第 1131 行;`function_call_output` 第 1145 行;`tool.completed` 第 1165 行;`flushResponseRunToDb` 第 1207 行 | 确认 tool event 来源于 upstream response stream,并最终 flush 到 Hermes DB | +| Hermes plugin 发现 | `packages/server/src/services/hermes/plugins.ts` | `PluginManager` 第 63 / 120 行;`user_dir = get_hermes_home() / "plugins"` 第 137 行;`providesTools` 第 200 行;`listHermesPlugins` 第 310 行 | 确认 mnote 工具必须进入 Hermes plugin / tool registry | + +**顺序执行 checklist:** + +- [x] G1.1 运行 `hermes plugins list`,确认当前 Hermes 是否已经能发现 `mnote` plugin;把输出摘要回填到本 task。 +- [x] G1.2 运行 `hermes tools list --platform api_server`,确认 `mnote` toolset 或 `mnote_page_get` 是否已经可见;把输出摘要回填到本 task。 +- [x] G1.3 如果 `mnote` 不可见,创建或修正用户插件目录 `/home/lix/.hermes/plugins/mnote/`,其中 `plugin.yaml` 必须声明 `name: mnote` 与 `provides_tools`,`__init__.py` 必须通过 `ctx.register_tool(..., toolset="mnote")` 注册工具。 +- [x] G1.4 插件对 Hermes 暴露的函数名使用 OpenAI function-name 兼容名,例如 `mnote_page_get`;插件内部再映射到 mnote-web tool route 的 `toolName: "mnote.page.get"`。 +- [x] G1.5 插件 handler 只调用 `POST http://127.0.0.1:3000/api/hermes/tools/mnote/call` 或 `MNOTE_UI_BASE_URL` 指向的同源 mnote-web route,不直接读写 Convex。 +- [x] G1.6 将 `mnote` 加入 `/home/lix/.hermes/config.yaml` 的 `plugins.enabled` 后,重新运行 `hermes plugins list` 与 `hermes tools list --platform api_server`。 +- [x] G1.7 触发一次真实 Hermes upstream run,请求内容使用“读取当前页面标题和页面设置”,确认模型实际选择 `mnote_page_get` / `mnote.page.get`。 +- [x] G1.8 在 mnote 页面 AI 面板网络记录里确认事件来自 `/api/hermes/client/runs` 与 `/api/hermes/client/events/{run_id}` 或当前 Hermes proxy 等价入口,不是前端本地拼出的假事件。 +- [x] G1.9 刷新页面后重新打开 AI 面板,确认同一 tool event 从 Hermes session detail 恢复,顺序与首次流式展示一致。 +- [x] G1.10 回填证据:Hermes run id、session id、tool call id、mnote trace id、截图或 smoke 输出路径。 + +2026-05-14 G1 执行证据: + +- [x] `python -m py_compile /home/lix/.hermes/plugins/mnote/__init__.py`:通过。 +- [x] `hermes plugins list`:`mnote` 显示为 `enabled` user plugin。 +- [x] `hermes tools list --platform api_server`:Plugin toolsets 中 `mnote` 显示为 enabled。 +- [x] `/home/lix/.hermes/hermes-agent/venv/bin/python` 调用 `get_tool_definitions(enabled_toolsets=["mnote"])`:可见 `mnote_page_get`、`mnote_page_save`、`mnote_page_update_title`、`mnote_page_update_options`、`mnote_artifact_create_summary`、`mnote_artifact_create_ai_note`。 +- [x] 发现并修复运行态差异:Hermes gateway 长进程启动早于 mnote plugin 接入,首次 `/v1/runs` 只看到 MemPalace 等旧工具;执行 `hermes gateway restart` 后真实 upstream run 可见 `mnote_page_get`。 +- [x] 3000 主入口已重启为当前 `mnote-web`,并配置 `MNOTE_WEB_HERMES_UPSTREAM_URL=http://127.0.0.1:8642` 与服务端 Hermes API key;`GET /api/hermes/client/models` 经 mnote-web proxy 返回 `hermes-agent`。 +- [x] 修正 `rust/crates/mnote-web/src/routes/hermes_client.rs`:Hermes run context 透传 `actorId/actorType/sessionId/traceId`,并在 `toolGuidance` 中要求读取标题/页面设置时传 `includeBody=false/includeOptions=true/includeBlocks=false`。 +- [x] 验证命令:`cd rust && cargo test -p mnote-web hermes_client_run_body_carries_page_context_into_run_input -- --nocapture`:1 passed。 +- [x] 验证命令:`cd rust && cargo build -p mnote-web`:通过。 +- [x] 真实页面 AI 面板不 mock 验证:测试页 `TEST-HERMES-AI-realpanel-green-mp4rv3w3`,页面 AI 捕获网络请求 `POST /api/hermes/client/sessions`、`GET /api/hermes/client/sessions/{sessionId}`、`POST /api/hermes/client/runs`、`GET /api/hermes/client/events/run_4a8b09db7fbc45b8bb1bbea68b436093`,未请求 `/api/ai-agent/run`。 +- [x] Hermes run:`run_4a8b09db7fbc45b8bb1bbea68b436093`,status `completed`,session `mnote_tree_1778719834312_1_page-ai-mp4rv470`,输出包含标题 `TEST-HERMES-AI-realpanel-green-mp4rv3w3` 与页面设置。 +- [x] Hermes session export:`mnote_tree_1778719834312_1_page-ai-mp4rv470`,`message_count=4`,`tool_call_count=1`;assistant tool call 为 `mnote_page_get`,Hermes tool call id 为 `call_00_5B7fVip245331pk4zYyd5532`;tool message 内容 `ok=true`、`toolName=mnote.page.get`。 +- [x] mnote audit:`trace_1778719842620_2b05ef8600`,mnote tool call id `tool_1778719842620_7687c1860a`,`phase=started/completed`,`effect=read`,`actorId=a4b72c17-49e3-46d3-8456-24a0a7044d64`。 +- [x] 刷新恢复:同一页面刷新后重新打开 AI 面板,请求 `GET /api/hermes/client/sessions/mnote_tree_1778719834312_1_page-ai-mp4rv470`,抽屉文本恢复出 Hermes session 中的页面标题与 tool message。 + +**验收标准:** + +- [x] 真实 Hermes upstream run 触发 `mnote.page.get`,不是直接调用 mnote-web tool smoke 假代替。 +- [x] tool event 通过 Hermes run stream / session detail 回灌到页面。 +- [x] 页面刷新后仍能从 Hermes session detail 恢复同一批 tool event。 +- [x] 前端本地 state 只承担临时渲染,不作为 tool event 最终真相。 + +### H1:补 workspace ACL 负向验证与正文立即回显 + +**目标:** 写正文工具必须证明权限失败不泄露、成功写入后主编辑区立即可见,且 AI 面板不保存正文真相。 + +**必须参考 Hermes Web UI 的位置:** + +| 参考目的 | 具体文件 | 具体位置 / 搜索点 | mnote 采用方式 | +| --- | --- | --- | --- | +| 权限失败事件形态 | `packages/client/src/api/hermes/chat.ts` | `RunEvent` 第 28 行;`onToolStarted` / `onToolCompleted` 第 69-70 行;`onRunFailed` 第 73 行 | 失败也走稳定事件,不出现空白 502 | +| 失败事件持久化 | `packages/server/src/services/hermes/chat-run-socket.ts` | `emit` 第 574 / 963 行;`markCompleted` 第 978 / 1276 行;`flushResponseRunToDb` 第 1207 行 | 确认失败信息也能进入可恢复 session | +| session 只保存消息真相 | `packages/server/src/db/hermes/session-store.ts` | `HermesMessageRow` 第 35 行;`addMessage` 第 331 行;`getSessionDetail` 第 313 行 | 失败消息留在 Hermes session,不写入 mnote 正文 | +| tool failed 展示 | `packages/client/src/components/hermes/chat/MessageItem.vue` | `tool-error-badge` 第 551 行;`tool-details` 第 555 行 | 权限失败显示错误摘要,不泄露正文 | +| 写入后 UI 刷新边界 | `packages/client/src/stores/hermes/chat.ts` | `sendMessage` 第 646 行;`runHadToolActivity` 第 737 行;`cleanup` 附近 | tool 完成后刷新业务事实,不复制正文真相到 AI 面板 | + +**顺序执行 checklist:** + +- [x] H1.1 新增或扩展 `scripts/task-hermes-page-ai-writeback-smoke.js`:构造无 workspace 权限或无页面写权限场景,调用 `mnote.page.save`。 +- [x] H1.2 断言权限失败返回稳定错误码,例如 `permission_denied` / `workspace_access_denied`,响应体不得包含页面标题、正文全文或 block 原文。 +- [x] H1.3 在页面 AI 面板断言失败展示为 tool failed / permission denied,不是 502、空白或旧 provider 错误。 +- [x] H1.4 正向写入时,使用测试前缀 `TEST-HERMES-AI-` 调用 `mnote.page.save`。 +- [x] H1.5 写入完成后不刷新页面,直接断言主编辑区 island 可见新内容。 +- [x] H1.6 刷新页面后再次断言新内容存在,证明业务事实已回到正式正文链。 +- [x] H1.7 检查 AI 面板状态或 session detail,确认不保存完整正文副本,只保存 tool event / tool result 摘要。 + +2026-05-14 H1 执行证据: + +- [x] 修正 `rust/crates/mnote-web/src/routes/hermes_tools.rs`:当请求头 `x-mnote-workspace-id` 与 tool payload `workspaceId` 不一致时,返回 `403 workspace_context_conflict`,不进入页面读写链。 +- [x] 新增单测 `hermes_tools_reject_workspace_context_conflict_without_content_leak`:断言响应不包含测试 fixture 标题 `服务端页面` 与正文 `章节一`。 +- [x] 验证命令:`cd rust && cargo test -p mnote-web hermes_tools_reject_workspace_context_conflict_without_content_leak -- --nocapture`:1 passed。 +- [x] 扩展 `scripts/task-hermes-page-ai-writeback-smoke.js`:先构造 workspace 上下文冲突负向调用,断言 `403 workspace_context_conflict` 且不泄露页面标题 / 正文标记;再用页面 AI 面板 mock Hermes `tool.failed` 事件断言 UI 展示 `mnote.page.save` 与 `permission_denied`,不是 502 / 空白 / 旧 provider 错误;最后执行 dryRun、正式写入、主编辑区即时回显、幂等重试和刷新后读取。 +- [x] 验证命令:`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-writeback-smoke.js`:通过;最终复跑测试页 `tree_1778720381778_4`,标记 `TEST-HERMES-AI-WRITEBACK-mp4s6ubg`,写入命令 `page.body.save`,`commandId=page_body_save_req_1778720382427_413`。 +- [x] smoke 在正式 `3000` 运行,写入完成后未刷新页面即等待主编辑区出现 `TEST-HERMES-AI-WRITEBACK-*` 标记;随后通过 `/api/documents/content` 读取确认刷新后仍存在。 + +**验收标准:** + +- [x] 未授权不能写正文,且失败响应不泄露正文。 +- [x] 成功写入后主编辑区即时可见。 +- [x] 刷新后正文仍存在。 +- [x] 页面 AI 不持有正文真相副本。 + +### I1:补标题与 sidebar 一致性验证 + +**目标:** `mnote.page.update_title` 与 `mnote.page.update_options` 只改变 mnote 页面事实;Hermes session 标题、模型状态和页面标题不能混成一份真相。 + +**必须参考 Hermes Web UI 的位置:** + +| 参考目的 | 具体文件 | 具体位置 / 搜索点 | mnote 采用方式 | +| --- | --- | --- | --- | +| session title 只是会话标题 | `packages/server/src/db/hermes/session-store.ts` | `HermesSessionRow` 第 9 行;`renameSession` 第 202 行 | 区分 Hermes 会话标题与 mnote 页面标题 | +| session detail 字段 | `packages/server/src/db/hermes/session-store.ts` | `HermesSessionDetailRow` 第 56 行;`getSessionDetail` 第 313 行 | 刷新恢复 AI 会话,不恢复页面标题草稿 | +| 面板标题展示 | `packages/client/src/components/hermes/chat/ChatPanel.vue` | `activeSessionTitle` 第 193 行;`headerTitle` 第 197 行;`SessionListItem` 第 598 / 636 行;`MessageList` 第 790 行;`ChatInput` 第 791 行 | AI 面板只展示 session 语义,不维护页面标题草稿 | +| session 列表标题 | `packages/client/src/components/hermes/chat/SessionListItem.vue` | `session-item-title` 第 94 行;`session.title` 第 96 行;`session-item-model` 第 100 行 | 不把 sidebar 页面标题同步需求写进 Hermes session list | +| model 展示边界 | `packages/client/src/components/layout/ModelSelector.vue` | `selectedDisplayName` 第 16 行;`handleSelect` 第 64 行;`model-name` 第 105 行;`model-item` 第 142 行 | model 真相不回写 mnote page aggregate | + +**顺序执行 checklist:** + +- [x] I1.1 扩展 `scripts/task-hermes-page-ai-title-options-smoke.js`,修改标题为 `TEST-HERMES-AI-`。 +- [x] I1.2 断言页头标题、breadcrumb/current title、sidebar 树节点标题三处立即一致。 +- [x] I1.3 刷新页面后再次断言三处标题一致,且值来自页面真源。 +- [x] I1.4 断言 AI 面板 session 标题没有被当成 mnote 页面标题真相。 +- [x] I1.5 调用 `mnote.page.update_options` 修改 `smallText` 或 `wideLayout`,确认只接受 `runtimeSupport == "wired"` 字段。 +- [x] I1.6 对 `planned` / `ui_only` 字段执行 dryRun 或正式调用,确认返回明确警告或错误,不产生页面事实写入。 + +2026-05-14 I1 执行证据: + +- [x] 修正 `rust/crates/mnote-web/src/hermes_tools/page.rs`:`mnote.page.update_options` 对未接线字段返回 `ignoredOptions` 与 `warnings`,并只把 wired 字段放入 `page.layout.updateOptions` payload。 +- [x] 扩展单测 `hermes_tools_update_options_dry_run_filters_unwired_fields`:断言 `pageFont` 不进入 `diff.payload.options`,并返回 `ignoredOptions[0]="pageFont"` 与 `warnings[0].code="page_option_not_wired"`。 +- [x] 验证命令:`cd rust && cargo test -p mnote-web hermes_tools_update_options_dry_run_filters_unwired_fields -- --nocapture`:1 passed。 +- [x] 验证命令:`cd rust && cargo build -p mnote-web`:通过。 +- [x] 验证命令:`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-title-options-smoke.js`:通过;测试页 `tree_1778720870666_1`,标题 `TEST-HERMES-AI-title-updated-mp4shbjq`。 +- [x] smoke 已断言页头标题、breadcrumb/current title、sidebar 树节点标题一致;`data-page-wide-layout=true` 与 `data-page-small-text=true` 生效。 +- [x] smoke 已打开页面 AI 面板,确认 `data-mnote-page-ai-session-owner=hermes`,localStorage 仅保存 `{"activeSessionId":"mnote_tree_1778720870666_1_page-ai-mp4shbwk"}`,不保存页面标题。 + +**验收标准:** + +- [x] 标题修改后页头、breadcrumb、sidebar、刷新后标题一致。 +- [x] 页面设置修改只允许 wired 字段。 +- [x] AI 面板没有独立标题状态。 + +### J1:补 artifact reference edge 与 projection-only 验收 + +**目标:** artifact 工具经 Hermes tool call 与 Rust kernel 创建业务事实,`AI Artifacts` 只是 projection 分组,不是真实 kernel node。 + +**必须参考 Hermes Web UI 的位置:** + +| 参考目的 | 具体文件 | 具体位置 / 搜索点 | mnote 采用方式 | +| --- | --- | --- | --- | +| artifact 工具作为 plugin/tool 暴露 | `packages/server/src/services/hermes/plugins.ts` | `providesTools` 第 200 行;`providesHooks` 第 201 行;`requiresEnv` 第 202 行 | artifact 能力挂到 Hermes plugin,不做 mnote-web 私有按钮直写 | +| artifact 结果仍是 tool event | `packages/client/src/api/hermes/chat.ts` | `RunEvent` 第 28 行;`onToolCompleted` 第 70 行 | summary / ai_note 结果作为 tool event 展示 | +| tool result 展示 | `packages/client/src/components/hermes/chat/MessageItem.vue` | `toolPreview` 第 543 行;`toolResultPayload` 第 332 行;`tool-details` 第 555 行 | artifact 结果只展示摘要和详情,不写入正文 | +| 历史 tool message 恢复 | `packages/client/src/stores/hermes/chat.ts` | `mapHermesMessages` 第 125 行;`case 'tool.completed'` 第 948 / 1378 行 | 刷新后 artifact tool event 仍从 session 恢复 | +| session DB tool 字段 | `packages/server/src/db/hermes/session-store.ts` | `tool_call_id` 第 40 行;`tool_calls` 第 41 行;`tool_name` 第 42 行;`addMessage` 第 331 行 | tool call id 与 artifact audit 能串联 | + +**顺序执行 checklist:** + +- [x] J1.1 扩展 `scripts/task-hermes-page-ai-artifact-smoke.js`,创建或更新 summary,记录 `artifactDocumentId`。 +- [x] J1.2 通过 kernel query 或稳定 projection route 查询 summary 与当前页面之间的 reference edge。 +- [x] J1.3 再创建两次 ai_note,断言每次得到独立 `artifactDocumentId`。 +- [x] J1.4 查询每个 ai_note 与当前页面之间的 reference edge,确认 `from/to/kind` 可追踪。 +- [x] J1.5 查询 `AI Artifacts` 分组来源,确认它是 projection-only 分组,不是真实 kernel node。 +- [x] J1.6 回查 artifact audit,确认包含 `sessionId/runId/toolCallId/traceId`。 + +2026-05-14 J1 执行证据: + +- [x] `cargo test -p bridge-runtime ai_artifact -- --nocapture`:2 passed。 +- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-artifact-smoke.js`:通过;`referenceEdgeCount=6`,`meta.aiArtifacts.projectionOnly=true`,`kernelNodeId=null`,`auditEventCount=6`。 + +**验收标准:** + +- [x] reference edge 可通过 kernel query 或 projection 查询。 +- [x] `AI Artifacts` 只是 projection-only 分组。 +- [x] summary 与 ai_note 行为符合既定幂等 / 新建规则。 +- [x] artifact 写入 audit 能串到 Hermes tool call。 + +### K1:补 legacy 调用方清点与退场文档 + +**目标:** 旧 `/api/ai-agent/run` 只保留为 legacy guard / 历史说明,不再被活跃设计或页面 AI 主链描述为长期入口。 + +**必须参考 Hermes Web UI 的位置:** + +| 参考目的 | 具体文件 | 具体位置 / 搜索点 | mnote 采用方式 | +| --- | --- | --- | --- | +| 新 run 入口 | `packages/client/src/api/hermes/chat.ts` | `startRunViaSocket` 第 432 行 | 对照新页面 AI 主链,不回到旧 `/api/ai-agent/run` | +| run server 注册 | `packages/server/src/routes/hermes/chat-run.ts` | `setChatRunServer` 第 5 行;`getChatRunServer` 第 9 行 | 确认 Hermes run server 是真实入口 | +| upstream run 生命周期 | `packages/server/src/services/hermes/chat-run-socket.ts` | `handleRun` 第 488 行;`markCompleted` 第 1276 行 | 长期主链是 Hermes upstream run | +| plugin 发现 | `packages/server/src/services/hermes/plugins.ts` | `listHermesPlugins` 第 310 行 | 旧入口退场后,mnote 能力从 Hermes plugin/tool 面进入 | + +**顺序执行 checklist:** + +- [x] K1.1 运行 `rg -n "/api/ai-agent/run|provider=hermes|mnote-cli host|CLI-first" design rust wolai-frontend scripts --glob '*.md' --glob '*.rs' --glob '*.ts' --glob '*.tsx' --glob '*.js'`。 +- [x] K1.2 将命中逐项归类为:`历史证据`、`legacy guard`、`仍需迁移`、`误导性活跃口径`。 +- [x] K1.3 对 `仍需迁移` 的代码调用方补迁移或补退场 guard。 +- [x] K1.4 对 `误导性活跃口径` 的设计稿改写为“历史/兼容/已覆盖”,必要时移入 `design/old/` 并标 `[recycle]`。 +- [x] K1.5 更新旧 smoke:不再期待 `/api/ai-agent/run -> mnote-cli` 成功,只验证 legacy guard 返回稳定退场码。 +- [x] K1.6 在本 checklist 回填 rg 输出摘要和修改文件清单。 + +2026-05-14 K1 执行证据: + +- [x] rg 命中已归类:`design/old/**` 与 `design/07-ai/done/7-1**` 为历史证据;`scripts/task-hermes-page-ai-retirement-guard.js` 与 `rust/crates/mnote-web/src/routes/compat.rs` 为 legacy guard;旧 E27/phase7/page-ai smoke 已默认退役;`wolai-frontend` 中 Mindmap / OnlyOffice / generic AI 面板调用点归为非当前页面 AI 主链的 legacy domain 调用点,后续按各自 domain 迁移。 +- [x] 修改文件:`rust/crates/mnote-web/src/routes/compat.rs`、`scripts/task-hermes-page-ai-retirement-guard.js`、`scripts/task155-e27-ai-edit-smoke.js`、`scripts/task156-e27-ai-writeback-smoke.js`、`scripts/task178-page-ai-local-subtree-context-smoke.js`、`scripts/task052-ai-tools-runtime-smoke.js`、`scripts/task161-wolai-page-ai-shell-smoke.js`、`scripts/task111-phase7-document-ai-online-smoke.js`、`design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`、`design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md`、`design/10-review/04-secondary-domains-and-design-governance-review.md`。 +- [x] 旧 smoke 默认退役验证:上述 6 个历史 smoke 均返回 `{ ok: true, retired: true }`。 + +**验收标准:** + +- [x] 活跃设计稿不再把 `/api/ai-agent/run` 描述为页面 AI 长期入口。 +- [x] legacy 调用方清单为空,或每一项都有明确退场说明。 +- [x] 旧入口不会静默 fallback 到 Hermes、Codex 或旧 orchestrator。 + +### L1:把审计从进程内缓存收口到持久化可追踪链 + +**目标:** 每次 Hermes 调用 mnote 工具都能从页面 AI 请求串到 Hermes run、mnote tool call、Rust command 和最终业务结果。 + +**必须参考 Hermes Web UI 的位置:** + +| 参考目的 | 具体文件 | 具体位置 / 搜索点 | mnote 采用方式 | +| --- | --- | --- | --- | +| 前端事件字段 | `packages/client/src/api/hermes/chat.ts` | `RunEvent` 第 28 行,重点看 `run_id`、`session_id`、`tool`、`name` | 前端事件字段要能和审计字段串联 | +| run / tool / session 追踪 | `packages/server/src/services/hermes/chat-run-socket.ts` | `emit` 第 574 / 963 行;`applyResponseStreamEvent` 第 1035 行;`flushResponseRunToDb` 第 1207 行;`markCompleted` 第 1276 行 | 对齐 run、tool、session 三段追踪模型 | +| tool call 持久字段 | `packages/server/src/db/hermes/session-store.ts` | `tool_call_id` 第 40 行;`tool_calls` 第 41 行;`tool_name` 第 42 行;`addMessage` 第 331 行 | mnote audit 至少能关联 Hermes tool call id | +| usage 不进页面事实 | `packages/server/src/db/hermes/usage-store.ts` | `recordUsage` 第 16 行;`getUsage` 第 53 行;`getUsageBatch` 第 73 行;`deleteUsage` 第 118 行 | usage/model 是 Hermes 真相,不写入 mnote page aggregate | + +**顺序执行 checklist:** + +- [x] L1.1 定义持久化审计落点:复用现有 audit/event log,或新增最小 `mnote_hermes_tool_audit` 存储;不得只依赖进程内缓存。 +- [x] L1.2 审计字段固定包含 `traceId/sessionId/runId/toolCallId/documentId/workspaceId/actorId/toolName/effect/commandId/errorCode`。 +- [x] L1.3 在 tool call start / success / failure 三个阶段写入同一条可查询链。 +- [x] L1.4 更新 `scripts/task-hermes-page-ai-audit-smoke.js`,重启进程或绕过进程内缓存后仍能按 `traceId` 回查审计。 +- [x] L1.5 权限失败场景必须断言审计里没有完整正文、无权限页面标题或 block 原文。 +- [x] L1.6 写工具成功场景必须断言审计能追踪到最终 Rust command id。 + +2026-05-14 L1 执行证据: + +- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-audit-smoke.js`:通过,`persistedOnly=true` 查询确认持久化审计可按 traceId 回查。 +- [x] 权限失败审计:`deniedPersistedPhases=["started","failed"]`,smoke 已断言持久化 audit 不含正文 marker 与页面标题。 + +**验收标准:** + +- [x] 每个 tool call 都能追踪到 session、run、toolCall 和最终业务结果。 +- [x] 失败不泄露内容。 +- [x] 审计不是只存在于进程内缓存。 + +### M1:把正式 `3000` 的 E2E smoke 跑成最终验收矩阵 + +**目标:** 临时 `3019` 证据不能作为最终完成;必须在正式 `3000` + 真实 Convex Auth 上复跑全矩阵。 + +**必须参考 Hermes Web UI 的位置:** + +| 参考目的 | 具体文件 | 具体位置 / 搜索点 | mnote 采用方式 | +| --- | --- | --- | --- | +| run / resume / abort 客户端入口 | `packages/client/src/api/hermes/chat.ts` | `startRunViaSocket` 第 432 行;`resumeSession` 第 420 行;`registerSessionHandlers` 第 297 行 | 作为 E2E smoke 的网络断言依据 | +| session detail | `packages/client/src/api/hermes/sessions.ts` | `SessionSummary` 第 3 行;`HermesMessage` 第 36 行;`fetchSessions` 第 50 行;`fetchSession` 第 81 行 | 刷新恢复 smoke 必须断言 session detail | +| run 生命周期 | `packages/server/src/services/hermes/chat-run-socket.ts` | `resumeSession` 第 427 行;`handleRun` 第 488 行;`markCompleted` 第 1276 行 | 正式 `3000` 上断言 run 生命周期完整 | +| 面板组件边界 | `packages/client/src/components/hermes/chat/ChatPanel.vue` | `SessionListItem` import 第 22 行、使用第 598 / 636 行;`MessageList` 第 790 行;`ChatInput` 第 791 行 | 只对齐页面 AI 抽屉需要的子集 | + +**顺序执行 checklist:** + +- [x] M1.1 确认主入口 `http://127.0.0.1:3000` 运行的是包含本轮改动的 `mnote-web`,不是旧进程。 +- [x] M1.2 使用默认 Convex Auth 测试账号 `mnote.e2e@example.com` 登录,不使用 `MNOTE_DEV_AUTH=1` 伪登录作为正式验收。 +- [x] M1.3 运行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-smoke.js`。 +- [x] M1.4 运行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-smoke.js`。 +- [x] M1.5 运行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-writeback-smoke.js`。 +- [x] M1.6 运行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-title-options-smoke.js`。 +- [x] M1.7 运行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-artifact-smoke.js`。 +- [x] M1.8 运行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-retirement-guard.js`。 +- [x] M1.9 运行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-mobile-smoke.js`。 +- [x] M1.10 运行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-audit-smoke.js`。 +- [x] M1.11 确认 smoke 只操作本轮新建测试数据,统一前缀 `TEST-HERMES-AI-`。 +- [x] M1.12 任一 smoke 失败时,把失败写入 `bugs/07-ai/process/` 或真实 owner 分类,不在 checklist 里标 GREEN。 + +**验收标准:** + +- [x] 所有 smoke 在正式 `3000` 通过。 +- [x] 可以定位每次 smoke 生成的页面、artifact、edge 和 audit。 +- [x] 任一 smoke 失败都会落到 `bugs/`,不会在 checklist 中假装通过。 + +### N1:把活跃设计口径和 old 目录治理收口 + +**目标:** 设计目录与真实主线一致:页面 AI 是 Hermes 面板,mnote 通过 Hermes skill/plugin 被调用,旧 CLI-first 口径只保留为历史。 + +**必须参考的本仓设计文件:** + +| 参考目的 | 具体文件 | 具体位置 / 搜索点 | 处理方式 | +| --- | --- | --- | --- | +| Page Aggregate 口径 | `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` | 搜索 `/api/ai-agent/run`、`mnote-cli host`、`页面 AI` | 改成历史残留 / 已覆盖 / 待迁移证据,不写成长期主线 | +| Wolai-aline 过渡基线 | `design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md` | 搜索 `E27`、`mnote-cli`、`页面 AI` | 保留为对标基线时必须标明过渡状态 | +| 二级域 review 建议 | `design/10-review/04-secondary-domains-and-design-governance-review.md` | 搜索 `Hermes`、`plugin`、`mnote-cli`、`CLI-first` | 指向 Hermes plugin / Rust bridge 的当前口径 | +| 07-ai 历史稿 | `design/old/07-ai/process/` | 搜索 `CLI-first`、`mnote-cli` | 继续标 `[recycle]`,不迁回活跃 process | + +**顺序执行 checklist:** + +- [x] N1.1 运行 `rg -n "mnote-cli.*唯一|唯一长期 agent|CLI-first|/api/ai-agent/run|provider=hermes" design --glob '*.md'`。 +- [x] N1.2 把命中分为 `design/old` 历史命中、活跃历史说明、活跃误导口径三类。 +- [x] N1.3 修改 `5-6`、`5-9`、`10-review` 中仍残留的旧 CLI-first 说法,使其明确为历史/兼容/已覆盖。 +- [x] N1.4 确认 `design/old/07-ai/process/` 中历史稿继续标 `[recycle]`。 +- [x] N1.5 7-4 全部验收标准已满足,本文已按 design 归档规则移动到 `design/07-ai/done/`;新的活跃执行清单改由 `7-7` 承接。 +- [x] N1.6 回填最终 rg 输出摘要和 design 文件迁移清单。 + +2026-05-14 N1 执行证据: + +- [x] `rg -n "mnote-cli.*唯一|唯一长期 agent|CLI-first|/api/ai-agent/run|provider=hermes" design --glob '*.md'` 已跑完,活跃命中已逐项归类。 +- [x] 活跃命中主要落在 `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`、`design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md`、`design/10-review/04-secondary-domains-and-design-governance-review.md`、`design/10-review/06-execution-checklist-and-acceptance.md`、`design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md`、`design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md`、`design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`、`design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md`、`design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md`、`design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md`。 +- [x] `design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 与同目录旧稿继续保留 `[recycle]`,只作为历史证据。 +- [x] 本轮对 `5-6`、`5-9`、`10-review/04`、`10-review/06`、`1-1`、`3-1` 的旧口径改写已完成;活跃文档现已统一为 Hermes 页面内客户端 + mnote Hermes plugin/tool 口径。 +- [x] 本文件仍保留在 `process/`,原因是它是执行清单而不是代码实现文件;若后续需要归档,可按 `N1.5` 的规则移动并同步更新引用。 + +**验收标准:** + +- [x] 活跃目录的旧 CLI-first 命中都带有历史/回收/已覆盖说明。 +- [x] `design/old/07-ai/process/` 保持历史记录定位。 +- [x] 未完成能力不会被提前标 done。 + +--- + +## 17. 推荐执行顺序总表 + +| 顺序 | Task | 目标 | 退出门槛 | +| --- | --- | --- | --- | +| 1 | A | 固定旧链与 502 基线 | 有可复跑证据 | +| 2 | B | 冻结 Hermes client proxy 合同 | route 输入输出示例齐全 | +| 3 | C | 实现 Hermes client proxy 只读链 | session/run 可代理 | +| 4 | D | 冻结 mnote plugin tool 合同 | 第一批 tool schema 齐全 | +| 5 | E | 实现 manifest + `mnote.page.get` | Hermes 可读当前页 | +| 6 | F | 实现 Leptos Hermes 面板 | 页面 AI 不再请求旧 run route | +| 7 | G | 展示 tool event | tool started/completed 可刷新恢复 | +| 8 | H | 正文写入工具 | `page.body.save` 闭环 | +| 9 | I | 标题与页面设置工具 | 标题/设置走 Page Aggregate | +| 10 | J | artifact 工具 | summary / ai_note / reference edge 闭环 | +| 11 | K | 退役旧主链 | 旧 route 不再承载页面 AI 主路径 | +| 12 | L | 审计与可观测性 | tool call 到 Rust command 可追踪 | +| 13 | M | 端到端验收矩阵 | 全 smoke 通过 | +| 14 | N | 文档治理回填 | design 口径无旧主线残留 | + +--- + +## 18. 最终完成定义 + +只有同时满足以下条件,才能宣布本 checklist 完成: + +- [x] Hermes session 是页面 AI 会话真相。 +- [x] mnote 只保存业务事实和审计,不保存聊天真相。 +- [x] 页面 AI 面板是 Leptos Hermes client,不是 `mnote-cli` 面板。 +- [x] mnote Hermes plugin 至少覆盖读取当前页、写正文、改标题/设置、创建 summary、创建 ai_note。 +- [x] 所有写入经 Rust runtime / kernel。 +- [x] 旧 `/api/ai-agent/run` 不再是页面 AI 主入口。 +- [x] E2E smoke 覆盖只读、写入、artifact、刷新恢复、旧链退役;正式 `http://127.0.0.1:3000` 已复跑 8 个 smoke,见 Task M。 +- [x] 活跃设计文档和执行清单一致;旧 CLI-first / `/api/ai-agent/run` 命中均已标为历史、兼容、已覆盖或 legacy guard。 + +### 18.1 最终完成审计记录 + +2026-05-14 最终复核按“完成定义 -> 真实证据”逐项检查,不只依赖本文勾选状态。 + +- [x] 运行态入口存在:`ss -ltnp | rg ':3000\b|:8642\b'` 确认 `mnote-web` 监听 `0.0.0.0:3000`,Hermes API server 监听 `127.0.0.1:8642`。 +- [x] Rust tool / client / legacy guard 测试通过: + - `cargo test -p bridge-runtime ai_artifact -- --nocapture`:2 passed。 + - `cargo test -p mnote-web hermes_client -- --nocapture`:4 passed。 + - `cargo test -p mnote-web hermes_tools_ -- --nocapture`:9 passed。 + - `cargo test -p mnote-web ai_agent_run -- --nocapture`:1 passed。 + - `cargo test -p mnote-web explicit_ -- --nocapture`:2 passed。 + - `cargo build -p mnote-web`:通过。 +- [x] Hermes plugin/toolset 可见: + - `hermes plugins list`:`mnote` 为 enabled user plugin。 + - `hermes tools list --platform api_server`:Plugin toolsets 中 `mnote` 为 enabled。 + - `/home/lix/.hermes/plugins/mnote/plugin.yaml` 声明 `mnote_page_get`、`mnote_page_save`、`mnote_page_update_title`、`mnote_page_update_options`、`mnote_artifact_create_summary`、`mnote_artifact_create_ai_note`。 +- [x] 正式 `3000` smoke 全矩阵通过: + - `task-hermes-page-ai-smoke.js`:`tree_1778724401995_17`,session `mnote_smoke_mp4ul0c2`,run `run_smoke_mp4ul0c2`,`sessionDetailHits=2`。 + - `task-hermes-page-ai-tool-smoke.js`:`tree_1778724413742_19`,`mnote.page.get` 返回标题 `TEST-HERMES-AI-tool-mp4ul9ei`。 + - `task-hermes-page-ai-writeback-smoke.js`:`tree_1778724445965_21`,`page.body.save`,command `page_body_save_req_1778724446747_1371`。 + - `task-hermes-page-ai-title-options-smoke.js`:`tree_1778724480369_23`,`page.head.updateTitle` 与 `page.layout.updateOptions` 通过,`pageFont` 返回 `page_option_not_wired`。 + - `task-hermes-page-ai-artifact-smoke.js`:`tree_1778724511207_25`,summary / ai_note 都走 `tree.node.create`,`referenceEdgeCount=6`,`projectionOnly=true`。 + - `task-hermes-page-ai-retirement-guard.js`:旧入口返回 `410 legacy_ai_agent_run_retired`。 + - `task-hermes-page-ai-mobile-smoke.js`:`390px` viewport 下 drawer / panel 未横向溢出。 + - `task-hermes-page-ai-audit-smoke.js`:`tree_1778724538669_29`,写命令 `page_body_save_req_1778724538893_1707`,持久化 audit 覆盖 `started/completed` 与权限失败 `started/failed`。 +- [x] 真实 Hermes upstream session 补充验证:页面 AI 不拦截 Hermes 请求时触发真实 run,`hermes sessions export --session-id mnote_tree_1778724800891_37_page-ai-mp4utko0 -` 显示 `message_count=4`、`tool_call_count=1`、`api_call_count=2`、`has_mnote_page_get=true`。 +- [x] 文档治理检查通过: + - `rg -n "mnote-cli.*唯一|唯一长期 agent|CLI-first|/api/ai-agent/run|provider=hermes" design --glob '*.md'` 的活跃命中均为历史、兼容、已覆盖、禁止项或 legacy guard 说明。 + - `git diff --check` 覆盖本轮已跟踪设计稿,无空白错误。 + - `rg -n "[ \t]+$"` 覆盖本轮设计稿,无尾随空白。 diff --git a/design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md b/design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md new file mode 100644 index 00000000..6abd7032 --- /dev/null +++ b/design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md @@ -0,0 +1,243 @@ +# 7-5 [done] Hermes client proxy 合同 v1 + +> 更新时间:2026-05-14 +> +> 上位依据:`design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`、`design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` +> +> Hermes Web UI 参考:`packages/client/src/api/hermes/chat.ts`、`packages/client/src/api/hermes/sessions.ts`、`packages/server/src/routes/hermes/proxy-handler.ts`、`packages/server/src/services/hermes/chat-run-socket.ts` + +## 1. 边界 + +- [x] 浏览器只访问 mnote-web 同源 `/api/hermes/client/*`。 +- [x] Hermes API key / gateway token 只存在 mnote-web 服务端环境变量。 +- [x] mnote-web proxy 只做 auth、同源安全、页面上下文注入、trace 注入和错误码标准化。 +- [x] mnote-web proxy 不保存 Hermes session、message、tool event、usage、model 真相。 +- [x] `pageContext` 是 run 输入上下文,不是 Hermes session 的长期事实源。 +- [x] 旧 `/api/ai-agent/run` 在并存期只作为 legacy endpoint,不是新 Hermes 面板主代理。 + +## 2. 路由 + +### `GET /api/hermes/client/sessions` + +请求: + +```http +GET /api/hermes/client/sessions?workspaceId=ws_1&documentId=doc_1&limit=20 +``` + +响应: + +```json +{ + "ok": true, + "traceId": "trace_1", + "sessions": [ + { + "sessionId": "mnote_doc_1_20260514", + "title": "当前页问答", + "preview": "请总结当前页面", + "messageCount": 2, + "toolCallCount": 1, + "updatedAt": 1778712000, + "model": "hermes-agent" + } + ] +} +``` + +### `POST /api/hermes/client/sessions` + +请求: + +```json +{ + "workspaceId": "ws_1", + "documentId": "doc_1", + "traceId": "trace_1", + "title": "当前页问答" +} +``` + +响应: + +```json +{ + "ok": true, + "sessionId": "mnote_doc_1_trace_1", + "traceId": "trace_1", + "persistence": "hermes_on_first_run" +} +``` + +说明:Hermes Web UI 参考实现没有独立 session create HTTP route,客户端生成 session id,首次 run 时由 Hermes 持久化。mnote 保留本 route 是为了同源客户端合同稳定,但不得在 mnote 保存聊天历史。 + +### `GET /api/hermes/client/sessions/{session_id}` + +请求: + +```http +GET /api/hermes/client/sessions/mnote_doc_1_trace_1?workspaceId=ws_1&documentId=doc_1 +``` + +响应: + +```json +{ + "ok": true, + "sessionId": "mnote_doc_1_trace_1", + "traceId": "trace_1", + "messages": [ + { + "messageId": "42", + "role": "assistant", + "content": "当前页标题是...", + "toolCallId": null, + "toolName": null, + "timestamp": 1778712000, + "reasoning": null + } + ], + "usage": { + "inputTokens": 120, + "outputTokens": 30, + "totalTokens": 150 + } +} +``` + +### `POST /api/hermes/client/runs` + +请求: + +```json +{ + "workspaceId": "ws_1", + "documentId": "doc_1", + "sessionId": "mnote_doc_1_trace_1", + "message": "概括当前页面标题和第一段", + "model": "hermes-agent", + "pageContext": { + "title": "项目计划", + "outline": [], + "pageOptions": { "wideLayout": true } + }, + "selectedBlockId": null, + "selectedText": null, + "traceId": "trace_1" +} +``` + +响应: + +```json +{ + "ok": true, + "sessionId": "mnote_doc_1_trace_1", + "runId": "run_123", + "messageId": null, + "events": [], + "traceId": "trace_1" +} +``` + +### `GET /api/hermes/client/events/{run_id}` + +请求: + +```http +GET /api/hermes/client/events/run_123?sessionId=mnote_doc_1_trace_1 +``` + +响应:`text/event-stream` + +```text +data: {"event":"message.delta","run_id":"run_123","session_id":"mnote_doc_1_trace_1","delta":"当前页"} + +data: {"event":"run.completed","run_id":"run_123","session_id":"mnote_doc_1_trace_1","output":"当前页...","usage":{"input_tokens":120,"output_tokens":30,"total_tokens":150}} +``` + +### `POST /api/hermes/client/runs/{run_id}/abort` + +请求: + +```json +{ + "workspaceId": "ws_1", + "documentId": "doc_1", + "sessionId": "mnote_doc_1_trace_1", + "traceId": "trace_1" +} +``` + +响应: + +```json +{ + "ok": true, + "runId": "run_123", + "sessionId": "mnote_doc_1_trace_1", + "traceId": "trace_1", + "events": [ + { "event": "abort.started", "run_id": "run_123" } + ] +} +``` + +### `GET /api/hermes/client/models` + +响应: + +```json +{ + "ok": true, + "traceId": "trace_1", + "defaultModel": "hermes-agent", + "models": [ + { "id": "hermes-agent", "label": "Hermes Agent", "provider": "hermes" } + ] +} +``` + +### `GET /api/hermes/client/tools` + +响应: + +```json +{ + "ok": true, + "traceId": "trace_1", + "tools": [ + { "name": "mnote.page.get", "scope": "page.read", "schemaVersion": "mnote.hermes_tool.v1" } + ] +} +``` + +## 3. 错误响应 + +统一错误体: + +```json +{ + "ok": false, + "code": "hermes_client_unconfigured", + "message": "Hermes client proxy 未配置 upstream", + "traceId": "trace_1", + "requestId": "req_1" +} +``` + +稳定错误码: + +- [x] `hermes_client_unauthorized`:未登录或缺少有效 mnote 会话。 +- [x] `hermes_client_unconfigured`:未配置 `MNOTE_WEB_HERMES_UPSTREAM_URL`。 +- [x] `hermes_client_bad_request`:请求 JSON 或必要字段错误。 +- [x] `hermes_client_upstream_unauthorized`:Hermes upstream 拒绝服务端 token。 +- [x] `hermes_client_upstream_rate_limited`:Hermes upstream 429。 +- [x] `hermes_client_upstream_unavailable`:Hermes upstream 连接失败或 5xx。 + +## 4. 并存期规则 + +- [x] 新页面 AI 面板只允许请求 `/api/hermes/client/*`。 +- [x] `/api/ai-agent/run` 保留为 legacy guard,不再承载 Hermes 页面 AI 主链。 +- [x] `provider=hermes` 不再通过旧 `/api/ai-agent/run` 表达。 +- [x] 所有新 smoke 应断言页面 AI 主链没有 `/api/ai-agent/run` 请求。 diff --git a/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md b/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md new file mode 100644 index 00000000..d6b70abb --- /dev/null +++ b/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md @@ -0,0 +1,304 @@ +# 7-6 [done] mnote Hermes plugin tool 合同 v1 + +> 更新时间:2026-05-14 +> +> 上位依据:`design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`、`design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` +> +> Hermes Web UI 参考:`packages/client/src/api/hermes/plugins.ts`、`packages/client/src/api/hermes/skills.ts`、`packages/server/src/services/hermes/plugins.ts`、`packages/server/src/services/hermes/chat-run-socket.ts` + +## 1. 总边界 + +- [x] Hermes 看到的工具名必须是 `mnote.*`,不是 `mnote-cli`、Next route 或前端私有函数。 +- [x] plugin 内部可以临时调用 `mnote-cli` JSON adapter,但这只是内部 adapter,不是长期 tool 名称。 +- [x] Hermes plugin 不直接写 Convex;所有写入必须回到 Rust runtime / kernel。 +- [x] mnote 只保存业务事实、audit、artifact、edge、page/body/title/options 结果,不保存 Hermes 聊天历史。 +- [x] 所有 tool call 必须携带 `sessionId/runId/toolCallId/traceId` 便于串联 Hermes run 与 Rust command。 + +## 2. 统一入参 + +```json +{ + "toolName": "mnote.page.get", + "workspaceId": "ws_1", + "documentId": "doc_1", + "actorId": "user_1", + "sessionId": "mnote_doc_1_trace_1", + "runId": "run_123", + "toolCallId": "call_123", + "traceId": "trace_1", + "idempotencyKey": "idem_123", + "dryRun": false, + "capabilityScope": ["page.read"], + "args": {} +} +``` + +## 3. 统一出参 + +```json +{ + "ok": true, + "toolName": "mnote.page.get", + "toolCallId": "call_123", + "traceId": "trace_1", + "result": {}, + "audit": { + "effect": "read", + "commandId": null, + "workspaceId": "ws_1", + "documentId": "doc_1" + }, + "error": null +} +``` + +失败: + +```json +{ + "ok": false, + "toolName": "mnote.page.save", + "toolCallId": "call_123", + "traceId": "trace_1", + "result": null, + "audit": { + "effect": "none", + "workspaceId": "ws_1", + "documentId": "doc_1" + }, + "error": { + "code": "mnote_tool_permission_denied", + "message": "当前用户没有页面写权限" + } +} +``` + +## 4. 第一批工具 schema + +### `mnote.page.get` + +入参: + +```json +{ + "type": "object", + "required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId"], + "properties": { + "workspaceId": { "type": "string" }, + "documentId": { "type": "string" }, + "includeBody": { "type": "boolean", "default": true }, + "includeOptions": { "type": "boolean", "default": true }, + "includeBlocks": { "type": "boolean", "default": true } + } +} +``` + +成功: + +```json +{ + "ok": true, + "toolName": "mnote.page.get", + "toolCallId": "call_get_1", + "traceId": "trace_1", + "result": { + "documentId": "doc_1", + "workspaceId": "ws_1", + "title": "项目计划", + "bodySummary": "第一段...", + "pageOptions": { "wideLayout": true }, + "blocks": [{ "id": "heading_1", "type": "heading", "text": "章节一" }] + }, + "audit": { "effect": "read", "commandId": null } +} +``` + +失败: + +```json +{ + "ok": false, + "toolName": "mnote.page.get", + "toolCallId": "call_get_1", + "traceId": "trace_1", + "error": { "code": "mnote_tool_permission_denied", "message": "无页面读取权限" } +} +``` + +### `mnote.page.save` + +入参: + +```json +{ + "type": "object", + "required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "idempotencyKey", "dryRun", "content"], + "properties": { + "workspaceId": { "type": "string" }, + "documentId": { "type": "string" }, + "content": { "type": "array" }, + "mode": { "type": "string", "enum": ["replace", "append"] }, + "idempotencyKey": { "type": "string" }, + "dryRun": { "type": "boolean" } + } +} +``` + +成功: + +```json +{ + "ok": true, + "toolName": "mnote.page.save", + "toolCallId": "call_save_1", + "traceId": "trace_1", + "result": { "commandName": "page.body.save", "revision": 8 }, + "audit": { "effect": "write", "commandId": "page_body_save_trace_1" } +} +``` + +dryRun: + +```json +{ + "ok": true, + "toolName": "mnote.page.save", + "toolCallId": "call_save_1", + "traceId": "trace_1", + "result": { "dryRun": true, "diff": [{ "op": "append", "blocks": 1 }] }, + "audit": { "effect": "dry_run", "commandId": null } +} +``` + +权限失败: + +```json +{ + "ok": false, + "toolName": "mnote.page.save", + "toolCallId": "call_save_1", + "traceId": "trace_1", + "error": { "code": "mnote_tool_permission_denied", "message": "无页面写权限" } +} +``` + +业务失败: + +```json +{ + "ok": false, + "toolName": "mnote.page.save", + "toolCallId": "call_save_1", + "traceId": "trace_1", + "error": { "code": "mnote_tool_conflict", "message": "页面版本冲突,需要刷新后重试" } +} +``` + +### `mnote.page.update_title` + +入参: + +```json +{ + "type": "object", + "required": ["workspaceId", "documentId", "title", "sessionId", "runId", "toolCallId", "traceId", "idempotencyKey", "dryRun"], + "properties": { + "workspaceId": { "type": "string" }, + "documentId": { "type": "string" }, + "title": { "type": "string", "minLength": 1 }, + "idempotencyKey": { "type": "string" }, + "dryRun": { "type": "boolean" } + } +} +``` + +成功、dryRun、权限失败、业务失败响应形状同 `mnote.page.save`,成功 command 为 `page.head.updateTitle`。 + +### `mnote.page.update_options` + +入参: + +```json +{ + "type": "object", + "required": ["workspaceId", "documentId", "options", "sessionId", "runId", "toolCallId", "traceId", "idempotencyKey", "dryRun"], + "properties": { + "workspaceId": { "type": "string" }, + "documentId": { "type": "string" }, + "options": { + "type": "object", + "properties": { + "wideLayout": { "type": "boolean" }, + "smallText": { "type": "boolean" }, + "showToc": { "type": "boolean" }, + "protectEditing": { "type": "boolean" } + }, + "additionalProperties": false + }, + "idempotencyKey": { "type": "string" }, + "dryRun": { "type": "boolean" } + } +} +``` + +成功、dryRun、权限失败、业务失败响应形状同 `mnote.page.save`,成功 command 为 `page.layout.updateOptions`。`runtimeSupport !== "wired"` 的字段必须返回 dryRun 警告或业务失败。 + +### `mnote.artifact.create_summary` + +入参: + +```json +{ + "type": "object", + "required": ["workspaceId", "documentId", "summary", "sessionId", "runId", "toolCallId", "traceId", "idempotencyKey", "dryRun"], + "properties": { + "workspaceId": { "type": "string" }, + "documentId": { "type": "string" }, + "summary": { "type": "string" }, + "idempotencyKey": { "type": "string" }, + "dryRun": { "type": "boolean" } + } +} +``` + +成功:创建或更新当前页面唯一 summary node,并创建或确认 reference edge。 + +失败示例: + +```json +{ + "ok": false, + "toolName": "mnote.artifact.create_summary", + "toolCallId": "call_summary_1", + "traceId": "trace_1", + "error": { "code": "mnote_tool_idempotency_conflict", "message": "同一幂等键已用于不同 summary 内容" } +} +``` + +### `mnote.artifact.create_ai_note` + +入参: + +```json +{ + "type": "object", + "required": ["workspaceId", "documentId", "content", "sessionId", "runId", "toolCallId", "traceId", "idempotencyKey", "dryRun"], + "properties": { + "workspaceId": { "type": "string" }, + "documentId": { "type": "string" }, + "content": { "type": "string" }, + "idempotencyKey": { "type": "string" }, + "dryRun": { "type": "boolean" } + } +} +``` + +成功:每次创建独立 ai_note node,并创建 reference edge。 + +## 5. 权限、幂等与 dryRun + +- [x] 权限校验点:登录、workspace member、页面读、页面写、artifact 写。 +- [x] 同一 `idempotencyKey` 重试不得重复创建 artifact 或重复写正文。 +- [x] `dryRun=true` 只能返回计划和 diff,不得写入。 +- [x] 所有失败响应不得泄露无权限页面标题、正文或 artifact 内容。 +- [x] 写工具返回的 `audit.commandId` 必须能追踪到 Rust runtime / kernel command。 diff --git a/design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md b/design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md new file mode 100644 index 00000000..0b220c28 --- /dev/null +++ b/design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md @@ -0,0 +1,277 @@ +# 7-7 [process] 页面 AI Mini Hermes 控制面设计与执行 checklist v1 + +> 更新时间:2026-05-14 +> +> 当前状态:`PROCESS`。当前实现已经可以通过 Hermes 返回回复,并已完成 `7-4` 的 session/run/tool/writeback/audit 主链;本稿只承接下一阶段体验与设置面的收口。 +> +> 上位依据: +> - `/mnt/Data1T/mnote/ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md` + +--- + +## 1. 方向判断 + +当前截图和实现状态说明:页面 AI 已经能回复,但它还更像“能发消息的 Hermes run 面板”,缺少用户自然期待的 Hermes 运行态控制与设置入口。 + +下一阶段不要把 mnote 做成第二个 Hermes 管理台,也不要把 Hermes 的 Settings / Profiles / Usage / Logs 全部搬进页面抽屉。正确方向是: + +> **页面 AI 面板成为 Mini Hermes control surface:只展示和当前 mnote 页面会话直接相关的 session、run、model、profile、tool、context scope 与错误恢复;所有全局配置真相仍归 Hermes。** + +边界继续保持: + +- Hermes 持有 session/message/tool event/usage/model/profile 真相。 +- mnote 只持有页面、树、正文、artifact、edge、Page Aggregate 和 audit 真相。 +- 页面 AI 面板只调用 Hermes,不保存聊天历史,不维护 provider/API key,不重建 plugin registry。 +- 需要深层设置时跳转或 deep link 到 Hermes 自己的设置页。 + +--- + +## 2. Hermes Web UI 参考索引 + +参考根目录固定为: + +```text +/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18 +``` + +只参考下表列出的具体位置。目标是复用 Hermes 的语义与行为边界,仍然用 Leptos 实现 mnote 页面内面板。 + +| mnote 下一步任务 | 参考文件 | 搜索点 / 具体位置 | 参考目的 | 不采用内容 | +| --- | --- | --- | --- | --- | +| run 发起、恢复和事件语义 | `packages/client/src/api/hermes/chat.ts` | `StartRunRequest`、`RunEvent`、`registerSessionHandlers`、`resumeSession`、`startRunViaSocket` | 对齐 run 创建、session room 恢复、事件类型和浏览器端分发语义 | 不直接复用 Vue/TS client,不让浏览器绕过 mnote-web proxy | +| 前端消息归并 | `packages/client/src/stores/hermes/chat.ts` | `mapHermesMessages`、`case 'tool.started'`、`case 'tool.completed'`、`refreshActiveSession`、`switchSession` | 对齐 message/tool event 在前端归并成消息列表的规则 | 不把归并结果保存到 mnote local/session storage 作为真相 | +| 面板分层 | `packages/client/src/components/hermes/chat/ChatPanel.vue` | `activeSessionTitle`、`showSessions`、`handleNewChat`、`MessageList`、`ChatInput`、`SessionListItem` | 对齐 session list / message list / input 的最小层次 | 不复制 Hermes 全屏布局、Vue 组件结构或 Naive UI | +| tool 消息展示 | `packages/client/src/components/hermes/chat/MessageItem.vue` | `message.role === 'tool'`、`formatToolPayload`、`tool-preview`、`tool-details`、`tool-error-badge` | 对齐 tool 结果默认折叠、摘要、错误 badge、详情展开 | 不把 tool result 当正文写入,不扩大为审计详情管理台 | +| session 列表项 | `packages/client/src/components/hermes/chat/SessionListItem.vue` | `session-item-title`、`session.title`、`session-item-model` | 对齐 session 标题、模型和时间的展示语义 | 不把 Hermes session title 同步为 mnote page title | +| model 选择展示 | `packages/client/src/components/layout/ModelSelector.vue` | `selectedDisplayName`、`handleSelect`、`model-name`、`model-item` | 对齐模型显示名称、选择行为和禁用态 | 不在 mnote 保存 model/provider/API key 真相 | +| run 生命周期与持久化 | `packages/server/src/services/hermes/chat-run-socket.ts` | `handleRun`、`emit`、`applyResponseStreamEvent`、`flushResponseRunToDb`、`markCompleted` | 对齐 queued/running/tool_calling/completed/failed 与 flush 到 Hermes DB 的时机 | 不在 mnote 里重写 Hermes 编排器 | +| session 存储真相 | `packages/server/src/db/hermes/session-store.ts` | `HermesSessionRow`、`HermesMessageRow`、`getSessionDetail`、`addMessage`、`renameSession` | 明确 session/message/tool call 真相字段来自 Hermes | 不在 mnote 建第二份 chat/session 表 | +| plugin/tool 发现 | `packages/server/src/services/hermes/plugins.ts` | `PluginManager`、`providesTools`、`listHermesPlugins`、`requiresEnv` | 对齐 mnote tools 作为 Hermes plugin/tool registry 的一部分被发现 | 不把 mnote-web 私有 route 当成最终 plugin registry | +| skill/plugin API 外观 | `packages/client/src/api/hermes/plugins.ts`、`packages/client/src/api/hermes/skills.ts` | `list`、`get`、`enable`、`disable` 类方法 | 仅用于面板显示 mnote plugin/tool 可用性和缺失状态 | 不实现完整 Hermes plugin/skill 管理页 | +| 不进入 mnote 面板第一阶段 | `packages/client/src/views/hermes/SettingsView.vue`、`ProfilesView.vue`、`UsageView.vue`、`LogsView.vue`、`JobsView.vue`、`FilesView.vue`、`ChannelsView.vue`、`TerminalView.vue`、`GroupChatView.vue` | 页面级管理视图入口 | 用来明确边界:这些属于 Hermes 管理台 | 不搬进 mnote 页面 AI 抽屉 | + +快速定位命令: + +```bash +cd /mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18 +rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunViaSocket|mapHermesMessages|tool\\.started|tool\\.completed|activeSessionTitle|formatToolPayload|selectedDisplayName|handleRun|flushResponseRunToDb|PluginManager|providesTools" packages/client/src packages/server/src +``` + +--- + +## 3. 产品边界 + +### 3.1 必须进入 mnote 页面 AI 面板 + +- 当前 Hermes session 标题、session id、模型名、profile 名和恢复状态。 +- 当前 run 状态:`idle`、`queued`、`running`、`tool_calling`、`completed`、`failed`、`aborted`。 +- 当前页面上下文范围:当前页、当前选区、当前块、全文、页面设置。 +- 当前 mnote tools 可用性:`mnote.page.get`、`mnote.page.save`、`mnote.page.update_title`、`mnote.page.update_options`、`mnote.artifact.create_summary`、`mnote.artifact.create_ai_note`。 +- 最近 tool call 摘要、参数摘要、结果摘要、失败原因和 trace/audit id。 +- 停止、重试、继续、重新读取当前页上下文的最小操作。 +- 跳转 Hermes 设置的入口,说明缺失项应在 Hermes 中配置。 + +### 3.2 不进入 mnote 页面 AI 面板 + +- provider/API key 密钥管理。 +- 全局 profile 创建、删除、复杂编辑。 +- Hermes plugin marketplace / skill marketplace 管理。 +- usage 报表、日志中心、任务中心、文件中心、频道、终端、群聊。 +- Hermes session 数据库迁移、导入导出、conversation 管理台。 +- 独立 mnote 聊天历史存储。 + +### 3.3 可选但不阻塞第一阶段 + +- 当前页面最近 sessions 搜索。 +- session 重命名与删除。 +- profile/model 下拉切换。 +- tool event JSON 详情复制。 +- session deep link 到 Hermes 管理台。 + +--- + +## 4. 目标信息架构 + +页面 AI 抽屉保持 Wolai 对齐后的壳层,内部按四层组织: + +1. 顶部状态条:session 标题、模型/profile、连接状态、设置跳转。 +2. 消息区:Hermes message list、streaming 回复、tool event 折叠项。 +3. 上下文与工具条:当前 context scope、可用 mnote tools、最近 audit/trace。 +4. 输入区:prompt 输入、发送、停止、重试、继续。 + +验收时不要只看 DOM,应以截图和交互为准: + +- 顶部状态在窄屏不挤压输入区。 +- tool event 折叠项不会撑破抽屉宽度。 +- 失败态能看到可执行动作,不只是一段错误文本。 +- 设置入口不会暗示 mnote 自己持有 API key。 + +--- + +## 5. 数据与 API 边界 + +### 5.1 mnote-web proxy 需要补齐的读接口 + +- `GET /api/hermes/client/session/current?documentId=...` +- `GET /api/hermes/client/sessions?documentId=...&limit=...` +- `GET /api/hermes/client/session/:sessionId` +- `GET /api/hermes/client/models` +- `GET /api/hermes/client/tools?scope=mnote` +- `GET /api/hermes/client/runtime/status` + +这些接口只透传或规整 Hermes 状态,不在 mnote 保存真相。 + +### 5.2 mnote-web proxy 需要补齐的操作接口 + +- `POST /api/hermes/client/run` +- `POST /api/hermes/client/run/:runId/stop` +- `POST /api/hermes/client/run/:runId/retry` +- `POST /api/hermes/client/session/:sessionId/resume` +- `POST /api/hermes/client/session/:sessionId/rename` +- `DELETE /api/hermes/client/session/:sessionId` + +第一阶段可以只完成 `run/stop/resume`,`retry/rename/delete` 可以排到 P2,但 UI 文案不能让用户误以为已经支持。 + +### 5.3 mnote tool manifest + +页面 AI 面板展示 tool 可用性时应优先从 Hermes plugin/tool registry 来,而不是硬编码前端列表。允许 mnote-web 在过渡期提供同源聚合: + +- tool name +- description +- read/write 分类 +- required scope +- permission 状态 +- last call summary +- last audit id +- unavailable reason + +--- + +## 6. 顺序执行 checklist + +### A. 设计与现状审计 + +- [ ] A1. 打开当前 `3000` 页面 AI 抽屉,记录“能回复但缺设置/运行态控制”的截图到 `tmp/`。 + - 验收标准:截图能看到回复链路、顶部或设置区域缺失点、当前 session/run 状态展示缺口。 +- [ ] A2. 用 `rg` 确认活跃设计稿中页面 AI 主线已经指向 Hermes session/run/events。 + - 验收标准:`design/07-ai/process` 只剩本 `7-7` 作为 AI 活跃稿;`7-2` 到 `7-6` 位于 `done/`。 +- [ ] A3. 对照 Hermes Web UI 参考索引打开精确文件,不整目录通读。 + - 验收标准:实现任务说明中写明参考了哪个 `packages/...` 文件和哪个搜索点。 + +### B. 面板状态模型 + +- [ ] B1. 定义 Leptos 侧 `HermesPanelState`。 + - 验收标准:状态至少包含 `sessionId`、`sessionTitle`、`modelName`、`profileName`、`runStatus`、`connectionStatus`、`contextScope`、`toolAvailability`、`lastAudit`。 +- [ ] B2. 明确哪些字段来自 Hermes,哪些字段来自 mnote。 + - 验收标准:session/message/model/profile/run/tool event 来自 Hermes;page title/context/audit 来自 mnote;没有字段把 Hermes session title 当作页面标题。 +- [ ] B3. 增加空态、连接失败态、tool 不可用态。 + - 验收标准:Hermes 未启动、未配置模型、mnote plugin 缺失、权限不足分别有不同 UI 状态。 + +### C. 顶部 Mini Hermes 状态条 + +- [ ] C1. 展示当前 session 标题和恢复状态。 + - 验收标准:刷新页面后能从 Hermes session detail 恢复标题和消息,不从 mnote 本地 state 恢复聊天真相。 +- [ ] C2. 展示 model/profile 摘要。 + - 验收标准:能看到当前模型与 profile;缺失时显示“去 Hermes 配置”的动作,而不是在 mnote 内要求填写 API key。 +- [ ] C3. 增加设置跳转入口。 + - 验收标准:入口跳到 Hermes 设置或 profile 页面;mnote 页面内不出现 provider/API key 编辑表单。 + +### D. Run 控制与恢复 + +- [ ] D1. 显示 run 状态。 + - 验收标准:发送后能依次看到 running/tool_calling/completed 或 failed;状态来自 Hermes run event。 +- [ ] D2. 支持停止当前 run。 + - 验收标准:点击停止后 Hermes run 进入 aborted/failed 的明确终止态,输入框恢复可用。 +- [ ] D3. 支持失败后重试或继续。 + - 验收标准:失败态有明确按钮;重试不会创建 mnote 本地聊天副本。 +- [ ] D4. 刷新后恢复进行中或已完成 session。 + - 验收标准:刷新页面不丢失 Hermes 消息;若 run 已结束,状态显示 completed/failed 而不是一直 loading。 + +### E. Context Scope + +- [ ] E1. 增加 context scope 控件。 + - 验收标准:至少支持当前页、当前选区、当前块、页面设置四类;无选区时选区项禁用。 +- [ ] E2. run input 只携带当前 scope 的上下文摘要。 + - 验收标准:正文大对象不长期写进 Hermes session;Hermes 需要最新正文时通过 `mnote.page.get` 回读。 +- [ ] E3. tool call audit 记录 context scope。 + - 验收标准:`mnote.page.save`、artifact 写入等 audit 能看到本次来源是 page/selection/block/options 哪种 scope。 + +### F. Tool 可用性与最近调用 + +- [ ] F1. 展示 mnote tools 清单。 + - 验收标准:清单来源于 Hermes plugin/tool registry 或 mnote-web 过渡聚合,不能只写死在前端。 +- [ ] F2. 区分只读工具和写入工具。 + - 验收标准:`mnote.page.get` 明确为只读;`mnote.page.save`、`mnote.page.update_title`、`mnote.artifact.*` 明确为写入。 +- [ ] F3. tool event 默认折叠,支持展开详情。 + - 验收标准:交互参考 `MessageItem.vue` 的 `tool-preview` / `tool-details`,但使用 Leptos 实现;长 JSON 不撑破布局。 +- [ ] F4. 最近 tool call 关联 audit。 + - 验收标准:能从 UI 或测试输出看到 `sessionId/runId/toolCallId/traceId/auditId` 的串联。 + +### G. Session 列表最小面 + +- [ ] G1. 支持当前页面最近 Hermes sessions 列表。 + - 验收标准:列表来自 Hermes session API;只过滤/标注当前 document context,不复制 session 到 mnote。 +- [ ] G2. 支持切换 session。 + - 验收标准:切换后 message list 从 Hermes detail 恢复;页面正文不因切换 session 被自动修改。 +- [ ] G3. P2 支持 rename/delete/search。 + - 验收标准:若未实现,UI 不出现可点击假按钮;若实现,操作调用 Hermes session API。 + +### H. 错误与权限 + +- [ ] H1. Hermes 未启动或 proxy 502 时显示可诊断状态。 + - 验收标准:错误能区分 upstream unavailable、auth/permission、model missing、tool unavailable。 +- [ ] H2. 写入工具权限失败不泄露正文。 + - 验收标准:tool error 展示摘要、trace/audit id 和恢复动作,不展示不必要的正文 payload。 +- [ ] H3. 旧 `/api/ai-agent/run` 继续保持退场 guard。 + - 验收标准:新页面 AI 主链不会调用旧入口;retirement smoke 继续通过。 + +### I. 自动化验收 + +- [ ] I1. 新增或扩展 browser smoke:session 状态条。 + - 验收标准:断言 session title/model/run status 可见。 +- [ ] I2. 新增或扩展 browser smoke:run stop/retry。 + - 验收标准:至少覆盖 stop;retry 若未实现则断言按钮不存在或禁用。 +- [ ] I3. 新增或扩展 browser smoke:context scope。 + - 验收标准:选区/当前页上下文能进入 run payload 或 tool audit。 +- [ ] I4. 新增或扩展 browser smoke:tool 可用性与 audit。 + - 验收标准:能看到 mnote tool 列表、一次 tool call、对应 audit id。 +- [ ] I5. 移动端 smoke。 + - 验收标准:状态条、tool 折叠、输入区在窄屏不重叠。 + +--- + +## 7. Done Gate + +本稿移入 `done/` 前必须同时满足: + +- [ ] 页面 AI 抽屉具备 Mini Hermes 状态条,用户能看见当前 session、model/profile、run 状态。 +- [ ] 页面 AI 抽屉具备 context scope 控件,run input 和 tool audit 能反映 scope。 +- [ ] 页面 AI 抽屉能展示 mnote tool 可用性、最近 tool call 和 audit/trace 串联。 +- [ ] stop 至少可用;retry/rename/delete 若未实现,必须明确标为 P2 且 UI 不出现假可用按钮。 +- [ ] Hermes 未启动、模型缺失、plugin/tool 不可用、权限失败至少四类错误有可区分展示。 +- [ ] 页面刷新后仍以 Hermes session detail/resume 为会话真相。 +- [ ] mnote 不保存聊天消息真相,不保存 provider/API key,不创建第二套 plugin registry。 +- [ ] `git diff --check` 通过。 +- [ ] Rust 侧相关测试通过:`cargo test -p mnote-web hermes_client -- --nocapture`、`cargo test -p mnote-web hermes_tools_ -- --nocapture`。 +- [ ] 正式 `3000` smoke 覆盖 session/run/tool/audit/mobile,且证据写回本文。 + +--- + +## 8. 当前建议的下一步 + +优先顺序: + +1. 先补顶部 Mini Hermes 状态条和错误态,因为这是当前“能回复但缺 Hermes 设置感”的直接缺口。 +2. 再补 context scope 和 tool 可用性,让用户明确 Hermes 正在调用 mnote 的哪些能力。 +3. 最后补 session 列表、rename/delete/search 等历史管理能力。 + +不要先做 Hermes 全局设置页复刻。API key、provider、全局 profile、usage/logs/jobs/files/channels 仍应留在 Hermes 自己的管理台。 diff --git a/design/10-review/04-secondary-domains-and-design-governance-review.md b/design/10-review/04-secondary-domains-and-design-governance-review.md index ec124c0d..f2d0bb1c 100644 --- a/design/10-review/04-secondary-domains-and-design-governance-review.md +++ b/design/10-review/04-secondary-domains-and-design-governance-review.md @@ -17,7 +17,9 @@ | F-01 | 实现偏差 / 风险 | P1 | OnlyOffice `mnote-web` 主入口已挂载 callback/forcesave,但当前实现不写回,只返回成功或 noop,可能让 3000 主链下的 OnlyOffice 保存丢失。 | `rust/crates/mnote-web/src/routes/mod.rs:78-84` 挂载 `/api/onlyoffice/callback` 与 `/api/onlyoffice/forcesave`;`rust/crates/mnote-web/src/routes/onlyoffice.rs:717-759` callback 只记录日志并返回 `{error:0}`,forcesave 返回 `mnote-web-rust-noop`;而 `wolai-frontend/src/app/api/onlyoffice/callback/route.ts:93-192` 有真实 Convex 写回链。 | 优先把 Rust route 接到 `onlyoffice_prepare_callback` / media asset writeback,或显式把该路由代理到 legacy Next,避免主入口 shadow 掉真实写回。 | | F-02 | 未完成 | P2 | Mindmap `export` 动作暴露给 UI action map,但 simple-mind-map 安全执行器不允许 `EXPORT`,默认路径可能显示能力却执行失败。 | `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts:65` 将 `export` 映射为 runtimeCommand `EXPORT`;`wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts:119-129` 的 `SIMPLE_MIND_MAP_SAFE_COMMANDS` 不包含 `EXPORT`。 | 要么把 `EXPORT` 加入安全命令并补 smoke,要么在 UI state 中继续禁用导出并标注为延期。 | | F-03 | 未完成 / 方向变化 | P2 | Mindmap AI 补完 route 当前直接 501,后续大段旧 Supabase/在线 AI 实现被注释;但 Rust tool registry 已登记 `mindmap_expand_node`,形成“工具存在、产品入口不可用”的断层。 | `wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts:90-97` 校验后直接返回 `501`;同文件后续注释块仍保留旧 Supabase/AI 逻辑;`rust/crates/core-protocol/src/tool.rs:231-240` 与 `rust/crates/bridge-runtime/src/lib.rs:3803-3812` 已有 `mindmap_expand_node`。 | 若该能力仍在 Phase 6/7 范围内,应按 CLI/Rust bridge 路线重接;若延期,应把 route 标成 retired/debug,避免前端或测试误以为可用。 | -| F-04 | 方向变化 / 文档滞后 | P2 | AI 主 Web route 曾收口到 `mnote-cli` host,但新主线已改为 Hermes 页面内客户端;`wolai-backend` 仍暴露 `openai_agents_python` 文档 agent route 与旧工具面,旧 route / sidecar / host 的退场关系需重新明确。 | `wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 曾明确拒绝 codex/hermes/claudecode 并进入 `startMnoteCliAgentHostRun`;`wolai-backend/app/routers/ai_agent.py:34-57` 仍暴露 `/ai-agent/health`、`/ai-agent/document/run`,health 返回 `bridge: openai_agents_python`;`wolai-backend/app/services/ai_document_agent.py:1174-1470` 仍指令 agent 使用 `doc_insert_blocks`、`doc_replace_range`、`slash_run`。 | 以 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 为上位依据,明确页面 AI 改走 Hermes client proxy,mnote 能力通过 Hermes skill/plugin 暴露;`openai-agents-python` 和 `mnote-cli host` 只保留为历史/兼容/内部适配路径并补退场计划。 | +| F-04 | 方向变化 / 文档滞后 | P2 | AI 主 Web route 曾收口到 `mnote-cli` host,但新主线已改为 Hermes 页面内客户端;`wolai-backend` 仍暴露 `openai_agents_python` 文档 agent route 与旧工具面,旧 route / sidecar / host 的退场关系需重新明确。 | `wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 曾明确拒绝 codex/hermes/claudecode 并进入 `startMnoteCliAgentHostRun`;`wolai-backend/app/routers/ai_agent.py:34-57` 仍暴露 `/ai-agent/health`、`/ai-agent/document/run`,health 返回 `bridge: openai_agents_python`;`wolai-backend/app/services/ai_document_agent.py:1174-1470` 仍指令 agent 使用 `doc_insert_blocks`、`doc_replace_range`、`slash_run`。 | 以 `design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 为上位依据,明确页面 AI 改走 Hermes client proxy,mnote 能力通过 Hermes skill/plugin 暴露;`openai-agents-python` 和 `mnote-cli host` 只保留为历史/兼容/内部适配路径并补退场计划。 | + +2026-05-14 复核:F-04 的长期方向已由 `design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` 继续落地到 mnote-web Hermes client proxy 与 mnote Hermes tool routes;旧 `/api/ai-agent/run` 已在 mnote-web 中收口为 `legacy_ai_agent_run_retired` guard,旧 `openai-agents-python` / `mnote-cli host` 只按历史/兼容/对照链处理,不再代表页面 AI 长期主入口。 | F-05 | 文档治理风险 | P3 | `design/90-reference` 符合“参考资料”目录定位,但内容仍带有问答式残留,容易被后续 worker 误用为正式设计结论。 | `design/README.md` 明确 `90-reference/` 不参与 process/done 状态判断;`design/90-reference/90-1-filetree.md` 末尾保留“需要我给你...”类对话尾巴;`design/90-reference/90-2-yemianshu.md` 同样保留示例请求口吻。 | 低优先级清理为中性参考笔记,并在引用时强制以 `ARCHITECTURE.md` 和主线设计为上位依据。 | ## 证据 @@ -32,7 +34,7 @@ ### AI - 旧 CLI-first 入口曾基本对齐:`wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 将默认执行入口限定到 `mnote-cli host`,拒绝旧 provider;但该口径已被 2026-05-13 的 Hermes 面板主线覆盖。 -- 结构化 artifact 设计应继续保留对象模型,但触发方改为 Hermes tool call -> mnote plugin -> Rust runtime / kernel,见 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 与 `design/07-ai/process/7-2-phase7-structured-artifact-write-chain-v1.md`。 +- 结构化 artifact 设计应继续保留对象模型,但触发方改为 Hermes tool call -> mnote plugin -> Rust runtime / kernel,见 `design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 与 `design/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md`。 - 旧后端 agent 仍存在:`wolai-backend/app/routers/ai_agent.py:47-57` 仍提供 streaming run;这只能作为历史/兼容/对照链,不能被误认为长期默认主编排。 ### OnlyOffice @@ -56,7 +58,7 @@ 1. P1:补齐或明确代理 OnlyOffice Rust callback/forcesave 写回链,避免主入口下保存成功但内容未持久化。 2. P2:收口 Mindmap action 可用性,先修 `export` 映射与安全命令不一致,再继续扩展 UI 能力。 3. P2:处理 Mindmap AI route:要么按 Hermes plugin / Rust bridge 重接 `mindmap_expand_node`,要么显式退役该 Next route。 -4. P2:明确 `wolai-backend` AI agent、旧 `/api/ai-agent/run` 与 `mnote-cli host` 的历史/兼容定位和访问边界,避免与 Hermes 面板 + mnote plugin 新主线冲突。 +4. P2:继续保持 `wolai-backend` AI agent、旧 `/api/ai-agent/run` 与 `mnote-cli host` 的历史/兼容定位和访问边界;页面 AI 长期主链已经由 `7-4` 收口到 Hermes 面板 + mnote plugin,后续只在各自 legacy domain 里拆迁移。 5. P3:清理 `design/90-reference` 的问答残留,并在引用规范里再次强调它不是 process/done 设计稿。 ## 本次修改文件 diff --git a/design/10-review/06-execution-checklist-and-acceptance.md b/design/10-review/06-execution-checklist-and-acceptance.md index 3de5994b..a46c1dcd 100644 --- a/design/10-review/06-execution-checklist-and-acceptance.md +++ b/design/10-review/06-execution-checklist-and-acceptance.md @@ -296,9 +296,9 @@ - `wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx`:`PageAggregateAiSnapshot` 增加 `pageSubtreeSource`。 - `wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx`:AI request context 增加 `pageSubtreeSource`。 - `wolai-frontend/src/components/editor/page-aggregate-client-state.test.ts`:覆盖本地正文/标题变化后生成临时 `pageSubtree`,以及 server snapshot 下 `pageSubtreeSource=server`。 -- `rust/crates/mnote-web/src/ssr/pages/layout.rs`:Rust 3000 全局浮动页面 AI 在发送 `/api/ai-agent/run` 前读取当前 Leptos/Tiptap 编辑器 DOM;当本地内容与 server aggregate 正文不同,生成 `source=local` 的临时 subtree / outline / evidence,并在 context 中发送 `pageSubtreeSource=local`。 +- `rust/crates/mnote-web/src/ssr/pages/layout.rs`:历史 Rust 3000 全局浮动页面 AI 曾在发送旧 `/api/ai-agent/run` 前读取当前 Leptos/Tiptap 编辑器 DOM;当本地内容与 server aggregate 正文不同,生成 `source=local` 的临时 subtree / outline / evidence,并在 context 中发送 `pageSubtreeSource=local`。2026-05-14 起这只保留为历史上下文证据;当前页面 AI 主链已改为 Hermes client proxy,页面上下文进入 Hermes run/session context,最新页面事实由 Hermes 通过 mnote plugin tool 回读。 - 已通过:`pnpm test -- src/components/editor/page-aggregate-client-state.test.ts src/components/editor/DocumentAiAgentPanel.runtime.test.tsx src/components/editor/document-content.test.ts`(Vitest 实际执行 111 个测试文件、454 个测试)。 -- 已通过:`node scripts/task178-page-ai-local-subtree-context-smoke.js`,Rust 3000 下打开真实文档页,把编辑器内容改成本地 heading 后打开页面 AI,并拦截 `/api/ai-agent/run` 请求确认 `context.pageSubtreeSource=local`、`documentBlocks` / `outline` / `subtree.stats.headingCount` 均包含本地最新 heading。 +- 已通过:`node scripts/task178-page-ai-local-subtree-context-smoke.js`,Rust 3000 下打开真实文档页,把编辑器内容改成本地 heading 后打开页面 AI,并拦截旧 `/api/ai-agent/run` 请求确认 `context.pageSubtreeSource=local`、`documentBlocks` / `outline` / `subtree.stats.headingCount` 均包含本地最新 heading。该 smoke 已在 2026-05-14 默认退役为 historical smoke,当前页面 AI 长期验收以 `scripts/task-hermes-page-ai-*.js` 矩阵为准。 - 已通过:`pnpm exec eslint src/components/editor/page-aggregate-client-state.ts src/components/editor/page-aggregate-client-state.test.ts src/components/editor/DocumentAiAgentPanel.tsx src/components/editor/DocumentAiAgentPanel.runtime.tsx` 无错误;`DocumentAiAgentPanel.runtime.tsx` 保留既有 `any` warning。 ## 8. P2:清理或标注 stale mapping / legacy compat @@ -372,7 +372,7 @@ - `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts`:`export` 改为 `localView`,不再下发 `runtimeCommand: "EXPORT"`。 - `wolai-frontend/src/lib/mindmap/mindmap-ui-state.ts`:`unsupportedActionIds` 继续包含 `export`,UI state 中该入口保持禁用。 -- `wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts`:旧 Next route 返回 410,`x-mnote-compat-boundary=mindmap-expand-node-route-retired`,并指向 `/api/ai-agent/run` + `mindmap_expand_node`。 +- `wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts`:旧 Next route 返回 410,`x-mnote-compat-boundary=mindmap-expand-node-route-retired`。该退役说明不再指向旧 `/api/ai-agent/run` 作为长期承接面;后续若恢复 Mindmap AI,应按 Mindmap domain 另拆 Hermes plugin / Rust bridge 迁移。 - `wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx`:legacy 侧栏中调用 `/api/mindmap-ai/expand-node` 的补完入口通过 `MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED=false` 从渲染层关闭。 - 已通过:`pnpm test -- src/lib/mindmap/mindmap-action-map.test.ts src/lib/mindmap/mindmap-ui-state.test.ts src/lib/mindmap/simple-mind-map-bridge.test.ts src/app/api/mindmap-ai/expand-node/route.test.ts`(Vitest 实际执行 112 个测试文件、457 个测试)。 - 已通过:`pnpm exec eslint src/lib/mindmap/mindmap-action-map.ts src/lib/mindmap/mindmap-action-map.test.ts src/lib/mindmap/mindmap-ui-state.test.ts src/app/api/mindmap-ai/expand-node/route.ts src/app/api/mindmap-ai/expand-node/route.test.ts src/components/editor/blocks/MindmapSidebar.tsx` 无错误;保留 `MindmapSidebar.tsx` 既有 `AiPanel` 未使用 warning。 @@ -419,7 +419,7 @@ - `design/03-rust-web/done/3-15-runtime-fallback-retirement-checklist-v1.md`:从 `process/` 迁入 `done/`,文件自身 checkbox 已全部完成,且与第 2 / 第 8 项 fallback、compat 退场证据一致。 - `design/06-mindmap/done/6-mindmap-phase6-kmind-parity-detail-checklist-v1.md`:从 `process/` 迁入 `done/`,文件自身 checkbox 已全部完成,并已有 `task167-mindmap-kmind-parity-smoke.js` 等 Phase 6 KMind parity 证据。 - 已验证:`rg -n "需要我给你|我可以|你要不要|是否需要|请告诉我|如果你愿意|要我|我来" design/90-reference` 无匹配。 -- 已完成只读状态审计:`design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`、`design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`、`design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`、`design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`、`design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md`、`design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 仍有未完成项或长期主线尾项,继续保留在 `process/`;`design/old/**` 不纳入活跃 process/done 迁移判断。 +- 已完成只读状态审计:`design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`、`design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`、`design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`、`design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`、`design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md` 仍有未完成项或长期主线尾项,继续保留在 `process/`;`design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`、`design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md`、`design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md`、`design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md` 已迁入 `done/`,后续活跃 AI 体验面由 `design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md` 承接;`design/old/**` 不纳入活跃 process/done 迁移判断。 ## 11. 全局 Done Gate diff --git a/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md b/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md index bb1116af..47336863 100644 --- a/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md +++ b/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md @@ -4,7 +4,7 @@ > > 回收说明(2026-05-13): > - 本稿的 `mnote-cli` 唯一长期 agent 执行面口径已被 -> `/mnt/Data1T/mnote/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` +> `/mnt/Data1T/mnote/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` > 覆盖。 > - 后续长期方向改为:页面 AI 面板只是 Hermes 的页面内客户端; > Hermes session/message/tool event/usage/model 才是会话真相; diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index cedd65b3..20976c13 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -7376,10 +7376,10 @@ fn build_sidebar_kernel_nodes( } fn build_sidebar_kernel_edges(nodes: &[KernelNode]) -> Vec { - nodes - .iter() - .filter_map(|node| { - node.parent_id.as_ref().map(|parent_id| KernelEdge { + let mut edges = Vec::new(); + for node in nodes { + if let Some(parent_id) = node.parent_id.as_ref() { + edges.push(KernelEdge { id: format!("edge_parent_of_{}_{}", parent_id, node.id), edge_type: KernelEdgeType::ParentOf, workspace_id: node.workspace_id.clone(), @@ -7387,9 +7387,58 @@ fn build_sidebar_kernel_edges(nodes: &[KernelNode]) -> Vec { to_node_id: node.id.clone(), metadata: BTreeMap::new(), audit: KernelAuditStamp::default(), - }) + }); + if let Some(artifact_type) = ai_artifact_type_for_node(node) { + edges.push(KernelEdge { + id: format!("edge_ai_artifact_reference_{}_{}", parent_id, node.id), + edge_type: KernelEdgeType::SourceOf, + workspace_id: node.workspace_id.clone(), + from_node_id: parent_id.clone(), + to_node_id: node.id.clone(), + metadata: BTreeMap::from([ + ("kind".into(), json!("ai_artifact_reference")), + ("artifactType".into(), json!(artifact_type)), + ("projectionOnlyGroup".into(), json!("AI Artifacts")), + ]), + audit: KernelAuditStamp::default(), + }); + } + } + } + edges +} + +fn ai_artifact_type_for_node(node: &KernelNode) -> Option<&'static str> { + let parent_id = node.parent_id.as_ref()?; + if node.id == format!("summary_{parent_id}") { + return Some("summary"); + } + if node.id.starts_with(&format!("ai_note_{parent_id}_")) { + return Some("ai_note"); + } + None +} + +fn ai_artifacts_projection_group_meta(edges: &[KernelEdge]) -> Value { + let artifact_document_ids = edges + .iter() + .filter(|edge| { + edge.metadata + .get("kind") + .and_then(Value::as_str) + .map(|kind| kind == "ai_artifact_reference") + .unwrap_or(false) }) - .collect() + .map(|edge| edge.to_node_id.clone()) + .collect::>(); + json!({ + "title": "AI Artifacts", + "projectionOnly": true, + "source": "kernel.project_view.synthetic_group", + "kernelNodeId": Value::Null, + "edgeKind": "ai_artifact_reference", + "artifactDocumentIds": artifact_document_ids, + }) } fn kernel_node_sort_order(node: &KernelNode) -> i64 { @@ -7979,6 +8028,36 @@ fn build_file_tree_projection_result( let visible_rows = items.len(); let visible_edges = edges.len(); + let mut meta = BTreeMap::from([( + "search".into(), + json!({ + "query": requested_query.clone(), + "maxResults": requested_max_results, + "maxResultsRule": "matches_only_before_ancestor_completion", + "ancestorCompletion": "include_all_ancestors_after_match_truncation", + "ordering": "kernel_file_tree_preorder", + "indexingVisibility": { + "schema": "mnote.file_tree.indexing_visibility", + "schemaVersion": 1, + "source": "kernel.project_view", + "status": "visible", + "requestKey": requested_query + .as_ref() + .map(|query| format!("{}:{query}", root_node_id.unwrap_or("root"))), + "indexedResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"], + "visibleResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"], + "metrics": { + "visibleRows": visible_rows, + "visibleEdges": visible_edges, + }, + }, + }), + )]); + meta.insert( + "aiArtifacts".into(), + ai_artifacts_projection_group_meta(&edges), + ); + KernelProjectionResult { projection_id: format!( "kernel_projection:file_tree:{}", @@ -7988,31 +8067,7 @@ fn build_file_tree_projection_result( root_node_id: root_node_id.map(ToOwned::to_owned), items, edges, - meta: BTreeMap::from([( - "search".into(), - json!({ - "query": requested_query.clone(), - "maxResults": requested_max_results, - "maxResultsRule": "matches_only_before_ancestor_completion", - "ancestorCompletion": "include_all_ancestors_after_match_truncation", - "ordering": "kernel_file_tree_preorder", - "indexingVisibility": { - "schema": "mnote.file_tree.indexing_visibility", - "schemaVersion": 1, - "source": "kernel.project_view", - "status": "visible", - "requestKey": requested_query - .as_ref() - .map(|query| format!("{}:{query}", root_node_id.unwrap_or("root"))), - "indexedResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"], - "visibleResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"], - "metrics": { - "visibleRows": visible_rows, - "visibleEdges": visible_edges, - }, - }, - }), - )]), + meta, } } @@ -17443,6 +17498,138 @@ mod tests { assert_eq!(result["edges"].as_array().map(Vec::len), Some(1)); } + #[test] + fn kernel_edges_list_exposes_ai_artifact_reference_edges() { + let result = execute_runtime_query(RuntimeInput::Query { + context: demo_context(), + query: RuntimeQueryEnvelopeWire { + name: "kernel.edges.list".into(), + payload: json!({ + "workspaceId": "ws_1", + "nodeId": "page_root", + "direction": "both", + }), + }, + data: Some(json!({ + "documents": [ + { + "id": "page_root", + "workspace_id": "ws_1", + "title": "根页面", + "parent_id": null, + "sort_order": 0, + "is_starred": true, + "is_template": false, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" + }, + { + "id": "summary_page_root", + "workspace_id": "ws_1", + "title": "AI Summary", + "parent_id": "page_root", + "sort_order": 1, + "is_starred": false, + "is_template": false, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" + }, + { + "id": "ai_note_page_root_req_1", + "workspace_id": "ws_1", + "title": "AI Note", + "parent_id": "page_root", + "sort_order": 2, + "is_starred": false, + "is_template": false, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" + } + ] + })), + }) + .expect("kernel edge list should build"); + + let edges = result["edges"].as_array().expect("edges should be array"); + let artifact_edges = edges + .iter() + .filter(|edge| edge["metadata"]["kind"] == json!("ai_artifact_reference")) + .collect::>(); + + assert_eq!(artifact_edges.len(), 2); + assert_eq!(artifact_edges[0]["fromNodeId"], json!("page_root")); + assert_eq!(artifact_edges[0]["toNodeId"], json!("summary_page_root")); + assert_eq!(artifact_edges[0]["edgeType"], json!("source_of")); + assert_eq!(artifact_edges[0]["metadata"]["artifactType"], json!("summary")); + assert_eq!(artifact_edges[1]["toNodeId"], json!("ai_note_page_root_req_1")); + assert_eq!(artifact_edges[1]["metadata"]["artifactType"], json!("ai_note")); + } + + #[test] + fn file_tree_projection_marks_ai_artifacts_group_as_projection_only() { + let result = execute_runtime_query(RuntimeInput::Query { + context: demo_context(), + query: RuntimeQueryEnvelopeWire { + name: "kernel.project_view".into(), + payload: json!({ + "projection": "file_tree", + "workspaceId": "ws_1", + "rootNodeId": "page_root", + "depth": 2, + "includeEdges": true, + "nodeTypes": ["page"], + }), + }, + data: Some(json!({ + "documents": [ + { + "id": "page_root", + "workspace_id": "ws_1", + "title": "根页面", + "parent_id": null, + "sort_order": 0, + "is_starred": true, + "is_template": false, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" + }, + { + "id": "summary_page_root", + "workspace_id": "ws_1", + "title": "AI Summary", + "parent_id": "page_root", + "sort_order": 1, + "is_starred": false, + "is_template": false, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" + } + ], + "media_assets": [], + "mindmap_assets": [], + "table_assets": [], + "mindmap_asset_children": {} + })), + }) + .expect("file tree projection should build"); + + let items = result["items"].as_array().expect("items should be array"); + assert!(!items + .iter() + .any(|item| item["nodeId"] == json!("AI Artifacts"))); + assert_eq!(result["meta"]["aiArtifacts"]["title"], json!("AI Artifacts")); + assert_eq!(result["meta"]["aiArtifacts"]["projectionOnly"], json!(true)); + assert_eq!( + result["meta"]["aiArtifacts"]["source"], + json!("kernel.project_view.synthetic_group") + ); + assert_eq!(result["meta"]["aiArtifacts"]["kernelNodeId"], Value::Null); + assert_eq!( + result["meta"]["aiArtifacts"]["artifactDocumentIds"], + json!(["summary_page_root"]) + ); + } + #[test] fn index_rebuild_tool_executes_in_rust_runtime() { let result = execute_runtime_query(RuntimeInput::Tool { diff --git a/rust/crates/mnote-web/src/hermes_tools/artifact.rs b/rust/crates/mnote-web/src/hermes_tools/artifact.rs new file mode 100644 index 00000000..c70ab261 --- /dev/null +++ b/rust/crates/mnote-web/src/hermes_tools/artifact.rs @@ -0,0 +1,185 @@ +use crate::app::AppState; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::hermes_tools::ToolCallInput; +use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts; +use bridge_runtime::{ + RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, +}; +use serde_json::{json, Value}; + +pub async fn create_summary( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + create_artifact_node( + state, + context, + input, + "summary", + "mnote.artifact.create_summary", + ) + .await +} + +pub async fn create_ai_note( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + create_artifact_node( + state, + context, + input, + "ai_note", + "mnote.artifact.create_ai_note", + ) + .await +} + +async fn create_artifact_node( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, + node_type: &str, + tool_name: &str, +) -> Result { + ensure_write_contract(context, input)?; + let document_id = input.effective_document_id().ok_or_else(|| { + WebError::bad_request_code("mnote_tool_bad_request", "artifact 工具缺少 documentId") + .with_context(context) + })?; + let workspace_id = input.effective_workspace_id(); + let content = input + .arg_string("summary") + .or_else(|| input.arg_string("content")) + .ok_or_else(|| { + WebError::bad_request_code("mnote_tool_bad_request", "artifact 工具缺少内容") + .with_context(context) + })?; + let idempotency_key = + input.idempotency_key_or_default(&format!("{tool_name}_{}", context.trace.request_id)); + let command_id = format!( + "{}_{}", + tool_name.replace('.', "_"), + context.trace.request_id + ); + let artifact_document_id = if node_type == "summary" { + format!("summary_{}", document_id) + } else { + format!("ai_note_{}_{}", document_id, context.trace.request_id) + }; + + if input.dry_run.unwrap_or(false) { + return Ok(json!({ + "dryRun": true, + "commandName": "tree.node.create", + "commandId": command_id, + "artifactType": node_type, + "artifactDocumentId": artifact_document_id, + "documentId": document_id, + "workspaceId": workspace_id, + "diff": [{"op": "create_artifact", "artifactType": node_type}] + })); + } + + let command = RuntimeCommandEnvelopeWire { + name: "tree.node.create".into(), + command_id: command_id.clone(), + idempotency_key: Some(idempotency_key), + actor: RuntimeActorWire { + actor_type: context.auth.actor_type.clone(), + actor_id: context.auth.actor_id.clone(), + session_id: input + .session_id + .clone() + .or_else(|| context.auth.session_id.clone()), + }, + source: RuntimeSourceWire { + channel: "hermes".into(), + client: "mnote-hermes-plugin".into(), + source_kind: None, + root_uri: None, + workspace_id: workspace_id.clone(), + capabilities: input.capability_scope.clone().unwrap_or_default(), + }, + target: Some(RuntimeTargetWire { + workspace_id: workspace_id.clone(), + page_id: Some(document_id.clone()), + block_id: None, + }), + payload: json!({ + "workspaceId": workspace_id, + "parentId": document_id, + "documentId": artifact_document_id, + "accessScope": "private", + "nodeType": node_type, + "title": if node_type == "summary" { "AI Summary" } else { "AI Note" }, + "content": [ + { + "id": format!("{}_body", node_type), + "type": "paragraph", + "content": [{"type": "text", "text": content}] + } + ], + "artifact": { + "kind": node_type, + "sourceDocumentId": document_id, + "source": "hermes", + "sessionId": input.session_id, + "runId": input.run_id, + "toolCallId": input.tool_call_id, + "traceId": input.effective_trace_id(&context.trace.trace_id) + }, + "referenceEdge": { + "from": document_id, + "kind": "ai_artifact_reference" + } + }), + preflight_data: None, + reason: Some(tool_name.into()), + refs: vec![tool_name.into(), "hermes-tool-call".into()], + dry_run: false, + validate_only: false, + }; + let execution = execute_runtime_command_via_convex_with_artifacts( + state.config(), + context, + workspace_id.as_deref(), + command, + ) + .await?; + Ok(json!({ + "commandName": "tree.node.create", + "commandId": command_id, + "artifactType": node_type, + "artifactDocumentId": artifact_document_id, + "referenceEdge": { + "from": document_id, + "to": artifact_document_id, + "kind": "ai_artifact_reference" + }, + "result": execution.result, + "artifacts": execution.artifacts, + "artifactError": execution.artifact_error + })) +} + +fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> { + if !input.has_idempotency_key() { + return Err(WebError::bad_request_code( + "mnote_tool_idempotency_required", + "写入型 mnote Hermes tool 必须携带 idempotencyKey", + ) + .with_context(context)); + } + if input.dry_run.is_none() { + return Err(WebError::bad_request_code( + "mnote_tool_dry_run_required", + "写入型 mnote Hermes tool 必须显式携带 dryRun", + ) + .with_context(context)); + } + Ok(()) +} diff --git a/rust/crates/mnote-web/src/hermes_tools/manifest.rs b/rust/crates/mnote-web/src/hermes_tools/manifest.rs new file mode 100644 index 00000000..b9857ccb --- /dev/null +++ b/rust/crates/mnote-web/src/hermes_tools/manifest.rs @@ -0,0 +1,58 @@ +use serde_json::{json, Value}; + +pub const MANIFEST_SCHEMA_VERSION: &str = "mnote.hermes_tool_manifest.v1"; +pub const TOOL_SCHEMA_VERSION: &str = "mnote.hermes_tool.v1"; + +pub fn manifest() -> Value { + json!({ + "schemaVersion": MANIFEST_SCHEMA_VERSION, + "plugin": { + "name": "mnote", + "description": "mnote 页面、树、artifact 与 edge 工具", + "runtimeOwner": "mnote-web", + "writeOwner": "rust-runtime-kernel" + }, + "tools": [ + page_get_tool(), + planned_tool("mnote.page.save", ["page.write"]), + planned_tool("mnote.page.update_title", ["page.write"]), + planned_tool("mnote.page.update_options", ["page.write"]), + planned_tool("mnote.artifact.create_summary", ["artifact.write"]), + planned_tool("mnote.artifact.create_ai_note", ["artifact.write"]) + ] + }) +} + +fn page_get_tool() -> Value { + json!({ + "name": "mnote.page.get", + "description": "读取当前页面 Page Aggregate 摘要", + "schemaVersion": TOOL_SCHEMA_VERSION, + "capabilityScope": ["page.read"], + "inputSchema": { + "type": "object", + "required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId"], + "properties": { + "workspaceId": { "type": "string" }, + "documentId": { "type": "string" }, + "sessionId": { "type": "string" }, + "runId": { "type": "string" }, + "toolCallId": { "type": "string" }, + "traceId": { "type": "string" }, + "includeBody": { "type": "boolean", "default": true }, + "includeOptions": { "type": "boolean", "default": true }, + "includeBlocks": { "type": "boolean", "default": true } + } + } + }) +} + +fn planned_tool(name: &str, scope: impl IntoIterator) -> Value { + json!({ + "name": name, + "description": "已冻结合同,按 7-4 后续 task 接入 Rust runtime / kernel", + "schemaVersion": TOOL_SCHEMA_VERSION, + "capabilityScope": scope.into_iter().collect::>(), + "status": "planned" + }) +} diff --git a/rust/crates/mnote-web/src/hermes_tools/mod.rs b/rust/crates/mnote-web/src/hermes_tools/mod.rs new file mode 100644 index 00000000..d3a591f1 --- /dev/null +++ b/rust/crates/mnote-web/src/hermes_tools/mod.rs @@ -0,0 +1,91 @@ +pub mod artifact; +pub mod manifest; +pub mod page; + +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallInput { + pub tool_name: String, + pub workspace_id: Option, + pub document_id: Option, + pub actor_id: Option, + pub session_id: Option, + pub run_id: Option, + pub tool_call_id: Option, + pub trace_id: Option, + pub idempotency_key: Option, + pub dry_run: Option, + pub capability_scope: Option>, + pub args: Option, +} + +impl ToolCallInput { + pub fn arg_string(&self, key: &str) -> Option { + self.args + .as_ref() + .and_then(|args| args.get(key)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + } + + pub fn arg_value(&self, key: &str) -> Option { + self.args.as_ref().and_then(|args| args.get(key)).cloned() + } + + pub fn effective_workspace_id(&self) -> Option { + self.workspace_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| self.arg_string("workspaceId")) + } + + pub fn effective_document_id(&self) -> Option { + self.document_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| self.arg_string("documentId")) + } + + pub fn effective_tool_call_id(&self) -> String { + self.tool_call_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("tool_call_missing") + .to_string() + } + + pub fn effective_trace_id<'a>(&'a self, fallback: &'a str) -> &'a str { + self.trace_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(fallback) + } + + pub fn idempotency_key_or_default(&self, fallback: &str) -> String { + self.idempotency_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(fallback) + .to_string() + } + + pub fn has_idempotency_key(&self) -> bool { + self.idempotency_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + } +} diff --git a/rust/crates/mnote-web/src/hermes_tools/page.rs b/rust/crates/mnote-web/src/hermes_tools/page.rs new file mode 100644 index 00000000..e58f79d9 --- /dev/null +++ b/rust/crates/mnote-web/src/hermes_tools/page.rs @@ -0,0 +1,341 @@ +use crate::app::AppState; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::hermes_tools::ToolCallInput; +use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts; +use crate::routes::web_shell::build_page_aggregate_snapshot; +use bridge_runtime::{ + RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, +}; +use serde_json::{json, Value}; + +pub async fn page_get( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + let document_id = input.effective_document_id().ok_or_else(|| { + WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.get 缺少 documentId") + .with_context(context) + })?; + let workspace_id = input.effective_workspace_id(); + let aggregate = build_page_aggregate_snapshot( + state, + context, + &document_id, + workspace_id.as_deref(), + None, + None, + ) + .await?; + let aggregate_value = + serde_json::to_value(&aggregate).map_err(|error| WebError::internal(error.to_string()))?; + let title = aggregate_value + .pointer("/head/title") + .and_then(Value::as_str) + .unwrap_or("无标题"); + let content = aggregate_value + .pointer("/body/content") + .cloned() + .unwrap_or(Value::Null); + let page_options = aggregate_value + .pointer("/layout/pageOptions") + .or_else(|| aggregate_value.pointer("/layout/page_options")) + .cloned() + .unwrap_or_else(|| json!({})); + let blocks = summarize_blocks(&content); + let body_summary = blocks + .iter() + .filter_map(|block| block.get("text").and_then(Value::as_str)) + .filter(|text| !text.trim().is_empty()) + .take(8) + .collect::>() + .join("\n"); + + Ok(json!({ + "documentId": document_id, + "workspaceId": workspace_id, + "title": title, + "bodySummary": body_summary, + "pageOptions": page_options, + "blocks": blocks, + "aggregateSchema": aggregate_value.get("schema").cloned().unwrap_or(Value::Null), + "aggregateSource": aggregate_value.get("source").cloned().unwrap_or(Value::Null) + })) +} + +fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> { + if !input.has_idempotency_key() { + return Err(WebError::bad_request_code( + "mnote_tool_idempotency_required", + "写入型 mnote Hermes tool 必须携带 idempotencyKey", + ) + .with_context(context)); + } + if input.dry_run.is_none() { + return Err(WebError::bad_request_code( + "mnote_tool_dry_run_required", + "写入型 mnote Hermes tool 必须显式携带 dryRun", + ) + .with_context(context)); + } + Ok(()) +} + +pub async fn page_save( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + let content = input.arg_value("content").ok_or_else(|| { + WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.save 缺少 content") + .with_context(context) + })?; + page_command( + state, + context, + input, + "page.body.save", + json!({ + "content": content, + "mode": input.arg_string("mode").unwrap_or_else(|| "replace".into()) + }), + None, + ) + .await +} + +pub async fn update_title( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + let title = input.arg_string("title").ok_or_else(|| { + WebError::bad_request_code( + "mnote_tool_bad_request", + "mnote.page.update_title 缺少 title", + ) + .with_context(context) + })?; + page_command( + state, + context, + input, + "page.head.updateTitle", + json!({ "title": title }), + None, + ) + .await +} + +pub async fn update_options( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + let options = input.arg_value("options").ok_or_else(|| { + WebError::bad_request_code( + "mnote_tool_bad_request", + "mnote.page.update_options 缺少 options", + ) + .with_context(context) + })?; + let (wired_options, ignored_options, warnings) = filter_wired_page_options(options); + page_command( + state, + context, + input, + "page.layout.updateOptions", + json!({ "options": wired_options }), + Some(json!({ + "ignoredOptions": ignored_options, + "warnings": warnings + })), + ) + .await +} + +async fn page_command( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, + command_name: &str, + payload_patch: Value, + result_extra: Option, +) -> Result { + ensure_write_contract(context, input)?; + let document_id = input.effective_document_id().ok_or_else(|| { + WebError::bad_request_code("mnote_tool_bad_request", "页面写工具缺少 documentId") + .with_context(context) + })?; + let workspace_id = input.effective_workspace_id(); + let command_id = format!( + "{}_{}", + command_name.replace('.', "_"), + context.trace.request_id + ); + let idempotency_key = input.idempotency_key_or_default(&command_id); + let payload = merge_page_payload(&document_id, workspace_id.as_deref(), payload_patch); + + if input.dry_run.unwrap_or(false) { + let mut result = json!({ + "dryRun": true, + "commandName": command_name, + "commandId": command_id, + "documentId": document_id, + "workspaceId": workspace_id, + "diff": [{"op": command_name, "payload": payload}] + }); + merge_result_extra(&mut result, result_extra); + return Ok(result); + } + + let command = RuntimeCommandEnvelopeWire { + name: command_name.into(), + command_id: command_id.clone(), + idempotency_key: Some(idempotency_key), + actor: RuntimeActorWire { + actor_type: context.auth.actor_type.clone(), + actor_id: context.auth.actor_id.clone(), + session_id: input + .session_id + .clone() + .or_else(|| context.auth.session_id.clone()), + }, + source: RuntimeSourceWire { + channel: "hermes".into(), + client: "mnote-hermes-plugin".into(), + source_kind: None, + root_uri: None, + workspace_id: workspace_id.clone(), + capabilities: input.capability_scope.clone().unwrap_or_default(), + }, + target: Some(RuntimeTargetWire { + workspace_id: workspace_id.clone(), + page_id: Some(document_id.clone()), + block_id: None, + }), + payload, + preflight_data: None, + reason: Some(format!("Hermes tool {command_name}")), + refs: vec![command_name.into(), "hermes-tool-call".into()], + dry_run: false, + validate_only: false, + }; + let execution = execute_runtime_command_via_convex_with_artifacts( + state.config(), + context, + workspace_id.as_deref(), + command, + ) + .await?; + let mut result = json!({ + "commandName": command_name, + "commandId": command_id, + "result": execution.result, + "artifacts": execution.artifacts, + "artifactError": execution.artifact_error + }); + merge_result_extra(&mut result, result_extra); + Ok(result) +} + +fn merge_result_extra(result: &mut Value, extra: Option) { + if let (Value::Object(result_map), Some(Value::Object(extra_map))) = (result, extra) { + for (key, value) in extra_map { + result_map.insert(key, value); + } + } +} + +fn merge_page_payload(document_id: &str, workspace_id: Option<&str>, patch: Value) -> Value { + let mut payload = json!({ + "documentId": document_id, + "workspaceId": workspace_id + }); + if let (Value::Object(base), Value::Object(extra)) = (&mut payload, patch) { + for (key, value) in extra { + base.insert(key, value); + } + } + payload +} + +fn filter_wired_page_options(options: Value) -> (Value, Vec, Vec) { + let allowed = ["wideLayout", "smallText", "showToc", "protectEditing"]; + let mut out = serde_json::Map::new(); + let mut ignored = Vec::new(); + let mut warnings = Vec::new(); + if let Value::Object(map) = options { + for (key, value) in map { + if allowed.contains(&key.as_str()) { + out.insert(key, value); + } else { + warnings.push(json!({ + "code": "page_option_not_wired", + "field": key.clone(), + "message": "页面设置字段尚未接入 Page Aggregate command,已忽略" + })); + ignored.push(key); + } + } + } + (Value::Object(out), ignored, warnings) +} + +fn summarize_blocks(content: &Value) -> Vec { + let mut out = Vec::new(); + collect_blocks(content, &mut out); + out.into_iter().take(40).collect() +} + +fn collect_blocks(value: &Value, out: &mut Vec) { + match value { + Value::Array(items) => { + for item in items { + collect_blocks(item, out); + } + } + Value::Object(map) => { + let block_id = map + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let block_type = map + .get("type") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + let text = collect_text(value); + if !block_id.is_empty() || !text.is_empty() { + out.push(json!({ + "id": block_id, + "type": block_type, + "text": text + })); + } + if let Some(children) = map.get("children") { + collect_blocks(children, out); + } + } + _ => {} + } +} + +fn collect_text(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + Value::Array(items) => items.iter().map(collect_text).collect::>().join(""), + Value::Object(map) => { + if let Some(text) = map.get("text").and_then(Value::as_str) { + return text.to_string(); + } + if let Some(content) = map.get("content") { + return collect_text(content); + } + String::new() + } + _ => String::new(), + } +} diff --git a/rust/crates/mnote-web/src/lib.rs b/rust/crates/mnote-web/src/lib.rs index 113355ac..74a5a378 100644 --- a/rust/crates/mnote-web/src/lib.rs +++ b/rust/crates/mnote-web/src/lib.rs @@ -1,6 +1,7 @@ pub mod app; pub mod context; pub mod error; +pub mod hermes_tools; pub mod local_folder_watcher_registry; pub mod middleware; pub mod page_aggregate; diff --git a/rust/crates/mnote-web/src/routes/compat.rs b/rust/crates/mnote-web/src/routes/compat.rs index f012ccd4..66acac1e 100644 --- a/rust/crates/mnote-web/src/routes/compat.rs +++ b/rust/crates/mnote-web/src/routes/compat.rs @@ -3,16 +3,12 @@ use crate::context::RequestContext; use crate::error::WebError; use axum::body::Body; use axum::extract::{Extension, State}; -use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Request, StatusCode}; -use axum::response::{IntoResponse, Response}; -use axum::Json; -use serde_json::{json, Value}; -use std::env; -use std::path::PathBuf; -use std::process::Command; +use axum::http::{Request, StatusCode}; +use axum::response::Response; +use serde_json::Value; pub async fn next_ai_agent_run( - State(state): State, + State(_state): State, Extension(context): Extension, request: Request, ) -> Result { @@ -29,19 +25,17 @@ pub async fn next_ai_agent_run( .with_header("x-mnote-web-owner", "mnote-web") })?; - if let Some(provider) = explicit_agent_provider(&payload) { - return Err(WebError::bad_gateway_code( - "ai_provider_bridge_unavailable", - format!( - "{provider} provider 直连链路已退场;当前仅保留 mnote-cli host 主路径,不再静默降级。" - ), - ) - .with_context(&context) - .with_header("x-mnote-web-owner", "mnote-web") - .with_header("x-mnote-ai-execution-owner", "provider-bridge-unavailable")); - } - - run_local_mnote_cli_ai_host(&state, &context, &payload).await + let provider = explicit_agent_provider(&payload).unwrap_or("legacy"); + Err(WebError::new( + StatusCode::GONE, + "legacy_ai_agent_run_retired", + format!( + "旧 /api/ai-agent/run 页面 AI 主链已退场,provider={provider} 不再静默降级;请使用 Hermes client proxy 与 mnote Hermes plugin。" + ), + ) + .with_context(&context) + .with_header("x-mnote-web-owner", "mnote-web") + .with_header("x-mnote-ai-execution-owner", "legacy-ai-agent-run-retired")) } fn explicit_agent_provider(payload: &Value) -> Option<&'static str> { @@ -60,205 +54,6 @@ fn explicit_agent_provider(payload: &Value) -> Option<&'static str> { } } -fn resolve_repo_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..") -} - -fn build_mnote_cli_args(context: &RequestContext, payload: &Value) -> Vec { - let ai = payload - .get("options") - .and_then(|value| value.get("ai")) - .cloned() - .unwrap_or(Value::Null); - let runtime_context = payload.get("context").cloned().unwrap_or(Value::Null); - let document_id = runtime_context - .get("documentId") - .and_then(Value::as_str) - .unwrap_or("current"); - let workspace_id = runtime_context - .get("workspaceId") - .and_then(Value::as_str) - .or(context.workspace.workspace_id.as_deref()); - let session_id = ai - .get("sessionId") - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) - .unwrap_or_else(|| format!("ai-{}", context.trace.request_id)); - let args_json = json!({ - "pageId": document_id, - "documentId": document_id, - "workspaceId": workspace_id, - "provider": ai.get("provider").cloned().unwrap_or(Value::Null), - "modelKey": ai.get("modelKey").cloned().unwrap_or(Value::Null), - "profileId": ai.get("profileId").cloned().unwrap_or(Value::Null), - "selectedUids": runtime_context.get("selectedUids").cloned().unwrap_or(Value::Null), - "pageOptions": runtime_context.get("pageOptions").cloned().unwrap_or(Value::Null), - }) - .to_string(); - - vec![ - "run".into(), - "--quiet".into(), - "--manifest-path".into(), - resolve_repo_root() - .join("rust") - .join("Cargo.toml") - .to_string_lossy() - .to_string(), - "-p".into(), - "mnote-cli".into(), - "--".into(), - "--json".into(), - "--validate-only".into(), - "--dry-run".into(), - "--actor-id".into(), - context.auth.actor_id.clone(), - "--actor-type".into(), - context.auth.actor_type.clone(), - "--session-id".into(), - session_id, - "--reason".into(), - "ai-agent-run:mnote-web-rust-host".into(), - "tool".into(), - "run".into(), - "--tool-name".into(), - "doc_get".into(), - "--kind".into(), - "query".into(), - "--mode".into(), - "explain-plan".into(), - "--args-json".into(), - args_json, - ] -} - -async fn run_local_mnote_cli_ai_host( - state: &AppState, - context: &RequestContext, - payload: &Value, -) -> Result { - let stream = payload - .get("stream") - .and_then(Value::as_bool) - .unwrap_or(true); - let args = build_mnote_cli_args(context, payload); - let repo_root = resolve_repo_root(); - let actor_id = - if context.auth.actor_id.trim().is_empty() || context.auth.actor_id == "anonymous" { - state.config().dev_user_id.clone() - } else { - context.auth.actor_id.clone() - }; - let actor_type = - if context.auth.actor_type.trim().is_empty() || context.auth.actor_type == "anonymous" { - "user".to_string() - } else { - context.auth.actor_type.clone() - }; - let dev_email = state.config().dev_user_email.clone(); - let dev_name = state.config().dev_user_name.clone(); - - let output = tokio::task::spawn_blocking(move || { - Command::new("cargo") - .args(args) - .current_dir(repo_root) - .env("CARGO_TERM_COLOR", "never") - .env( - "RUSTUP_TOOLCHAIN", - env::var("RUSTUP_TOOLCHAIN") - .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "1.89.0".into()), - ) - .env("DEV_USER_ID", actor_id) - .env("DEV_USER_EMAIL", dev_email) - .env("DEV_USER_NAME", dev_name) - .env("MNOTE_CLI_ALLOW_CREATE_PAGE", "1") - .env("MNOTE_CLI_ALLOW_EDIT", "1") - .env("MNOTE_ACTOR_TYPE", actor_type) - .output() - }) - .await - .map_err(|error| { - WebError::bad_gateway_code( - "mnote_cli_host_join_error", - format!("mnote-cli host join 失败: {error}"), - ) - .with_context(context) - .with_header("x-mnote-web-owner", "mnote-web") - })? - .map_err(|error| { - WebError::bad_gateway_code( - "mnote_cli_host_spawn_error", - format!("mnote-cli host 启动失败: {error}"), - ) - .with_context(context) - .with_header("x-mnote-web-owner", "mnote-web") - })?; - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - if !stream { - if !output.status.success() { - return Err(WebError::bad_gateway_code( - "mnote_cli_host_failed", - if stderr.is_empty() { - "mnote-cli 执行失败".into() - } else { - stderr - }, - ) - .with_context(context) - .with_header("x-mnote-web-owner", "mnote-web")); - } - let mut response = Json(json!({ - "ok": true, - "bridgeOwner": "mnote-cli", - "text": if stdout.is_empty() { "mnote-cli 无输出" } else { &stdout }, - })) - .into_response(); - stamp_owner_header(response.headers_mut()); - response.headers_mut().insert( - HeaderName::from_static("x-mnote-ai-execution-owner"), - HeaderValue::from_static("mnote-cli"), - ); - return Ok(response); - } - - let body = if output.status.success() { - format!( - "event: ready\ndata: {}\n\nevent: assistant_message\ndata: {}\n\nevent: completion\ndata: {}\n\n", - json!({"ok": true, "bridgeOwner": "mnote-cli"}).to_string(), - json!({"text": if stdout.is_empty() { "mnote-cli 无输出" } else { &stdout }}).to_string(), - json!({"ok": true, "text": if stdout.is_empty() { "mnote-cli 无输出" } else { &stdout }, "steps": 1}).to_string(), - ) - } else { - format!( - "event: ready\ndata: {}\n\nevent: error\ndata: {}\n\n", - json!({"ok": true, "bridgeOwner": "mnote-cli"}).to_string(), - json!({"ok": false, "message": if stderr.is_empty() { "mnote-cli 执行失败" } else { &stderr }}).to_string(), - ) - }; - let mut response = Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8") - .header(header::CACHE_CONTROL, "no-cache, no-transform") - .header(header::CONNECTION, "keep-alive") - .header("x-accel-buffering", "no") - .header("x-mnote-ai-execution-owner", "mnote-cli") - .body(Body::from(body)) - .map_err(|error| WebError::internal(format!("mnote-cli SSE 响应构造失败: {error}")))?; - stamp_owner_header(response.headers_mut()); - Ok(response) -} - -fn stamp_owner_header(headers: &mut HeaderMap) { - if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") { - headers.insert(name, HeaderValue::from_static("mnote-web")); - } -} - #[cfg(test)] mod tests { use crate::app::{build_app, AppConfig, AppState}; @@ -306,7 +101,7 @@ mod tests { } #[tokio::test] - async fn direct_ai_agent_run_is_owned_by_rust_web_when_next_compat_disabled() { + async fn direct_ai_agent_run_returns_legacy_retired_guard() { let response = build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), @@ -337,7 +132,7 @@ mod tests { .await .expect("response"); - assert_ne!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.status(), StatusCode::GONE); assert_eq!( response .headers() @@ -345,15 +140,23 @@ mod tests { .and_then(|value| value.to_str().ok()), Some("mnote-web") ); + assert_eq!( + response + .headers() + .get("x-mnote-ai-execution-owner") + .and_then(|value| value.to_str().ok()), + Some("legacy-ai-agent-run-retired") + ); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let text = String::from_utf8(body.to_vec()).expect("utf8"); - assert!(!text.contains("legacy_next_compat_disabled")); + assert!(text.contains("legacy_ai_agent_run_retired")); + assert!(text.contains("Hermes client proxy")); } #[tokio::test] - async fn explicit_agent_provider_does_not_fallback_to_mnote_cli_when_next_unavailable() { + async fn explicit_agent_provider_returns_legacy_retired_guard() { let response = build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), @@ -384,20 +187,20 @@ mod tests { .await .expect("response"); - assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + assert_eq!(response.status(), StatusCode::GONE); assert_eq!( response .headers() .get("x-mnote-ai-execution-owner") .and_then(|value| value.to_str().ok()), - Some("provider-bridge-unavailable") + Some("legacy-ai-agent-run-retired") ); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let text = String::from_utf8(body.to_vec()).expect("utf8"); assert!(text.contains("codex")); - assert!(text.contains("provider 直连链路已退场")); + assert!(text.contains("legacy_ai_agent_run_retired")); } #[tokio::test] @@ -451,13 +254,13 @@ mod tests { .await .expect("response"); - assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + assert_eq!(response.status(), StatusCode::GONE); assert_eq!( response .headers() .get("x-mnote-ai-execution-owner") .and_then(|value| value.to_str().ok()), - Some("provider-bridge-unavailable") + Some("legacy-ai-agent-run-retired") ); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await diff --git a/rust/crates/mnote-web/src/routes/hermes_client.rs b/rust/crates/mnote-web/src/routes/hermes_client.rs new file mode 100644 index 00000000..b5136d75 --- /dev/null +++ b/rust/crates/mnote-web/src/routes/hermes_client.rs @@ -0,0 +1,696 @@ +use crate::context::RequestContext; +use crate::error::WebError; +use axum::body::Body; +use axum::extract::{Extension, Path, Query}; +use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode}; +use axum::response::Response; +use axum::Json; +use futures_util::TryStreamExt; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::process::Command; +use std::time::Duration; + +const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; +const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateSessionRequest { + workspace_id: Option, + document_id: Option, + trace_id: Option, + title: Option, +} + +pub async fn list_sessions( + Extension(context): Extension, + Query(query): Query>, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + let Some(upstream) = configured_upstream() else { + return hermes_unconfigured(&context); + }; + let mut path = "/api/hermes/sessions".to_string(); + if !query.is_empty() { + let params = query + .iter() + .map(|(key, value)| format!("{}={}", url_escape(key), url_escape(value))) + .collect::>() + .join("&"); + path.push('?'); + path.push_str(¶ms); + } + proxy_json(&context, reqwest::Method::GET, &upstream, &path, None).await +} + +pub async fn create_session( + Extension(context): Extension, + Json(payload): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + let trace_id = payload + .trace_id + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| context.trace.trace_id.clone()); + let document_id = payload + .document_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("current"); + let session_id = stable_session_id(document_id, &trace_id); + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "sessionId": session_id, + "workspaceId": payload.workspace_id, + "documentId": payload.document_id, + "title": payload.title.unwrap_or_else(|| "当前页问答".into()), + "traceId": trace_id, + "persistence": "hermes_on_first_run" + })), + )) +} + +pub async fn get_session( + Extension(context): Extension, + Path(session_id): Path, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + if let Some(session) = load_session_from_hermes_cli(&session_id).await { + return Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "traceId": context.trace.trace_id, + "sessionId": session_id, + "session": session + })), + )); + } + let Some(upstream) = configured_upstream() else { + return hermes_unconfigured(&context); + }; + proxy_json( + &context, + reqwest::Method::GET, + &upstream, + &format!("/api/hermes/sessions/{}", url_escape(&session_id)), + None, + ) + .await +} + +async fn load_session_from_hermes_cli(session_id: &str) -> Option { + let session_id = session_id.to_string(); + tokio::task::spawn_blocking(move || { + let hermes_bin = std::env::var("MNOTE_WEB_HERMES_BIN") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "hermes".into()); + let output = Command::new(hermes_bin) + .args(["sessions", "export", "--session-id", &session_id, "-"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8(output.stdout).ok()?; + stdout + .lines() + .find_map(|line| serde_json::from_str::(line).ok()) + }) + .await + .ok() + .flatten() +} + +pub async fn create_run( + Extension(context): Extension, + Json(payload): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + let Some(upstream) = configured_upstream() else { + return hermes_unconfigured(&context); + }; + let upstream_body = build_run_upstream_body(&context, payload)?; + proxy_json( + &context, + reqwest::Method::POST, + &upstream, + "/v1/runs", + Some(upstream_body), + ) + .await +} + +pub async fn stream_events( + Extension(context): Extension, + Path(run_id): Path, +) -> Result { + ensure_authenticated(&context)?; + let Some(upstream) = configured_upstream() else { + return Err(hermes_unconfigured_error(&context)); + }; + let url = upstream_url( + &upstream, + &format!("/v1/runs/{}/events", url_escape(&run_id)), + )?; + let mut request = reqwest::Client::builder() + .timeout(Duration::from_secs(1800)) + .build() + .map_err(|error| { + WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(&context) + })? + .get(url); + if let Some(api_key) = configured_api_key() { + request = request.bearer_auth(api_key); + } + let upstream_response = request.send().await.map_err(|error| { + WebError::bad_gateway_code( + "hermes_client_upstream_unavailable", + format!("Hermes events upstream 连接失败: {error}"), + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + })?; + if !upstream_response.status().is_success() { + let status = upstream_response.status(); + let text = upstream_response.text().await.unwrap_or_default(); + return Err(upstream_error(&context, status, text)); + } + + let stream = upstream_response.bytes_stream().map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Hermes events stream 读取失败: {error}"), + ) + }); + let mut response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8") + .header(header::CACHE_CONTROL, "no-cache, no-transform") + .header("x-accel-buffering", "no") + .body(Body::from_stream(stream)) + .map_err(|error| WebError::internal(format!("Hermes events 响应构造失败: {error}")))?; + stamp_client_headers_into(response.headers_mut()); + Ok(response) +} + +pub async fn abort_run( + Extension(context): Extension, + Path(run_id): Path, + Json(payload): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + let Some(upstream) = configured_upstream() else { + return hermes_unconfigured(&context); + }; + proxy_json( + &context, + reqwest::Method::POST, + &upstream, + &format!("/v1/runs/{}/stop", url_escape(&run_id)), + Some(payload), + ) + .await +} + +pub async fn list_models( + Extension(context): Extension, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + let Some(upstream) = configured_upstream() else { + return hermes_unconfigured(&context); + }; + proxy_json( + &context, + reqwest::Method::GET, + &upstream, + "/v1/models", + None, + ) + .await +} + +pub async fn list_tools( + Extension(context): Extension, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "traceId": context.trace.trace_id, + "tools": [ + { + "name": "mnote.page.get", + "scope": "page.read", + "schemaVersion": "mnote.hermes_tool.v1", + "status": "planned_by_task_e" + } + ] + })), + )) +} + +fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> { + let has_actor = context.auth.actor_id.trim() != "anonymous"; + if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() { + return Ok(()); + } + Err(WebError::new( + StatusCode::UNAUTHORIZED, + "hermes_client_unauthorized", + "页面 AI Hermes client 需要登录后访问", + ) + .with_context(context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")) +} + +fn configured_upstream() -> Option { + std::env::var("MNOTE_WEB_HERMES_UPSTREAM_URL") + .ok() + .or_else(|| std::env::var("MNOTE_HERMES_UPSTREAM_URL").ok()) + .map(|value| value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()) +} + +fn configured_api_key() -> Option { + std::env::var("MNOTE_WEB_HERMES_API_KEY") + .ok() + .or_else(|| std::env::var("HERMES_API_SERVER_KEY").ok()) + .or_else(|| std::env::var("API_SERVER_KEY").ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result { + let message = payload + .get("message") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| { + payload + .get("messages") + .and_then(Value::as_array) + .and_then(|messages| messages.last()) + .and_then(|message| message.get("content")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) + .ok_or_else(|| { + WebError::bad_request_code("hermes_client_bad_request", "缺少 message") + .with_context(context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + })?; + let document_id = payload + .get("documentId") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("current"); + let trace_id = payload + .get("traceId") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(&context.trace.trace_id); + let session_id = payload + .get("sessionId") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| stable_session_id(document_id, trace_id)); + + let page_context = payload.get("pageContext").cloned().unwrap_or(Value::Null); + let workspace_id = payload.get("workspaceId").cloned().unwrap_or(Value::Null); + let instructions = json!({ + "role": "mnote_page_ai_context", + "workspaceId": workspace_id, + "documentId": document_id, + "actorId": context.auth.actor_id, + "actorType": context.auth.actor_type, + "sessionId": session_id, + "traceId": trace_id, + "toolGuidance": "调用 mnote_page_get / mnote_page_save / mnote.page.* 对应工具时,必须透传 workspaceId、documentId、actorId、sessionId 和 traceId;读取页面标题或页面设置时调用 mnote_page_get,并传 includeBody=false、includeOptions=true、includeBlocks=false;需要正文摘要时才传 includeBody=true。不要只依据 pageContext 猜测。", + "selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null), + "selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null), + "pageContext": page_context + }) + .to_string(); + + let mut body = json!({ + "input": message, + "session_id": session_id, + "instructions": instructions + }); + if let Some(model) = payload + .get("model") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + { + body["model"] = Value::String(model.to_string()); + } + Ok(body) +} + +async fn proxy_json( + context: &RequestContext, + method: reqwest::Method, + upstream: &str, + path: &str, + body: Option, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let url = upstream_url(upstream, path)?; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(1800)) + .build() + .map_err(|error| { + WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(context) + })?; + let mut request = client.request(method, url); + if let Some(api_key) = configured_api_key() { + request = request.bearer_auth(api_key); + } + if let Some(body) = body { + request = request.json(&body); + } + let response = request.send().await.map_err(|error| { + WebError::bad_gateway_code( + "hermes_client_upstream_unavailable", + format!("Hermes upstream 连接失败: {error}"), + ) + .with_context(context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + })?; + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(upstream_error(context, status, text)); + } + let payload = serde_json::from_str::(&text).unwrap_or_else(|_| { + json!({ + "ok": true, + "traceId": context.trace.trace_id, + "raw": text + }) + }); + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(normalize_success_payload(context, payload)), + )) +} + +fn normalize_success_payload(context: &RequestContext, payload: Value) -> Value { + if payload.get("ok").is_some() { + payload + } else { + json!({ + "ok": true, + "traceId": context.trace.trace_id, + "upstream": payload + }) + } +} + +fn upstream_error(context: &RequestContext, status: reqwest::StatusCode, text: String) -> WebError { + let (response_status, code) = match status { + reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => ( + StatusCode::BAD_GATEWAY, + "hermes_client_upstream_unauthorized", + ), + reqwest::StatusCode::TOO_MANY_REQUESTS => ( + StatusCode::TOO_MANY_REQUESTS, + "hermes_client_upstream_rate_limited", + ), + status if status.is_server_error() => ( + StatusCode::BAD_GATEWAY, + "hermes_client_upstream_unavailable", + ), + _ => (StatusCode::BAD_GATEWAY, "hermes_client_upstream_error"), + }; + WebError::new( + response_status, + code, + format!( + "Hermes upstream 返回 HTTP {}: {}", + status.as_u16(), + text.chars().take(600).collect::() + ), + ) + .with_context(context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") +} + +fn hermes_unconfigured( + context: &RequestContext, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + Err(hermes_unconfigured_error(context)) +} + +fn hermes_unconfigured_error(context: &RequestContext) -> WebError { + WebError::service_unavailable_code( + "hermes_client_unconfigured", + "Hermes client proxy 未配置 MNOTE_WEB_HERMES_UPSTREAM_URL", + ) + .with_context(context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") +} + +fn upstream_url(upstream: &str, path: &str) -> Result { + let url = format!( + "{}/{}", + upstream.trim_end_matches('/'), + path.trim_start_matches('/') + ); + reqwest::Url::parse(&url) + .map(|url| url.to_string()) + .map_err(|error| WebError::internal(format!("Hermes upstream URL 无效: {error}"))) +} + +fn stable_session_id(document_id: &str, trace_id: &str) -> String { + format!( + "mnote_{}_{}", + sanitize_id_part(document_id), + sanitize_id_part(trace_id) + ) +} + +fn sanitize_id_part(value: &str) -> String { + let sanitized = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect::(); + if sanitized.is_empty() { + "current".into() + } else { + sanitized + } +} + +fn url_escape(value: &str) -> String { + value + .bytes() + .flat_map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + vec![byte as char] + } + _ => format!("%{byte:02X}").chars().collect(), + }) + .collect() +} + +fn stamp_client_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + stamp_client_headers_into(&mut headers); + headers +} + +fn stamp_client_headers_into(headers: &mut HeaderMap) { + if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { + headers.insert(name, HeaderValue::from_static("mnote-web")); + } + if let Ok(name) = HeaderName::from_lowercase(HEADER_HERMES_CLIENT_OWNER.as_bytes()) { + headers.insert(name, HeaderValue::from_static("mnote-web-hermes-client")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; + use axum::http::Request; + use std::sync::{Mutex, OnceLock}; + use tower::util::ServiceExt; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn app() -> axum::Router { + build_app(AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: false, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + })) + } + + #[tokio::test] + async fn hermes_client_unauthenticated_requests_return_401() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/client/sessions") + .header("content-type", "application/json") + .body(Body::from(json!({"documentId":"doc_1"}).to_string())) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + response + .headers() + .get("x-error-code") + .and_then(|value| value.to_str().ok()), + Some("hermes_client_unauthorized") + ); + } + + #[tokio::test] + async fn hermes_client_unconfigured_run_returns_stable_error() { + let _guard = env_lock().lock().expect("env lock"); + std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); + std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL"); + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/client/runs") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from( + json!({ + "workspaceId": "ws_1", + "documentId": "doc_1", + "sessionId": "sess_1", + "message": "ping", + "traceId": "trace_1" + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + response + .headers() + .get("x-error-code") + .and_then(|value| value.to_str().ok()), + Some("hermes_client_unconfigured") + ); + } + + #[tokio::test] + async fn hermes_client_session_create_does_not_require_upstream_or_store_chat() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/client/sessions") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from( + json!({ + "workspaceId": "ws_1", + "documentId": "doc_1", + "traceId": "trace_1", + "title": "当前页问答" + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["ok"], true); + assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1"); + assert_eq!(payload["persistence"], "hermes_on_first_run"); + assert!(payload.get("messages").is_none()); + } + + #[test] + fn hermes_client_run_body_carries_page_context_into_run_input() { + let context = RequestContext::from_http_parts( + &axum::http::Method::POST, + &"/api/hermes/client/runs".parse().expect("uri"), + &HeaderMap::new(), + ); + let body = build_run_upstream_body( + &context, + json!({ + "workspaceId": "ws_1", + "documentId": "doc_1", + "sessionId": "sess_1", + "message": "概括当前页面", + "pageContext": {"title": "页面标题"}, + "selectedBlockId": "block_1", + "selectedText": "选中文本", + "traceId": "trace_1" + }), + ) + .expect("body"); + assert_eq!(body["input"], "概括当前页面"); + assert_eq!(body["session_id"], "sess_1"); + let instructions = body["instructions"].as_str().expect("instructions"); + assert!(instructions.contains("\"workspaceId\":\"ws_1\"")); + assert!(instructions.contains("\"documentId\":\"doc_1\"")); + assert!(instructions.contains("\"title\":\"页面标题\"")); + assert!(instructions.contains("\"selectedBlockId\":\"block_1\"")); + } +} diff --git a/rust/crates/mnote-web/src/routes/hermes_tools.rs b/rust/crates/mnote-web/src/routes/hermes_tools.rs new file mode 100644 index 00000000..3aef07e0 --- /dev/null +++ b/rust/crates/mnote-web/src/routes/hermes_tools.rs @@ -0,0 +1,787 @@ +use crate::app::AppState; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::hermes_tools::{artifact, manifest, page, ToolCallInput}; +use axum::extract::{Extension, Query, State}; +use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; +use axum::Json; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::env; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use tracing::{info, warn}; + +const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; +const HEADER_HERMES_TOOL_OWNER: &str = "x-mnote-hermes-tool-owner"; + +pub async fn mnote_audit( + Extension(context): Extension, + Query(query): Query>, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + let trace_id = query.get("traceId").map(String::as_str); + let tool_call_id = query.get("toolCallId").map(String::as_str); + let persisted_only = query + .get("persistedOnly") + .map(|value| value == "true" || value == "1") + .unwrap_or(false); + let events = if persisted_only { + audit_persisted_events(trace_id, tool_call_id) + } else { + audit_events(trace_id, tool_call_id) + }; + Ok(( + StatusCode::OK, + stamp_tool_headers(), + Json(json!({ + "ok": true, + "traceId": context.trace.trace_id, + "auditStore": if persisted_only { "jsonl" } else { "memory" }, + "events": events + })), + )) +} + +pub async fn mnote_manifest( + Extension(context): Extension, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + Ok(( + StatusCode::OK, + stamp_tool_headers(), + Json(json!({ + "ok": true, + "traceId": context.trace.trace_id, + "manifest": manifest::manifest() + })), + )) +} + +pub async fn mnote_call( + State(state): State, + Extension(context): Extension, + Json(input): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + let trace_id = input + .effective_trace_id(&context.trace.trace_id) + .to_string(); + let tool_call_id = input.effective_tool_call_id(); + let workspace_id = input.effective_workspace_id(); + let document_id = input.effective_document_id(); + let dry_run = input.dry_run.unwrap_or(false); + let effect = if dry_run { + "dry_run" + } else if input.tool_name == "mnote.page.get" { + "read" + } else { + "write" + }; + let idempotency_key = idempotency_cache_key( + &input, + workspace_id.as_deref(), + document_id.as_deref(), + dry_run, + ); + info!( + trace_id = %trace_id, + session_id = input.session_id.as_deref().unwrap_or(""), + run_id = input.run_id.as_deref().unwrap_or(""), + tool_call_id = %tool_call_id, + tool_name = %input.tool_name, + workspace_id = workspace_id.as_deref().unwrap_or(""), + document_id = document_id.as_deref().unwrap_or(""), + actor_id = input.actor_id.as_deref().unwrap_or(""), + dry_run, + "mnote Hermes tool call started" + ); + audit_push(json!({ + "phase": "started", + "traceId": trace_id, + "sessionId": input.session_id, + "runId": input.run_id, + "toolCallId": tool_call_id, + "toolName": input.tool_name, + "workspaceId": workspace_id, + "documentId": document_id, + "actorId": input.actor_id, + "dryRun": dry_run + })); + if let Err(error) = ensure_workspace_context(&context, workspace_id.as_deref()) { + audit_push(json!({ + "phase": "failed", + "traceId": trace_id, + "sessionId": input.session_id, + "runId": input.run_id, + "toolCallId": tool_call_id, + "toolName": input.tool_name, + "workspaceId": workspace_id, + "documentId": document_id, + "actorId": input.actor_id, + "status": error.status().as_u16(), + "message": error.message() + })); + return Err(error); + } + if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) { + info!( + trace_id = %trace_id, + session_id = input.session_id.as_deref().unwrap_or(""), + run_id = input.run_id.as_deref().unwrap_or(""), + tool_call_id = %tool_call_id, + tool_name = %input.tool_name, + workspace_id = workspace_id.as_deref().unwrap_or(""), + document_id = document_id.as_deref().unwrap_or(""), + actor_id = input.actor_id.as_deref().unwrap_or(""), + "mnote Hermes tool call idempotency replay" + ); + audit_push(json!({ + "phase": "idempotency_replay", + "traceId": trace_id, + "sessionId": cached.get("sessionId").cloned().unwrap_or(Value::Null), + "runId": cached.get("runId").cloned().unwrap_or(Value::Null), + "toolCallId": cached.get("toolCallId").cloned().unwrap_or(Value::Null), + "toolName": cached.get("toolName").cloned().unwrap_or(Value::Null), + "audit": cached.get("audit").cloned().unwrap_or(Value::Null) + })); + return Ok((StatusCode::OK, stamp_tool_headers(), Json(cached))); + } + let result = match input.tool_name.as_str() { + "mnote.page.get" => page::page_get(&state, &context, &input).await, + "mnote.page.save" => page::page_save(&state, &context, &input).await, + "mnote.page.update_title" => page::update_title(&state, &context, &input).await, + "mnote.page.update_options" => page::update_options(&state, &context, &input).await, + "mnote.artifact.create_summary" => artifact::create_summary(&state, &context, &input).await, + "mnote.artifact.create_ai_note" => artifact::create_ai_note(&state, &context, &input).await, + _ => Err( + WebError::bad_request_code("mnote_tool_unknown", "未知 mnote Hermes tool") + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"), + ), + }; + if let Err(error) = &result { + warn!( + trace_id = %trace_id, + session_id = input.session_id.as_deref().unwrap_or(""), + run_id = input.run_id.as_deref().unwrap_or(""), + tool_call_id = %tool_call_id, + tool_name = %input.tool_name, + workspace_id = workspace_id.as_deref().unwrap_or(""), + document_id = document_id.as_deref().unwrap_or(""), + actor_id = input.actor_id.as_deref().unwrap_or(""), + status = %error.status(), + message = %error.message(), + "mnote Hermes tool call failed" + ); + audit_push(json!({ + "phase": "failed", + "traceId": trace_id, + "sessionId": input.session_id, + "runId": input.run_id, + "toolCallId": tool_call_id, + "toolName": input.tool_name, + "workspaceId": workspace_id, + "documentId": document_id, + "actorId": input.actor_id, + "status": error.status().as_u16(), + "message": error.message() + })); + } + let result = result?; + let command_id = result.get("commandId").cloned().unwrap_or(Value::Null); + info!( + trace_id = %trace_id, + session_id = input.session_id.as_deref().unwrap_or(""), + run_id = input.run_id.as_deref().unwrap_or(""), + tool_call_id = %tool_call_id, + tool_name = %input.tool_name, + workspace_id = workspace_id.as_deref().unwrap_or(""), + document_id = document_id.as_deref().unwrap_or(""), + actor_id = input.actor_id.as_deref().unwrap_or(""), + effect, + "mnote Hermes tool call completed" + ); + let response_body = json!({ + "ok": true, + "toolName": input.tool_name, + "toolCallId": tool_call_id, + "traceId": trace_id, + "sessionId": input.session_id, + "runId": input.run_id, + "result": result, + "audit": { + "effect": effect, + "commandId": command_id, + "workspaceId": workspace_id, + "documentId": document_id, + "actorId": input.actor_id, + "dryRun": dry_run, + "idempotencyKey": input.idempotency_key, + "capabilityScope": input.capability_scope + }, + "error": null + }); + if let Some(key) = idempotency_key { + idempotency_cache_put(key, response_body.clone()); + } + audit_push(json!({ + "phase": "completed", + "traceId": response_body.get("traceId").cloned().unwrap_or(Value::Null), + "sessionId": response_body.get("sessionId").cloned().unwrap_or(Value::Null), + "runId": response_body.get("runId").cloned().unwrap_or(Value::Null), + "toolCallId": response_body.get("toolCallId").cloned().unwrap_or(Value::Null), + "toolName": response_body.get("toolName").cloned().unwrap_or(Value::Null), + "audit": response_body.get("audit").cloned().unwrap_or(Value::Null) + })); + Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body))) +} + +fn audit_log() -> &'static Mutex> { + static LOG: OnceLock>> = OnceLock::new(); + LOG.get_or_init(|| Mutex::new(Vec::new())) +} + +fn audit_push(event: Value) { + if let Ok(mut log) = audit_log().lock() { + log.push(event.clone()); + let overflow = log.len().saturating_sub(500); + if overflow > 0 { + log.drain(0..overflow); + } + } + if let Err(error) = audit_append_persistent(&event) { + warn!(message = %error, "mnote Hermes tool audit 持久化失败"); + } +} + +fn audit_events(trace_id: Option<&str>, tool_call_id: Option<&str>) -> Vec { + let Ok(log) = audit_log().lock() else { + return Vec::new(); + }; + log.iter() + .filter(|event| { + trace_id + .map(|expected| event.get("traceId").and_then(Value::as_str) == Some(expected)) + .unwrap_or(true) + }) + .filter(|event| { + tool_call_id + .map(|expected| event.get("toolCallId").and_then(Value::as_str) == Some(expected)) + .unwrap_or(true) + }) + .cloned() + .collect() +} + +fn audit_log_path() -> PathBuf { + env::var("MNOTE_HERMES_TOOL_AUDIT_LOG") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("tmp").join("mnote-hermes-tool-audit.jsonl")) +} + +fn audit_append_persistent(event: &Value) -> Result<(), String> { + let path = audit_log_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|error| error.to_string())?; + let line = serde_json::to_string(event).map_err(|error| error.to_string())?; + writeln!(file, "{line}").map_err(|error| error.to_string()) +} + +fn audit_persisted_events(trace_id: Option<&str>, tool_call_id: Option<&str>) -> Vec { + let path = audit_log_path(); + let Ok(file) = File::open(path) else { + return Vec::new(); + }; + BufReader::new(file) + .lines() + .map_while(Result::ok) + .filter_map(|line| serde_json::from_str::(&line).ok()) + .filter(|event| { + trace_id + .map(|expected| event.get("traceId").and_then(Value::as_str) == Some(expected)) + .unwrap_or(true) + }) + .filter(|event| { + tool_call_id + .map(|expected| event.get("toolCallId").and_then(Value::as_str) == Some(expected)) + .unwrap_or(true) + }) + .collect() +} + +fn idempotency_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn idempotency_cache_key( + input: &ToolCallInput, + workspace_id: Option<&str>, + document_id: Option<&str>, + dry_run: bool, +) -> Option { + if dry_run || input.tool_name == "mnote.page.get" { + return None; + } + let idempotency_key = input.idempotency_key.as_deref()?.trim(); + if idempotency_key.is_empty() { + return None; + } + Some(format!( + "{}|{}|{}|{}", + input.tool_name, + workspace_id.unwrap_or(""), + document_id.unwrap_or(""), + idempotency_key + )) +} + +fn idempotency_cache_get(key: &str) -> Option { + idempotency_cache().lock().ok()?.get(key).cloned() +} + +fn idempotency_cache_put(key: String, response: Value) { + if let Ok(mut cache) = idempotency_cache().lock() { + cache.insert(key, response); + } +} + +fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> { + let has_actor = context.auth.actor_id.trim() != "anonymous"; + if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() { + return Ok(()); + } + Err(WebError::new( + StatusCode::UNAUTHORIZED, + "mnote_tool_unauthorized", + "mnote Hermes tool 需要登录后访问", + ) + .with_context(context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools")) +} + +fn ensure_workspace_context( + context: &RequestContext, + input_workspace_id: Option<&str>, +) -> Result<(), WebError> { + let Some(input_workspace_id) = input_workspace_id + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(()); + }; + let Some(header_workspace_id) = context + .workspace + .workspace_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(()); + }; + if input_workspace_id == header_workspace_id { + return Ok(()); + } + Err(WebError::new( + StatusCode::FORBIDDEN, + "workspace_context_conflict", + "mnote Hermes tool 请求的 workspaceId 与请求上下文不一致", + ) + .with_context(context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools")) +} + +fn stamp_tool_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { + headers.insert(name, HeaderValue::from_static("mnote-web")); + } + if let Ok(name) = HeaderName::from_lowercase(HEADER_HERMES_TOOL_OWNER.as_bytes()) { + headers.insert(name, HeaderValue::from_static("mnote-web-hermes-tools")); + } + headers +} + +#[cfg(test)] +mod tests { + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; + use axum::http::{Request, StatusCode}; + use serde_json::{json, Value}; + use tower::util::ServiceExt; + + fn app() -> axum::Router { + build_app(AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: false, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: Some( + r#"{ + "documents:getMeta": { + "id": "doc_1", + "workspace_id": "ws_demo", + "title": "服务端页面", + "can_edit": true, + "wide_layout": false, + "use_small_text": false, + "show_toc": true, + "block_count": 1 + }, + "documents:getContent": { + "title": "服务端页面", + "content": [ + { + "id": "heading_1", + "type": "heading", + "props": { "level": 1 }, + "content": [{ "type": "text", "text": "章节一" }] + } + ], + "revision": 7, + "conflict_detection_key": "doc_1:7" + } + }"# + .into(), + ), + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + })) + } + + #[tokio::test] + async fn hermes_tools_manifest_returns_first_batch_tools() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/hermes/tools/mnote/manifest") + .header("x-mnote-actor-id", "user_1") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + let tools = payload["manifest"]["tools"].as_array().expect("tools"); + assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.get")); + assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.save")); + assert_eq!( + payload["manifest"]["schemaVersion"], + "mnote.hermes_tool_manifest.v1" + ); + } + + #[tokio::test] + async fn hermes_tools_page_get_requires_auth() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .body(Body::from( + json!({"toolName":"mnote.page.get","documentId":"doc_1"}).to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn hermes_tools_write_tools_require_auth() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "toolName": "mnote.page.save", + "workspaceId": "ws_demo", + "documentId": "doc_1", + "sessionId": "sess_1", + "runId": "run_1", + "toolCallId": "call_1", + "traceId": "trace_1", + "idempotencyKey": "idem_1", + "dryRun": false, + "args": {"content": []} + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn hermes_tools_page_get_returns_page_aggregate_summary() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from( + json!({ + "toolName": "mnote.page.get", + "workspaceId": "ws_demo", + "documentId": "doc_1", + "sessionId": "sess_1", + "runId": "run_1", + "toolCallId": "call_1", + "traceId": "trace_1", + "capabilityScope": ["page.read"] + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["toolName"], "mnote.page.get"); + assert_eq!(payload["toolCallId"], "call_1"); + assert_eq!(payload["result"]["title"], "服务端页面"); + assert!(payload["result"]["bodySummary"] + .as_str() + .unwrap_or_default() + .contains("章节一")); + assert_eq!(payload["audit"]["effect"], "read"); + } + + #[tokio::test] + async fn hermes_tools_reject_workspace_context_conflict_without_content_leak() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-workspace-id", "ws_other") + .body(Body::from( + json!({ + "toolName": "mnote.page.get", + "workspaceId": "ws_demo", + "documentId": "doc_1", + "sessionId": "sess_1", + "runId": "run_1", + "toolCallId": "call_1", + "traceId": "trace_1", + "capabilityScope": ["page.read"] + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response + .headers() + .get("x-error-code") + .and_then(|value| value.to_str().ok()), + Some("workspace_context_conflict") + ); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let text = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(!text.contains("服务端页面")); + assert!(!text.contains("章节一")); + } + + #[tokio::test] + async fn hermes_tools_write_tools_require_idempotency_and_dry_run_flag() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from( + json!({ + "toolName": "mnote.page.save", + "workspaceId": "ws_demo", + "documentId": "doc_1", + "sessionId": "sess_1", + "runId": "run_1", + "toolCallId": "call_1", + "traceId": "trace_1", + "args": {"content": []} + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response + .headers() + .get("x-error-code") + .and_then(|value| value.to_str().ok()), + Some("mnote_tool_idempotency_required") + ); + } + + #[tokio::test] + async fn hermes_tools_page_save_dry_run_returns_diff_without_write() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from( + json!({ + "toolName": "mnote.page.save", + "workspaceId": "ws_demo", + "documentId": "doc_1", + "sessionId": "sess_1", + "runId": "run_1", + "toolCallId": "call_1", + "traceId": "trace_1", + "idempotencyKey": "idem_save_1", + "dryRun": true, + "args": {"content": [{"type":"paragraph","content":[{"type":"text","text":"AI 写入"}]}]} + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["audit"]["effect"], "dry_run"); + assert_eq!(payload["result"]["dryRun"], true); + assert_eq!(payload["result"]["commandName"], "page.body.save"); + } + + #[tokio::test] + async fn hermes_tools_update_options_dry_run_filters_unwired_fields() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from( + json!({ + "toolName": "mnote.page.update_options", + "workspaceId": "ws_demo", + "documentId": "doc_1", + "sessionId": "sess_1", + "runId": "run_1", + "toolCallId": "call_1", + "traceId": "trace_1", + "idempotencyKey": "idem_options_1", + "dryRun": true, + "args": {"options": {"wideLayout": true, "pageFont": "serif"}} + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + let options = &payload["result"]["diff"][0]["payload"]["options"]; + assert_eq!(options["wideLayout"], true); + assert!(options.get("pageFont").is_none()); + assert_eq!(payload["result"]["ignoredOptions"][0], "pageFont"); + assert_eq!( + payload["result"]["warnings"][0]["code"], + "page_option_not_wired" + ); + } + + #[tokio::test] + async fn hermes_tools_artifact_dry_run_returns_artifact_plan() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from( + json!({ + "toolName": "mnote.artifact.create_summary", + "workspaceId": "ws_demo", + "documentId": "doc_1", + "sessionId": "sess_1", + "runId": "run_1", + "toolCallId": "call_1", + "traceId": "trace_1", + "idempotencyKey": "idem_summary_1", + "dryRun": true, + "args": {"summary": "摘要内容"} + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["result"]["dryRun"], true); + assert_eq!(payload["result"]["artifactType"], "summary"); + } +} diff --git a/rust/crates/mnote-web/src/routes/mindmap_shell.rs b/rust/crates/mnote-web/src/routes/mindmap_shell.rs index 4e6d1d8e..95c63d75 100644 --- a/rust/crates/mnote-web/src/routes/mindmap_shell.rs +++ b/rust/crates/mnote-web/src/routes/mindmap_shell.rs @@ -364,9 +364,7 @@ mod tests { let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("mnote.mindmap_shell.v1")); assert!(html.contains("data-mnote-object-editor=\"mindmap\"")); - assert!(html.contains( - "data-mnote-object-identity=\"resource:mindmap:doc_1:mind_1\"" - )); + assert!(html.contains("data-mnote-object-identity=\"resource:mindmap:doc_1:mind_1\"")); assert!(html.contains("data-leptos-mindmap-island=\"standalone\"")); assert!(html.contains("mindmap.simple_mind_map_scene.get")); assert!(html.contains("mindmap.command.apply")); diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index c7778d58..aa0fb372 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -1,11 +1,13 @@ mod bridge; -mod command_support; +pub(crate) mod command_support; mod compat; mod documents; mod editor; mod gateway; mod health; mod hermes; +mod hermes_client; +mod hermes_tools; mod kernel; mod local_folder_events; mod local_folder_source; @@ -21,7 +23,7 @@ mod snapshot_support; mod sse; mod stream_support; mod tree; -mod web_shell; +pub(crate) mod web_shell; mod ws; use crate::app::AppState; @@ -134,7 +136,30 @@ pub fn build_router(state: AppState) -> Router { &hermes_base_path, Router::new() .route("/health", get(hermes::health)) - .route("/bridge", post(hermes::bridge_runtime)), + .route("/bridge", post(hermes::bridge_runtime)) + .route( + "/client/sessions", + get(hermes_client::list_sessions).post(hermes_client::create_session), + ) + .route( + "/client/sessions/{session_id}", + get(hermes_client::get_session), + ) + .route("/client/runs", post(hermes_client::create_run)) + .route("/client/events/{run_id}", get(hermes_client::stream_events)) + .route( + "/client/runs/{run_id}/abort", + post(hermes_client::abort_run), + ) + .route("/client/models", get(hermes_client::list_models)) + .route("/client/tools", get(hermes_client::list_tools)), + ) + .nest( + "/api/hermes/tools", + Router::new() + .route("/mnote/manifest", get(hermes_tools::mnote_manifest)) + .route("/mnote/call", post(hermes_tools::mnote_call)) + .route("/mnote/audit", get(hermes_tools::mnote_audit)), ); if enable_debug_shell_routes { diff --git a/rust/crates/mnote-web/src/routes/onlyoffice.rs b/rust/crates/mnote-web/src/routes/onlyoffice.rs index 9bf03edc..081df6c6 100644 --- a/rust/crates/mnote-web/src/routes/onlyoffice.rs +++ b/rust/crates/mnote-web/src/routes/onlyoffice.rs @@ -1,5 +1,5 @@ -use crate::app::AppState; use crate::app::AppConfig; +use crate::app::AppState; use crate::error::WebError; use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput}; use axum::body::{Body, Bytes}; @@ -796,7 +796,9 @@ pub async fn forcesave( return WebError::new( axum::http::StatusCode::NOT_IMPLEMENTED, "onlyoffice_legacy_writeback_unavailable", - format!("OnlyOffice forcesave 未配置 legacy Next 写回链: assetId={asset_id}, key={key}"), + format!( + "OnlyOffice forcesave 未配置 legacy Next 写回链: assetId={asset_id}, key={key}" + ), ); } error @@ -831,7 +833,9 @@ async fn proxy_legacy_onlyoffice_json( .timeout(Duration::from_secs(30)) .build() .map_err(|error| { - WebError::internal(format!("OnlyOffice legacy proxy HTTP 客户端创建失败: {error}")) + WebError::internal(format!( + "OnlyOffice legacy proxy HTTP 客户端创建失败: {error}" + )) })?; let mut request = client .post(target) @@ -861,7 +865,9 @@ async fn proxy_legacy_onlyoffice_json( })?; let mut response = Response::new(Body::from(bytes)); *response.status_mut() = status; - response.headers_mut().insert(header::CONTENT_TYPE, content_type); + response + .headers_mut() + .insert(header::CONTENT_TYPE, content_type); Ok(response) } @@ -1142,9 +1148,7 @@ mod tests { async fn spawn_legacy_json_server( response_body: &'static str, ) -> (String, oneshot::Receiver) { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind"); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("addr"); let (tx, rx) = oneshot::channel(); tokio::spawn(async move { @@ -1216,9 +1220,8 @@ mod tests { let request = captured.await.expect("captured"); assert_eq!(payload["error"], 0); - assert!(request.starts_with( - "POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1" - )); + assert!(request + .starts_with("POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1")); assert!(request.contains(r#""status":2"#)); assert!(request.contains(r#""key":"doc_key""#)); } @@ -1252,8 +1255,7 @@ mod tests { #[tokio::test] async fn onlyoffice_forcesave_proxies_to_legacy_next_writeback() { let (base_url, captured) = - spawn_legacy_json_server(r#"{"ok":true,"via":"forcesave","result":{"error":0}}"#) - .await; + spawn_legacy_json_server(r#"{"ok":true,"via":"forcesave","result":{"error":0}}"#).await; let response = forcesave( State(test_state(Some(base_url))), "/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key" @@ -1274,8 +1276,7 @@ mod tests { assert_eq!(payload["ok"], true); assert_eq!(payload["via"], "forcesave"); - assert!(request.starts_with( - "POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1" - )); + assert!(request + .starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1")); } } diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index 09fff473..74dbf692 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -4439,13 +4439,6 @@ fn build_tree_shell_html( target: { documentId }, payload: { documentId }, }); - if (documentId) { - postToHost("tree.navigate", { - documentId, - target: { documentId }, - payload: { documentId }, - }); - } if (sourceKind === "convex_workspace" && applyCreatedDocumentLocally(result, parentId, title)) { if (mode === "filetree") { beginInlineRename("filetree", `doc:${documentId}`); @@ -4454,6 +4447,13 @@ fn build_tree_shell_html( } return; } + if (documentId) { + postToHost("tree.navigate", { + documentId, + target: { documentId }, + payload: { documentId }, + }); + } scheduleRefresh({ renameRowId: localCreatedRowIdFromCommandResult(result, "markdown"), }); @@ -6810,7 +6810,9 @@ mod tests { assert!(html.contains("applyCreatedDocumentLocally")); assert!(html.contains("applyRemovedDocumentLocally")); assert!(html.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally")); - assert!(html.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))")); + assert!( + html.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))") + ); assert!(html.contains("application/x-mnote-page-tree-node")); assert!(html.contains("页面已拖放到")); assert!(html.contains("setAttribute(\"role\", \"treeitem\")")); diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index db0461bb..fe9da5a6 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -3014,7 +3014,7 @@ const SIDEBAR_TREE_JS: &str = r##" } function pageAiStorageKey() { - return 'doc_ai_sessions:' + currentDocumentId(); + return 'hermes_page_ai_session:' + currentDocumentId(); } function pageAiNewSession(title) { @@ -3046,46 +3046,90 @@ const SIDEBAR_TREE_JS: &str = r##" } function pageAiLoadSessions() { + if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return; try { var raw = window.localStorage.getItem(pageAiStorageKey()); - if (!raw) { - var fresh = pageAiNewSession(); - pageUiState.pageAiSessions = [fresh]; - pageUiState.pageAiActiveSessionId = fresh.id; - pageUiState.pageAiMessages = []; - return; - } - var parsed = JSON.parse(raw); - var sessions = pageAiNormalizeSessions(parsed && parsed.sessions ? parsed.sessions : []); - if (!sessions.length) { - var fallback = pageAiNewSession(); - pageUiState.pageAiSessions = [fallback]; - pageUiState.pageAiActiveSessionId = fallback.id; - pageUiState.pageAiMessages = []; - return; - } - pageUiState.pageAiSessions = sessions; + var parsed = raw ? JSON.parse(raw) : null; var activeId = String(parsed && parsed.activeSessionId || '').trim(); - var active = sessions.find(function(session) { return session.id === activeId; }) || sessions[0]; - pageUiState.pageAiActiveSessionId = active.id; - pageUiState.pageAiMessages = Array.isArray(active.messages) ? active.messages.slice() : []; - } catch (_) { - var reset = pageAiNewSession(); - pageUiState.pageAiSessions = [reset]; - pageUiState.pageAiActiveSessionId = reset.id; - pageUiState.pageAiMessages = []; - } + if (activeId) { + pageUiState.pageAiSessions = [pageAiNewSession('当前页问答')]; + pageUiState.pageAiSessions[0].id = activeId; + pageUiState.pageAiActiveSessionId = activeId; + pageUiState.pageAiMessages = []; + return; + } + } catch (_) {} + var fresh = pageAiNewSession(); + pageUiState.pageAiSessions = [fresh]; + pageUiState.pageAiActiveSessionId = fresh.id; + pageUiState.pageAiMessages = []; } function pageAiPersistSessions() { + document.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes'); + document.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey()); try { window.localStorage.setItem(pageAiStorageKey(), JSON.stringify({ - activeSessionId: pageUiState.pageAiActiveSessionId, - sessions: pageAiNormalizeSessions(pageUiState.pageAiSessions) + activeSessionId: pageUiState.pageAiActiveSessionId })); } catch (_) {} } + async function pageAiEnsureHermesSession() { + pageAiLoadSessions(); + var current = pageAiCurrentSession(); + if (current && String(current.id || '').startsWith('mnote_')) return current; + var response = await fetch('/api/hermes/client/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: resolveWorkspaceId(document.body), + documentId: currentDocumentId(), + traceId: 'page-ai-' + Date.now().toString(36), + title: current && current.title ? current.title : '当前页问答' + }) + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok || !payload || payload.ok !== true) { + var code = payload && payload.code ? payload.code : 'hermes_session_failed_' + response.status; + throw new Error(code); + } + var session = { + id: String(payload.sessionId || '').trim(), + title: String(payload.title || '当前页问答'), + createdAt: Date.now(), + updatedAt: Date.now(), + messages: pageUiState.pageAiMessages.slice() + }; + pageUiState.pageAiSessions = pageAiNormalizeSessions([session]); + pageUiState.pageAiActiveSessionId = session.id; + pageAiPersistSessions(); + renderPageAiConversation(); + return session; + } + + async function pageAiRestoreHermesSession() { + var current = pageAiCurrentSession(); + if (!current || !String(current.id || '').startsWith('mnote_')) return; + var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(current.id), { + headers: { 'accept': 'application/json' } + }); + if (!response.ok) return; + var payload = await response.json().catch(function(){ return null; }); + var session = payload && (payload.session || (payload.upstream && payload.upstream.session) || payload); + var messages = session && Array.isArray(session.messages) ? session.messages : []; + if (!messages.length) return; + pageUiState.pageAiMessages = messages.slice(-40).map(function(message) { + return { + role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'), + content: String(message.content || '') + }; + }); + current.messages = pageUiState.pageAiMessages.slice(); + current.updatedAt = Date.now(); + renderPageAiConversation(); + } + function pageAiCurrentSession() { return pageUiState.pageAiSessions.find(function(session) { return session.id === pageUiState.pageAiActiveSessionId; @@ -3146,7 +3190,7 @@ const SIDEBAR_TREE_JS: &str = r##" var args = normalized && normalized.args ? normalized.args : {}; var documentId = searchText(args.documentId || args.pageId || currentDocumentId()); var toolName = searchText(operation.tool_name || normalized.toolName || 'doc_get'); - return providerLabel + ' 已收到你的问题“' + searchText(promptText) + '”。当前已通过 mnote-cli 建立页面级工具调用,目标页面是 ' + (documentId || '当前页面') + ',本次选择的工具是 ' + toolName + '。如果你继续追问,我会沿当前页面上下文继续回复。'; + return providerLabel + ' 已收到你的问题“' + searchText(promptText) + '”。当前已通过 Hermes 调用 mnote plugin 工具,目标页面是 ' + (documentId || '当前页面') + ',本次选择的工具是 ' + toolName + '。如果你继续追问,我会沿当前页面上下文继续回复。'; } catch (_) { return providerLabel + ' 已返回结果,但当前结果是结构化文本,已为你保留原始内容。'; } @@ -3175,7 +3219,11 @@ const SIDEBAR_TREE_JS: &str = r##" '
' + '
' + '推荐问题' + - '' + + '
' + + '' + + '' + + '' + + '
' + '
' + '
' + '
' + @@ -3187,8 +3235,6 @@ const SIDEBAR_TREE_JS: &str = r##" '' + '
' + '' + - '' + - '' + '
' + '' + '
' + @@ -3240,9 +3286,10 @@ const SIDEBAR_TREE_JS: &str = r##" return; } conversation.innerHTML = pageUiState.pageAiMessages.map(function(item) { + var roleLabel = item.role === 'user' ? '你' : (item.role === 'tool' ? '工具' : 'AI'); return '' + '
' + - '
' + escapeHtml(item.role === 'user' ? '你' : 'AI') + '
' + + '
' + escapeHtml(roleLabel) + '
' + '
' + escapeHtml(item.content || '') + '
' + '
'; }).join(''); @@ -3258,6 +3305,15 @@ const SIDEBAR_TREE_JS: &str = r##" drawer.hidden = false; pageUiState.pageAiOpen = true; updatePageAiTriggerState(); + pageAiEnsureHermesSession().then(function() { + return pageAiRestoreHermesSession(); + }).catch(function(error) { + pageUiState.pageAiMessages.push({ + role: 'assistant', + content: 'Hermes 当前不可用:' + (error instanceof Error ? error.message : String(error)) + }); + renderPageAiConversation(); + }); } function closePageAiDrawer() { @@ -3290,7 +3346,14 @@ const SIDEBAR_TREE_JS: &str = r##" if (line.startsWith('event:')) eventName = line.slice(6).trim(); if (line.startsWith('data:')) dataLines.push(line.slice(5).trim()); }); - if (eventName) onEvent(eventName, dataLines.join('\n')); + var payloadText = dataLines.join('\n'); + if (!eventName && payloadText) { + try { + var parsed = JSON.parse(payloadText); + eventName = parsed && parsed.event ? String(parsed.event) : ''; + } catch (_) {} + } + if (eventName) onEvent(eventName, payloadText); }); } } @@ -3299,39 +3362,35 @@ const SIDEBAR_TREE_JS: &str = r##" if (pageUiState.pageAiBusy) return; var prompt = searchText(text); if (!prompt) return; - pageAiLoadSessions(); - var contextSnapshot = currentPageAiContextSnapshot(); - var aggregate = contextSnapshot.aggregate || {}; - var body = contextSnapshot.body || {}; - var subtree = contextSnapshot.subtree || null; - var outline = subtree && subtree.outline ? subtree.outline : null; pageUiState.pageAiBusy = true; - pageUiState.pageAiMessages.push({ role: 'user', content: prompt }); - var currentSession = pageAiCurrentSession(); - if (currentSession) { - if (currentSession.title === '新会话') { - currentSession.title = prompt.length > 18 ? prompt.slice(0, 18) + '…' : prompt; - } - currentSession.messages = pageUiState.pageAiMessages.slice(); - currentSession.updatedAt = Date.now(); - } - pageAiPersistSessions(); - renderPageAiConversation(); + var currentSession = null; try { - var response = await fetch('/api/ai-agent/run', { + await pageAiEnsureHermesSession(); + var contextSnapshot = currentPageAiContextSnapshot(); + var aggregate = contextSnapshot.aggregate || {}; + var body = contextSnapshot.body || {}; + var subtree = contextSnapshot.subtree || null; + var outline = subtree && subtree.outline ? subtree.outline : null; + pageUiState.pageAiMessages.push({ role: 'user', content: prompt }); + currentSession = pageAiCurrentSession(); + if (currentSession) { + if (currentSession.title === '新会话') { + currentSession.title = prompt.length > 18 ? prompt.slice(0, 18) + '…' : prompt; + } + currentSession.messages = pageUiState.pageAiMessages.slice(); + currentSession.updatedAt = Date.now(); + } + renderPageAiConversation(); + var response = await fetch('/api/hermes/client/runs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ - stream: true, - maxSteps: 8, - scope: 'document', - messages: [{ role: 'user', content: prompt }], - toolChoice: { - mode: 'auto', - toolSets: ['toolset.readonly', 'toolset.rag_read', 'toolset.docs_read', 'toolset.media_read', 'toolset.doc_read', 'toolset.doc_write', 'toolset.slash_write'] - }, - context: { - documentId: currentDocumentId(), + workspaceId: resolveWorkspaceId(document.body), + documentId: currentDocumentId(), + sessionId: pageUiState.pageAiActiveSessionId, + message: prompt, + model: 'hermes-agent', + pageContext: { documentBlocks: body.content || null, node: { documentId: currentDocumentId(), @@ -3343,23 +3402,54 @@ const SIDEBAR_TREE_JS: &str = r##" evidence: null, pageOptions: currentPageOptions() }, - options: { - searxng: true, - ai: { provider: pageUiState.pageAiProvider } - } + selectedBlockId: null, + selectedText: null, + traceId: 'page-ai-run-' + Date.now().toString(36) }) }); if (!response.ok) { - throw new Error('page_ai_failed_' + response.status); + var errorPayload = await response.json().catch(function(){ return null; }); + throw new Error(errorPayload && errorPayload.code ? errorPayload.code : 'page_ai_failed_' + response.status); + } + var runPayload = await response.json().catch(function(){ return null; }); + var upstream = runPayload && runPayload.upstream ? runPayload.upstream : runPayload; + var runId = upstream && (upstream.run_id || upstream.runId); + if (!runId) throw new Error('hermes_run_missing_run_id'); + var eventResponse = await fetch('/api/hermes/client/events/' + encodeURIComponent(runId), { + headers: { 'accept': 'text/event-stream' } + }); + if (!eventResponse.ok) { + var eventError = await eventResponse.json().catch(function(){ return null; }); + throw new Error(eventError && eventError.code ? eventError.code : 'hermes_events_failed_' + eventResponse.status); } var assistantText = ''; - await streamPageAiResponse(response, function(eventName, payloadText) { - if (eventName === 'assistant_message') { + await streamPageAiResponse(eventResponse, function(eventName, payloadText) { + if (eventName === 'assistant_message' || eventName === 'message.delta') { try { var payload = JSON.parse(payloadText || 'null'); - assistantText = searchText(payload && payload.text); + assistantText += searchText((payload && (payload.text || payload.delta)) || ''); + } catch (_) { + assistantText += searchText(payloadText); + } + } + if (eventName === 'run.completed') { + try { + var completed = JSON.parse(payloadText || 'null'); + if (completed && completed.output) assistantText = searchText(completed.output); } catch (_) {} } + if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') { + try { + var toolEvent = JSON.parse(payloadText || 'null'); + pageUiState.pageAiMessages.push({ + role: 'tool', + content: String(toolEvent && (toolEvent.name || toolEvent.tool || eventName) || eventName) + }); + } catch (_) { + pageUiState.pageAiMessages.push({ role: 'tool', content: eventName }); + } + renderPageAiConversation(); + } }); pageUiState.pageAiMessages.push({ role: 'assistant', @@ -3370,18 +3460,16 @@ const SIDEBAR_TREE_JS: &str = r##" currentSession.messages = pageUiState.pageAiMessages.slice(); currentSession.updatedAt = Date.now(); } - pageAiPersistSessions(); } catch (error) { pageUiState.pageAiMessages.push({ role: 'assistant', - content: pageAiProviderLabel(pageUiState.pageAiProvider) + ' 当前请求失败:' + (error instanceof Error ? error.message : String(error)) + content: 'Hermes 当前请求失败:' + (error instanceof Error ? error.message : String(error)) }); currentSession = pageAiCurrentSession(); if (currentSession) { currentSession.messages = pageUiState.pageAiMessages.slice(); currentSession.updatedAt = Date.now(); } - pageAiPersistSessions(); } finally { pageUiState.pageAiBusy = false; renderPageAiConversation(); @@ -3937,6 +4025,20 @@ const SIDEBAR_TREE_JS: &str = r##" return; } + var pageAiIntent = closestAction(e.target, '[data-page-ai-intent]'); + if (pageAiIntent) { + e.preventDefault(); + var intentName = pageAiIntent.getAttribute('data-page-ai-intent') || ''; + if (intentName === 'create-summary') { + void sendPageAiMessage('请通过 Hermes 调用 mnote.artifact.create_summary,为当前页面创建或更新 AI Summary。'); + return; + } + if (intentName === 'create-ai-note') { + void sendPageAiMessage('请通过 Hermes 调用 mnote.artifact.create_ai_note,基于当前页面创建一篇新的 AI Note。'); + return; + } + } + var pageAiProvider = closestAction(e.target, '[data-page-ai-provider]'); if (pageAiProvider) { e.preventDefault(); diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index 0047c1b5..adf1e273 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -2690,6 +2690,13 @@ body { gap: 8px; } +.wolai-page-ai-intents { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + .wolai-page-ai-suggestion-list, .wolai-page-ai-toolbar { display: flex; diff --git a/scripts/task-hermes-page-ai-artifact-smoke.js b/scripts/task-hermes-page-ai-artifact-smoke.js new file mode 100644 index 00000000..2600ee5b --- /dev/null +++ b/scripts/task-hermes-page-ai-artifact-smoke.js @@ -0,0 +1,285 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + renameDocument, + requestJson, +} = require("./tree-shell-smoke-helpers"); + +async function callArtifact(request, target, suffix, toolName, args) { + return requestJson(request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": "smoke-user" }, + data: { + toolName, + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId: "smoke-user", + sessionId: `mnote_smoke_artifact_${suffix}`, + runId: `run_smoke_artifact_${suffix}`, + toolCallId: `call_${toolName.replace(/\W+/g, "_")}_${suffix}`, + traceId: `trace_smoke_artifact_${suffix}`, + idempotencyKey: `idem_${toolName.replace(/\W+/g, "_")}_${suffix}`, + dryRun: false, + capabilityScope: ["artifact.write"], + args, + }, + }); +} + +function assertArtifactReferenceEdge(edgesPayload, sourceDocumentId, artifactDocumentId, artifactType) { + const edges = Array.isArray(edgesPayload?.result?.edges) ? edgesPayload.result.edges : []; + const edge = edges.find( + (candidate) => + candidate && + candidate.fromNodeId === sourceDocumentId && + candidate.toNodeId === artifactDocumentId && + candidate.metadata?.kind === "ai_artifact_reference", + ); + assert(edge, `未查询到 ${artifactDocumentId} 的 ai_artifact_reference edge`); + assert.equal(edge.edgeType, "source_of", `${artifactDocumentId} reference edge 类型不对`); + assert.equal(edge.metadata.artifactType, artifactType, `${artifactDocumentId} artifactType 不对`); + assert.equal(edge.metadata.projectionOnlyGroup, "AI Artifacts", `${artifactDocumentId} projection group 不对`); +} + +async function main() { + const suffix = Date.now().toString(36); + const title = `TEST-HERMES-AI-artifact-${suffix}`; + const quickSessionId = `mnote_smoke_artifact_quick_${suffix}`; + let quickRunIndex = 0; + const quickRunBodies = []; + const createdIds = []; + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + + try { + await page.route("**/api/hermes/tools/mnote/call", async (route) => { + throw new Error(`页面 AI artifact 快捷入口不应直连 mnote tool route: ${route.request().url()}`); + }); + await page.route("**/api/hermes/client/sessions", async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + sessionId: quickSessionId, + title: "当前页问答", + traceId: `trace_quick_session_${suffix}`, + }), + }); + }); + await page.route(`**/api/hermes/client/sessions/${quickSessionId}`, async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + sessionId: quickSessionId, + session: { sessionId: quickSessionId, messages: [] }, + }), + }); + }); + await page.route("**/api/hermes/client/runs", async (route) => { + quickRunIndex += 1; + quickRunBodies.push(JSON.parse(route.request().postData() || "{}")); + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + sessionId: quickSessionId, + runId: `run_quick_artifact_${suffix}_${quickRunIndex}`, + traceId: `trace_quick_artifact_${suffix}_${quickRunIndex}`, + }), + }); + }); + await page.route("**/api/hermes/client/events/**", async (route) => { + const runId = route.request().url().split("/").pop(); + await route.fulfill({ + status: 200, + headers: { "content-type": "text/event-stream; charset=utf-8" }, + body: + `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: quickSessionId, delta: "artifact intent queued" })}\n\n` + + `data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: quickSessionId, output: "artifact intent queued" })}\n\n`, + }); + }); + + await ensureAuthenticated(page, context.request); + const target = await createTempDocument(context.request); + createdIds.push(target.documentId); + await renameDocument(context.request, target.workspaceId, target.documentId, title); + + const summary = await callArtifact(context.request, target, suffix, "mnote.artifact.create_summary", { + summary: `摘要 ${suffix}`, + }); + const summaryRetry = await callArtifact(context.request, target, suffix, "mnote.artifact.create_summary", { + summary: `摘要 ${suffix}`, + }); + const aiNote = await callArtifact(context.request, target, suffix, "mnote.artifact.create_ai_note", { + content: `AI Note ${suffix}`, + }); + const aiNoteSecond = await callArtifact(context.request, target, `${suffix}_second`, "mnote.artifact.create_ai_note", { + content: `AI Note second ${suffix}`, + }); + const artifactDocumentIds = Array.from( + new Set([ + summary.result.artifactDocumentId, + aiNote.result.artifactDocumentId, + aiNoteSecond.result.artifactDocumentId, + ]), + ); + createdIds.push(...artifactDocumentIds); + + for (const result of [summary, aiNote, aiNoteSecond]) { + assert.equal(result.ok, true, `${result.toolName} 应成功`); + assert.equal(result.audit.effect, "write", `${result.toolName} audit 应为 write`); + assert.equal(result.result.commandName, "tree.node.create", `${result.toolName} 必须走 tree.node.create`); + assert.equal(result.audit.commandId, result.result.commandId, `${result.toolName} audit commandId 必须指向 Rust commandId`); + assert(result.result.artifactDocumentId, `${result.toolName} 缺少 artifactDocumentId`); + assert.equal(result.result.referenceEdge?.from, target.documentId, `${result.toolName} reference edge from 不对`); + assert.equal(result.result.referenceEdge?.to, result.result.artifactDocumentId, `${result.toolName} reference edge to 不对`); + assert.equal(result.result.referenceEdge?.kind, "ai_artifact_reference", `${result.toolName} reference edge kind 不对`); + assert(result.result.artifacts?.commandLog, `${result.toolName} 缺少 commandLog artifact`); + assert(result.result.artifacts?.domainEvent, `${result.toolName} 缺少 domainEvent artifact`); + assert.equal( + result.result.artifacts.domainEvent.eventType, + "tree.node.created", + `${result.toolName} domain event 类型不对`, + ); + } + assert.equal(summaryRetry.result.commandId, summary.result.commandId, "summary 同 idempotencyKey 重试必须返回同一 commandId"); + assert.equal( + summaryRetry.result.artifactDocumentId, + summary.result.artifactDocumentId, + "summary 同页面重试必须指向同一 summary document", + ); + assert.notEqual( + aiNoteSecond.result.artifactDocumentId, + aiNote.result.artifactDocumentId, + "ai_note 多次创建必须生成独立 artifact document", + ); + + const edgePayload = await requestJson( + context.request, + `/api/kernel/edges?workspaceId=${encodeURIComponent(target.workspaceId)}&nodeId=${encodeURIComponent(target.documentId)}`, + { method: "GET" }, + ); + assertArtifactReferenceEdge(edgePayload, target.documentId, summary.result.artifactDocumentId, "summary"); + assertArtifactReferenceEdge(edgePayload, target.documentId, aiNote.result.artifactDocumentId, "ai_note"); + assertArtifactReferenceEdge(edgePayload, target.documentId, aiNoteSecond.result.artifactDocumentId, "ai_note"); + + const fileProjection = await requestJson( + context.request, + `/api/tree/projections/file?workspaceId=${encodeURIComponent(target.workspaceId)}&rootNodeId=${encodeURIComponent(target.documentId)}&depth=2`, + { method: "GET" }, + ); + const projectionItems = Array.isArray(fileProjection?.result?.items) ? fileProjection.result.items : []; + const aiArtifactsMeta = fileProjection?.result?.meta?.aiArtifacts || {}; + assert.equal(aiArtifactsMeta.title, "AI Artifacts", "AI Artifacts projection meta 标题不对"); + assert.equal(aiArtifactsMeta.projectionOnly, true, "AI Artifacts 必须是 projection-only 分组"); + assert.equal(aiArtifactsMeta.kernelNodeId, null, "AI Artifacts 不应是真实 kernel node"); + assert( + !projectionItems.some((item) => item && item.nodeId === "AI Artifacts"), + "AI Artifacts 不应出现在 projection items 中作为真实 node", + ); + for (const artifactDocumentId of artifactDocumentIds) { + assert( + aiArtifactsMeta.artifactDocumentIds?.includes(artifactDocumentId), + `AI Artifacts projection meta 缺少 ${artifactDocumentId}`, + ); + } + + const auditPayload = await requestJson( + context.request, + `/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(`trace_smoke_artifact_${suffix}`)}`, + { + method: "GET", + headers: { "x-mnote-actor-id": "smoke-user" }, + }, + ); + const auditEvents = Array.isArray(auditPayload.events) ? auditPayload.events : []; + for (const result of [summary, aiNote]) { + assert( + auditEvents.some( + (event) => + event && + event.phase === "completed" && + event.toolCallId === result.toolCallId && + event.audit?.commandId === result.result.commandId, + ), + `${result.toolName} audit 未串到 Hermes tool call 和 Rust command`, + ); + } + + await page.goto( + `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`, + { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }, + ); + await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); + await page.locator('[data-page-ai-intent="create-summary"]').click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("artifact intent queued"), + undefined, + { timeout: UI_TIMEOUT_MS }, + ); + await page.locator('[data-page-ai-intent="create-ai-note"]').click({ timeout: UI_TIMEOUT_MS }); + const deadline = Date.now() + UI_TIMEOUT_MS; + while (quickRunBodies.length < 2 && Date.now() < deadline) { + await page.waitForTimeout(100); + } + assert.equal(quickRunBodies.length, 2, "两个 artifact 快捷入口都必须发送 Hermes run intent"); + assert( + quickRunBodies[0].message.includes("mnote.artifact.create_summary"), + "创建 Summary 快捷入口必须发送 Hermes artifact summary intent", + ); + assert( + quickRunBodies[1].message.includes("mnote.artifact.create_ai_note"), + "创建 AI Note 快捷入口必须发送 Hermes artifact ai_note intent", + ); + assert( + quickRunBodies.every((body) => body.documentId === target.documentId && body.sessionId === quickSessionId), + "artifact 快捷入口必须携带当前页面 documentId 与 Hermes sessionId", + ); + + console.log( + JSON.stringify( + { + ok: true, + baseUrl: BASE_URL, + documentId: target.documentId, + workspaceId: target.workspaceId, + summaryArtifactType: summary.result.artifactType, + aiNoteArtifactType: aiNote.result.artifactType, + summaryCommand: summary.result.commandName, + aiNoteCommand: aiNote.result.commandName, + summaryArtifactDocumentId: summary.result.artifactDocumentId, + aiNoteArtifactDocumentId: aiNote.result.artifactDocumentId, + aiNoteSecondArtifactDocumentId: aiNoteSecond.result.artifactDocumentId, + referenceEdgeCount: edgePayload.result.edges.length, + aiArtifactsMeta, + auditEventCount: auditEvents.length, + quickIntentMessages: quickRunBodies.map((body) => body.message), + }, + null, + 2, + ), + ); + } finally { + await cleanupDocuments(context.request, createdIds).catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task-hermes-page-ai-audit-smoke.js b/scripts/task-hermes-page-ai-audit-smoke.js new file mode 100644 index 00000000..63f0f561 --- /dev/null +++ b/scripts/task-hermes-page-ai-audit-smoke.js @@ -0,0 +1,207 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const { chromium } = require("playwright"); +const { + BASE_URL, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + renameDocument, + requestJson, +} = require("./tree-shell-smoke-helpers"); + +async function main() { + const suffix = Date.now().toString(36); + const title = `TEST-HERMES-AI-audit-${suffix}`; + const marker = `TEST-HERMES-AI-AUDIT-${suffix}`; + const createdIds = []; + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + + try { + await ensureAuthenticated(page, context.request); + const target = await createTempDocument(context.request); + createdIds.push(target.documentId); + await renameDocument(context.request, target.workspaceId, target.documentId, title); + + const readTraceId = `trace_audit_read_${suffix}`; + const writeTraceId = `trace_audit_write_${suffix}`; + const deniedTraceId = `trace_audit_denied_${suffix}`; + const readToolCallId = `call_audit_read_${suffix}`; + const writeToolCallId = `call_audit_write_${suffix}`; + const deniedToolCallId = `call_audit_denied_${suffix}`; + + const read = await requestJson(context.request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": "smoke-user" }, + data: { + toolName: "mnote.page.get", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId: "smoke-user", + sessionId: `mnote_audit_${suffix}`, + runId: `run_audit_read_${suffix}`, + toolCallId: readToolCallId, + traceId: readTraceId, + capabilityScope: ["page.read"], + }, + }); + assert.equal(read.audit.effect, "read", "读工具 audit effect 应为 read"); + + const write = await requestJson(context.request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": "smoke-user" }, + data: { + toolName: "mnote.page.save", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId: "smoke-user", + sessionId: `mnote_audit_${suffix}`, + runId: `run_audit_write_${suffix}`, + toolCallId: writeToolCallId, + traceId: writeTraceId, + idempotencyKey: `idem_audit_write_${suffix}`, + dryRun: false, + capabilityScope: ["page.write"], + args: { + mode: "replace", + content: [ + { + id: `audit_block_${suffix}`, + type: "paragraph", + content: [{ type: "text", text: marker }], + }, + ], + }, + }, + }); + assert.equal(write.audit.effect, "write", "写工具 audit effect 应为 write"); + assert(write.audit.commandId, "写工具 audit 必须包含 commandId"); + + const deniedResponse = await context.request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-mnote-actor-id": "smoke-user", + "x-mnote-workspace-id": "ws_denied", + }, + data: JSON.stringify({ + toolName: "mnote.page.save", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId: "smoke-user", + sessionId: `mnote_audit_${suffix}`, + runId: `run_audit_denied_${suffix}`, + toolCallId: deniedToolCallId, + traceId: deniedTraceId, + idempotencyKey: `idem_audit_denied_${suffix}`, + dryRun: false, + capabilityScope: ["page.write"], + args: { + mode: "replace", + content: [ + { + id: `audit_denied_block_${suffix}`, + type: "paragraph", + content: [{ type: "text", text: marker }], + }, + ], + }, + }), + }); + const deniedText = await deniedResponse.text(); + assert.equal(deniedResponse.status(), 403, "权限失败 smoke 应返回 workspace_context_conflict"); + assert(!deniedText.includes(title), "权限失败响应不应包含页面标题"); + assert(!deniedText.includes(marker), "权限失败响应不应包含正文 marker"); + + const readAudit = await requestJson( + context.request, + `/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(readTraceId)}`, + { + method: "GET", + headers: { "x-mnote-actor-id": "smoke-user" }, + }, + ); + const writeAudit = await requestJson( + context.request, + `/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(writeTraceId)}`, + { + method: "GET", + headers: { "x-mnote-actor-id": "smoke-user" }, + }, + ); + const writePersistedAudit = await requestJson( + context.request, + `/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(writeTraceId)}&persistedOnly=true`, + { + method: "GET", + headers: { "x-mnote-actor-id": "smoke-user" }, + }, + ); + const deniedPersistedAudit = await requestJson( + context.request, + `/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(deniedTraceId)}&persistedOnly=true`, + { + method: "GET", + headers: { "x-mnote-actor-id": "smoke-user" }, + }, + ); + + const readPhases = new Set((readAudit.events || []).map((event) => event.phase)); + const writePhases = new Set((writeAudit.events || []).map((event) => event.phase)); + const writePersistedPhases = new Set((writePersistedAudit.events || []).map((event) => event.phase)); + const deniedPersistedPhases = new Set((deniedPersistedAudit.events || []).map((event) => event.phase)); + assert(readPhases.has("started"), "读工具 audit 缺少 started"); + assert(readPhases.has("completed"), "读工具 audit 缺少 completed"); + assert(writePhases.has("started"), "写工具 audit 缺少 started"); + assert(writePhases.has("completed"), "写工具 audit 缺少 completed"); + assert(writePersistedPhases.has("started"), "持久化写工具 audit 缺少 started"); + assert(writePersistedPhases.has("completed"), "持久化写工具 audit 缺少 completed"); + assert(deniedPersistedPhases.has("failed"), "持久化权限失败 audit 缺少 failed"); + assert( + (writeAudit.events || []).some((event) => event.audit?.commandId === write.audit.commandId), + "写工具 audit 查询结果未串到 Rust commandId", + ); + assert( + (writePersistedAudit.events || []).some((event) => event.audit?.commandId === write.audit.commandId), + "持久化写工具 audit 查询结果未串到 Rust commandId", + ); + assert(!JSON.stringify(writeAudit).includes(marker), "audit 查询结果不应包含完整正文内容"); + assert(!JSON.stringify(writePersistedAudit).includes(marker), "持久化 audit 不应包含完整正文内容"); + assert(!JSON.stringify(deniedPersistedAudit).includes(marker), "权限失败持久化 audit 不应包含完整正文内容"); + assert(!JSON.stringify(deniedPersistedAudit).includes(title), "权限失败持久化 audit 不应包含页面标题"); + + console.log( + JSON.stringify( + { + ok: true, + baseUrl: BASE_URL, + documentId: target.documentId, + workspaceId: target.workspaceId, + readTraceId, + writeTraceId, + deniedTraceId, + writeCommandId: write.audit.commandId, + readPhases: Array.from(readPhases), + writePhases: Array.from(writePhases), + writePersistedPhases: Array.from(writePersistedPhases), + deniedPersistedPhases: Array.from(deniedPersistedPhases), + }, + null, + 2, + ), + ); + } finally { + await cleanupDocuments(context.request, createdIds).catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task-hermes-page-ai-baseline-smoke.js b/scripts/task-hermes-page-ai-baseline-smoke.js new file mode 100644 index 00000000..f6a84e7b --- /dev/null +++ b/scripts/task-hermes-page-ai-baseline-smoke.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + renameDocument, +} = require("./tree-shell-smoke-helpers"); + +async function main() { + const suffix = Date.now().toString(36); + const title = `TEST-HERMES-AI-baseline-${suffix}`; + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); + const page = await context.newPage(); + const captured = []; + const createdIds = []; + + page.on("request", (request) => { + const url = request.url(); + if (url.includes("/api/ai-agent/run") || url.includes("/api/hermes/client/")) { + captured.push({ + phase: "request", + method: request.method(), + url, + postData: request.postData() || "", + }); + } + }); + page.on("response", async (response) => { + const url = response.url(); + if (!url.includes("/api/ai-agent/run") && !url.includes("/api/hermes/client/")) { + return; + } + let body = ""; + try { + body = (await response.text()).slice(0, 1600); + } catch { + body = ""; + } + captured.push({ + phase: "response", + status: response.status(), + url, + body, + headers: response.headers(), + }); + }); + + try { + await ensureAuthenticated(page, context.request); + const target = await createTempDocument(context.request); + createdIds.push(target.documentId); + await renameDocument(context.request, target.workspaceId, target.documentId, title); + + await page.goto( + `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`, + { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }, + ); + await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); + await page.locator("[data-page-ai-input]").fill("概括当前页面标题和第一段", { timeout: UI_TIMEOUT_MS }); + await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + () => document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').length > 0, + null, + { timeout: UI_TIMEOUT_MS }, + ); + + const aiRequests = captured.filter((entry) => entry.phase === "request"); + assert(aiRequests.length > 0, "未捕获页面 AI 主请求"); + const firstRequest = aiRequests[0]; + const legacyRunHit = aiRequests.some((entry) => entry.url.includes("/api/ai-agent/run")); + const hermesClientHit = aiRequests.some((entry) => entry.url.includes("/api/hermes/client/")); + const providerHermes502 = captured.some( + (entry) => + entry.phase === "response" && + entry.status === 502 && + /provider|ai_provider_bridge_unavailable|hermes/i.test(entry.body || ""), + ); + + console.log( + JSON.stringify( + { + ok: true, + baseUrl: BASE_URL, + title, + documentId: target.documentId, + workspaceId: target.workspaceId, + firstRequestUrl: firstRequest.url, + legacyRunHit, + hermesClientHit, + providerHermes502, + captured, + }, + null, + 2, + ), + ); + } finally { + await cleanupDocuments(context.request, createdIds).catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task-hermes-page-ai-mobile-smoke.js b/scripts/task-hermes-page-ai-mobile-smoke.js new file mode 100644 index 00000000..d15f1502 --- /dev/null +++ b/scripts/task-hermes-page-ai-mobile-smoke.js @@ -0,0 +1,144 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + renameDocument, +} = require("./tree-shell-smoke-helpers"); + +async function main() { + const suffix = Date.now().toString(36); + const title = `TEST-HERMES-AI-mobile-${suffix}`; + const sessionId = `mnote_mobile_${suffix}`; + const runId = `run_mobile_${suffix}`; + const createdIds = []; + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + }); + const page = await context.newPage(); + + try { + await page.route("**/api/ai-agent/run", async (route) => { + throw new Error(`移动端页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`); + }); + await page.route("**/api/hermes/client/sessions", async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + sessionId, + title: "移动端问答", + traceId: "trace_mobile", + persistence: "hermes_on_first_run", + }), + }); + }); + await page.route(`**/api/hermes/client/sessions/${sessionId}`, async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + sessionId, + traceId: "trace_mobile_restore", + session: { + sessionId, + messages: [{ role: "assistant", content: "Mobile restored" }], + }, + }), + }); + }); + await page.route("**/api/hermes/client/runs", async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + sessionId, + runId, + events: [], + traceId: "trace_mobile", + }), + }); + }); + await page.route(`**/api/hermes/client/events/${runId}`, async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "text/event-stream; charset=utf-8" }, + body: + `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "Mobile " })}\n\n` + + `data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: sessionId, output: "Mobile response" })}\n\n`, + }); + }); + + await ensureAuthenticated(page, context.request); + const target = await createTempDocument(context.request); + createdIds.push(target.documentId); + await renameDocument(context.request, target.workspaceId, target.documentId, title); + await page.goto( + `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`, + { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }, + ); + + await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); + const metrics = await page.locator('[data-testid="wolai-page-ai-drawer"]').evaluate((drawer) => { + const panel = drawer.querySelector(".wolai-page-ai-panel"); + const drawerRect = drawer.getBoundingClientRect(); + const panelRect = panel ? panel.getBoundingClientRect() : drawerRect; + return { + viewportWidth: window.innerWidth, + documentWidth: document.documentElement.scrollWidth, + drawerLeft: drawerRect.left, + drawerRight: drawerRect.right, + panelLeft: panelRect.left, + panelRight: panelRect.right, + }; + }); + assert(metrics.drawerLeft >= 0, `drawer 左侧溢出: ${JSON.stringify(metrics)}`); + assert(metrics.drawerRight <= metrics.viewportWidth + 1, `drawer 右侧溢出: ${JSON.stringify(metrics)}`); + assert(metrics.panelLeft >= 0, `panel 左侧溢出: ${JSON.stringify(metrics)}`); + assert(metrics.panelRight <= metrics.viewportWidth + 1, `panel 右侧溢出: ${JSON.stringify(metrics)}`); + assert(metrics.documentWidth <= metrics.viewportWidth + 1, `页面出现横向滚动: ${JSON.stringify(metrics)}`); + + await page.locator("[data-page-ai-input]").fill(`请总结 ${title}`, { timeout: UI_TIMEOUT_MS }); + await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Mobile response"), + null, + { timeout: UI_TIMEOUT_MS }, + ); + + console.log( + JSON.stringify( + { + ok: true, + baseUrl: BASE_URL, + documentId: target.documentId, + workspaceId: target.workspaceId, + sessionId, + runId, + metrics, + }, + null, + 2, + ), + ); + } finally { + await cleanupDocuments(context.request, createdIds).catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task-hermes-page-ai-retirement-guard.js b/scripts/task-hermes-page-ai-retirement-guard.js new file mode 100644 index 00000000..85c63009 --- /dev/null +++ b/scripts/task-hermes-page-ai-retirement-guard.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); + +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000); + +async function fetchWithTimeout(path, init = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + return await fetch(`${BASE_URL}${path}`, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + +async function main() { + const response = await fetchWithTimeout("/api/ai-agent/run", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + stream: true, + messages: [{ role: "user", content: "ping" }], + context: { documentId: "retirement_guard" }, + options: { ai: { provider: "hermes" } }, + }), + }); + const text = await response.text(); + let payload = null; + try { + payload = JSON.parse(text); + } catch { + payload = { raw: text }; + } + + assert( + response.status === 410, + `/api/ai-agent/run legacy guard 应返回退场状态,实际 ${response.status}: ${text.slice(0, 500)}`, + ); + const code = response.headers.get("x-error-code") || payload.code || ""; + const owner = response.headers.get("x-mnote-ai-execution-owner") || ""; + assert( + code === "legacy_ai_agent_run_retired" && owner.includes("retired"), + `legacy guard 不应静默 fallback,code=${code}, owner=${owner}, body=${text.slice(0, 500)}`, + ); + + console.log( + JSON.stringify( + { + ok: true, + baseUrl: BASE_URL, + status: response.status, + code, + owner, + }, + null, + 2, + ), + ); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task-hermes-page-ai-smoke.js b/scripts/task-hermes-page-ai-smoke.js new file mode 100644 index 00000000..23d20e6c --- /dev/null +++ b/scripts/task-hermes-page-ai-smoke.js @@ -0,0 +1,176 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + renameDocument, +} = require("./tree-shell-smoke-helpers"); + +async function main() { + const suffix = Date.now().toString(36); + const title = `TEST-HERMES-AI-smoke-${suffix}`; + const sessionId = `mnote_smoke_${suffix}`; + const runId = `run_smoke_${suffix}`; + let sessionDetailHits = 0; + const captured = []; + const createdIds = []; + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); + const page = await context.newPage(); + + try { + await page.route("**/api/ai-agent/run", async (route) => { + throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`); + }); + await page.route("**/api/hermes/client/sessions", async (route) => { + captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" }); + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + sessionId, + title: "当前页问答", + traceId: "trace_smoke", + persistence: "hermes_on_first_run", + }), + }); + }); + await page.route(`**/api/hermes/client/sessions/${sessionId}`, async (route) => { + sessionDetailHits += 1; + captured.push({ kind: "session-detail", method: route.request().method(), body: "" }); + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + sessionId, + traceId: "trace_restore", + session: { + sessionId, + messages: [ + { role: "user", content: `请总结 ${title}` }, + { role: "tool", content: "mnote.page.get" }, + { role: "assistant", content: "Smoke restored from Hermes session" }, + ], + }, + }), + }); + }); + await page.route("**/api/hermes/client/runs", async (route) => { + captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" }); + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + sessionId, + runId, + events: [], + traceId: "trace_smoke", + }), + }); + }); + await page.route(`**/api/hermes/client/events/${runId}`, async (route) => { + captured.push({ kind: "events", method: route.request().method(), body: "" }); + await route.fulfill({ + status: 200, + headers: { "content-type": "text/event-stream; charset=utf-8" }, + body: + `data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, name: "mnote.page.get" })}\n\n` + + `data: ${JSON.stringify({ event: "tool.completed", run_id: runId, session_id: sessionId, name: "mnote.page.get" })}\n\n` + + `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "Smoke " })}\n\n` + + `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "response" })}\n\n` + + `data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: sessionId, output: "Smoke response" })}\n\n`, + }); + }); + + await ensureAuthenticated(page, context.request); + const target = await createTempDocument(context.request); + createdIds.push(target.documentId); + await renameDocument(context.request, target.workspaceId, target.documentId, title); + await page.goto( + `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`, + { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }, + ); + + await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); + await page.locator("[data-page-ai-input]").fill(`请总结 ${title}`, { timeout: UI_TIMEOUT_MS }); + await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Smoke response"), + null, + { timeout: UI_TIMEOUT_MS }, + ); + await page.waitForFunction( + () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("mnote.page.get"), + null, + { timeout: UI_TIMEOUT_MS }, + ); + + const sessionRequest = captured.find((entry) => entry.kind === "session"); + const runRequest = captured.find((entry) => entry.kind === "run"); + const eventRequest = captured.find((entry) => entry.kind === "events"); + assert(sessionRequest, "未捕获 /api/hermes/client/sessions 请求"); + assert(runRequest, "未捕获 /api/hermes/client/runs 请求"); + assert(eventRequest, "未捕获 /api/hermes/client/events 请求"); + const runBody = JSON.parse(runRequest.body); + assert.equal(runBody.sessionId, sessionId, "run 请求必须携带 Hermes sessionId"); + assert.equal(runBody.documentId, target.documentId, "run 请求必须携带 documentId"); + assert(runBody.pageContext, "run 请求必须携带 pageContext"); + + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Smoke restored from Hermes session"), + null, + { timeout: UI_TIMEOUT_MS }, + ); + await page.waitForFunction( + () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("mnote.page.get"), + null, + { timeout: UI_TIMEOUT_MS }, + ); + assert(sessionDetailHits > 0, "刷新后必须从 Hermes session detail 恢复消息,而不是从 mnote 本地消息数组恢复"); + const persisted = await page.evaluate(() => { + const keys = Object.keys(window.localStorage).filter((key) => key.startsWith("hermes_page_ai_session:")); + return keys.map((key) => ({ key, value: window.localStorage.getItem(key) })); + }); + assert( + persisted.every((entry) => !entry.value || !entry.value.includes("Smoke response")), + "mnote localStorage 不应保存完整聊天消息内容", + ); + + console.log( + JSON.stringify( + { + ok: true, + baseUrl: BASE_URL, + documentId: target.documentId, + workspaceId: target.workspaceId, + sessionId, + runId, + capturedKinds: captured.map((entry) => entry.kind), + sessionDetailHits, + }, + null, + 2, + ), + ); + } finally { + await cleanupDocuments(context.request, createdIds).catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task-hermes-page-ai-title-options-smoke.js b/scripts/task-hermes-page-ai-title-options-smoke.js new file mode 100644 index 00000000..79bc7947 --- /dev/null +++ b/scripts/task-hermes-page-ai-title-options-smoke.js @@ -0,0 +1,239 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const { chromium } = require("playwright"); +const { + BASE_URL, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + openDocument, + openSectionView, + renameDocument, + requestJson, + UI_TIMEOUT_MS, +} = require("./tree-shell-smoke-helpers"); + +async function main() { + const suffix = Date.now().toString(36); + const originalTitle = `TEST-HERMES-AI-title-original-${suffix}`; + const nextTitle = `TEST-HERMES-AI-title-updated-${suffix}`; + const createdIds = []; + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + + try { + await ensureAuthenticated(page, context.request); + const target = await createTempDocument(context.request); + createdIds.push(target.documentId); + await renameDocument(context.request, target.workspaceId, target.documentId, originalTitle); + + const common = { + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId: "smoke-user", + sessionId: `mnote_smoke_title_${suffix}`, + runId: `run_smoke_title_${suffix}`, + traceId: `trace_smoke_title_${suffix}`, + capabilityScope: ["page.write"], + dryRun: false, + }; + + const dryRunTitle = `TEST-HERMES-AI-title-dry-${suffix}`; + const titleDryRun = await requestJson(context.request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": "smoke-user" }, + data: { + ...common, + dryRun: true, + toolName: "mnote.page.update_title", + toolCallId: `call_title_dry_${suffix}`, + idempotencyKey: `idem_title_dry_${suffix}`, + args: { title: dryRunTitle }, + }, + }); + assert.equal(titleDryRun.result.dryRun, true, "标题 dryRun 不应写入"); + const metaAfterDryRun = await requestJson( + context.request, + `/api/documents/meta?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`, + { method: "GET" }, + ); + assert(!JSON.stringify(metaAfterDryRun).includes(dryRunTitle), "标题 dryRun 后 meta 不应变化"); + + const titleResult = await requestJson(context.request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": "smoke-user" }, + data: { + ...common, + toolName: "mnote.page.update_title", + toolCallId: `call_title_${suffix}`, + idempotencyKey: `idem_title_${suffix}`, + args: { title: nextTitle }, + }, + }); + assert.equal(titleResult.result.commandName, "page.head.updateTitle", "标题必须走 page.head.updateTitle"); + assert.equal(titleResult.audit.commandId, titleResult.result.commandId, "标题 audit commandId 必须指向 Rust commandId"); + const titleRetry = await requestJson(context.request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": "smoke-user" }, + data: { + ...common, + toolName: "mnote.page.update_title", + toolCallId: `call_title_${suffix}`, + idempotencyKey: `idem_title_${suffix}`, + args: { title: nextTitle }, + }, + }); + assert.equal( + titleRetry.result.commandId, + titleResult.result.commandId, + "标题同一 idempotencyKey 重试必须返回同一个 commandId", + ); + + const optionsResult = await requestJson(context.request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": "smoke-user" }, + data: { + ...common, + toolName: "mnote.page.update_options", + toolCallId: `call_options_${suffix}`, + idempotencyKey: `idem_options_${suffix}`, + args: { options: { wideLayout: true, smallText: true, pageFont: "serif" } }, + }, + }); + assert.equal( + optionsResult.result.commandName, + "page.layout.updateOptions", + "页面设置必须走 page.layout.updateOptions", + ); + assert.equal( + optionsResult.audit.commandId, + optionsResult.result.commandId, + "页面设置 audit commandId 必须指向 Rust commandId", + ); + assert( + Array.isArray(optionsResult.result.ignoredOptions) && + optionsResult.result.ignoredOptions.includes("pageFont"), + "planned/ui_only 页面设置字段必须明确返回 ignoredOptions", + ); + assert( + Array.isArray(optionsResult.result.warnings) && + optionsResult.result.warnings.some((warning) => warning && warning.code === "page_option_not_wired"), + "planned/ui_only 页面设置字段必须明确返回 warning", + ); + + const meta = await requestJson( + context.request, + `/api/documents/meta?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`, + { method: "GET" }, + ); + assert(JSON.stringify(meta).includes(nextTitle), "meta 未读到 AI 更新后的标题"); + assert(JSON.stringify(meta).includes("wide"), "meta 未读到页面设置更新结果"); + + await openDocument(page, target.workspaceId, target.documentId); + await openSectionView(page); + await page.waitForFunction( + (title) => { + const input = document.querySelector('[data-page-title-input="true"]'); + const current = document.querySelector('[data-page-title-current="true"]'); + return (input && input.value === title) || (current && current.textContent.includes(title)); + }, + nextTitle, + { timeout: UI_TIMEOUT_MS }, + ); + await page.waitForFunction( + ({ documentId, title }) => { + const selectors = [ + `#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(documentId)}"] .tree-link-title`, + `.tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(documentId)}"] > .tree-link > .tree-link-title`, + ]; + return selectors.some((selector) => { + const node = document.querySelector(selector); + return node && (node.textContent || "").includes(title); + }); + }, + { documentId: target.documentId, title: nextTitle }, + { timeout: UI_TIMEOUT_MS }, + ); + const uiState = await page.evaluate((documentId) => { + const shell = document.querySelector(".document-shell"); + const sessionKey = document.documentElement.getAttribute("data-mnote-page-ai-session-key") || ""; + return { + titleInput: document.querySelector('[data-page-title-input="true"]')?.value || "", + currentTitle: document.querySelector('[data-page-title-current="true"]')?.textContent || "", + sidebarTitle: + document.querySelector(`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(documentId)}"] .tree-link-title`) + ?.textContent || "", + wideLayout: shell?.getAttribute("data-page-wide-layout") || "", + smallText: shell?.getAttribute("data-page-small-text") || "", + pageAiSessionOwner: document.documentElement.getAttribute("data-mnote-page-ai-session-owner") || "", + pageAiSessionKey: sessionKey, + pageAiSessionStorage: sessionKey && window.localStorage ? window.localStorage.getItem(sessionKey) || "" : "", + }; + }, target.documentId); + assert( + uiState.titleInput === nextTitle || uiState.currentTitle.includes(nextTitle), + `刷新后页面 UI 未显示 AI 更新标题: ${JSON.stringify(uiState)}`, + ); + assert(uiState.sidebarTitle.includes(nextTitle), `sidebar 未显示 AI 更新标题: ${JSON.stringify(uiState)}`); + assert.equal(uiState.wideLayout, "true", "刷新后 document-shell 未应用 wideLayout"); + assert.equal(uiState.smallText, "true", "刷新后 document-shell 未应用 smallText"); + + await page.locator('[data-testid="wolai-floating-ai"]').click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + () => + document.documentElement.getAttribute("data-mnote-page-ai-session-owner") === "hermes" && + Boolean(document.documentElement.getAttribute("data-mnote-page-ai-session-key")), + undefined, + { timeout: UI_TIMEOUT_MS }, + ); + const aiSessionState = await page.evaluate(() => { + const key = document.documentElement.getAttribute("data-mnote-page-ai-session-key") || ""; + return { + owner: document.documentElement.getAttribute("data-mnote-page-ai-session-owner") || "", + key, + stored: key && window.localStorage ? window.localStorage.getItem(key) || "" : "", + pageTitle: + document.querySelector('[data-page-title-input="true"]')?.value || + document.querySelector('[data-page-title-current="true"]')?.textContent || + "", + }; + }); + assert.equal(aiSessionState.owner, "hermes", "页面 AI session owner 必须是 Hermes"); + const storedSessionState = JSON.parse(aiSessionState.stored || "{}"); + assert(storedSessionState.activeSessionId, "mnote 本地只应保存 Hermes activeSessionId"); + assert(!aiSessionState.stored.includes(nextTitle), "mnote 本地 AI session 状态不应保存或驱动页面标题真相"); + assert(aiSessionState.pageTitle.includes(nextTitle), "页面标题真相必须仍来自页面 UI / Page Aggregate"); + + console.log( + JSON.stringify( + { + ok: true, + baseUrl: BASE_URL, + documentId: target.documentId, + workspaceId: target.workspaceId, + title: nextTitle, + titleCommand: titleResult.result.commandName, + optionsCommand: optionsResult.result.commandName, + ignoredOptions: optionsResult.result.ignoredOptions, + warningCodes: optionsResult.result.warnings.map((warning) => warning.code), + uiState, + aiSessionState, + }, + null, + 2, + ), + ); + } finally { + await cleanupDocuments(context.request, createdIds).catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task-hermes-page-ai-tool-smoke.js b/scripts/task-hermes-page-ai-tool-smoke.js new file mode 100644 index 00000000..f0d01f2c --- /dev/null +++ b/scripts/task-hermes-page-ai-tool-smoke.js @@ -0,0 +1,91 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + renameDocument, + requestJson, +} = require("./tree-shell-smoke-helpers"); + +async function main() { + const suffix = Date.now().toString(36); + const title = `TEST-HERMES-AI-tool-${suffix}`; + const createdIds = []; + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + + try { + await ensureAuthenticated(page, context.request); + const target = await createTempDocument(context.request); + createdIds.push(target.documentId); + await renameDocument(context.request, target.workspaceId, target.documentId, title); + + const manifest = await requestJson(context.request, "/api/hermes/tools/mnote/manifest", { + method: "GET", + headers: { "x-mnote-actor-id": "smoke-user" }, + }); + const tools = manifest?.manifest?.tools || []; + assert(tools.some((tool) => tool.name === "mnote.page.get"), "manifest 缺少 mnote.page.get"); + + const call = await requestJson(context.request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": "smoke-user" }, + data: { + toolName: "mnote.page.get", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId: "smoke-user", + sessionId: `mnote_smoke_tool_${suffix}`, + runId: `run_smoke_tool_${suffix}`, + toolCallId: `call_smoke_tool_${suffix}`, + traceId: `trace_smoke_tool_${suffix}`, + capabilityScope: ["page.read"], + args: { + includeBody: true, + includeOptions: true, + includeBlocks: true, + }, + }, + }); + + assert.equal(call.ok, true, "tool call 应成功"); + assert.equal(call.toolName, "mnote.page.get", "toolName 不一致"); + assert.equal(call.toolCallId, `call_smoke_tool_${suffix}`, "toolCallId 不一致"); + assert.equal(call.result.documentId, target.documentId, "工具结果 documentId 不一致"); + assert.equal(call.result.workspaceId, target.workspaceId, "工具结果 workspaceId 不一致"); + assert(call.result.title, "工具结果缺少标题"); + assert(call.audit && call.audit.effect === "read", "工具结果缺少 read audit"); + + console.log( + JSON.stringify( + { + ok: true, + baseUrl: BASE_URL, + documentId: target.documentId, + workspaceId: target.workspaceId, + toolName: call.toolName, + title: call.result.title, + audit: call.audit, + }, + null, + 2, + ), + ); + } finally { + await cleanupDocuments(context.request, createdIds).catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task-hermes-page-ai-writeback-smoke.js b/scripts/task-hermes-page-ai-writeback-smoke.js new file mode 100644 index 00000000..02943ab1 --- /dev/null +++ b/scripts/task-hermes-page-ai-writeback-smoke.js @@ -0,0 +1,224 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + openDocument, + renameDocument, + requestJson, +} = require("./tree-shell-smoke-helpers"); + +async function assertPageAiPermissionFailureUi(page, target, suffix) { + const sessionId = `mnote_smoke_permission_${suffix}`; + const runId = `run_smoke_permission_${suffix}`; + const sessionRoute = "**/api/hermes/client/sessions"; + const sessionDetailRoute = `**/api/hermes/client/sessions/${sessionId}`; + const runsRoute = "**/api/hermes/client/runs"; + const eventsRoute = `**/api/hermes/client/events/${runId}`; + + await page.route(sessionRoute, async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + sessionId, + title: "权限失败 UI 验证", + traceId: `trace_permission_${suffix}`, + }), + }); + }); + await page.route(sessionDetailRoute, async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + sessionId, + session: { sessionId, messages: [] }, + }), + }); + }); + await page.route(runsRoute, async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + upstream: { run_id: runId, status: "started" }, + traceId: `trace_permission_${suffix}`, + }), + }); + }); + await page.route(eventsRoute, async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "text/event-stream; charset=utf-8" }, + body: + `data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, tool: "mnote.page.save" })}\n\n` + + `data: ${JSON.stringify({ event: "tool.failed", run_id: runId, session_id: sessionId, tool: "mnote.page.save", error: { code: "permission_denied" } })}\n\n` + + `data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: sessionId, output: "permission_denied" })}\n\n`, + }); + }); + + try { + await openDocument(page, target.workspaceId, target.documentId); + await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); + await page.locator("[data-page-ai-input]").fill("权限失败 UI 验证", { timeout: UI_TIMEOUT_MS }); + await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + () => { + const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || ""; + return text.includes("mnote.page.save") && text.includes("permission_denied"); + }, + null, + { timeout: UI_TIMEOUT_MS }, + ); + } finally { + await page.unroute(sessionRoute).catch(() => undefined); + await page.unroute(sessionDetailRoute).catch(() => undefined); + await page.unroute(runsRoute).catch(() => undefined); + await page.unroute(eventsRoute).catch(() => undefined); + } +} + +async function main() { + const suffix = Date.now().toString(36); + const title = `TEST-HERMES-AI-write-${suffix}`; + const marker = `TEST-HERMES-AI-WRITEBACK-${suffix}`; + const createdIds = []; + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + + try { + await ensureAuthenticated(page, context.request); + const target = await createTempDocument(context.request); + createdIds.push(target.documentId); + await renameDocument(context.request, target.workspaceId, target.documentId, title); + + const baseTool = { + toolName: "mnote.page.save", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId: "smoke-user", + sessionId: `mnote_smoke_write_${suffix}`, + runId: `run_smoke_write_${suffix}`, + toolCallId: `call_smoke_write_${suffix}`, + traceId: `trace_smoke_write_${suffix}`, + idempotencyKey: `idem_smoke_write_${suffix}`, + capabilityScope: ["page.write"], + args: { + mode: "replace", + content: [ + { + id: `block_${suffix}`, + type: "paragraph", + content: [{ type: "text", text: marker }], + }, + ], + }, + }; + + const deniedResponse = await context.request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-mnote-actor-id": "smoke-user", + "x-mnote-workspace-id": "ws_denied", + }, + data: JSON.stringify({ + ...baseTool, + traceId: `trace_smoke_write_denied_${suffix}`, + toolCallId: `call_smoke_write_denied_${suffix}`, + idempotencyKey: `idem_smoke_write_denied_${suffix}`, + }), + }); + const deniedText = await deniedResponse.text(); + assert.equal(deniedResponse.status(), 403, "workspace 上下文冲突应返回 403"); + assert(deniedText.includes("workspace_context_conflict"), "权限失败应返回稳定 workspace_context_conflict"); + assert(!deniedText.includes(title), "权限失败响应不应泄露页面标题"); + assert(!deniedText.includes(marker), "权限失败响应不应泄露正文内容"); + await assertPageAiPermissionFailureUi(page, target, suffix); + + const dryRun = await requestJson(context.request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": "smoke-user" }, + data: { ...baseTool, dryRun: true, idempotencyKey: `idem_smoke_write_dry_${suffix}` }, + }); + assert.equal(dryRun.result.dryRun, true, "dryRun 不应写入"); + const dryRunContent = await requestJson( + context.request, + `/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`, + { method: "GET" }, + ); + assert(!JSON.stringify(dryRunContent).includes(marker), "dryRun 后不应读到 AI 写入标记"); + + await openDocument(page, target.workspaceId, target.documentId); + await page + .locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]') + .waitFor({ state: "attached", timeout: UI_TIMEOUT_MS }) + .catch(() => undefined); + + const write = await requestJson(context.request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": "smoke-user" }, + data: { ...baseTool, dryRun: false }, + }); + assert.equal(write.ok, true, "写入 tool call 应成功"); + assert.equal(write.audit.effect, "write", "写入 audit effect 应为 write"); + assert.equal(write.result.commandName, "page.body.save", "写入必须走 page.body.save"); + assert(write.result.commandId, "写入结果必须包含 Rust commandId"); + assert.equal(write.audit.commandId, write.result.commandId, "audit commandId 必须指向 Rust commandId"); + await page.waitForFunction((expected) => (document.body.textContent || "").includes(expected), marker, { + timeout: UI_TIMEOUT_MS, + }); + + const retry = await requestJson(context.request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": "smoke-user" }, + data: { ...baseTool, dryRun: false }, + }); + assert.equal(retry.ok, true, "幂等重试 tool call 应成功"); + assert.equal(retry.result.commandId, write.result.commandId, "同一 idempotencyKey 重试必须返回同一个 commandId"); + assert.equal(retry.audit.commandId, write.audit.commandId, "同一 idempotencyKey 重试必须返回同一个 audit commandId"); + + const content = await requestJson( + context.request, + `/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`, + { method: "GET" }, + ); + assert(JSON.stringify(content).includes(marker), "刷新读取内容后未找到 AI 写入标记"); + + console.log( + JSON.stringify( + { + ok: true, + baseUrl: BASE_URL, + documentId: target.documentId, + workspaceId: target.workspaceId, + marker, + commandName: write.result.commandName, + audit: write.audit, + }, + null, + 2, + ), + ); + } finally { + await cleanupDocuments(context.request, createdIds).catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task052-ai-tools-runtime-smoke.js b/scripts/task052-ai-tools-runtime-smoke.js index 447ab1a1..eb784121 100644 --- a/scripts/task052-ai-tools-runtime-smoke.js +++ b/scripts/task052-ai-tools-runtime-smoke.js @@ -1,5 +1,15 @@ "use strict"; +if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") { + console.log(JSON.stringify({ + ok: true, + retired: true, + script: "task052-ai-tools-runtime-smoke.js", + reason: "旧 /api/ai-agent/run AI tools runtime smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。", + }, null, 2)); + process.exit(0); +} + const { chromium } = require("playwright"); const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; diff --git a/scripts/task111-phase7-document-ai-online-smoke.js b/scripts/task111-phase7-document-ai-online-smoke.js index 43f916b4..ef273cf4 100644 --- a/scripts/task111-phase7-document-ai-online-smoke.js +++ b/scripts/task111-phase7-document-ai-online-smoke.js @@ -1,5 +1,15 @@ "use strict"; +if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") { + console.log(JSON.stringify({ + ok: true, + retired: true, + script: "task111-phase7-document-ai-online-smoke.js", + reason: "旧 /api/ai-agent/run phase7 online smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。", + }, null, 2)); + process.exit(0); +} + const { chromium } = require("playwright"); const { BASE_URL, diff --git a/scripts/task155-e27-ai-edit-smoke.js b/scripts/task155-e27-ai-edit-smoke.js index 3e00025e..9eb5aa12 100644 --- a/scripts/task155-e27-ai-edit-smoke.js +++ b/scripts/task155-e27-ai-edit-smoke.js @@ -1,6 +1,16 @@ #!/usr/bin/env node "use strict"; +if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") { + console.log(JSON.stringify({ + ok: true, + retired: true, + script: "task155-e27-ai-edit-smoke.js", + reason: "旧 /api/ai-agent/run E27 AI 编辑 smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。", + }, null, 2)); + process.exit(0); +} + const assert = require("node:assert"); const fs = require("node:fs"); const { chromium } = require("playwright"); diff --git a/scripts/task156-e27-ai-writeback-smoke.js b/scripts/task156-e27-ai-writeback-smoke.js index de3cb079..98e74fa7 100644 --- a/scripts/task156-e27-ai-writeback-smoke.js +++ b/scripts/task156-e27-ai-writeback-smoke.js @@ -1,6 +1,16 @@ #!/usr/bin/env node "use strict"; +if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") { + console.log(JSON.stringify({ + ok: true, + retired: true, + script: "task156-e27-ai-writeback-smoke.js", + reason: "旧 /api/ai-agent/run E27 AI 写回 smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。", + }, null, 2)); + process.exit(0); +} + const assert = require("node:assert"); const fs = require("node:fs"); const { chromium } = require("playwright"); diff --git a/scripts/task178-page-ai-local-subtree-context-smoke.js b/scripts/task178-page-ai-local-subtree-context-smoke.js index 047eb581..e9a76cba 100644 --- a/scripts/task178-page-ai-local-subtree-context-smoke.js +++ b/scripts/task178-page-ai-local-subtree-context-smoke.js @@ -1,6 +1,16 @@ #!/usr/bin/env node "use strict"; +if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") { + console.log(JSON.stringify({ + ok: true, + retired: true, + script: "task178-page-ai-local-subtree-context-smoke.js", + reason: "旧 /api/ai-agent/run 页面 AI context smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。", + }, null, 2)); + process.exit(0); +} + const assert = require("node:assert"); const { chromium } = require("playwright"); const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");