feat: complete hermes page ai runtime bff
This commit is contained in:
@@ -0,0 +1,404 @@
|
|||||||
|
# 7-8 [process] 页面 AI Hermes Runtime BFF 下一阶段设计 v1
|
||||||
|
|
||||||
|
> 更新时间:2026-05-15
|
||||||
|
>
|
||||||
|
> 当前状态:`PROCESS`。本文承接 `7-3`、`7-4`、`7-5`、`7-6`、`7-7` 已完成的 Hermes 页面内客户端主线,写入对 `hermes-web-ui-0.5.18` Chat Runtime 架构的吸收结论。
|
||||||
|
>
|
||||||
|
> 上位依据:
|
||||||
|
> - `/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`
|
||||||
|
> - `/mnt/Data1T/mnote/design/07-ai/done/7-7-page-ai-mini-hermes-control-surface-v1.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 结论
|
||||||
|
|
||||||
|
`hermes-web-ui-0.5.18` 对 mnote 有用,但不能原样照搬。
|
||||||
|
|
||||||
|
可吸收的是:
|
||||||
|
|
||||||
|
- Web 端应采用 `Browser -> BFF -> Hermes gateway`,不是浏览器直接 ACP。
|
||||||
|
- BFF 应承担 runtime 管理:active run registry、resume、queue、abort、event normalization、gateway manager、错误规整。
|
||||||
|
- 前端只展示 message、tool event、状态和输入,不持有 AI 编排真相。
|
||||||
|
|
||||||
|
不可吸收的是:
|
||||||
|
|
||||||
|
- 不把 mnote 的页面 AI 聊天真相改为 Convex、SQLite 或 mnote 自建 chat DB。
|
||||||
|
- 不把 Hermes 降级成 `/v1/responses store=false` 的纯模型执行器。
|
||||||
|
- 不让 mnote-web 根据自有数据库长期组装完整 `conversation_history`。
|
||||||
|
|
||||||
|
当前冻结方向:
|
||||||
|
|
||||||
|
> **mnote 学 `hermes-web-ui` 的 BFF/runtime 架构思想,但会话真相继续归 Hermes;mnote 只保存业务事实、audit、artifact、edge、page/body/title/options 结果。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 当前实现定位
|
||||||
|
|
||||||
|
当前页面 AI 接入是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Leptos 页面 AI 面板
|
||||||
|
-> mnote-web /api/hermes/client/*
|
||||||
|
-> Hermes gateway /v1/runs
|
||||||
|
-> Hermes gateway /v1/runs/{run_id}/events
|
||||||
|
```
|
||||||
|
|
||||||
|
同时:
|
||||||
|
|
||||||
|
- `profiles / skills / memory` 等设置能力由 `mnote-web` 本地 BFF 通过 Hermes CLI 或 `~/.hermes` 文件读写补齐。
|
||||||
|
- `mnote.*` 工具由 mnote Rust runtime 承接,Hermes 只通过 skill/plugin/tool 调用。
|
||||||
|
- 页面 AI 不再走旧 `/api/ai-agent/run`。
|
||||||
|
|
||||||
|
这已经满足最小闭环,但仍只是“最小 runtime 接入”,缺少 `hermes-web-ui` 已经具备的完整 Chat Runtime 能力。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ACP、gateway、responses 的取舍
|
||||||
|
|
||||||
|
### 3.1 ACP
|
||||||
|
|
||||||
|
ACP 适合:
|
||||||
|
|
||||||
|
- VSCode 插件
|
||||||
|
- Tauri / Electron 桌面端
|
||||||
|
- 本地 IDE host
|
||||||
|
- 需要由宿主启动 `hermes acp` 并通过 stdio JSON-RPC 深度交互的场景
|
||||||
|
|
||||||
|
ACP 不适合作为 mnote 页面 AI 第一主链:
|
||||||
|
|
||||||
|
- 浏览器不能直接 `spawn hermes acp`。
|
||||||
|
- 若 Web 使用 ACP,仍需 mnote-web 作为 ACP bridge 管理 stdio 子进程。
|
||||||
|
- 这会引入长期进程、session、backpressure、并发、恢复和崩溃清理复杂度。
|
||||||
|
- 当前 Hermes gateway 已提供 Web 更自然的 `/v1/*` 与 stream 能力。
|
||||||
|
|
||||||
|
结论:
|
||||||
|
|
||||||
|
> **ACP 后续只作为桌面端或高级 host bridge 候选,不作为页面 AI Web 主链。**
|
||||||
|
|
||||||
|
### 3.2 `/v1/runs`
|
||||||
|
|
||||||
|
`/v1/runs` 适合当前 mnote 主线:
|
||||||
|
|
||||||
|
- Hermes 持有 run / session / event 语义。
|
||||||
|
- mnote 页面 AI 只是 Hermes client。
|
||||||
|
- mnote 不需要长期组装 `conversation_history`。
|
||||||
|
- 与“AI 会话归 Hermes,mnote 能力归 Rust kernel”一致。
|
||||||
|
|
||||||
|
风险:
|
||||||
|
|
||||||
|
- mnote-web 需要补 runtime 状态管理,否则刷新、断线、队列、abort 体验弱。
|
||||||
|
- Hermes gateway 的非 `/v1/*` 管理面不足,需要 BFF 补 profiles/skills/memory/gateway manager。
|
||||||
|
|
||||||
|
结论:
|
||||||
|
|
||||||
|
> **短期继续以 `/v1/runs` 为默认页面 AI 主链,并在 mnote-web 上补 Runtime BFF。**
|
||||||
|
|
||||||
|
### 3.3 `/v1/responses`
|
||||||
|
|
||||||
|
`hermes-web-ui` 的产品化 Chat Runtime 使用 `/v1/responses stream=true`,典型形态是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser Vue UI
|
||||||
|
-> Socket.IO /chat-run
|
||||||
|
-> hermes-web-ui server
|
||||||
|
-> /v1/responses stream=true
|
||||||
|
-> server 从本地 DB 组装 conversation_history
|
||||||
|
-> server 把 response.* 事件转成 run/message/tool 事件
|
||||||
|
-> server 写入 SQLite session/messages
|
||||||
|
```
|
||||||
|
|
||||||
|
它适合“自建 Chat Runtime”:
|
||||||
|
|
||||||
|
- 后端自有 session/messages 数据库。
|
||||||
|
- 后端自行压缩上下文。
|
||||||
|
- 后端自行映射 tool event。
|
||||||
|
- `store=false` 时 Hermes 不再是会话真相。
|
||||||
|
|
||||||
|
这与 mnote 当前页面 AI 方向有冲突。除非后续明确改变设计,把“AI 会话真相”从 Hermes 迁到 mnote,否则不能把 `/v1/responses store=false + mnote conversation_history` 写成默认主链。
|
||||||
|
|
||||||
|
允许使用 `/v1/responses` 的条件:
|
||||||
|
|
||||||
|
- 只作为特定 runtime 实验或 fallback。
|
||||||
|
- 必须证明 Hermes session 仍能作为用户可见会话真相,或者另开设计稿明确改变会话真相。
|
||||||
|
- 不得把 mnote page aggregate / Convex 页面数据变成聊天消息数据库。
|
||||||
|
|
||||||
|
结论:
|
||||||
|
|
||||||
|
> **`/v1/responses` 先作为参考实现和可评估候选,不作为当前页面 AI 默认主链。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 目标架构
|
||||||
|
|
||||||
|
下一阶段目标不是改成 ACP,也不是直接改成 `/v1/responses`,而是在当前 gateway 主线上补一个 mnote-web Runtime BFF:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Leptos 页面 AI 面板
|
||||||
|
-> mnote-web Hermes Runtime BFF
|
||||||
|
- active run registry
|
||||||
|
- session/run resume
|
||||||
|
- queue
|
||||||
|
- abort
|
||||||
|
- event normalization
|
||||||
|
- gateway/profile/skill/memory BFF
|
||||||
|
- trace/audit 串联
|
||||||
|
-> Hermes gateway /v1/runs
|
||||||
|
-> Hermes session storage
|
||||||
|
-> mnote skill/plugin/tool
|
||||||
|
-> Rust runtime / kernel
|
||||||
|
```
|
||||||
|
|
||||||
|
真相边界:
|
||||||
|
|
||||||
|
- Hermes:AI session、message、run、tool event、usage、model、profile。
|
||||||
|
- mnote:页面、树、正文、artifact、edge、projection、audit、权限、idempotency。
|
||||||
|
- mnote-web Runtime BFF:运行时连接和事件编排,不保存长期聊天真相。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Hermes Web UI 参考索引
|
||||||
|
|
||||||
|
参考根目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18
|
||||||
|
```
|
||||||
|
|
||||||
|
只参考下表,不整站搬运:
|
||||||
|
|
||||||
|
| 参考主题 | 文件 | 搜索点 | mnote 中吸收什么 | mnote 中不采用什么 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Socket runtime 主链 | `packages/server/src/services/hermes/chat-run-socket.ts` | `ChatRunSocket`、`onConnection`、`resumeSession`、`handleRun`、`dequeueNextQueuedRun`、`handleAbort` | active run registry、resume、queue、abort、前端刷新不丢运行态 | 不照搬 Socket.IO;mnote 可用 SSE / WebSocket / HTTP 状态轮询 |
|
||||||
|
| `/v1/responses` 事件映射 | `packages/server/src/services/hermes/chat-run-socket.ts` | `/v1/responses`、`applyResponseStreamEvent`、`response.output_text.delta`、`response.output_item.done`、`response.completed` | 学习如何把上游事件标准化为 `message.delta`、`tool.started`、`tool.completed`、`run.completed` | 不把 `/v1/responses store=false` 写成默认主链 |
|
||||||
|
| 后端 session store | `packages/server/src/db/hermes/session-store.ts` | `HermesSessionRow`、`HermesMessageRow`、`createSession`、`addMessage`、`getSessionDetail` | 理解 Hermes Web UI 如何建完整 chat DB,以及字段形态 | 不在 mnote 建第二套 chat DB;不把 Convex 当 AI 会话真相 |
|
||||||
|
| 前端 chat store | `packages/client/src/stores/hermes/chat.ts` | `resumeServerWorkingRun`、`queueLengths`、`abortState`、`case 'message.delta'`、`case 'tool.started'`、`case 'run.completed'` | 前端状态机、队列提示、abort UI、tool event 归并 | 不复用 Vue/Pinia;不把前端 state 当真相 |
|
||||||
|
| 前端 API | `packages/client/src/api/hermes/chat.ts` | `startRunViaSocket`、`resumeSession`、`registerSessionHandlers`、`RunEvent` | 客户端事件合同与 runtime event 类型 | 不让浏览器绕过 mnote-web 直连 Hermes |
|
||||||
|
| VSCode ACP tool UI | `design/05-editor-mainline/reference-code/hermes-vscode-main/src/sessionManager.ts`、`src/protocol.ts`、`src/chatPanel.ts`、`src/webview/main.ts` | `tool_call`、`tool_call_update`、`parseToolCall`、`parseToolCallUpdate`、`case 'toolCall'`、`data-tool-id` | 学习按 `toolCallId` 追加工具行、再用后续 update 原地更新状态的 UI 模型 | 不采用 ACP 作为 Web 主链;只参考 tool event UI 状态机 |
|
||||||
|
| gateway 管理 | `packages/server/src/services/hermes/gateway-manager.ts`、`packages/server/src/services/gateway-bootstrap.ts` | `getUpstream`、`start`、`stop`、`health` | profile 对应 gateway 的发现、启动、健康检查、错误提示 | 不让页面 AI 抽屉变成完整 gateway 管理台 |
|
||||||
|
| proxy handler | `packages/server/src/routes/hermes/proxy-handler.ts` | `resolveUpstream`、`SSE_EVENTS_PATH`、`streamSSE`、`/v1/runs` | 路由改写、SSE 拦截、upstream 错误规整 | 不把全量 proxy catch-all 暴露给浏览器 |
|
||||||
|
| skills 来源 | `packages/server/src/controllers/hermes/skills.ts` | `.bundled_manifest`、`.hub/lock.json`、`.usage.json`、`source`、`modified` | skill 来源与生成/安装/本地分类 | 不复制完整 skill 管理台 |
|
||||||
|
|
||||||
|
快速定位命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18
|
||||||
|
rg -n "ChatRunSocket|resumeSession|handleRun|dequeueNextQueuedRun|handleAbort|/v1/responses|applyResponseStreamEvent|response\\.output_text\\.delta|response\\.output_item\\.done|response\\.completed|HermesSessionRow|startRunViaSocket|gateway-manager" packages/server/src packages/client/src
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. RuntimeEvent 合同
|
||||||
|
|
||||||
|
mnote-web BFF 对前端暴露的事件应稳定为 `mnote.hermes_runtime_event.v1`。
|
||||||
|
|
||||||
|
最小事件集:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "event": "run.started", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "traceId": "trace_1" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "event": "message.delta", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "delta": "当前页", "traceId": "trace_1" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "event": "tool.started", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "toolCallId": "call_1", "toolName": "mnote.page.get", "traceId": "trace_1" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "event": "tool.completed", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "toolCallId": "call_1", "toolName": "mnote.page.get", "summary": "读取当前页面", "auditId": "audit_1", "traceId": "trace_1" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "event": "run.completed", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "output": "总结文本", "usage": { "inputTokens": 0, "outputTokens": 0 }, "traceId": "trace_1" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "event": "run.failed", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "code": "hermes_client_upstream_error", "message": "Hermes upstream 返回错误", "traceId": "trace_1" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "event": "abort.started", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "traceId": "trace_1" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "event": "abort.completed", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "synced": true, "traceId": "trace_1" }
|
||||||
|
```
|
||||||
|
|
||||||
|
如果上游是 `/v1/runs/{run_id}/events`,BFF 只做字段规整;如果后续试验 `/v1/responses`,BFF 负责把 `response.*` 映射为上述 RuntimeEvent。前端不直接依赖上游原始事件格式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 下一阶段 checklist
|
||||||
|
|
||||||
|
### A. Runtime 状态注册表
|
||||||
|
|
||||||
|
- [x] 在 `mnote-web` 建立内存级 `sessionId -> active run state` registry。
|
||||||
|
- [x] 记录 `sessionId`、`runId`、`profile`、`documentId`、`traceId`、`status`、`startedAt`、`lastEventAt`。
|
||||||
|
- [x] registry 只保存运行态和最近事件摘要,不保存完整聊天历史。
|
||||||
|
- [x] 服务重启后允许 registry 丢失,但页面必须能从 Hermes session detail 恢复已完成消息。
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- [x] 页面刷新时,若 run 仍在 registry 中,前端能看到 `running/tool_calling` 状态。
|
||||||
|
- [x] 关闭页面再打开,不会把 mnote local storage 当作聊天真相。
|
||||||
|
|
||||||
|
### B. Resume
|
||||||
|
|
||||||
|
- [x] 增加 `POST /api/hermes/client/sessions/{session_id}/resume` 或等价同源接口。
|
||||||
|
- [x] 返回 Hermes session detail 中的 messages,并附带 registry 中当前运行态。
|
||||||
|
- [x] 若 session 不在 registry 中,只返回 Hermes 已持久化状态。
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- [x] run 过程中刷新页面,UI 能恢复 run 状态或明确显示“正在同步 Hermes 状态”。
|
||||||
|
- [x] run 完成后刷新页面,消息来自 Hermes session detail / export,不来自 mnote 自建聊天表。
|
||||||
|
|
||||||
|
验证证据:
|
||||||
|
|
||||||
|
- `cargo test -p mnote-web hermes_client_ -- --nocapture`:通过 9 个 Hermes client 单测,覆盖 registry 不保存 `messages`、tool event 摘要和 `/resume` 返回 runtime。
|
||||||
|
- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-smoke.js`:通过。13000 是本轮临时启动的新 `mnote-web`,用于避免打断当前 3000;验证后已关闭。
|
||||||
|
- 当前 3000 运行的是本轮修改前的旧 `mnote-web` 二进制,`POST /api/hermes/client/sessions/{id}/resume` 返回 404;需要重启 3000 后再用同一 smoke 复验主入口。
|
||||||
|
|
||||||
|
### C. Queue
|
||||||
|
|
||||||
|
- [x] 同一 session 已有 active run 时,后续输入进入后端 queue。
|
||||||
|
- [x] queue item 只保存待发送 input、profile、context snapshot 摘要和 trace,不保存长期聊天真相。
|
||||||
|
- [x] 当前 run 结束后自动触发下一条。
|
||||||
|
- [x] 前端显示 queue length 和可取消项。
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- [x] 连续发送两条消息时,第二条显示 queued,而不是并发打到同一 Hermes session。
|
||||||
|
- [x] 取消 queued item 后,不触发 Hermes run。
|
||||||
|
|
||||||
|
验证证据:
|
||||||
|
|
||||||
|
- `cargo test -p mnote-web hermes_client_ -- --nocapture`:通过 12 个 Hermes client 单测,新增覆盖 active run 时第二条入队、queue item 不返回完整 `messages/pageContext`、后端取消 queued item、终态后用 mock Hermes upstream 自动启动下一条 queued run。
|
||||||
|
- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-queue-smoke.js`:通过。验证连续发送两条消息时第二条走 `queued` 响应,UI 显示队列长度,并能从 Runtime 面板取消 queued item。
|
||||||
|
- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-smoke.js`:通过。确认 resume / tool / 完成态基础链路未被 queue 改动破坏。
|
||||||
|
- 13000 是本轮临时启动的新 `mnote-web`,验证后已关闭;当前 3000 仍需重启后复验。
|
||||||
|
|
||||||
|
### D. Abort
|
||||||
|
|
||||||
|
- [x] abort 由 BFF 统一执行,调用 Hermes `/v1/runs/{run_id}/stop` 或中断当前 upstream request。
|
||||||
|
- [x] abort 前后发出 `abort.started`、`abort.completed`。
|
||||||
|
- [x] abort 后清理 registry 状态,并处理队列后续项。
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- [x] 点击停止后,UI 不再继续追加该 run 的 assistant delta。
|
||||||
|
- [x] 若 abort 后有队列项,后端按顺序继续处理或明确要求用户确认。
|
||||||
|
|
||||||
|
验证证据:
|
||||||
|
|
||||||
|
- `cargo test -p mnote-web hermes_client_ -- --nocapture`:通过 13 个 Hermes client 单测,新增覆盖 abort 响应返回 `abort.started/abort.completed`,并在 abort 后继续处理 queued run。
|
||||||
|
- 临时 `mnote-web` 以 `MNOTE_WEB_HERMES_UPSTREAM_URL=http://127.0.0.1:3999` 启动后,执行 `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-runtime-controls-smoke.js`:通过。验证停止 run 后 UI 状态进入 `aborted`,并且后续 `message.delta` 不再追加到聊天区。
|
||||||
|
- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-queue-smoke.js` 与 `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-smoke.js`:通过。确认 queue / resume 基础链路未被 abort 改动破坏。
|
||||||
|
- 13000 是本轮临时启动的新 `mnote-web`,验证后已关闭;当前 3000 仍需重启后复验。
|
||||||
|
|
||||||
|
### E. Event normalization
|
||||||
|
|
||||||
|
- [x] mnote-web 将 Hermes 上游事件规整为 `mnote.hermes_runtime_event.v1`。
|
||||||
|
- [x] 前端只消费标准事件,不直接分支处理多套 Hermes 原始事件。
|
||||||
|
- [x] tool event 展示独立于 assistant 文本。
|
||||||
|
- [x] 当前 UI 已能在聊天区输出 `工具` 消息并显示工具名,例如 `mnote.page.get`;下一阶段必须升级为按 `toolCallId` 合并的 tool event card。
|
||||||
|
- [x] `tool.started` 创建或更新同一个工具卡,显示工具名、运行中状态、参数摘要。
|
||||||
|
- [x] `tool.completed` 原地更新同一个工具卡,显示完成状态、结果摘要、audit/trace id。
|
||||||
|
- [x] `tool.failed` 原地更新同一个工具卡,显示失败状态、错误码和可复制详情。
|
||||||
|
- [x] Runtime 页的最近 tool call 摘要继续保留,但不能替代聊天区内联工具卡。
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- [x] `message.delta`、`tool.started`、`tool.completed`、`run.completed`、`run.failed` 均有 smoke 覆盖。
|
||||||
|
- [x] `tool.started -> tool.completed` 不产生两条互不关联的纯文本工具消息;同一 `toolCallId` 在 UI 中表现为同一张工具卡状态变化。
|
||||||
|
- [x] 工具卡至少展示 `toolName`、`toolCallId`、状态、参数摘要、结果摘要、`traceId/auditId`。
|
||||||
|
- [x] tool result 不混入正文编辑器,不写入 page body,除非 Hermes 明确调用 `mnote.page.save` 等写工具。
|
||||||
|
|
||||||
|
验证证据:
|
||||||
|
|
||||||
|
- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-smoke.js`:通过。smoke 发送同一 `toolCallId=call_smoke_page_get` 的 `tool.started -> tool.completed`,断言聊天区只有 1 张 completed 工具卡;同时发送 `toolCallId=call_smoke_page_save` 的 `tool.started -> tool.failed`,断言 failed 工具卡显示 `permission_denied`。
|
||||||
|
- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-runtime-controls-smoke.js` 与 `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-queue-smoke.js`:通过。确认工具卡改动未破坏 abort / queue。
|
||||||
|
- `cargo test -p mnote-web hermes_client_ -- --nocapture`:通过 14 个 Hermes client 单测,新增覆盖 `assistant_message`、`tool.failed` 等上游事件被规整为 `mnote.hermes_runtime_event.v1`。
|
||||||
|
- mnote-web `/client/events/{run_id}` 输出 `event: <canonical>` 与 `schemaVersion: "mnote.hermes_runtime_event.v1"`;前端消费路径只处理 `message.delta`、`run.completed`、`run.failed`、`run.aborted` 与 `tool.*` 标准事件名。
|
||||||
|
|
||||||
|
### F. Gateway manager
|
||||||
|
|
||||||
|
- [x] BFF 能检查当前 Hermes gateway health。
|
||||||
|
- [x] BFF 能识别 profile 对应 gateway / model 状态。
|
||||||
|
- [x] 缺 model/default 或 API key 时,错误提示指向 Hermes 设置,而不是只显示 502。
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- [x] `hermes_client_upstream_error` 场景能显示具体修复建议,例如“当前 profile 缺 model.default”。
|
||||||
|
- [x] profile/skill/settings API 不再错误代理到 Hermes gateway 非 `/v1/*` 路径。
|
||||||
|
|
||||||
|
验证证据:
|
||||||
|
|
||||||
|
- `mnote-web` 新增 `GET /api/hermes/client/gateway/health?profile=<name>`,只做只读探测,不启动/停止 gateway;返回 `gateway.configured/upstream/status/httpStatus`、`profile.modelDefault/provider/apiKeyConfigured` 与 `suggestions`。
|
||||||
|
- `profile_detail_payload()` 与 fallback profiles 会从 Hermes profile `config.yaml` 读取 `model.default`、`model.provider`、provider `api_key/key_env` 摘要,但不返回 API key 原文。
|
||||||
|
- `upstream_error()` 对 `hermes_client_upstream_error` 增加 Hermes 设置修复建议;当上游错误正文包含 `model.default`、`API key`、`profile/provider` 等线索时,错误 message 会指向 Hermes 设置。
|
||||||
|
- Runtime 面板增加 gateway/profile 状态区;页面请求失败时前端显示后端 `message`,不再只显示错误 code。
|
||||||
|
- `cargo test -p mnote-web hermes_client_ -- --nocapture`:通过 17 个 Hermes client 单测,新增覆盖 gateway 未配置、profile 缺 model/API key、`hermes_client_upstream_error` 错误建议。
|
||||||
|
- 临时 `mnote-web` 以 `MNOTE_WEB_HERMES_UPSTREAM_URL=http://127.0.0.1:3999` 启动到 `127.0.0.1:13000` 后,`task-hermes-page-ai-smoke.js`、`task-hermes-page-ai-runtime-controls-smoke.js`、`task-hermes-page-ai-queue-smoke.js` 均通过;验证后已关闭 13000。
|
||||||
|
- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-agent-skill-smoke.js`:通过。确认新增 gateway health 请求不影响 agent/profile 选择、SOUL.md 保存、skill toggle 与 run profile 透传。
|
||||||
|
|
||||||
|
### G. `/v1/responses` 评估,不默认切换
|
||||||
|
|
||||||
|
- [x] 增加只读 spike 文档或测试,验证当前 Hermes gateway 的 `/v1/responses stream=true` 事件格式。
|
||||||
|
- [x] 明确 `/v1/responses` 是否能在不迁移会话真相的前提下复用 Hermes session。
|
||||||
|
- [x] 若不能保持 Hermes session 真相,则不得进入默认页面 AI 主链。
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- [x] 有明确结论:继续 `/v1/runs`,或另开设计稿说明为什么改变会话真相。
|
||||||
|
- [x] 任何 `/v1/responses` 实验都不得把 mnote Convex / page aggregate 变成聊天消息真相。
|
||||||
|
|
||||||
|
只读 spike 结论:
|
||||||
|
|
||||||
|
- 参考 `hermes-web-ui-0.5.18/packages/server/src/services/hermes/chat-run-socket.ts`,其 `/v1/responses` 主链在发送前从后端 DB 组装 `conversation_history`,随后设置 `body.stream = true` 与 `body.store = false`,再请求 `${upstream}/v1/responses`。
|
||||||
|
- 同一文件的 `applyResponseStreamEvent()` 表明 responses 事件格式主要是 `response.created`、`response.output_text.delta`、`response.output_item.added`、`response.output_item.done`、`response.completed`、`response.failed`,由上层 BFF 映射为 `run.started`、`message.delta`、`tool.started`、`tool.completed`、`run.completed`、`run.failed`。
|
||||||
|
- `response.output_item.done` 中 `function_call` 会被映射为 `tool.started`;`function_call_output` 会被映射为 `tool.completed`。这对 mnote 的 tool card 状态机有参考价值,但不改变主链。
|
||||||
|
- 本机只读探测 `http://127.0.0.1:3999/health`、`/v1/models`、`OPTIONS /v1/responses` 时,当前没有 gateway 监听,无法取得 live `/v1/responses stream=true` 事件样本;因此本轮不把 live event capture 写成验收依据。
|
||||||
|
- 基于参考实现,`/v1/responses store=false + conversation_history` 的顺滑体验依赖上层 BFF/SQLite 自建 chat session 真相。除非后续确认 Hermes gateway 支持把 `/v1/responses` 直接绑定到 Hermes session storage,否则 mnote 不应把它切成默认主链。
|
||||||
|
|
||||||
|
当前决策:
|
||||||
|
|
||||||
|
> **继续 `/v1/runs` 作为页面 AI 默认主链;`/v1/responses` 只作为事件标准化与 tool card 的参考实现。若未来要切换,必须另开设计稿证明 Hermes session 仍是 AI 会话真相,或明确批准迁移会话真相。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 非目标
|
||||||
|
|
||||||
|
- 不在本阶段引入 ACP 主链。
|
||||||
|
- 不在本阶段把页面 AI 主链切到 `/v1/responses store=false`。
|
||||||
|
- 不在 mnote 建 Hermes Web UI 那样的 SQLite chat DB。
|
||||||
|
- 不把 Convex 作为 AI 会话真相。
|
||||||
|
- 不把页面 AI 抽屉扩成完整 Hermes 管理台。
|
||||||
|
- 不让 mnote plugin 直接写 Convex;写入仍回到 Rust runtime / kernel。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 最终验收口径
|
||||||
|
|
||||||
|
本稿完成时,应能同时成立:
|
||||||
|
|
||||||
|
- [x] 页面 AI 仍是 Hermes 页面内客户端。
|
||||||
|
- [x] Hermes 仍是 AI session/message/tool event/usage/model/profile 真相。
|
||||||
|
- [x] mnote-web 有 Runtime BFF 能力,支持 run resume、queue、abort 和事件标准化。
|
||||||
|
- [x] 页面刷新或断线不导致 active run UI 状态丢失;完成后的消息从 Hermes 恢复。
|
||||||
|
- [x] mnote 不保存完整聊天历史,只保存 mnote 业务写入结果和 audit。
|
||||||
|
- [x] `/v1/responses` 是否进入主链已有单独评估结论,且没有绕过 Hermes session 真相。
|
||||||
|
|
||||||
|
一句话:
|
||||||
|
|
||||||
|
> **补齐 Web Chat Runtime 的工程能力,但不改变“AI 会话归 Hermes,mnote 事实归 Rust kernel”的长期边界。**
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ mod mindmap_api;
|
|||||||
mod mindmap_shell;
|
mod mindmap_shell;
|
||||||
mod onlyoffice;
|
mod onlyoffice;
|
||||||
mod query_support;
|
mod query_support;
|
||||||
|
mod resource_trash;
|
||||||
mod search;
|
mod search;
|
||||||
mod session;
|
mod session;
|
||||||
mod snapshot_support;
|
mod snapshot_support;
|
||||||
@@ -27,7 +28,7 @@ pub(crate) mod web_shell;
|
|||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use axum::routing::{any, get, post, put};
|
use axum::routing::{any, delete, get, post, put};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
pub fn build_router(state: AppState) -> Router {
|
pub fn build_router(state: AppState) -> Router {
|
||||||
@@ -37,6 +38,7 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
let mut router = Router::new()
|
let mut router = Router::new()
|
||||||
.route("/health", get(health::health))
|
.route("/health", get(health::health))
|
||||||
.route("/", get(gateway::root_entry))
|
.route("/", get(gateway::root_entry))
|
||||||
|
.route("/trash", get(gateway::trash_entry))
|
||||||
.route("/favicon.ico", get(gateway::favicon))
|
.route("/favicon.ico", get(gateway::favicon))
|
||||||
.route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
|
.route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
|
||||||
.route("/search", get(search::shell))
|
.route("/search", get(search::shell))
|
||||||
@@ -46,7 +48,14 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/mindmap/{doc_id}/{mindmap_id}",
|
"/api/mindmap/{doc_id}/{mindmap_id}",
|
||||||
get(mindmap_api::get_mindmap).post(mindmap_api::apply_mindmap_command),
|
get(mindmap_api::get_mindmap)
|
||||||
|
.post(mindmap_api::apply_mindmap_command)
|
||||||
|
.delete(resource_trash::mindmap_delete)
|
||||||
|
.patch(resource_trash::mindmap_trash_action),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/mindmap-trash/empty",
|
||||||
|
post(resource_trash::mindmap_empty_trash),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/documents/{document_id}",
|
"/documents/{document_id}",
|
||||||
@@ -86,13 +95,35 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route("/api/onlyoffice/forcesave", post(onlyoffice::forcesave))
|
.route("/api/onlyoffice/forcesave", post(onlyoffice::forcesave))
|
||||||
.route("/api/media/upload", post(media::upload))
|
.route("/api/media/upload", post(media::upload))
|
||||||
.route("/api/media/sign", get(media::sign))
|
.route("/api/media/sign", get(media::sign))
|
||||||
|
.route("/api/media/batch", post(resource_trash::media_batch))
|
||||||
|
.route("/api/media/purge", post(resource_trash::media_purge))
|
||||||
|
.route(
|
||||||
|
"/api/media/empty-trash",
|
||||||
|
post(resource_trash::media_empty_trash),
|
||||||
|
)
|
||||||
|
.route("/api/tables/restore", post(resource_trash::table_restore))
|
||||||
|
.route("/api/tables/create", post(resource_trash::table_create))
|
||||||
|
.route(
|
||||||
|
"/api/tables/{table_id}",
|
||||||
|
delete(resource_trash::table_delete),
|
||||||
|
)
|
||||||
|
.route("/api/tables/purge", post(resource_trash::table_purge))
|
||||||
|
.route(
|
||||||
|
"/api/tables/empty-trash",
|
||||||
|
post(resource_trash::table_empty_trash),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/tree/filetree/upload-target-preflight",
|
"/api/tree/filetree/upload-target-preflight",
|
||||||
post(media::filetree_upload_target_preflight),
|
post(media::filetree_upload_target_preflight),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/tree/filetree/drop-preflight",
|
||||||
|
post(tree::filetree_drop_preflight),
|
||||||
|
)
|
||||||
.route("/api/documents/meta", get(documents::meta))
|
.route("/api/documents/meta", get(documents::meta))
|
||||||
.route("/api/documents/content", get(documents::content))
|
.route("/api/documents/content", get(documents::content))
|
||||||
.route("/api/documents/purge", post(documents::purge))
|
.route("/api/documents/purge", post(documents::purge))
|
||||||
|
.route("/api/documents/empty-trash", post(documents::empty_trash))
|
||||||
.route("/api/documents/title", post(documents::title))
|
.route("/api/documents/title", post(documents::title))
|
||||||
.route("/api/documents/options", post(documents::options))
|
.route("/api/documents/options", post(documents::options))
|
||||||
.route("/api/documents/save", post(documents::save))
|
.route("/api/documents/save", post(documents::save))
|
||||||
@@ -145,6 +176,11 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
"/client/sessions/{session_id}",
|
"/client/sessions/{session_id}",
|
||||||
get(hermes_client::get_session),
|
get(hermes_client::get_session),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/client/sessions/{session_id}/resume",
|
||||||
|
post(hermes_client::resume_session),
|
||||||
|
)
|
||||||
|
.route("/client/gateway/health", get(hermes_client::gateway_health))
|
||||||
.route("/client/profiles", get(hermes_client::list_profiles))
|
.route("/client/profiles", get(hermes_client::list_profiles))
|
||||||
.route(
|
.route(
|
||||||
"/client/profiles/active",
|
"/client/profiles/active",
|
||||||
@@ -156,18 +192,15 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/client/profile-memory",
|
"/client/profile-memory",
|
||||||
get(hermes_client::get_profile_memory)
|
get(hermes_client::get_profile_memory).post(hermes_client::save_profile_memory),
|
||||||
.post(hermes_client::save_profile_memory),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/client/skills",
|
|
||||||
get(hermes_client::list_skills),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/client/skills/toggle",
|
|
||||||
put(hermes_client::toggle_skill),
|
|
||||||
)
|
)
|
||||||
|
.route("/client/skills", get(hermes_client::list_skills))
|
||||||
|
.route("/client/skills/toggle", put(hermes_client::toggle_skill))
|
||||||
.route("/client/runs", post(hermes_client::create_run))
|
.route("/client/runs", post(hermes_client::create_run))
|
||||||
|
.route(
|
||||||
|
"/client/sessions/{session_id}/queue/{queue_id}",
|
||||||
|
delete(hermes_client::cancel_queued_run),
|
||||||
|
)
|
||||||
.route("/client/events/{run_id}", get(hermes_client::stream_events))
|
.route("/client/events/{run_id}", get(hermes_client::stream_events))
|
||||||
.route(
|
.route(
|
||||||
"/client/runs/{run_id}/abort",
|
"/client/runs/{run_id}/abort",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
|||||||
|
#!/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-queue-${suffix}`;
|
||||||
|
const sessionId = `mnote_queue_${suffix}`;
|
||||||
|
const runId = `run_queue_${suffix}`;
|
||||||
|
const queueId = `queue_${suffix}`;
|
||||||
|
const createdIds = [];
|
||||||
|
const runBodies = [];
|
||||||
|
const cancelBodies = [];
|
||||||
|
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/profiles", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, profiles: [{ name: "default", active: true, model: "gpt-5" }] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/hermes/client/profile-memory**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, memory: "", user: "", soul: "" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/hermes/client/skills**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/hermes/client/tools**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, tools: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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: "队列测试", profile: "default" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
sessionId,
|
||||||
|
session: { sessionId, profile: "default", messages: [] },
|
||||||
|
runtime: { sessionId, runId, status: "running", queueLength: 0 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/hermes/client/runs", async (route) => {
|
||||||
|
runBodies.push(JSON.parse(route.request().postData() || "{}"));
|
||||||
|
if (runBodies.length === 1) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, runId, sessionId, traceId: `trace_run_${suffix}` }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
status: 202,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
queued: true,
|
||||||
|
queueId,
|
||||||
|
sessionId,
|
||||||
|
status: "queued",
|
||||||
|
queueLength: 1,
|
||||||
|
traceId: `trace_queue_${suffix}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route(`**/api/hermes/client/sessions/${sessionId}/queue/${queueId}`, async (route) => {
|
||||||
|
cancelBodies.push({ method: route.request().method(), url: route.request().url() });
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, sessionId, queueId, cancelled: true, queueLength: 0 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route(`**/api/hermes/client/events/${runId}`, async () => {
|
||||||
|
// 保持第一个 run 处于 active 状态,让第二条输入进入队列。
|
||||||
|
});
|
||||||
|
|
||||||
|
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 waitForCondition(() => runBodies.length === 1, UI_TIMEOUT_MS, "未捕获第一条 run 请求");
|
||||||
|
|
||||||
|
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("已加入 Hermes 队列"),
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (document.querySelector("[data-page-ai-run-status]")?.textContent || "").includes("队列 1"),
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
await page.locator('.wolai-page-ai-icon[data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="runtime"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator(`[data-page-ai-queue-item="${queueId}"] [data-page-ai-action="cancel-queued-run"]`).click({
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (document.querySelector("[data-page-ai-run-status]")?.textContent || "").includes("队列 1") === false,
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
await waitForCondition(() => cancelBodies.length === 1, UI_TIMEOUT_MS, "未捕获取消 queued item 请求");
|
||||||
|
|
||||||
|
assert.equal(runBodies.length, 2, "第二条输入必须到达 mnote-web BFF");
|
||||||
|
assert.equal(cancelBodies.length, 1, "取消 queued item 必须调用 mnote-web queue cancel API");
|
||||||
|
assert.equal(cancelBodies[0].method, "DELETE", "取消 queued item 必须使用 DELETE");
|
||||||
|
assert.equal(runBodies[0].sessionId, sessionId, "第一条 run 必须携带 Hermes sessionId");
|
||||||
|
assert.equal(runBodies[1].sessionId, sessionId, "第二条 queued run 必须携带同一个 sessionId");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
documentId: target.documentId,
|
||||||
|
workspaceId: target.workspaceId,
|
||||||
|
sessionId,
|
||||||
|
runId,
|
||||||
|
queueId,
|
||||||
|
runRequests: runBodies.length,
|
||||||
|
cancelRequests: cancelBodies.length,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||||
|
await context.close().catch(() => undefined);
|
||||||
|
await browser.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForCondition(predicate, timeoutMs, message) {
|
||||||
|
const started = Date.now();
|
||||||
|
while (Date.now() - started < timeoutMs) {
|
||||||
|
if (predicate()) return;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -76,7 +76,7 @@ async function main() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await page.route(`**/api/hermes/client/sessions/${sessionId}`, async (route) => {
|
await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
@@ -96,7 +96,9 @@ async function main() {
|
|||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||||
body: `data: ${JSON.stringify({ event: "run.aborted", run_id: runId, session_id: sessionId })}\n\n`,
|
body:
|
||||||
|
`data: ${JSON.stringify({ event: "run.aborted", run_id: runId, session_id: sessionId })}\n\n` +
|
||||||
|
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "SHOULD_NOT_APPEAR_AFTER_ABORT" })}\n\n`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await page.route(`**/api/hermes/client/runs/${runId}/abort`, async (route) => {
|
await page.route(`**/api/hermes/client/runs/${runId}/abort`, async (route) => {
|
||||||
@@ -105,7 +107,16 @@ async function main() {
|
|||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ ok: true, runId, status: "aborted" }),
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
runId,
|
||||||
|
status: "aborted",
|
||||||
|
runtime: { sessionId, runId, status: "aborted" },
|
||||||
|
events: [
|
||||||
|
{ event: "abort.started", runId },
|
||||||
|
{ event: "abort.completed", runId },
|
||||||
|
],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,6 +161,14 @@ async function main() {
|
|||||||
null,
|
null,
|
||||||
{ timeout: UI_TIMEOUT_MS },
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
);
|
);
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
const drawerTextAfterAbort = await page.locator('[data-testid="wolai-page-ai-drawer"]').textContent({
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
!drawerTextAfterAbort.includes("SHOULD_NOT_APPEAR_AFTER_ABORT"),
|
||||||
|
"abort 后不应继续追加该 run 的 assistant delta",
|
||||||
|
);
|
||||||
|
|
||||||
assert(sessionBodies.some((body) => body.profile === "default"), "创建 session 必须携带 profile");
|
assert(sessionBodies.some((body) => body.profile === "default"), "创建 session 必须携带 profile");
|
||||||
assert(abortBodies.some((body) => body.reason === "page_ai_user_stop"), "停止 run 必须调用 Hermes abort API");
|
assert(abortBodies.some((body) => body.reason === "page_ai_user_stop"), "停止 run 必须调用 Hermes abort API");
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ async function main() {
|
|||||||
const sessionId = `mnote_smoke_${suffix}`;
|
const sessionId = `mnote_smoke_${suffix}`;
|
||||||
const runId = `run_smoke_${suffix}`;
|
const runId = `run_smoke_${suffix}`;
|
||||||
let sessionDetailHits = 0;
|
let sessionDetailHits = 0;
|
||||||
|
let allowSessionRestore = false;
|
||||||
const captured = [];
|
const captured = [];
|
||||||
const createdIds = [];
|
const createdIds = [];
|
||||||
const browser = await chromium.launch({ headless: true });
|
const browser = await chromium.launch({ headless: true });
|
||||||
@@ -42,9 +43,10 @@ async function main() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await page.route(`**/api/hermes/client/sessions/${sessionId}`, async (route) => {
|
await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => {
|
||||||
sessionDetailHits += 1;
|
sessionDetailHits += 1;
|
||||||
captured.push({ kind: "session-detail", method: route.request().method(), body: "" });
|
captured.push({ kind: "session-detail", method: route.request().method(), body: "" });
|
||||||
|
assert.equal(route.request().method(), "POST", "刷新恢复必须调用 runtime resume 接口");
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
@@ -54,11 +56,22 @@ async function main() {
|
|||||||
traceId: "trace_restore",
|
traceId: "trace_restore",
|
||||||
session: {
|
session: {
|
||||||
sessionId,
|
sessionId,
|
||||||
messages: [
|
messages: allowSessionRestore
|
||||||
{ role: "user", content: `请总结 ${title}` },
|
? [
|
||||||
{ role: "tool", content: "mnote.page.get" },
|
{ role: "user", content: `请总结 ${title}` },
|
||||||
{ role: "assistant", content: "Smoke restored from Hermes session" },
|
{ role: "tool", content: "mnote.page.get" },
|
||||||
],
|
{ role: "assistant", content: "Smoke restored from Hermes session" },
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
sessionId,
|
||||||
|
runId,
|
||||||
|
status: "completed",
|
||||||
|
profile: "default",
|
||||||
|
documentId: "doc_smoke",
|
||||||
|
traceId: "trace_restore",
|
||||||
|
lastToolName: "mnote.page.get",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -83,8 +96,10 @@ async function main() {
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||||
body:
|
body:
|
||||||
`data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, name: "mnote.page.get" })}\n\n` +
|
`data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, toolCallId: "call_smoke_page_get", name: "mnote.page.get", args: { includeBody: false } })}\n\n` +
|
||||||
`data: ${JSON.stringify({ event: "tool.completed", run_id: runId, session_id: sessionId, name: "mnote.page.get" })}\n\n` +
|
`data: ${JSON.stringify({ event: "tool.completed", run_id: runId, session_id: sessionId, toolCallId: "call_smoke_page_get", name: "mnote.page.get", summary: "读取当前页面", auditId: "audit_smoke_page_get" })}\n\n` +
|
||||||
|
`data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, toolCallId: "call_smoke_page_save", name: "mnote.page.save", args: { dryRun: true } })}\n\n` +
|
||||||
|
`data: ${JSON.stringify({ event: "tool.failed", run_id: runId, session_id: sessionId, toolCallId: "call_smoke_page_save", name: "mnote.page.save", code: "permission_denied", error: "写入被拒绝", auditId: "audit_smoke_page_save" })}\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: "Smoke " })}\n\n` +
|
||||||
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "response" })}\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`,
|
`data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: sessionId, output: "Smoke response" })}\n\n`,
|
||||||
@@ -118,6 +133,22 @@ async function main() {
|
|||||||
null,
|
null,
|
||||||
{ timeout: UI_TIMEOUT_MS },
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
);
|
);
|
||||||
|
const toolCards = await page.$$eval("[data-page-ai-tool-card]", (cards) =>
|
||||||
|
cards.map((card) => ({
|
||||||
|
id: card.getAttribute("data-page-ai-tool-call-id"),
|
||||||
|
status: card.getAttribute("data-page-ai-tool-status"),
|
||||||
|
text: card.textContent || "",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
assert.equal(toolCards.length, 2, `应渲染 2 张工具卡:${JSON.stringify(toolCards)}`);
|
||||||
|
assert(
|
||||||
|
toolCards.some((card) => card.status === "completed" && card.text.includes("call_smoke_page_get")),
|
||||||
|
`缺少 completed 工具卡:${JSON.stringify(toolCards)}`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
toolCards.some((card) => card.status === "failed" && card.text.includes("permission_denied")),
|
||||||
|
`缺少 failed 工具卡:${JSON.stringify(toolCards)}`,
|
||||||
|
);
|
||||||
|
|
||||||
const sessionRequest = captured.find((entry) => entry.kind === "session");
|
const sessionRequest = captured.find((entry) => entry.kind === "session");
|
||||||
const runRequest = captured.find((entry) => entry.kind === "run");
|
const runRequest = captured.find((entry) => entry.kind === "run");
|
||||||
@@ -130,6 +161,7 @@ async function main() {
|
|||||||
assert.equal(runBody.documentId, target.documentId, "run 请求必须携带 documentId");
|
assert.equal(runBody.documentId, target.documentId, "run 请求必须携带 documentId");
|
||||||
assert(runBody.pageContext, "run 请求必须携带 pageContext");
|
assert(runBody.pageContext, "run 请求必须携带 pageContext");
|
||||||
|
|
||||||
|
allowSessionRestore = true;
|
||||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
|
|||||||
Reference in New Issue
Block a user