feat(ai): switch page ai to hermes panel
This commit is contained in:
@@ -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。
|
||||
+4
-4
@@ -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` owner,Next 仅保留 legacy compat/debug 边界
|
||||
> - `MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT` 默认关闭与 `task117` guard 已落地,故迁入 `done/`
|
||||
> - 2026-05-13 追加说明:本文中“长期 agent 执行面收口到 `mnote-cli`”是历史完成口径;当前 AI 长期方向已由 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖为 Hermes 页面内客户端 + mnote Hermes skill/plugin
|
||||
> - 2026-05-13 追加说明:本文中“长期 agent 执行面收口到 `mnote-cli`”是历史完成口径;当前 AI 长期方向已由 `design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖为 Hermes 页面内客户端 + mnote Hermes skill/plugin
|
||||
|
||||
## 目标
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
+2
-2
@@ -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. 文档目的
|
||||
|
||||
+1
-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”,而不是“绕过系统写编辑器”。
|
||||
- [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 写临时 class;Rust runtime 复制链接和 hash 滚动继续只消费 `EditorBlock.block_id` 派生的 `attrs.blockId`。`mnote-web` 保存 payload 不再发送空 `content: []`,而是派生 `editorDocument`、legacy `content`、`tiptapDocument` 和 `blockCount` 写回 Convex-backed 持久化底座;reload 侧 legacy block 恢复继续补 `attrs.blockId`。本地 smoke `node scripts/task154-e26-anchor-smoke.js` 已覆盖保存请求、`/api/documents/content`、复制链接、DOM `id/:target` 和 reload/hash 定位,截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task154-e26-anchor-local-smoke`。剩余:列表项/分割线等更多块类型的统一 anchor attr 覆盖、Wolai 视觉细节、块引用/页面引用预览与移动端行为另拆后续。
|
||||
- E27 执行前口径纠偏:AI 编辑能力包不是“补 AI 菜单文案”或“点击后显示 feedback”。最小闭环必须从 `leptos-tiptap` 的 slash / 块菜单入口出发,携带当前 `documentId`、`workspaceId`、Rust `blockId`、selection 摘要和 Tiptap JSON 快照,进入 Hermes run/session context;Hermes 通过 mnote skill/plugin 调用稳定工具,再由 Rust runtime / `page.body.save` / Convex-backed content 链写回。2026-05-13 起长期口径进一步修正:`/api/ai-agent/run`、`openai-agents-python` sidecar 和 `mnote-cli host` 都只作为历史/兼容路径,不再代表页面 AI 长期主入口;`mnote-cli` 只可作为 mnote plugin 内部适配器或调试入口。E27 第一刀已完成的 sidecar / `/api/ai-agent/run` 路径只作为过渡基线,不宣称是长期主线。
|
||||
- E27 AI agent 入口与写入闭环:2026-05-02 已完成历史主路径纠偏,修正此前把 `/api/hermes/bridge` 当作 E27 主入口的误导口径。块菜单 `AI 助理` 当时从当前 `leptos-tiptap` editor 读取 `documentId`、`workspaceId`、Rust `blockId`、selection state、selected text 与 `tiptapDocument` 快照,发起 `/api/ai-agent/run` 请求,payload 固定 `scope=document`、`stream=true`、`options.ai.provider=online`,并显示 `mnote-leptos-tiptap-ai-status` 的 `idle/pending/ready/error` 状态;请求上下文标记 `source=leptos-tiptap-island`、`action=ask_ai`,Next sidecar adapter 已透传 `workspaceId / selectedBlockId / selection / tiptapDocument` 等 block 级上下文给 `openai-agents-python`。本地 smoke `node scripts/task155-e27-ai-edit-smoke.js` 已先 RED 于 Hermes 主入口,再 GREEN 覆盖请求 URL、payload 真源字段和 ready 状态;`node scripts/task156-e27-ai-writeback-smoke.js` 已先 RED 于只返回不写入,再 GREEN 覆盖 SSE `doc_replace_range` tool_result -> 编辑器改写 -> `/api/documents/save` -> `/api/documents/content` 真源读回 -> reload 后页面读回。截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task156-e27-ai-writeback-local-smoke`。剩余:把入口改为 Hermes client proxy,把上下文作为 Hermes run/session context 传入,把写入工具注册为 mnote Hermes plugin,Wolai AI 菜单视觉基线、slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate 子命令、Hermes streaming UI 状态、`doc_insert_blocks` 多块插入和标题写入另拆后续。
|
||||
- E27 AI agent 入口与写入闭环:2026-05-02 已完成历史主路径纠偏,修正此前把 `/api/hermes/bridge` 当作 E27 主入口的误导口径。块菜单 `AI 助理` 当时从当前 `leptos-tiptap` editor 读取 `documentId`、`workspaceId`、Rust `blockId`、selection state、selected text 与 `tiptapDocument` 快照,发起 `/api/ai-agent/run` 请求,payload 固定 `scope=document`、`stream=true`、`options.ai.provider=online`,并显示 `mnote-leptos-tiptap-ai-status` 的 `idle/pending/ready/error` 状态;请求上下文标记 `source=leptos-tiptap-island`、`action=ask_ai`,Next sidecar adapter 已透传 `workspaceId / selectedBlockId / selection / tiptapDocument` 等 block 级上下文给 `openai-agents-python`。本地 smoke `node scripts/task155-e27-ai-edit-smoke.js` 已先 RED 于 Hermes 主入口,再 GREEN 覆盖请求 URL、payload 真源字段和 ready 状态;`node scripts/task156-e27-ai-writeback-smoke.js` 已先 RED 于只返回不写入,再 GREEN 覆盖 SSE `doc_replace_range` tool_result -> 编辑器改写 -> `/api/documents/save` -> `/api/documents/content` 真源读回 -> reload 后页面读回。截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task156-e27-ai-writeback-local-smoke`。2026-05-14 起,页面 AI 主入口改为 Hermes client proxy、上下文进入 Hermes run/session context、写入工具注册为 mnote Hermes plugin 的部分已由 `7-4` 完成;剩余为 Wolai AI 菜单视觉基线、slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate 子命令、`doc_insert_blocks` 多块插入和标题写入等细分入口如何复用同一条 Hermes tool chain。
|
||||
- E27 Auth 真源纠偏:2026-05-02 补充确认,AI 编辑和在线 smoke 的用户身份必须以真实 Convex Auth 为主线;不同账号的数据隔离依赖 Convex `getAuthUserId(ctx)` 解析真实 identity subject,并由 workspace/member 权限链校验。`/auth` 复用既有 Convex Auth 页面和统一测试账号 `test@example.com` / `Test123456`;`/api/auth/session`、`/api/auth/whoami`、AI orchestrator 转发和 Convex transport 均应真实 token / forwarded actor 优先。`DEV_USER_ID` / admin acting identity 只允许作为本地底层 fallback 或显式 `MNOTE_DEV_AUTH=1` 联调模式,不能作为 E27 在线验收、多账号隔离或正式数据真源。
|
||||
- E27 当前推进备注:2026-05-03 主线程先切到 E28 Mention / Emoji,E27 暂停在 online smoke 模型网关层排障状态。当前已确认 `3000 -> 8000` orchestrator 主链可达,本地 `task155/task156` 通过;剩余在线阻塞点在 `20128 /v1/responses` 上游模型/渠道可用性,以及后续真实 actor 收口。恢复 E27 时应从这两点继续,不要回退成“主入口未迁完”。
|
||||
- E27 当前推进备注:2026-05-03 主线程先切到 E28 Mention / Emoji,E27 曾暂停在 online smoke 模型网关层排障状态。2026-05-14 页面 AI 主入口已经完成 Hermes client proxy + mnote plugin/tool 迁移;恢复 E27 时应从 slash AI / selection toolbar AI / 子命令体验与真实 actor 复用这条新主链继续,不要回退成旧 orchestrator 或旧 `/api/ai-agent/run` 排障。
|
||||
- E28/E29 暂停与 E30 先行口径:2026-05-03 主线程先暂停 E28 Mention / Emoji 与 E29 Comment / History,转入 E30 Menu / Floating 状态机能力包。E28 已有 Wolai Hermes baseline 显示正文输入 `@` 当前弹出提醒/会议/成员候选,并可插入成员 mention,不是页面引用搜索;证据目录为 `/mnt/Data1T/mnote/tmp/wolai-editor-parity/task157-e28-wolai-mention-baseline/`,恢复 E28 时需先重新定 `@mention` 口径,不要按“页面引用第一刀”继续。E29 在评论/历史后端边界未重新确认前保持暂停。E30 第一刀只收敛已有 slash、块菜单、二级菜单、selection/image/table floating toolbar 的打开互斥、Esc、外部点击、方向键、Enter 与层级收起,不扩新业务命令。
|
||||
- E30 Menu / Floating 状态机第一刀:2026-05-03 已参考 `use-floating-element`、`use-menu-navigation`、`use-floating-toolbar-visibility` 的集中可见性/键盘/关闭模型,把本地已有 slash、块菜单、selection toolbar、image toolbar、table toolbar/options 的关闭与互斥收口到 `close_editor_floating_overlays*` 与 `open_*_overlay` 入口;`task158-e30-menu-state-smoke.js` 覆盖 slash `ArrowDown` active、Esc 关闭、块菜单二级菜单、selection color panel、image toolbar、table options、slash 打开关闭 selection toolbar、块菜单打开关闭 image toolbar。Wolai 基线目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task158-e30-wolai-baseline/`;本地主线程截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task158-e30-menu-state-local-smoke-main3`;本地 subagent 复测目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task158-e30-local-postfix-subagent`。剩余:Wolai slash / 块菜单二级菜单在只读条件下未稳定验证,Enter 选中、外部点击和更多三级菜单收起需后续 editable-test 小切片继续补。
|
||||
- E31 Auth 入口回收:2026-05-04 已把 `3000` 的 `/auth` 收回为本地 `mnote-web` SSR 登录页,不再代理 3100;未登录访问 `/` 现在 303 到 `/auth`,已登录(forwarded actor / Convex Auth cookie)才进入工作区。`task159-auth-entry-smoke.js` 覆盖 `/auth` 本地 200、`/` 未登录 303、已登录 200、无第三方快捷登录/隐私政策文案,`task114-rust-web-gateway-entry-smoke.js` 与 `task117-next-retirement-guard.js` 已同步更新。截图待复核:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task159-auth-entry-baseline/`。
|
||||
@@ -418,7 +418,9 @@ GREEN:
|
||||
| E24 | PARTIAL | Image / Media 能力包 | 已完成图片 `/tp` 最小真源闭环与图片 toolbar 小闭环:slash `媒体与附件` 分组入口调用 `leptos-tiptap` 官方 `set_image(TiptapImageResource)`,启用 `TiptapExtension::Image`,保存链新增 `EditorBlockType::Image` / `TiptapNode::Image` 并保留 `props.tiptapImage`,`mnote-web` reload 恢复 `<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/command,slash `页面目录 /toc` 插入真实节点,NodeView 从当前 headings 派生目录项,支持点击定位、标题显示开关、保存请求、content API 与 reload 恢复;`task153-e25-toc-smoke.js` 覆盖入口、动态 heading 更新、hash 定位和 `props.tiptapTocNode`。剩余 TOC sidebar、active heading 高亮、完整块菜单入口、官方 TableOfContents v3 数据源评估、移动端与像素级视觉 |
|
||||
| E26 | PARTIAL | UniqueID / Anchor 能力包 | 已完成最小真源闭环:Rust `EditorBlock.block_id` -> Tiptap `attrs.blockId` -> DOM `data-block-id` + `id` -> 复制链接 hash -> `page.body.save` / `/api/documents/content` -> reload 后 `:target` 定位;`task154-e26-anchor-smoke.js` 覆盖保存、复制、reload/hash 和截图。Tiptap `UniqueID` 仍只作为参考/辅助口径,不作为正式块 id;剩余更多块类型 anchor 覆盖、Wolai 视觉细节、引用预览和移动端行为。 |
|
||||
| E27 | PARTIAL | AI 编辑能力包 | 已完成历史块菜单 `AI 助理` 主路径与最小写入闭环:点击后发起 `/api/ai-agent/run`,payload 使用 `scope=document`、`stream=true`、`options.ai.provider=online`,携带 `documentId`、`workspaceId`、Rust `blockId`、selection、selected text、Tiptap 快照和 `action=ask_ai`;`task155-e27-ai-edit-smoke.js` 与 `task156-e27-ai-writeback-smoke.js` 保留为过渡行为基线。2026-05-13 起长期口径改为:页面 AI 入口应调用 Hermes client proxy,Hermes session/message/tool event/usage/model 是会话真相,mnote 通过 Hermes skill/plugin 执行 `page.body.save`、标题、页面设置、artifact、edge 等业务工具;`mnote-cli` 仅可作为 plugin 内部适配器。Auth 验收必须复用 Convex Auth 测试账号,真实 token / actor 优先,`dev identity` 仅为本地 fallback。当前主线程已于 2026-05-03 先切 E28;E27 暂停在 online smoke 的 `20128 /v1/responses` 模型网关排障与真实 actor 收口。剩余 Hermes client proxy 接入、mnote plugin tool 注册、slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate、Wolai 视觉基线、Hermes streaming UI、`doc_insert_blocks` 多块插入与标题写入。 |
|
||||
| E27 | PARTIAL | AI 编辑能力包 | 已完成历史块菜单 `AI 助理` 主路径与最小写入闭环:点击后曾发起 `/api/ai-agent/run`,payload 使用 `scope=document`、`stream=true`、`options.ai.provider=online`,携带 `documentId`、`workspaceId`、Rust `blockId`、selection、selected text、Tiptap 快照和 `action=ask_ai`;`task155-e27-ai-edit-smoke.js` 与 `task156-e27-ai-writeback-smoke.js` 已默认退役为 historical smoke,当前只保留显式环境变量下的历史对照。2026-05-14 起页面 AI 主链已调用 Hermes client proxy,Hermes session/message/tool event/usage/model 是会话真相,mnote 通过 Hermes skill/plugin 执行 `page.body.save`、标题、页面设置、artifact、edge 等业务工具;`mnote-cli` 仅可作为 plugin 内部适配器。Auth 验收必须复用 Convex Auth 测试账号,真实 token / actor 优先,`dev identity` 仅为本地 fallback。剩余 slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate、Wolai 视觉基线、`doc_insert_blocks` 多块插入与标题写入等细分入口。 |
|
||||
|
||||
2026-05-14 追加说明:E27 行中的 “Hermes client proxy 接入、mnote plugin tool 注册” 已由 `design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` 对应的 7-5 / 7-6 合同和 mnote-web 实现推进到 GREEN:页面 AI 主链已走 Hermes session/run/events,mnote tool surface 已覆盖 `mnote.page.get`、`mnote.page.save`、`mnote.page.update_title`、`mnote.page.update_options`、`mnote.artifact.create_summary`、`mnote.artifact.create_ai_note`,旧 `/api/ai-agent/run` 已改为 `legacy_ai_agent_run_retired` guard。本文件后续只保留 Wolai-aline 体验、slash/selection toolbar AI 子命令和视觉/交互对标;页面 AI 长期主链完成状态以 `7-4` 为准。
|
||||
| E28 | PAUSED | Mention / Emoji 能力包 | 2026-05-03 暂停;Wolai baseline 已纠偏:正文 `@` 当前是提醒/会议/成员候选入口,不是页面引用搜索,恢复时先重定 mention/emoji 口径,再决定 Tiptap JSON 与 mnote 保存链 |
|
||||
| E29 | PAUSED | Comment / History 能力包 | 2026-05-03 暂停;评论/历史入口仍需后端边界和 Wolai 基线复核,待 E30 统一菜单状态机后再接入,避免继续复制独立弹层逻辑 |
|
||||
| E30 | PARTIAL | Menu / Floating 状态机能力包 | 2026-05-03 第一刀已完成本地统一状态机 smoke:`task158` 覆盖 slash、块菜单/二级菜单、selection toolbar/color panel、image floating toolbar、table toolbar/options 的互斥打开、Esc 统一关闭、方向键 active,以及打开块菜单时关闭 image toolbar;Wolai 只读基线已确认 selection/type menu 和 table popper 的 Esc/外部点击/URL 不变,slash 与块菜单二级菜单在只读条件下不稳定,后续仍需 editable-test 复核 Enter、外部点击和更多层级收起 |
|
||||
|
||||
@@ -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 暴露业务工具
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+3
-2
@@ -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 来自 Hermes;page title/context/audit 来自 mnote;没有字段把 Hermes session title 当作页面标题。
|
||||
- [ ] B3. 增加空态、连接失败态、tool 不可用态。
|
||||
- 验收标准:Hermes 未启动、未配置模型、mnote plugin 缺失、权限不足分别有不同 UI 状态。
|
||||
|
||||
### C. 顶部 Mini Hermes 状态条
|
||||
|
||||
- [ ] C1. 展示当前 session 标题和恢复状态。
|
||||
- 验收标准:刷新页面后能从 Hermes session detail 恢复标题和消息,不从 mnote 本地 state 恢复聊天真相。
|
||||
- [ ] C2. 展示 model/profile 摘要。
|
||||
- 验收标准:能看到当前模型与 profile;缺失时显示“去 Hermes 配置”的动作,而不是在 mnote 内要求填写 API key。
|
||||
- [ ] C3. 增加设置跳转入口。
|
||||
- 验收标准:入口跳到 Hermes 设置或 profile 页面;mnote 页面内不出现 provider/API key 编辑表单。
|
||||
|
||||
### D. Run 控制与恢复
|
||||
|
||||
- [ ] D1. 显示 run 状态。
|
||||
- 验收标准:发送后能依次看到 running/tool_calling/completed 或 failed;状态来自 Hermes run event。
|
||||
- [ ] D2. 支持停止当前 run。
|
||||
- 验收标准:点击停止后 Hermes run 进入 aborted/failed 的明确终止态,输入框恢复可用。
|
||||
- [ ] D3. 支持失败后重试或继续。
|
||||
- 验收标准:失败态有明确按钮;重试不会创建 mnote 本地聊天副本。
|
||||
- [ ] D4. 刷新后恢复进行中或已完成 session。
|
||||
- 验收标准:刷新页面不丢失 Hermes 消息;若 run 已结束,状态显示 completed/failed 而不是一直 loading。
|
||||
|
||||
### E. Context Scope
|
||||
|
||||
- [ ] E1. 增加 context scope 控件。
|
||||
- 验收标准:至少支持当前页、当前选区、当前块、页面设置四类;无选区时选区项禁用。
|
||||
- [ ] E2. run input 只携带当前 scope 的上下文摘要。
|
||||
- 验收标准:正文大对象不长期写进 Hermes session;Hermes 需要最新正文时通过 `mnote.page.get` 回读。
|
||||
- [ ] E3. tool call audit 记录 context scope。
|
||||
- 验收标准:`mnote.page.save`、artifact 写入等 audit 能看到本次来源是 page/selection/block/options 哪种 scope。
|
||||
|
||||
### F. Tool 可用性与最近调用
|
||||
|
||||
- [ ] F1. 展示 mnote tools 清单。
|
||||
- 验收标准:清单来源于 Hermes plugin/tool registry 或 mnote-web 过渡聚合,不能只写死在前端。
|
||||
- [ ] F2. 区分只读工具和写入工具。
|
||||
- 验收标准:`mnote.page.get` 明确为只读;`mnote.page.save`、`mnote.page.update_title`、`mnote.artifact.*` 明确为写入。
|
||||
- [ ] F3. tool event 默认折叠,支持展开详情。
|
||||
- 验收标准:交互参考 `MessageItem.vue` 的 `tool-preview` / `tool-details`,但使用 Leptos 实现;长 JSON 不撑破布局。
|
||||
- [ ] F4. 最近 tool call 关联 audit。
|
||||
- 验收标准:能从 UI 或测试输出看到 `sessionId/runId/toolCallId/traceId/auditId` 的串联。
|
||||
|
||||
### G. Session 列表最小面
|
||||
|
||||
- [ ] G1. 支持当前页面最近 Hermes sessions 列表。
|
||||
- 验收标准:列表来自 Hermes session API;只过滤/标注当前 document context,不复制 session 到 mnote。
|
||||
- [ ] G2. 支持切换 session。
|
||||
- 验收标准:切换后 message list 从 Hermes detail 恢复;页面正文不因切换 session 被自动修改。
|
||||
- [ ] G3. P2 支持 rename/delete/search。
|
||||
- 验收标准:若未实现,UI 不出现可点击假按钮;若实现,操作调用 Hermes session API。
|
||||
|
||||
### H. 错误与权限
|
||||
|
||||
- [ ] H1. Hermes 未启动或 proxy 502 时显示可诊断状态。
|
||||
- 验收标准:错误能区分 upstream unavailable、auth/permission、model missing、tool unavailable。
|
||||
- [ ] H2. 写入工具权限失败不泄露正文。
|
||||
- 验收标准:tool error 展示摘要、trace/audit id 和恢复动作,不展示不必要的正文 payload。
|
||||
- [ ] H3. 旧 `/api/ai-agent/run` 继续保持退场 guard。
|
||||
- 验收标准:新页面 AI 主链不会调用旧入口;retirement smoke 继续通过。
|
||||
|
||||
### I. 自动化验收
|
||||
|
||||
- [ ] I1. 新增或扩展 browser smoke:session 状态条。
|
||||
- 验收标准:断言 session title/model/run status 可见。
|
||||
- [ ] I2. 新增或扩展 browser smoke:run stop/retry。
|
||||
- 验收标准:至少覆盖 stop;retry 若未实现则断言按钮不存在或禁用。
|
||||
- [ ] I3. 新增或扩展 browser smoke:context scope。
|
||||
- 验收标准:选区/当前页上下文能进入 run payload 或 tool audit。
|
||||
- [ ] I4. 新增或扩展 browser smoke:tool 可用性与 audit。
|
||||
- 验收标准:能看到 mnote tool 列表、一次 tool call、对应 audit id。
|
||||
- [ ] I5. 移动端 smoke。
|
||||
- 验收标准:状态条、tool 折叠、输入区在窄屏不重叠。
|
||||
|
||||
---
|
||||
|
||||
## 7. Done Gate
|
||||
|
||||
本稿移入 `done/` 前必须同时满足:
|
||||
|
||||
- [ ] 页面 AI 抽屉具备 Mini Hermes 状态条,用户能看见当前 session、model/profile、run 状态。
|
||||
- [ ] 页面 AI 抽屉具备 context scope 控件,run input 和 tool audit 能反映 scope。
|
||||
- [ ] 页面 AI 抽屉能展示 mnote tool 可用性、最近 tool call 和 audit/trace 串联。
|
||||
- [ ] stop 至少可用;retry/rename/delete 若未实现,必须明确标为 P2 且 UI 不出现假可用按钮。
|
||||
- [ ] Hermes 未启动、模型缺失、plugin/tool 不可用、权限失败至少四类错误有可区分展示。
|
||||
- [ ] 页面刷新后仍以 Hermes session detail/resume 为会话真相。
|
||||
- [ ] mnote 不保存聊天消息真相,不保存 provider/API key,不创建第二套 plugin registry。
|
||||
- [ ] `git diff --check` 通过。
|
||||
- [ ] Rust 侧相关测试通过:`cargo test -p mnote-web hermes_client -- --nocapture`、`cargo test -p mnote-web hermes_tools_ -- --nocapture`。
|
||||
- [ ] 正式 `3000` smoke 覆盖 session/run/tool/audit/mobile,且证据写回本文。
|
||||
|
||||
---
|
||||
|
||||
## 8. 当前建议的下一步
|
||||
|
||||
优先顺序:
|
||||
|
||||
1. 先补顶部 Mini Hermes 状态条和错误态,因为这是当前“能回复但缺 Hermes 设置感”的直接缺口。
|
||||
2. 再补 context scope 和 tool 可用性,让用户明确 Hermes 正在调用 mnote 的哪些能力。
|
||||
3. 最后补 session 列表、rename/delete/search 等历史管理能力。
|
||||
|
||||
不要先做 Hermes 全局设置页复刻。API key、provider、全局 profile、usage/logs/jobs/files/channels 仍应留在 Hermes 自己的管理台。
|
||||
@@ -17,7 +17,9 @@
|
||||
| F-01 | 实现偏差 / 风险 | P1 | OnlyOffice `mnote-web` 主入口已挂载 callback/forcesave,但当前实现不写回,只返回成功或 noop,可能让 3000 主链下的 OnlyOffice 保存丢失。 | `rust/crates/mnote-web/src/routes/mod.rs:78-84` 挂载 `/api/onlyoffice/callback` 与 `/api/onlyoffice/forcesave`;`rust/crates/mnote-web/src/routes/onlyoffice.rs:717-759` callback 只记录日志并返回 `{error:0}`,forcesave 返回 `mnote-web-rust-noop`;而 `wolai-frontend/src/app/api/onlyoffice/callback/route.ts:93-192` 有真实 Convex 写回链。 | 优先把 Rust route 接到 `onlyoffice_prepare_callback` / media asset writeback,或显式把该路由代理到 legacy Next,避免主入口 shadow 掉真实写回。 |
|
||||
| F-02 | 未完成 | P2 | Mindmap `export` 动作暴露给 UI action map,但 simple-mind-map 安全执行器不允许 `EXPORT`,默认路径可能显示能力却执行失败。 | `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts:65` 将 `export` 映射为 runtimeCommand `EXPORT`;`wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts:119-129` 的 `SIMPLE_MIND_MAP_SAFE_COMMANDS` 不包含 `EXPORT`。 | 要么把 `EXPORT` 加入安全命令并补 smoke,要么在 UI state 中继续禁用导出并标注为延期。 |
|
||||
| F-03 | 未完成 / 方向变化 | P2 | Mindmap AI 补完 route 当前直接 501,后续大段旧 Supabase/在线 AI 实现被注释;但 Rust tool registry 已登记 `mindmap_expand_node`,形成“工具存在、产品入口不可用”的断层。 | `wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts:90-97` 校验后直接返回 `501`;同文件后续注释块仍保留旧 Supabase/AI 逻辑;`rust/crates/core-protocol/src/tool.rs:231-240` 与 `rust/crates/bridge-runtime/src/lib.rs:3803-3812` 已有 `mindmap_expand_node`。 | 若该能力仍在 Phase 6/7 范围内,应按 CLI/Rust bridge 路线重接;若延期,应把 route 标成 retired/debug,避免前端或测试误以为可用。 |
|
||||
| F-04 | 方向变化 / 文档滞后 | P2 | AI 主 Web route 曾收口到 `mnote-cli` host,但新主线已改为 Hermes 页面内客户端;`wolai-backend` 仍暴露 `openai_agents_python` 文档 agent route 与旧工具面,旧 route / sidecar / host 的退场关系需重新明确。 | `wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 曾明确拒绝 codex/hermes/claudecode 并进入 `startMnoteCliAgentHostRun`;`wolai-backend/app/routers/ai_agent.py:34-57` 仍暴露 `/ai-agent/health`、`/ai-agent/document/run`,health 返回 `bridge: openai_agents_python`;`wolai-backend/app/services/ai_document_agent.py:1174-1470` 仍指令 agent 使用 `doc_insert_blocks`、`doc_replace_range`、`slash_run`。 | 以 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 为上位依据,明确页面 AI 改走 Hermes client proxy,mnote 能力通过 Hermes skill/plugin 暴露;`openai-agents-python` 和 `mnote-cli host` 只保留为历史/兼容/内部适配路径并补退场计划。 |
|
||||
| F-04 | 方向变化 / 文档滞后 | P2 | AI 主 Web route 曾收口到 `mnote-cli` host,但新主线已改为 Hermes 页面内客户端;`wolai-backend` 仍暴露 `openai_agents_python` 文档 agent route 与旧工具面,旧 route / sidecar / host 的退场关系需重新明确。 | `wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 曾明确拒绝 codex/hermes/claudecode 并进入 `startMnoteCliAgentHostRun`;`wolai-backend/app/routers/ai_agent.py:34-57` 仍暴露 `/ai-agent/health`、`/ai-agent/document/run`,health 返回 `bridge: openai_agents_python`;`wolai-backend/app/services/ai_document_agent.py:1174-1470` 仍指令 agent 使用 `doc_insert_blocks`、`doc_replace_range`、`slash_run`。 | 以 `design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 为上位依据,明确页面 AI 改走 Hermes client proxy,mnote 能力通过 Hermes skill/plugin 暴露;`openai-agents-python` 和 `mnote-cli host` 只保留为历史/兼容/内部适配路径并补退场计划。 |
|
||||
|
||||
2026-05-14 复核:F-04 的长期方向已由 `design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` 继续落地到 mnote-web Hermes client proxy 与 mnote Hermes tool routes;旧 `/api/ai-agent/run` 已在 mnote-web 中收口为 `legacy_ai_agent_run_retired` guard,旧 `openai-agents-python` / `mnote-cli host` 只按历史/兼容/对照链处理,不再代表页面 AI 长期主入口。
|
||||
| F-05 | 文档治理风险 | P3 | `design/90-reference` 符合“参考资料”目录定位,但内容仍带有问答式残留,容易被后续 worker 误用为正式设计结论。 | `design/README.md` 明确 `90-reference/` 不参与 process/done 状态判断;`design/90-reference/90-1-filetree.md` 末尾保留“需要我给你...”类对话尾巴;`design/90-reference/90-2-yemianshu.md` 同样保留示例请求口吻。 | 低优先级清理为中性参考笔记,并在引用时强制以 `ARCHITECTURE.md` 和主线设计为上位依据。 |
|
||||
|
||||
## 证据
|
||||
@@ -32,7 +34,7 @@
|
||||
### AI
|
||||
|
||||
- 旧 CLI-first 入口曾基本对齐:`wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 将默认执行入口限定到 `mnote-cli host`,拒绝旧 provider;但该口径已被 2026-05-13 的 Hermes 面板主线覆盖。
|
||||
- 结构化 artifact 设计应继续保留对象模型,但触发方改为 Hermes tool call -> mnote plugin -> Rust runtime / kernel,见 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 与 `design/07-ai/process/7-2-phase7-structured-artifact-write-chain-v1.md`。
|
||||
- 结构化 artifact 设计应继续保留对象模型,但触发方改为 Hermes tool call -> mnote plugin -> Rust runtime / kernel,见 `design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 与 `design/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md`。
|
||||
- 旧后端 agent 仍存在:`wolai-backend/app/routers/ai_agent.py:47-57` 仍提供 streaming run;这只能作为历史/兼容/对照链,不能被误认为长期默认主编排。
|
||||
|
||||
### OnlyOffice
|
||||
@@ -56,7 +58,7 @@
|
||||
1. P1:补齐或明确代理 OnlyOffice Rust callback/forcesave 写回链,避免主入口下保存成功但内容未持久化。
|
||||
2. P2:收口 Mindmap action 可用性,先修 `export` 映射与安全命令不一致,再继续扩展 UI 能力。
|
||||
3. P2:处理 Mindmap AI route:要么按 Hermes plugin / Rust bridge 重接 `mindmap_expand_node`,要么显式退役该 Next route。
|
||||
4. P2:明确 `wolai-backend` AI agent、旧 `/api/ai-agent/run` 与 `mnote-cli host` 的历史/兼容定位和访问边界,避免与 Hermes 面板 + mnote plugin 新主线冲突。
|
||||
4. P2:继续保持 `wolai-backend` AI agent、旧 `/api/ai-agent/run` 与 `mnote-cli host` 的历史/兼容定位和访问边界;页面 AI 长期主链已经由 `7-4` 收口到 Hermes 面板 + mnote plugin,后续只在各自 legacy domain 里拆迁移。
|
||||
5. P3:清理 `design/90-reference` 的问答残留,并在引用规范里再次强调它不是 process/done 设计稿。
|
||||
|
||||
## 本次修改文件
|
||||
|
||||
@@ -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 才是会话真相;
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
.collect()
|
||||
.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,
|
||||
})
|
||||
}
|
||||
|
||||
fn kernel_node_sort_order(node: &KernelNode) -> i64 {
|
||||
@@ -7979,6 +8028,36 @@ fn build_file_tree_projection_result(
|
||||
let visible_rows = items.len();
|
||||
let visible_edges = edges.len();
|
||||
|
||||
let mut meta = BTreeMap::from([(
|
||||
"search".into(),
|
||||
json!({
|
||||
"query": requested_query.clone(),
|
||||
"maxResults": requested_max_results,
|
||||
"maxResultsRule": "matches_only_before_ancestor_completion",
|
||||
"ancestorCompletion": "include_all_ancestors_after_match_truncation",
|
||||
"ordering": "kernel_file_tree_preorder",
|
||||
"indexingVisibility": {
|
||||
"schema": "mnote.file_tree.indexing_visibility",
|
||||
"schemaVersion": 1,
|
||||
"source": "kernel.project_view",
|
||||
"status": "visible",
|
||||
"requestKey": requested_query
|
||||
.as_ref()
|
||||
.map(|query| format!("{}:{query}", root_node_id.unwrap_or("root"))),
|
||||
"indexedResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
"visibleResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
"metrics": {
|
||||
"visibleRows": visible_rows,
|
||||
"visibleEdges": visible_edges,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)]);
|
||||
meta.insert(
|
||||
"aiArtifacts".into(),
|
||||
ai_artifacts_projection_group_meta(&edges),
|
||||
);
|
||||
|
||||
KernelProjectionResult {
|
||||
projection_id: format!(
|
||||
"kernel_projection:file_tree:{}",
|
||||
@@ -7988,31 +8067,7 @@ fn build_file_tree_projection_result(
|
||||
root_node_id: root_node_id.map(ToOwned::to_owned),
|
||||
items,
|
||||
edges,
|
||||
meta: BTreeMap::from([(
|
||||
"search".into(),
|
||||
json!({
|
||||
"query": requested_query.clone(),
|
||||
"maxResults": requested_max_results,
|
||||
"maxResultsRule": "matches_only_before_ancestor_completion",
|
||||
"ancestorCompletion": "include_all_ancestors_after_match_truncation",
|
||||
"ordering": "kernel_file_tree_preorder",
|
||||
"indexingVisibility": {
|
||||
"schema": "mnote.file_tree.indexing_visibility",
|
||||
"schemaVersion": 1,
|
||||
"source": "kernel.project_view",
|
||||
"status": "visible",
|
||||
"requestKey": requested_query
|
||||
.as_ref()
|
||||
.map(|query| format!("{}:{query}", root_node_id.unwrap_or("root"))),
|
||||
"indexedResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
"visibleResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
"metrics": {
|
||||
"visibleRows": visible_rows,
|
||||
"visibleEdges": visible_edges,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)]),
|
||||
meta,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17443,6 +17498,138 @@ mod tests {
|
||||
assert_eq!(result["edges"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_edges_list_exposes_ai_artifact_reference_edges() {
|
||||
let result = execute_runtime_query(RuntimeInput::Query {
|
||||
context: demo_context(),
|
||||
query: RuntimeQueryEnvelopeWire {
|
||||
name: "kernel.edges.list".into(),
|
||||
payload: json!({
|
||||
"workspaceId": "ws_1",
|
||||
"nodeId": "page_root",
|
||||
"direction": "both",
|
||||
}),
|
||||
},
|
||||
data: Some(json!({
|
||||
"documents": [
|
||||
{
|
||||
"id": "page_root",
|
||||
"workspace_id": "ws_1",
|
||||
"title": "根页面",
|
||||
"parent_id": null,
|
||||
"sort_order": 0,
|
||||
"is_starred": true,
|
||||
"is_template": false,
|
||||
"created_at": "2026-04-16T00:00:00Z",
|
||||
"updated_at": "2026-04-16T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "summary_page_root",
|
||||
"workspace_id": "ws_1",
|
||||
"title": "AI Summary",
|
||||
"parent_id": "page_root",
|
||||
"sort_order": 1,
|
||||
"is_starred": false,
|
||||
"is_template": false,
|
||||
"created_at": "2026-04-16T00:00:00Z",
|
||||
"updated_at": "2026-04-16T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "ai_note_page_root_req_1",
|
||||
"workspace_id": "ws_1",
|
||||
"title": "AI Note",
|
||||
"parent_id": "page_root",
|
||||
"sort_order": 2,
|
||||
"is_starred": false,
|
||||
"is_template": false,
|
||||
"created_at": "2026-04-16T00:00:00Z",
|
||||
"updated_at": "2026-04-16T00:00:00Z"
|
||||
}
|
||||
]
|
||||
})),
|
||||
})
|
||||
.expect("kernel edge list should build");
|
||||
|
||||
let edges = result["edges"].as_array().expect("edges should be array");
|
||||
let artifact_edges = edges
|
||||
.iter()
|
||||
.filter(|edge| edge["metadata"]["kind"] == json!("ai_artifact_reference"))
|
||||
.collect::<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,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;
|
||||
|
||||
@@ -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",
|
||||
format!(
|
||||
"{provider} provider 直连链路已退场;当前仅保留 mnote-cli host 主路径,不再静默降级。"
|
||||
),
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header("x-mnote-web-owner", "mnote-web")
|
||||
.with_header("x-mnote-ai-execution-owner", "provider-bridge-unavailable"));
|
||||
}
|
||||
|
||||
run_local_mnote_cli_ai_host(&state, &context, &payload).await
|
||||
let provider = explicit_agent_provider(&payload).unwrap_or("legacy");
|
||||
Err(WebError::new(
|
||||
StatusCode::GONE,
|
||||
"legacy_ai_agent_run_retired",
|
||||
format!(
|
||||
"旧 /api/ai-agent/run 页面 AI 主链已退场,provider={provider} 不再静默降级;请使用 Hermes client proxy 与 mnote Hermes plugin。"
|
||||
),
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header("x-mnote-web-owner", "mnote-web")
|
||||
.with_header("x-mnote-ai-execution-owner", "legacy-ai-agent-run-retired"))
|
||||
}
|
||||
|
||||
fn explicit_agent_provider(payload: &Value) -> Option<&'static str> {
|
||||
@@ -60,205 +54,6 @@ fn explicit_agent_provider(payload: &Value) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_repo_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..")
|
||||
}
|
||||
|
||||
fn build_mnote_cli_args(context: &RequestContext, payload: &Value) -> Vec<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(¶ms);
|
||||
}
|
||||
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"));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\")"));
|
||||
|
||||
@@ -3014,7 +3014,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function pageAiStorageKey() {
|
||||
return 'doc_ai_sessions:' + currentDocumentId();
|
||||
return 'hermes_page_ai_session:' + currentDocumentId();
|
||||
}
|
||||
|
||||
function pageAiNewSession(title) {
|
||||
@@ -3046,46 +3046,90 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function pageAiLoadSessions() {
|
||||
if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return;
|
||||
try {
|
||||
var raw = window.localStorage.getItem(pageAiStorageKey());
|
||||
if (!raw) {
|
||||
var fresh = pageAiNewSession();
|
||||
pageUiState.pageAiSessions = [fresh];
|
||||
pageUiState.pageAiActiveSessionId = fresh.id;
|
||||
pageUiState.pageAiMessages = [];
|
||||
return;
|
||||
}
|
||||
var parsed = JSON.parse(raw);
|
||||
var sessions = pageAiNormalizeSessions(parsed && parsed.sessions ? parsed.sessions : []);
|
||||
if (!sessions.length) {
|
||||
var fallback = pageAiNewSession();
|
||||
pageUiState.pageAiSessions = [fallback];
|
||||
pageUiState.pageAiActiveSessionId = fallback.id;
|
||||
pageUiState.pageAiMessages = [];
|
||||
return;
|
||||
}
|
||||
pageUiState.pageAiSessions = sessions;
|
||||
var parsed = raw ? JSON.parse(raw) : null;
|
||||
var activeId = String(parsed && parsed.activeSessionId || '').trim();
|
||||
var active = sessions.find(function(session) { return session.id === activeId; }) || sessions[0];
|
||||
pageUiState.pageAiActiveSessionId = active.id;
|
||||
pageUiState.pageAiMessages = Array.isArray(active.messages) ? active.messages.slice() : [];
|
||||
} catch (_) {
|
||||
var reset = pageAiNewSession();
|
||||
pageUiState.pageAiSessions = [reset];
|
||||
pageUiState.pageAiActiveSessionId = reset.id;
|
||||
pageUiState.pageAiMessages = [];
|
||||
}
|
||||
if (activeId) {
|
||||
pageUiState.pageAiSessions = [pageAiNewSession('当前页问答')];
|
||||
pageUiState.pageAiSessions[0].id = activeId;
|
||||
pageUiState.pageAiActiveSessionId = activeId;
|
||||
pageUiState.pageAiMessages = [];
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
var fresh = pageAiNewSession();
|
||||
pageUiState.pageAiSessions = [fresh];
|
||||
pageUiState.pageAiActiveSessionId = fresh.id;
|
||||
pageUiState.pageAiMessages = [];
|
||||
}
|
||||
|
||||
function pageAiPersistSessions() {
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes');
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
|
||||
try {
|
||||
window.localStorage.setItem(pageAiStorageKey(), JSON.stringify({
|
||||
activeSessionId: pageUiState.pageAiActiveSessionId,
|
||||
sessions: pageAiNormalizeSessions(pageUiState.pageAiSessions)
|
||||
activeSessionId: pageUiState.pageAiActiveSessionId
|
||||
}));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function pageAiEnsureHermesSession() {
|
||||
pageAiLoadSessions();
|
||||
var current = pageAiCurrentSession();
|
||||
if (current && String(current.id || '').startsWith('mnote_')) return current;
|
||||
var response = await fetch('/api/hermes/client/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
documentId: currentDocumentId(),
|
||||
traceId: 'page-ai-' + Date.now().toString(36),
|
||||
title: current && current.title ? current.title : '当前页问答'
|
||||
})
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
var code = payload && payload.code ? payload.code : 'hermes_session_failed_' + response.status;
|
||||
throw new Error(code);
|
||||
}
|
||||
var session = {
|
||||
id: String(payload.sessionId || '').trim(),
|
||||
title: String(payload.title || '当前页问答'),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
messages: pageUiState.pageAiMessages.slice()
|
||||
};
|
||||
pageUiState.pageAiSessions = pageAiNormalizeSessions([session]);
|
||||
pageUiState.pageAiActiveSessionId = session.id;
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
return session;
|
||||
}
|
||||
|
||||
async function pageAiRestoreHermesSession() {
|
||||
var current = pageAiCurrentSession();
|
||||
if (!current || !String(current.id || '').startsWith('mnote_')) return;
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(current.id), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
if (!response.ok) return;
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
var session = payload && (payload.session || (payload.upstream && payload.upstream.session) || payload);
|
||||
var messages = session && Array.isArray(session.messages) ? session.messages : [];
|
||||
if (!messages.length) return;
|
||||
pageUiState.pageAiMessages = messages.slice(-40).map(function(message) {
|
||||
return {
|
||||
role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'),
|
||||
content: String(message.content || '')
|
||||
};
|
||||
});
|
||||
current.messages = pageUiState.pageAiMessages.slice();
|
||||
current.updatedAt = Date.now();
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
function pageAiCurrentSession() {
|
||||
return pageUiState.pageAiSessions.find(function(session) {
|
||||
return session.id === pageUiState.pageAiActiveSessionId;
|
||||
@@ -3146,7 +3190,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var args = normalized && normalized.args ? normalized.args : {};
|
||||
var documentId = searchText(args.documentId || args.pageId || currentDocumentId());
|
||||
var toolName = searchText(operation.tool_name || normalized.toolName || 'doc_get');
|
||||
return providerLabel + ' 已收到你的问题“' + searchText(promptText) + '”。当前已通过 mnote-cli 建立页面级工具调用,目标页面是 ' + (documentId || '当前页面') + ',本次选择的工具是 ' + toolName + '。如果你继续追问,我会沿当前页面上下文继续回复。';
|
||||
return providerLabel + ' 已收到你的问题“' + searchText(promptText) + '”。当前已通过 Hermes 调用 mnote plugin 工具,目标页面是 ' + (documentId || '当前页面') + ',本次选择的工具是 ' + toolName + '。如果你继续追问,我会沿当前页面上下文继续回复。';
|
||||
} catch (_) {
|
||||
return providerLabel + ' 已返回结果,但当前结果是结构化文本,已为你保留原始内容。';
|
||||
}
|
||||
@@ -3175,7 +3219,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
'<div class="wolai-page-ai-suggestions">' +
|
||||
'<div class="wolai-page-ai-suggestions-header">' +
|
||||
'<span>推荐问题</span>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="rotate">换一换</button>' +
|
||||
'<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>' +
|
||||
@@ -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,39 +3362,35 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (pageUiState.pageAiBusy) return;
|
||||
var prompt = searchText(text);
|
||||
if (!prompt) return;
|
||||
pageAiLoadSessions();
|
||||
var contextSnapshot = currentPageAiContextSnapshot();
|
||||
var aggregate = contextSnapshot.aggregate || {};
|
||||
var body = contextSnapshot.body || {};
|
||||
var subtree = contextSnapshot.subtree || null;
|
||||
var outline = subtree && subtree.outline ? subtree.outline : null;
|
||||
pageUiState.pageAiBusy = true;
|
||||
pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
|
||||
var currentSession = pageAiCurrentSession();
|
||||
if (currentSession) {
|
||||
if (currentSession.title === '新会话') {
|
||||
currentSession.title = prompt.length > 18 ? prompt.slice(0, 18) + '…' : prompt;
|
||||
}
|
||||
currentSession.messages = pageUiState.pageAiMessages.slice();
|
||||
currentSession.updatedAt = Date.now();
|
||||
}
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
var currentSession = null;
|
||||
try {
|
||||
var response = await fetch('/api/ai-agent/run', {
|
||||
await pageAiEnsureHermesSession();
|
||||
var contextSnapshot = currentPageAiContextSnapshot();
|
||||
var aggregate = contextSnapshot.aggregate || {};
|
||||
var body = contextSnapshot.body || {};
|
||||
var subtree = contextSnapshot.subtree || null;
|
||||
var outline = subtree && subtree.outline ? subtree.outline : null;
|
||||
pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
|
||||
currentSession = pageAiCurrentSession();
|
||||
if (currentSession) {
|
||||
if (currentSession.title === '新会话') {
|
||||
currentSession.title = prompt.length > 18 ? prompt.slice(0, 18) + '…' : prompt;
|
||||
}
|
||||
currentSession.messages = pageUiState.pageAiMessages.slice();
|
||||
currentSession.updatedAt = Date.now();
|
||||
}
|
||||
renderPageAiConversation();
|
||||
var response = await fetch('/api/hermes/client/runs', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
maxSteps: 8,
|
||||
scope: 'document',
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
toolChoice: {
|
||||
mode: 'auto',
|
||||
toolSets: ['toolset.readonly', 'toolset.rag_read', 'toolset.docs_read', 'toolset.media_read', 'toolset.doc_read', 'toolset.doc_write', 'toolset.slash_write']
|
||||
},
|
||||
context: {
|
||||
documentId: currentDocumentId(),
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
documentId: currentDocumentId(),
|
||||
sessionId: pageUiState.pageAiActiveSessionId,
|
||||
message: prompt,
|
||||
model: 'hermes-agent',
|
||||
pageContext: {
|
||||
documentBlocks: body.content || null,
|
||||
node: {
|
||||
documentId: currentDocumentId(),
|
||||
@@ -3343,23 +3402,54 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
evidence: null,
|
||||
pageOptions: currentPageOptions()
|
||||
},
|
||||
options: {
|
||||
searxng: true,
|
||||
ai: { provider: pageUiState.pageAiProvider }
|
||||
}
|
||||
selectedBlockId: null,
|
||||
selectedText: null,
|
||||
traceId: 'page-ai-run-' + Date.now().toString(36)
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('page_ai_failed_' + response.status);
|
||||
var errorPayload = await response.json().catch(function(){ return null; });
|
||||
throw new Error(errorPayload && errorPayload.code ? errorPayload.code : 'page_ai_failed_' + response.status);
|
||||
}
|
||||
var runPayload = await response.json().catch(function(){ return null; });
|
||||
var upstream = runPayload && runPayload.upstream ? runPayload.upstream : runPayload;
|
||||
var runId = upstream && (upstream.run_id || upstream.runId);
|
||||
if (!runId) throw new Error('hermes_run_missing_run_id');
|
||||
var eventResponse = await fetch('/api/hermes/client/events/' + encodeURIComponent(runId), {
|
||||
headers: { 'accept': 'text/event-stream' }
|
||||
});
|
||||
if (!eventResponse.ok) {
|
||||
var eventError = await eventResponse.json().catch(function(){ return null; });
|
||||
throw new Error(eventError && eventError.code ? eventError.code : 'hermes_events_failed_' + eventResponse.status);
|
||||
}
|
||||
var assistantText = '';
|
||||
await streamPageAiResponse(response, function(eventName, payloadText) {
|
||||
if (eventName === 'assistant_message') {
|
||||
await streamPageAiResponse(eventResponse, function(eventName, payloadText) {
|
||||
if (eventName === 'assistant_message' || eventName === 'message.delta') {
|
||||
try {
|
||||
var payload = JSON.parse(payloadText || 'null');
|
||||
assistantText = searchText(payload && payload.text);
|
||||
assistantText += searchText((payload && (payload.text || payload.delta)) || '');
|
||||
} catch (_) {
|
||||
assistantText += searchText(payloadText);
|
||||
}
|
||||
}
|
||||
if (eventName === 'run.completed') {
|
||||
try {
|
||||
var completed = JSON.parse(payloadText || 'null');
|
||||
if (completed && completed.output) assistantText = searchText(completed.output);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') {
|
||||
try {
|
||||
var toolEvent = JSON.parse(payloadText || 'null');
|
||||
pageUiState.pageAiMessages.push({
|
||||
role: 'tool',
|
||||
content: String(toolEvent && (toolEvent.name || toolEvent.tool || eventName) || eventName)
|
||||
});
|
||||
} catch (_) {
|
||||
pageUiState.pageAiMessages.push({ role: 'tool', content: eventName });
|
||||
}
|
||||
renderPageAiConversation();
|
||||
}
|
||||
});
|
||||
pageUiState.pageAiMessages.push({
|
||||
role: 'assistant',
|
||||
@@ -3370,18 +3460,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
currentSession.messages = pageUiState.pageAiMessages.slice();
|
||||
currentSession.updatedAt = Date.now();
|
||||
}
|
||||
pageAiPersistSessions();
|
||||
} catch (error) {
|
||||
pageUiState.pageAiMessages.push({
|
||||
role: 'assistant',
|
||||
content: pageAiProviderLabel(pageUiState.pageAiProvider) + ' 当前请求失败:' + (error instanceof Error ? error.message : String(error))
|
||||
content: 'Hermes 当前请求失败:' + (error instanceof Error ? error.message : String(error))
|
||||
});
|
||||
currentSession = pageAiCurrentSession();
|
||||
if (currentSession) {
|
||||
currentSession.messages = pageUiState.pageAiMessages.slice();
|
||||
currentSession.updatedAt = Date.now();
|
||||
}
|
||||
pageAiPersistSessions();
|
||||
} finally {
|
||||
pageUiState.pageAiBusy = false;
|
||||
renderPageAiConversation();
|
||||
@@ -3937,6 +4025,20 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiIntent = closestAction(e.target, '[data-page-ai-intent]');
|
||||
if (pageAiIntent) {
|
||||
e.preventDefault();
|
||||
var intentName = pageAiIntent.getAttribute('data-page-ai-intent') || '';
|
||||
if (intentName === 'create-summary') {
|
||||
void sendPageAiMessage('请通过 Hermes 调用 mnote.artifact.create_summary,为当前页面创建或更新 AI Summary。');
|
||||
return;
|
||||
}
|
||||
if (intentName === 'create-ai-note') {
|
||||
void sendPageAiMessage('请通过 Hermes 调用 mnote.artifact.create_ai_note,基于当前页面创建一篇新的 AI Note。');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var pageAiProvider = closestAction(e.target, '[data-page-ai-provider]');
|
||||
if (pageAiProvider) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 不应静默 fallback,code=${code}, owner=${owner}, body=${text.slice(0, 500)}`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
status: response.status,
|
||||
code,
|
||||
owner,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user