feat(ai): switch page ai to hermes panel

This commit is contained in:
lix-2026
2026-05-14 15:10:33 +08:00
parent e9188716e6
commit 9816035491
48 changed files with 6353 additions and 412 deletions
@@ -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。
@@ -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`
@@ -3,7 +3,7 @@
> 状态说明:
> - 本稿定义的 default gate 已在当前主线代码中成立:`mnote-web` 是 `3000` ownerNext 仅保留 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
## 目标
@@ -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 分支。
@@ -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
---
@@ -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. 文档目的
@@ -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`
@@ -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”,而不是“绕过系统写编辑器”。
注:历史 `/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
---
@@ -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 写临时 classRust 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 contextHermes 通过 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 pluginWolai 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 / EmojiE27 暂停在 online smoke 模型网关层排障状态。当前已确认 `3000 -> 8000` orchestrator 主链可达,本地 `task155/task156` 通过;剩余在线阻塞点在 `20128 /v1/responses` 上游模型/渠道可用性,以及后续真实 actor 收口。恢复 E27 时应从这两点继续,不要回退成“主入口未迁完”
- E27 当前推进备注:2026-05-03 主线程先切到 E28 Mention / EmojiE27 暂停在 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 恢复 `<img>`;点击图片后出现 `image-floating-toolbar`,删除入口本切片保持禁用,左/中/右对齐通过官方 Image 扩展 `addAttributes("data-align")` + `updateAttributes("image")` 持久化,同源下载通过隐藏 `<a download>` 触发且不改文档;`task152-e24-image-smoke.js` 覆盖入口、实际图片加载、toolbar、同源下载、删除入口禁用态、居中对齐、保存请求、content API 和刷新恢复;剩余上传、最近上传、caption、resize、replace、跨源下载 fallback、删除保存链、移动端 toolbar、失败态 |
| E25 | PARTIAL | TOC 能力包 | 已完成 `/toc` 最小真源闭环:`leptos-tiptap` 新增真实 `tocNode` schema/commandslash `页面目录 /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 proxyHermes 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 先切 E28E27 暂停在 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 proxyHermes 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/eventsmnote 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 toolbarWolai 只读基线已确认 selection/type menu 和 table popper 的 Esc/外部点击/URL 不变,slash 与块菜单二级菜单在只读条件下不稳定,后续仍需 editable-test 复核 Enter、外部点击和更多层级收起 |
@@ -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 暴露业务工具
---
@@ -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
@@ -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`
File diff suppressed because it is too large Load Diff
@@ -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` 请求。
@@ -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。
@@ -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 来自 Hermespage 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 sessionHermes 需要最新正文时通过 `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 smokesession 状态条。
- 验收标准:断言 session title/model/run status 可见。
- [ ] I2. 新增或扩展 browser smokerun stop/retry。
- 验收标准:至少覆盖 stop;retry 若未实现则断言按钮不存在或禁用。
- [ ] I3. 新增或扩展 browser smokecontext scope。
- 验收标准:选区/当前页上下文能进入 run payload 或 tool audit。
- [ ] I4. 新增或扩展 browser smoketool 可用性与 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 自己的管理台。
@@ -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 proxymnote 能力通过 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 proxymnote 能力通过 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 设计稿。
## 本次修改文件
@@ -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
@@ -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 才是会话真相;
+203 -16
View File
@@ -7376,10 +7376,10 @@ fn build_sidebar_kernel_nodes(
}
fn build_sidebar_kernel_edges(nodes: &[KernelNode]) -> Vec<KernelEdge> {
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<KernelEdge> {
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)
})
.map(|edge| edge.to_node_id.clone())
.collect::<Vec<_>>();
json!({
"title": "AI Artifacts",
"projectionOnly": true,
"source": "kernel.project_view.synthetic_group",
"kernelNodeId": Value::Null,
"edgeKind": "ai_artifact_reference",
"artifactDocumentIds": artifact_document_ids,
})
.collect()
}
fn kernel_node_sort_order(node: &KernelNode) -> i64 {
@@ -7979,16 +8028,7 @@ fn build_file_tree_projection_result(
let visible_rows = items.len();
let visible_edges = edges.len();
KernelProjectionResult {
projection_id: format!(
"kernel_projection:file_tree:{}",
root_node_id.unwrap_or("root")
),
projection: KernelProjectionKind::FileTree,
root_node_id: root_node_id.map(ToOwned::to_owned),
items,
edges,
meta: BTreeMap::from([(
let mut meta = BTreeMap::from([(
"search".into(),
json!({
"query": requested_query.clone(),
@@ -8012,7 +8052,22 @@ fn build_file_tree_projection_result(
},
},
}),
)]),
)]);
meta.insert(
"aiArtifacts".into(),
ai_artifacts_projection_group_meta(&edges),
);
KernelProjectionResult {
projection_id: format!(
"kernel_projection:file_tree:{}",
root_node_id.unwrap_or("root")
),
projection: KernelProjectionKind::FileTree,
root_node_id: root_node_id.map(ToOwned::to_owned),
items,
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::<Vec<_>>();
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 {
@@ -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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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(())
}
@@ -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<Item = &'static str>) -> Value {
json!({
"name": name,
"description": "已冻结合同,按 7-4 后续 task 接入 Rust runtime / kernel",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": scope.into_iter().collect::<Vec<_>>(),
"status": "planned"
})
}
@@ -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<String>,
pub document_id: Option<String>,
pub actor_id: Option<String>,
pub session_id: Option<String>,
pub run_id: Option<String>,
pub tool_call_id: Option<String>,
pub trace_id: Option<String>,
pub idempotency_key: Option<String>,
pub dry_run: Option<bool>,
pub capability_scope: Option<Vec<String>>,
pub args: Option<Value>,
}
impl ToolCallInput {
pub fn arg_string(&self, key: &str) -> Option<String> {
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<Value> {
self.args.as_ref().and_then(|args| args.get(key)).cloned()
}
pub fn effective_workspace_id(&self) -> Option<String> {
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<String> {
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()
}
}
@@ -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<Value, WebError> {
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::<Vec<_>>()
.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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<Value>,
) -> Result<Value, WebError> {
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<Value>) {
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<String>, Vec<Value>) {
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<Value> {
let mut out = Vec::new();
collect_blocks(content, &mut out);
out.into_iter().take(40).collect()
}
fn collect_blocks(value: &Value, out: &mut Vec<Value>) {
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::<Vec<_>>().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(),
}
}
+1
View File
@@ -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;
+27 -224
View File
@@ -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<AppState>,
State(_state): State<AppState>,
Extension(context): Extension<RequestContext>,
request: Request<Body>,
) -> Result<Response, WebError> {
@@ -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",
let provider = explicit_agent_provider(&payload).unwrap_or("legacy");
Err(WebError::new(
StatusCode::GONE,
"legacy_ai_agent_run_retired",
format!(
"{provider} provider 直连链路已退场;当前仅保留 mnote-cli host 主路径,不再静默降级"
"旧 /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", "provider-bridge-unavailable"));
}
run_local_mnote_cli_ai_host(&state, &context, &payload).await
.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<String> {
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<Response, WebError> {
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
@@ -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<String>,
document_id: Option<String>,
trace_id: Option<String>,
title: Option<String>,
}
pub async fn list_sessions(
Extension(context): Extension<RequestContext>,
Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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::<Vec<_>>()
.join("&");
path.push('?');
path.push_str(&params);
}
proxy_json(&context, reqwest::Method::GET, &upstream, &path, None).await
}
pub async fn create_session(
Extension(context): Extension<RequestContext>,
Json(payload): Json<CreateSessionRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<RequestContext>,
Path(session_id): Path<String>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<Value> {
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::<Value>(line).ok())
})
.await
.ok()
.flatten()
}
pub async fn create_run(
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<RequestContext>,
Path(run_id): Path<String>,
) -> Result<Response, WebError> {
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<RequestContext>,
Path(run_id): Path<String>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<String> {
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<String> {
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<Value, WebError> {
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<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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::<Value>(&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::<String>()
),
)
.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<Value>), 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<String, WebError> {
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::<String>();
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<Mutex<()>> = 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\""));
}
}
@@ -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<RequestContext>,
Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Json(input): Json<ToolCallInput>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<Vec<Value>> {
static LOG: OnceLock<Mutex<Vec<Value>>> = 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<Value> {
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<Value> {
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::<Value>(&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<HashMap<String, Value>> {
static CACHE: OnceLock<Mutex<HashMap<String, Value>>> = 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<String> {
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<Value> {
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");
}
}
@@ -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"));
+28 -3
View File
@@ -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 {
+16 -15
View File
@@ -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<String>) {
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"));
}
}
+10 -8
View File
@@ -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\")"));
+159 -57
View File
@@ -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 parsed = raw ? JSON.parse(raw) : null;
var activeId = String(parsed && parsed.activeSessionId || '').trim();
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 = [];
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 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 = [];
}
}
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,8 +3219,12 @@ const SIDEBAR_TREE_JS: &str = r##"
'<div class="wolai-page-ai-suggestions">' +
'<div class="wolai-page-ai-suggestions-header">' +
'<span></span>' +
'<div class="wolai-page-ai-intents">' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-intent="create-summary"> Summary</button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-intent="create-ai-note"> AI Note</button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="rotate"></button>' +
'</div>' +
'</div>' +
'<div class="wolai-page-ai-suggestion-list" data-page-ai-suggestion-list></div>' +
'</div>' +
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
@@ -3187,8 +3235,6 @@ const SIDEBAR_TREE_JS: &str = r##"
'<button type="button" class="wolai-page-ai-tab" data-page-ai-action="history"></button>' +
'<div class="wolai-page-ai-provider-group">' +
'<button type="button" class="wolai-page-ai-model-chip is-active" data-page-ai-provider="hermes" aria-pressed="true">Hermes</button>' +
'<button type="button" class="wolai-page-ai-model-chip" data-page-ai-provider="codex" aria-pressed="false">Codex</button>' +
'<button type="button" class="wolai-page-ai-model-chip" data-page-ai-provider="claudecode" aria-pressed="false">ClaudeCode</button>' +
'</div>' +
'</div>' +
'<div class="wolai-page-ai-input-row">' +
@@ -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 '' +
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '">' +
'<div class="wolai-page-ai-message-role">' + escapeHtml(item.role === 'user' ? '你' : 'AI') + '</div>' +
'<div class="wolai-page-ai-message-role">' + escapeHtml(roleLabel) + '</div>' +
'<div class="wolai-page-ai-message-text">' + escapeHtml(item.content || '') + '</div>' +
'</div>';
}).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,15 +3362,17 @@ const SIDEBAR_TREE_JS: &str = r##"
if (pageUiState.pageAiBusy) return;
var prompt = searchText(text);
if (!prompt) return;
pageAiLoadSessions();
pageUiState.pageAiBusy = true;
var currentSession = null;
try {
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.pageAiBusy = true;
pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
var currentSession = pageAiCurrentSession();
currentSession = pageAiCurrentSession();
if (currentSession) {
if (currentSession.title === '') {
currentSession.title = prompt.length > 18 ? prompt.slice(0, 18) + '…' : prompt;
@@ -3315,23 +3380,17 @@ const SIDEBAR_TREE_JS: &str = r##"
currentSession.messages = pageUiState.pageAiMessages.slice();
currentSession.updatedAt = Date.now();
}
pageAiPersistSessions();
renderPageAiConversation();
try {
var response = await fetch('/api/ai-agent/run', {
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: {
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();
+7
View File
@@ -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;
@@ -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);
});
+207
View File
@@ -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);
});
@@ -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 = "<unreadable>";
}
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);
});
+144
View File
@@ -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);
});
@@ -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 不应静默 fallbackcode=${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);
});
+176
View File
@@ -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);
});
@@ -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);
});
+91
View File
@@ -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);
});
@@ -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);
});
+10
View File
@@ -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";
@@ -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,
+10
View File
@@ -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");
+10
View File
@@ -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");
@@ -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");