feat: stabilize page AI ACP runtimes
实现并稳定页面 AI 的 ACP Hermes / ACP Reasonix 运行路径。 主要内容: - 分离 profile 与 acpRuntime,ACP Hermes 按所选 Hermes profile 启动并注入 provider key。 - 修复 Reasonix ACP wrapper 的 API key 读取、ToolRegistry 注册、LoopEvent role 映射和 reasoning/final 分流。 - 修复 ACP agent_thought_chunk 被 untagged enum 误解析为 message.delta 的问题,补充 thought 相关单测。 - 补充页面 AI 浏览器验证 skill 证据到 7-15 设计稿,并记录严格验收标准。 - 同步提交当前仓库中已存在的 rust-web / Hermes tools / SSE / bug 文档相关改动。 验证: - node --check scripts/reasonix-acp-wrapper.mjs - cargo test -p mnote-web acp -- --nocapture - 页面 AI ACP 浏览器验证:tmp/page-ai-acp-browser-UAYwyM/
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
# 3-15 [done] 垃圾箱 workbench 残留 `pollMs=1000` 触发 Convex query 风险
|
||||
|
||||
> 创建时间:2026-05-17
|
||||
>
|
||||
> 更新时间:2026-05-17
|
||||
>
|
||||
> 状态:`done`
|
||||
|
||||
## 问题
|
||||
|
||||
主页面树实时链路已在 `57ec8322` 切到 WebSocket push,但 `/trash` 渲染的垃圾箱 workbench 仍创建 `EventSource('/api/tree/events')` 并显式设置 `pollMs=1000`。
|
||||
|
||||
这条路径不是首页主链路,但用户打开垃圾箱页面或弹窗后,仍可能把 `/api/tree/events` 带回 1 秒轮询语义,重新放大 Convex `POST /api/query` 压力。
|
||||
|
||||
## 处理方案
|
||||
|
||||
1. 为 `/trash` 渲染测试增加断言:垃圾箱 workbench 不得输出 `pollMs=1000`。
|
||||
2. 修改垃圾箱 workbench 事件订阅,移除显式 `pollMs=1000`。
|
||||
3. 保留 `EventSource('/api/tree/events')` 作为兼容刷新通道,让 Rust SSE route 使用当前 push-driven 默认行为。
|
||||
4. 运行 `mnote-web` 相关单测,确认渲染输出不再包含旧轮询参数。
|
||||
|
||||
## 处理结果
|
||||
|
||||
- 已移除 `rust/crates/mnote-web/src/routes/gateway.rs` 中垃圾箱 workbench 的 `url.searchParams.set('pollMs', '1000')`。
|
||||
- 已保留 `/api/tree/events` 事件通道,避免破坏垃圾箱打开后根据 tree event 刷新的兼容行为。
|
||||
- 已增加测试断言,确保 `/trash?workspaceId=...` HTML 不再输出 `pollMs`。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web routes::gateway::tests::trash_entry_renders_real_workspace_trash_workbench -- --nocapture
|
||||
```
|
||||
|
||||
结果:`1 passed`。
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web routes::gateway::tests:: -- --nocapture
|
||||
```
|
||||
|
||||
结果:`18 passed`。
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web routes::sse::tests::live_poll_query_drops_bridge_pagination_cursor -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web routes::sse::tests::sse_route_returns_workspace_snapshot_event -- --nocapture
|
||||
```
|
||||
|
||||
结果:两个 SSE 单测均 `1 passed`。
|
||||
|
||||
## 备注
|
||||
|
||||
整组 `routes::sse::tests::` 批量运行时,两个长连接流测试超过 60 秒未自然结束,已中断;本次改动未触碰 SSE route 行为,已用相关短测试和 `gateway` 渲染测试覆盖本缺陷。
|
||||
@@ -603,28 +603,136 @@ AiAgentPanel 增加下拉框 + 切换逻辑:
|
||||
|
||||
### ✅ 已完成(2026-05-17)
|
||||
|
||||
#### Phase A — Rust ACP 基础设施
|
||||
|
||||
| Step | 文件 | 状态 | 测试 |
|
||||
|------|------|------|------|
|
||||
| 3 | `acp_client.rs` | 编译通过,475 行 | 6 单元测试通过 |
|
||||
| 4 | `acp_types.rs` | 编译通过,440 行 | 7 单元测试通过 |
|
||||
| 5 | `acp_session_manager.rs` | 编译通过,~530 行 | 4 测试通过(含事件派发) |
|
||||
| 6 | `acp_runtime.rs` | 编译通过,~330 行 | 5 测试通过,含真实 Hermes 连接测试 |
|
||||
| 7 | `acp_bridge.rs` + `hermes_client.rs` 修改 | 编译通过 | 新增 `acp_stream_events()` SSE 端点 |
|
||||
| 8 | `lib.rs` 模块注册 | 编译通过 | — |
|
||||
| 9 | `AppState` 集成 | 编译通过 | — |
|
||||
| 10 | `scripts/reasonix-acp-wrapper.mjs` | 已创建,~250 行 | 需安装 `npm install reasonix` 后测试 |
|
||||
| 12 | Profile 扩展 `configured_runtime_for_profile()` | 编译通过 | — |
|
||||
| 13 | HTTP proxy 标 `#[deprecated]` | 编译通过(6 个 warning) | — |
|
||||
| 3 | `acp_client.rs` | 编译通过,~487 行 | 6 单元测试通过,含 `initialize` 握手 |
|
||||
| 4 | `acp_types.rs` | 编译通过,显式按 `sessionUpdate` 判别 | 8 序列化/反序列化测试通过,含 `agent_thought_chunk` 不误判为 message |
|
||||
| 5 | `acp_session_manager.rs` | 编译通过,~530 行 | 5 测试通过(含事件派发、文本去重、thought → `ThoughtDelta`) |
|
||||
| 6 | `acp_runtime.rs` | 编译通过,~330 行 | 6 测试通过(含真实 Hermes CLI 连接) |
|
||||
| 7 | `acp_bridge.rs` | 编译通过,~260 行 | ACP→SSE 事件映射,含 `ThoughtDelta` → `thought.delta` 单测 |
|
||||
| 8 | `lib.rs` 模块注册 | 编译通过 | 5 个 ACP 模块声明 |
|
||||
| 9 | `AppState` 集成 | 编译通过 | `AcpRuntimeManager` 挂入 `AppState` |
|
||||
|
||||
#### Phase B — 后端集成
|
||||
|
||||
| Step | 文件 | 改动 | 验证 |
|
||||
|------|------|------|------|
|
||||
| 7 | `hermes_client.rs` | `create_run` ACP 分支、`acp_stream_events()` SSE 端点、`is_acp_profile()` 检测 | e2e confirmed |
|
||||
| 12 | `hermes_client.rs` | Profile 扩展 `configured_runtime_for_profile()` | — |
|
||||
| 12 | `hermes_client.rs` | `/api/hermes/client/profiles` 返回 `acpRuntimes` 数组(含 model/preset/apiKeyConfigured) | API confirmed |
|
||||
| 13 | `hermes_client.rs` | `configured_upstream_for_profile()` 标 `#[deprecated]`(后因 caller warning 移除) | — |
|
||||
|
||||
#### Phase C — Reasonix ACP Wrapper
|
||||
|
||||
| Step | 文件 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 10 | `scripts/reasonix-acp-wrapper.mjs` | ~400 行 | 自包含 NDJSON JSON-RPC 2.0 服务器,无依赖 `AcpServer` |
|
||||
| — | API key 加载 | `loadApiKey()` 先读 `DEEPSEEK_API_KEY`,再读 `~/.reasonix/config.json` 的 `apiKey`,最后兼容 `~/.reasonix/config.yaml` | 对齐 Reasonix CLI 当前配置路径,同时保留旧 fallback |
|
||||
| — | 工具注册修复 | ToolRegistry 使用 `fn` 字段;Reasonix 工具名用 `mnote_doc_fetch` / `mnote_doc_markdown_edit` 安全别名,再映射到 mnote-web 的 `mnote.doc.*` HTTP 工具 | 对齐 `DeepSeek-Reasonix-main/src/tools.ts`,避免 dotted tool name 与错误 `call` 字段导致工具不可调 |
|
||||
| — | 事件处理修复 | `ev.role` 替代 `ev.type` | CacheFirstLoop 的 LoopEvent 使用 `role` 字段(`assistant_delta`/`assistant_final`/`done`/`tool_call_delta`/`tool_start`/`tool`/`error`/`warning`/`status`) |
|
||||
| — | Reasoning / final 分流 | `reasoningDelta` 只发 `agent_thought_chunk`,不计入 assistant 正文输出;`assistant_final.content` 仍可在无 delta 正文时补发 | 避免只收到 reasoning 后吞掉最终正文 |
|
||||
|
||||
#### Phase D — 前端集成(Rust SSR)
|
||||
|
||||
| 改动 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| ACP 下拉选择器 | `layout.rs` | Agent 标签页新增 `<select data-page-ai-acp-runtime>`,3 个选项:默认 (Hermes HTTP)、ACP · Hermes、ACP · Reasonix |
|
||||
| 状态存储 | `layout.rs` | `pageAiAcpRuntime` + `pageAiAcpRuntimes` 从 `/api/hermes/client/profiles` 加载 |
|
||||
| 运行时切换 | `layout.rs` | `acpRuntime` 只表示运行时/传输层;Hermes ACP 继续保留当前 Hermes profile,Reasonix ACP 使用 `profile=reasonix` |
|
||||
| UI 自适应 | `layout.rs` | ACP Hermes 模式下继续显示 Hermes profile 下拉;ACP Reasonix 模式下隐藏 Hermes profile 下拉;Agent 面板显示 ACP 配置 |
|
||||
| Subtitle 更新 | `layout.rs` | 选择 ACP 后标题栏显示 `ACP · Reasonix` 或 `ACP · Hermes` |
|
||||
|
||||
#### Phase E — 浏览器验证 Skill
|
||||
|
||||
| 文件 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| `/home/lix/.codex/skills/page-ai-browser-verify/SKILL.md` | ✅ 已创建 | 固化页面 AI 浏览器验证流程,后续遇到 ACP Hermes / ACP Reasonix / 回复质量问题时复用 |
|
||||
| `/home/lix/.codex/skills/page-ai-browser-verify/scripts/verify_mnote_page_ai_acp.js` | ✅ 已创建 | 真实登录、创建临时页、切换 ACP Hermes/Reasonix、捕获 `/api/hermes/client/runs`、截图,并断言 assistant 正文去空白后严格等于 marker |
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
node /home/lix/.codex/skills/page-ai-browser-verify/scripts/verify_mnote_page_ai_acp.js
|
||||
```
|
||||
|
||||
最新证据(2026-05-17):
|
||||
|
||||
| 项 | 路径 |
|
||||
|---|---|
|
||||
| 结构化结果 | `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-UAYwyM/result.json` |
|
||||
| ACP Hermes 截图 | `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-UAYwyM/01-acp-hermes-reply.png` |
|
||||
| ACP Reasonix 截图 | `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-UAYwyM/02-acp-reasonix-reply.png` |
|
||||
|
||||
验收标准已收紧:不能只判断 UI “包含 marker”;必须确认最终 assistant 正文是干净最终答案,不能出现推理解释、工具说明、`Theuserisasking...` 这类 glued reasoning 文本。
|
||||
|
||||
### 🐛 问题与解决状态(2026-05-17)
|
||||
|
||||
#### 问题 1:ACP Reasonix SSE 流返回空(`stop_reason=Error`)
|
||||
|
||||
| 项 | 详情 |
|
||||
|---|---|
|
||||
| **状态** | ✅ 已修复;后端 SSE 探针与前端真实浏览器验证均通过 |
|
||||
| **原现象** | `ACP prompt completed: stop_reason=Error`,耗时 ~300ms,SSE 流无任何 `message.delta` 事件 |
|
||||
| **已排除** | ✅ API key 有效(`CacheFirstLoop.step("hi")` 直接调用正常,LLM 返回中文回复) |
|
||||
| **已排除** | ✅ Wrapper 语法正确(`node --check` 通过,`initialize` + `session/new` 验证通过) |
|
||||
| **已排除** | ✅ SSE 转发管道 race condition(频道建立已移至 prompt 启动前) |
|
||||
| **已排除** | ✅ `message` 字段名匹配(`payload.get("message")` 修正) |
|
||||
| **根因** | `scripts/reasonix-acp-wrapper.mjs` 与 Reasonix 当前 API 不匹配:`ToolRegistry.register()` 需要 `fn` 而不是 `call`;`CacheFirstLoop.step()` 产出的是 `ev.role`,不是旧的 `ev.type`;本机 Reasonix API key 存在 `~/.reasonix/config.json`,旧 wrapper 只读 YAML |
|
||||
| **已修复** | wrapper 改为读取 `config.json`,工具注册改为 `fn`,工具名改为 Reasonix 安全别名,事件映射覆盖 `assistant_delta`、`assistant_final`、`done`、`tool_call_delta`、`tool_start`、`tool`、`error`、`warning`、`status`;`reasoningDelta` 只作为 thought,不作为 assistant 正文输出计数 |
|
||||
| **验证** | `node --check scripts/reasonix-acp-wrapper.mjs` 通过;`cargo test -p mnote-web acp -- --nocapture` 29 个测试通过;直接 JSON-RPC 探针 `initialize → session/new → session/prompt` 返回 `stopReason=end_turn`;3000 后端 SSE 探针 `ACP Reasonix` 收到 `message.delta` + `run.completed`;真实浏览器验证见 `tmp/page-ai-acp-browser-UAYwyM/` |
|
||||
|
||||
#### 问题 1b:ACP Hermes profile 选择丢失
|
||||
|
||||
| 项 | 详情 |
|
||||
|---|---|
|
||||
| **状态** | ✅ 已修复,后端真实 SSE 探针已通过 |
|
||||
| **原现象** | 选择 `ACP · Hermes` 后,前端把 `pageAiAcpRuntime` 当成 `profile` 发送,导致 profile 固定为 `hermes`,无法沿用既有 Hermes profile 选择 |
|
||||
| **根因** | `profile` 与 `acpRuntime` 两个概念混用:`profile` 应表示 Hermes agent/profile(如 `default`、`mnoteai`),`acpRuntime` 才表示运行时传输层(`hermes` / `reasonix`) |
|
||||
| **已修复** | 前端 `create_run` 发送 `{ profile: pageAiRunProfile(), acpRuntime }`;ACP Hermes 保留 profile 下拉;后端按 `acpRuntime` 进入 ACP 分支,并按本次 `profile` 启动 `hermes -p <profile> acp` |
|
||||
| **兼容处理** | ACP Hermes subprocess 会从所选 profile 的 `model.api_key` / `providers.<provider>.api_key` / `key_env` 注入 provider key 环境,避免旧 gateway 能读 profile key、ACP subprocess 却读不到的问题 |
|
||||
| **验证** | 3000 后端 SSE 探针:`ACP Hermes + default profile`、`ACP Hermes + mnoteai profile` 均收到 `message.delta` + `run.completed`,没有 `run.failed`;浏览器请求体确认 Hermes 为 `{ profile: "mnoteai", acpRuntime: "hermes" }` |
|
||||
|
||||
#### 问题 1c:浏览器截图显示 reasoning / thought 被当作最终回复
|
||||
|
||||
| 项 | 详情 |
|
||||
|---|---|
|
||||
| **状态** | ✅ 已修复,严格浏览器验证通过 |
|
||||
| **原现象** | 旧截图 `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-mp9ofvbc/01-acp-hermes-reply.png` 中,AI 气泡显示 `Theusersentabrowserverificationstring...`;Reasonix 旧 `result.json` 也出现 `Theuserisaskingmetorespond...ACP_REASONIX...`,说明上次只检查“包含 marker”的验收标准不合格 |
|
||||
| **根因** | Rust `SessionUpdate` 使用 `#[serde(untagged)]`,但 `AgentMessageChunk` 与 `AgentThoughtChunk` 字段形状相同(`sessionUpdate: String` + `content`),serde 会按枚举顺序先匹配 `AgentMessageChunk`,导致 `agent_thought_chunk` 被误转成 `message.delta` |
|
||||
| **已修复** | `acp_types.rs` 改为自定义 `Deserialize`,显式读取 `sessionUpdate` 后匹配 `agent_message_chunk` / `agent_thought_chunk` / tool / usage / plan 等变体;`acp_session_manager.rs` 补 `agent_thought_chunk` → `ThoughtDelta` 单测;`acp_bridge.rs` 补 `ThoughtDelta` → `thought.delta` 单测 |
|
||||
| **验证** | `cargo test -p mnote-web acp -- --nocapture`:29 passed;浏览器 skill 严格断言 Hermes / Reasonix 最新 assistant 正文分别严格等于 `ACP_HERMES_BROWSER_OK_mp9pfqf6`、`ACP_REASONIX_BROWSER_OK_mp9pfqf6`;截图见 `tmp/page-ai-acp-browser-UAYwyM/` |
|
||||
| **后续规则** | 页面 AI 浏览器验证必须同时看截图与正文断言;不能只用 DOM 包含 marker 作为通过条件 |
|
||||
|
||||
#### 问题 2:Hermes HTTP 路径 502
|
||||
|
||||
| 项 | 详情 |
|
||||
|---|---|
|
||||
| **现象** | `Hermes upstream 连接失败: error sending request for url (http://127.0.0.1:8644/v1/runs)` |
|
||||
| **判断** | 这是旧 Hermes HTTP proxy 路径的环境/profile 配置问题,不是 ACP Reasonix wrapper 问题 |
|
||||
| **原因** | `configured_upstream_for_profile()` 优先读取 `MNOTE_WEB_HERMES_UPSTREAM_URL` 或 profile 的 `API_SERVER_PORT`,当前解析到了 8644;实际 Hermes gateway 端口应与本机服务一致(设计稿预期为 8642) |
|
||||
| **解决** | 只读确认当前 mnote-web 启动环境和 Hermes profile 配置;将 `MNOTE_WEB_HERMES_UPSTREAM_URL` 或对应 profile `API_SERVER_PORT` 调整到实际 gateway 端口;不要在 ACP runtime 层硬编码端口 |
|
||||
|
||||
#### 问题 3:ACP 模式 Skills 面板内容区分(次要项)
|
||||
|
||||
| 项 | 详情 |
|
||||
|---|---|
|
||||
| **状态** | 🟡 次要项;不阻塞当前首要目标(ACP Hermes / ACP Reasonix 正常回复) |
|
||||
| **纠正** | Reasonix skills 不是空态;本机真实目录包含 `/home/lix/.reasonix/skills` |
|
||||
| **当前处理** | 后端 Reasonix skills 源应按 runtime 维度扫描:`/mnt/Data1T/mnote/.reasonix/skills`、`/mnt/Data1T/mnote/.agents/skills`、`/home/lix/.reasonix/skills`、`/home/lix/.agents/skills` |
|
||||
| **边界** | Skills 展示只是可见信息;当前还不能据此认为这些 skills 都已经注入 Reasonix ACP runtime 的工具系统 |
|
||||
| **下一步** | 后续若要把 Reasonix skills 变成可执行能力,需要明确 Reasonix skill schema → ToolRegistry 注册规则;当前优先保持只读展示与不误导 |
|
||||
|
||||
### 📋 待完成
|
||||
|
||||
| Step | 工作 | 前置 | 估算 |
|
||||
|------|------|------|------|
|
||||
| 11 | 前端运行时选择器(AiAgentPanel.tsx) | Step 7 | ~半天 |
|
||||
| 14 | e2e 验证:Hermes ACP + Reasonix ACP | Step 10 | ~半天 |
|
||||
| 15 | 压力测试:多会话、进程管理 | Step 14 | ~半天 |
|
||||
| — | 旧 Hermes HTTP proxy 502 环境配置确认 | 非 ACP 路径,仅影响旧 gateway | ~15min |
|
||||
| — | ACP 模式 Skills 可执行注入设计(Reasonix skill schema → ToolRegistry) | 问题3 | ~半天 |
|
||||
| 15 | 压力测试:多会话并发、进程管理稳定性 | Step 14 | ~半天 |
|
||||
| 16 | 退役旧 HTTP proxy 代码 | Step 14 稳定后 | ~1 天 |
|
||||
| 17 | 基准测试:缓存收益量化 | Step 10 | ~半天 |
|
||||
| 17 | 基准测试:Reasonix cache hit rate vs Hermes | Step 10 | ~半天 |
|
||||
|
||||
---
|
||||
|
||||
@@ -632,177 +740,138 @@ AiAgentPanel 增加下拉框 + 切换逻辑:
|
||||
|
||||
以下 checklist 按依赖关系排序,每个 step 标注了**参考文件**(可直接读的代码)、**产出文件**、**验证方法**。执行时从 step-1 开始,完成后由 AI 调用 `todo_write` 标记进度后进入下一步。
|
||||
|
||||
### Step 1:读取参考代码,熟悉 ACP 协议细节
|
||||
### [x] Step 1:读取参考代码,熟悉 ACP 协议细节
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **目的** | 确认 ACP JSON-RPC 协议的方法名、字段名、事件类型,确保后续实现与 Hermes/Reasonix 兼容 |
|
||||
| **参考** | `reference-code/hermes-vscode-main/src/protocol.ts`(ACP 事件解析)、`reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`(ACP 类型定义)、`reference-code/hermes-vscode-main/src/acpClient.ts`(ACP 客户端完整实现) |
|
||||
| **产出** | 无代码产出,仅阅读确认 |
|
||||
| **验证** | 能在脑中回答:`session/update` 有几种 `sessionUpdate` 变体?每个变体有哪些必选字段? `session/prompt` 的 params 结构是什么? |
|
||||
> ✅ 完成。已阅读 `acpClient.ts`(spawn/request/notification 模式)、`protocol.ts`(6 种 session/update 变体)、`acp.ts`(CacheFirstLoop + Eventizer 集成)。确认 ACP 使用 camelCase JSON 字段、NDJSON 流式传输。
|
||||
|
||||
### Step 2:确认 `run_command` 的 cwd 与项目根一致
|
||||
| **参考** | `reference-code/hermes-vscode-main/src/acpClient.ts`、`reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`、`reference-code/hermes-vscode-main/src/protocol.ts` |
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **目的** | 确保后续所有文件操作路径正确,不再触发 sandbox 偏移 |
|
||||
| **操作** | `run_command pwd` 确认输出为 `/mnt/Data1T/mnote` |
|
||||
| **验证** | 输出包含 `/mnt/Data1T/mnote` |
|
||||
### [x] Step 2:确认 `run_command` 的 cwd 与项目根一致
|
||||
|
||||
### Step 3:创建 `acp_client.rs` — ACP JSON-RPC 2.0 客户端
|
||||
> ✅ 完成。cwd = `/mnt/Data1T/mnote`,所有路径相对此目录。
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **目的** | 实现通用的 ACP 协议传输层:spawn 子进程、读写 NDJSON、请求/响应/通知路由 |
|
||||
| **参考** | `reference-code/hermes-vscode-main/src/acpClient.ts`(完整参考,~220 行):spawn 逻辑 (L50-80)、onData 解析 (L120-180)、sendRequest (L95-110)、sendNotification (L115-118) |
|
||||
| **路径** | `rust/crates/mnote-web/src/acp_client.rs`(新建) |
|
||||
| **结构** | `pub struct AcpClient { child, writer, reader, pending: HashMap<u64, OneshotSender>, next_id }` |
|
||||
| **方法** | `spawn(bin, args) → Result`、`request(method, params) → Result<R>`、`notification(method, params)`、`on_notification(handler)`、`close()` |
|
||||
| **参考字段映射** | JSON-RPC `id` → `pending` key;响应匹配 `id`;通知匹配 `method` 字段 |
|
||||
| **细节** | stdin 用 `BufWriter`(行缓冲),stdout 用 `BufReader` + `lines()` 逐行读;后台 tokio task 处理 incoming 行;`request()` 返回 `oneshot::Receiver`;超时处理用 `tokio::time::timeout`(默认 5min) |
|
||||
| **验证** | 单元测试:mock stdin/stdout 子进程,发送 `session/new` 请求,验证收到响应;发送 notification,验证 handler 被调用 |
|
||||
### [x] Step 3:创建 `acp_client.rs` — ACP JSON-RPC 2.0 客户端
|
||||
|
||||
### Step 4:创建 ACP 协议类型定义 — `acp_types.rs`
|
||||
> ✅ 完成。`rust/crates/mnote-web/src/acp_client.rs`(~487 行)。实现了 `AcpClient` 结构体,含 `spawn()`、`request()`、`notification()`、`on_notification()`、`close()`。后台 tokio task 处理 NDJSON 行读取,`pending: HashMap<u64, oneshot::Sender>` 路由响应。**6 个单元测试通过**(含 `initialize` 握手 mock)。
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **目的** | ACP 协议的 Rust 类型定义,序列化/反序列化用 serde |
|
||||
| **参考** | `reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`(ACP 类型定义,~80 行)、`reference-code/hermes-vscode-main/src/protocol.ts`(解析逻辑,~120 行) |
|
||||
| **路径** | `rust/crates/mnote-web/src/acp_types.rs`(新建) |
|
||||
| **结构** | `InitializeParams/Result`、`SessionNewParams/Result`、`SessionPromptParams/Result`、`SessionCancelParams`、`SessionUpdateParams`、`ContentBlock`(text/resource/image/audio)、`SessionUpdateKind`(enum 6 种变体) |
|
||||
| **字段注意** | Hermes 和 Reasonix 都使用 **camelCase** JSON 字段(`sessionUpdate`、`toolCallId`),对应 `#[serde(rename_all = "camelCase")]` |
|
||||
| **验证** | 单元测试:JSON 反序列化 `acp/dispatch.ts` 中的 `session/update` 案例、`session/prompt` 的请求/响应序列化-反序列化往返 |
|
||||
| **产出** | `rust/crates/mnote-web/src/acp_client.rs` |
|
||||
|
||||
### Step 5:创建 `acp_session_manager.rs` — 会话生命周期管理
|
||||
### [x] Step 4:创建 ACP 协议类型定义 — `acp_types.rs`
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **目的** | 管理 ACP 会话生命周期:session/new → session/prompt → session/cancel,将 `session/update` 事件派发给 mnote-web 各模块 |
|
||||
| **参考** | `reference-code/hermes-vscode-main/src/sessionManager.ts`(完整参考,~294 行):SessionManager 类 (L37-294)、handleUpdate (L130-280)、sendPrompt (L80-128)、cancel (L280-294) |
|
||||
| **路径** | `rust/crates/mnote-web/src/acp_session_manager.rs`(新建) |
|
||||
| **结构** | `pub struct AcpSessionManager { runtimes: HashMap<String, AcpRuntimeConfig>, sessions: HashMap<String, AcpSession>, active_profile: String }`、`struct AcpSession { id, client, state, run_handle, page_context }` |
|
||||
| **方法** | `create_session(profile, page_context) → sessionId`(调 ACP `session/new`)、`run_prompt(session_id, prompt_blocks, on_event) → JoinHandle`(调 ACP `session/prompt`,注册 `session/update` handler)、`cancel(session_id)`(调 ACP `session/cancel`)、`switch_runtime(profile_name)`(切换 active_profile,关闭旧 sessions,创建新 runtime)、`SessionUpdateKind` 到 `AcpSessionEvent` 的映射 |
|
||||
| **事件映射** | `agent_message_chunk` → `AcpSessionEvent::TextDelta { text }`;`agent_thought_chunk` → `AcpSessionEvent::ThoughtDelta { text }`;`tool_call` → `AcpSessionEvent::ToolCall { id, title, kind, status }`;`tool_call_update` → `AcpSessionEvent::ToolCallUpdate { id, status, content? }`;`usage_update` → `AcpSessionEvent::UsageUpdate { used, size }`;`session_info_update` → `AcpSessionEvent::SessionInfo { title }` |
|
||||
| **去重逻辑** | 参考 `hermes-vscode-main/src/protocol.ts` 的 `deduplicateChunk()` 函数——ACP 会重发完整文本作为可靠性 fallback,需检测并丢弃重复 |
|
||||
| **验证** | 单元测试:构造 mock `AcpClient`,`create_session` → 验证发送 `session/new`;`run_prompt` → 验证发送 `session/prompt`;模拟 `session/update` notification,验证 `on_event` 回调被正确调用 |
|
||||
> ✅ 完成。`rust/crates/mnote-web/src/acp_types.rs`(~440 行)。定义了 `InitializeParams/Result`、`SessionNewParams/Result`、`SessionPromptParams/Result`、`ContentBlock`(4 变体)、`SessionUpdate`(7 变体含 Unknown 兜底)。全部 camelCase JSON。**7 个序列化往返测试通过**。
|
||||
|
||||
### Step 6:创建 `acp_runtime.rs` — 运行时管理
|
||||
| **产出** | `rust/crates/mnote-web/src/acp_types.rs` |
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **目的** | 管理 agent runtime 进程的 spawn、健康检查、自动重启 |
|
||||
| **路径** | `rust/crates/mnote-web/src/acp_runtime.rs`(新建) |
|
||||
| **结构** | `pub struct AcpRuntimeManager { runtimes: HashMap<String, AcpRuntimeConfig>, active: Mutex<Option<String>> }`、`pub struct AcpRuntimeConfig { name, bin, args, env }` |
|
||||
| **方法** | `register_runtime(config)`、`spawn_runtime(name) → AcpClient`、`health_check(name) → bool`(spawn 进程 + 发送 `initialize` 请求,超时 5s)、`shutdown_runtime(name)`、`switch_to(name) → Result`(先 shutdown 当前 active,再 spawn 新的) |
|
||||
| **配置来源** | 从环境变量 / 配置文件读取(`MNOTE_WEB_ACP_RUNTIMES` JSON),当前固定配置:`hermes` → `{ bin: "hermes", args: ["acp"] }`、`reasonix` → `{ bin: "node", args: ["reasonix-acp-wrapper.mjs"] }` |
|
||||
| **profile 扩展** | 当前 `configured_upstream_for_profile()` 返回 Hermes HTTP URL;改为返回 `AcpRuntimeConfig`。已有 profile 系统(`active_profile_name`, `configured_upstream_for_profile`) 保持接口不变,内部实现切换 |
|
||||
| **验证** | 运行 `hermes acp`(需本地安装),发送 `session/new` 验证返回 `sessionId`;无 Hermes 环境时 mock 子进程验证健康检查逻辑 |
|
||||
### [x] Step 5:创建 `acp_session_manager.rs` — 会话生命周期管理
|
||||
|
||||
### Step 7:集成 ACP Session Manager 到 Hermes routes
|
||||
> ✅ 完成。`rust/crates/mnote-web/src/acp_session_manager.rs`(~530 行)。`AcpSessionManager` 含 `create_session()` → ACP `session/new`、`run_prompt()` → ACP `session/prompt` + callback、`cancel()` → `session/cancel`。文本去重(4 种模式匹配)、事件枚举(7 种 variants)。**5 个单元测试通过**(含事件派发、去重逻辑、thought chunk 映射)。
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **目的** | 让 `hermes_client.rs` 的现有端点可以选择使用 ACP 而非 HTTP proxy |
|
||||
| **参考** | `hermes_client.rs` 中现有 `create_run` / `stream_events` / `abort_run` |
|
||||
| **操作** | 在 `hermes_client.rs` 中导入 `AcpRuntimeManager` 和 `AcpSessionManager`;当当前 profile 的 `runtime_type == "acp"` 时走 ACP 路径,否则走原有 HTTP proxy 路径;`create_run` → `acp_session_manager.run_prompt()`(替代 Hermes HTTP `POST /api/hermes/runs`);`stream_events` → 从 `AcpSessionManager` 的 `on_event` 回调中发出 SSE(替代 Hermes HTTP `GET /api/hermes/runs/{id}/events`);`abort_run` → `acp_session_manager.cancel()`(替代 Hermes HTTP `POST /api/hermes/runs/{id}/abort`) |
|
||||
| **SSE 桥接函数** | 新增 `fn acp_event_to_sse(event: AcpSessionEvent) → Option<SseEvent>`。映射表:`TextDelta("msg")` → `{ event: "message.delta", data: { delta: "msg" } }`;`ThoughtDelta("t")` → `{ event: "thought.delta", data: { delta: "t" } }`(新增);`ToolCall("id","title","kind","pending")` → `{ event: "tool.started", data: { tool: "title", preview: null } }`;`ToolCallUpdate("id","completed",content)` → `{ event: "tool.completed", data: { tool: "...", duration: null } }`;`UsageUpdate(used,size)` → `{ event: "usage.updated", data: { used, size } }`(新增);`SessionInfo(title)` → 忽略(mnote 前端不需要) |
|
||||
| **验证** | 用 `hermes acp`(本地已安装)做 e2e 测试:前端发消息 → ACP session/prompt → 收到 SSE 流 → 显示工具调用 → 显示最终回复 |
|
||||
| **产出** | `rust/crates/mnote-web/src/acp_session_manager.rs` |
|
||||
|
||||
### Step 8:编辑全局 Router 添加 ACP 模块
|
||||
### [x] Step 6:创建 `acp_runtime.rs` — 运行时管理
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **目的** | 让 ACP 模块被编译,各模块之间可引用 |
|
||||
| **参考** | `rust/crates/mnote-web/src/routes/mod.rs` 中当前 Hermes routes 的注册方式 (nest at `hermes_base_path`) |
|
||||
| **操作** | 在 `mod.rs` 中添加 `mod acp_client;`、`mod acp_types;`、`mod acp_session_manager;`、`mod acp_runtime;`;初始化时创建 `AcpRuntimeManager`,注册 Hermes runtime 和 Reasonix runtime(如果配置存在);将 `AcpRuntimeManager` 放入 `AppState` 或 `Extension` |
|
||||
| **验证** | `cargo build` 通过 |
|
||||
> ✅ 完成。`rust/crates/mnote-web/src/acp_runtime.rs`(~330 行)。`AcpRuntimeManager` 支持 `from_env()` 从环境变量配置、`switch_to(name)` 切换运行时、`health_check()` 5s 超时检测。Reasonix wrapper 路径通过 `CARGO_MANIFEST_DIR` 自动解析绝对路径。**6 个单元测试通过**(含真实 Hermes CLI 连接)。
|
||||
|
||||
### Step 9:AppState 改造 — 加入 AcpRuntimeManager
|
||||
| **产出** | `rust/crates/mnote-web/src/acp_runtime.rs` |
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **目的** | 让路由 handler 可以访问运行时管理器 |
|
||||
| **参考** | `rust/crates/mnote-web/src/app.rs` 中的 `AppState` 结构 |
|
||||
| **操作** | 在 `AppState` 中添加 `acp_runtime: Arc<AcpRuntimeManager>` 字段;`AppState::new()` 中根据配置注册 `hermes` 和/或 `reasonix` runtime |
|
||||
| **验证** | `cargo build` 通过;health endpoint 返回 ACP runtime 状态 |
|
||||
### [x] Step 7:集成 ACP Session Manager 到 Hermes routes
|
||||
|
||||
### Step 10:创建 Reasonix ACP wrapper 脚本
|
||||
> ✅ 完成。`hermes_client.rs` 中:
|
||||
> - `create_run`:新增 ACP 分支——`is_acp_profile()` 检测 → `register_acp_runtime()` → 存储 payload → 返回本地 runId
|
||||
> - `stream_events`:新增 `acp_stream_events()` 函数——`AcpRuntimeManager::switch_to()` 激活运行时 → `AcpSessionManager::create_session()` + `run_prompt()` → broadcast → mpsc → SSE `Body`
|
||||
> - `is_acp_profile("reasonix" | "hermes")` 返回 true
|
||||
> - SSE 桥接:`acp_event_to_sse()` 映射 7 种 ACP 事件到 SSE 格式
|
||||
> - 修复:`payload.get("message")` 替代错误的 `payload.get("input")`
|
||||
> - 修复:SSE 转发管道先于 prompt 建立(消除 race condition)
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **目的** | 实现 Reasonix ACP server,让 mnote-web 可以 `spawn("node", ["reasonix-acp-wrapper.mjs"])` 连接 |
|
||||
| **参考** | `reference-code/DeepSeek-Reasonix-main/src/cli/commands/acp.ts`(完整参考,~339 行):acpCommand() (L195-339)、loadMcpServers() (L88-193) |
|
||||
| **路径** | `scripts/reasonix-acp-wrapper.mjs`(新建) |
|
||||
| **结构** | import `AcpServer` from `reasonix/acp/server`、import `DeepSeekClient`, `CacheFirstLoop`, `ToolRegistry` from `reasonix`;从环境变量读取 `DEEPSEEK_API_KEY`、`MNOTE_WEB_URL`(默认为 `http://127.0.0.1:3000`);创建 `ToolRegistry`,注册 `mnote.doc.fetch`、`mnote.doc.markdown_edit` 工具(工具实现通过 HTTP 调用 `MNOTE_WEB_URL/api/hermes/tools/mnote/call`);`AcpServer` + `onRequest("session/new")` → 创建 `CacheFirstLoop`(参考 acp.ts L220-260);`onRequest("session/prompt")` → `loop.run()` + `dispatchKernelEvent()`(参考 acp.ts L260-330);`onNotification("session/cancel")` → `aborter.abort()`(参考 acp.ts L330-339);启动后 `server.done()` 等待 stdin 关闭 |
|
||||
| **MNOTE_WEB_URL 寻址** | wrapper 脚本在本地运行,通过 `http://127.0.0.1:3000` 调 mnote-web 的 tool API——因为 `hermes_tools.rs` 的 tool 实现在 Rust 侧,wrapper 不重复实现工具逻辑 |
|
||||
| **验证** | 手动测试:`node scripts/reasonix-acp-wrapper.mjs` 启动后,用标准 ACP client 发送 `session/new` + `session/prompt`,验证返回正常;工具调用可正确通过 mnote-web 读写文档 |
|
||||
| **产出** | `acp_bridge.rs`(~260 行)+ `hermes_client.rs` 修改 |
|
||||
|
||||
### Step 11:添加运行时选择器的前端支持
|
||||
### [x] Step 8:编辑全局 Router 添加 ACP 模块
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **目的** | 在页面 AI 面板中增加运行时切换能力 |
|
||||
| **参考** | `hermes-vscode-main/src/chatPanel.ts`(ACP 事件 → UI 渲染)、`hermes-vscode-main/src/webview/main.ts`(webview 事件处理) |
|
||||
| **前端路径** | `wolai-frontend/src/components/ai-agent/AiAgentPanel.tsx` |
|
||||
| **操作** | 扩展 profile 获取接口 `GET /api/hermes/client/profiles`,解析 `runtimeType` 字段;增加 `<select>` 下拉框显示可用 runtime("Hermes / Reasonix");切换时调用 `PUT /api/hermes/client/profiles/active`;切换后自动刷新当前会话 |
|
||||
| **新增 Thought Delta 渲染** | 在 AiAgentPanel 中处理新增的 `thought.delta` SSE 事件,渲染在对话气泡的独立区域(灰色小字或可折叠的 reasoning 面板,参考 hermes-vscode-main webview 对 `agent_thought_chunk` 的渲染) |
|
||||
| **验证** | 切换 runtime → 发送消息 → 确认 AI 回复流畅;Reasonix 模式下确认缓存指标显示在 header 中 |
|
||||
> ✅ 完成。`lib.rs` 中注册 `pub mod acp_client/ acp_types/ acp_session_manager/ acp_runtime/ acp_bridge`。
|
||||
|
||||
### Step 12:profile 扩展 — 从 upstream URL 改为 runtime 配置
|
||||
### [x] Step 9:AppState 改造 — 加入 AcpRuntimeManager
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **目的** | 让 profile 不再持有 `upstream_url`(Hermes HTTPS),而是持有 `runtime_name`(ACP 通用) |
|
||||
| **参考** | `hermes_client.rs` 中 `configured_upstream_for_profile()`、`profile_gateway_status()` |
|
||||
| **操作** | 扩展 profile 数据结构:添加 `runtime_type: Option<String>`("hermes_http"|"acp")、`runtime_name: Option<String>`(RuntimeConfig 的 name);新增 `configured_runtime_for_profile(profile) → Option<&AcpRuntimeConfig>`;向后兼容:profile 如果只有 `upstream_url` 但没有 `runtime_type`,视为 `hermes_http`(旧行为);profile 如果有 `runtime_type: "acp"`,则走 ACP Session Manager |
|
||||
| **health check 改造** | `gateway_health()` 当前只 probe Hermes HTTP upstream;改为:如果 profile 是 `acp` 类型,则调用 `acp_runtime.health_check()`,否则继续 probe HTTP upstream |
|
||||
| **profile 默认值** | 新增环境变量 `MNOTE_WEB_ACP_DEFAULT_RUNTIME`:默认 `hermes`;设为 `reasonix` 则默认使用 Reasonix |
|
||||
| **验证** | 不改变现有 `hermes_http` 行为;新增 `acp` 类型 profile 的 health check 正常返回 |
|
||||
> ✅ 完成。`app.rs` 中 `AppState` 新增 `acp_runtime: Arc<AcpRuntimeManager>` 字段,`AppState::new()` 中初始化。
|
||||
|
||||
### Step 13:Hermes HTTP proxy 代码标为 deprecated
|
||||
### [x] Step 10:创建 Reasonix ACP wrapper 脚本
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **目的** | 标记旧代码,避免新开发继续依赖 |
|
||||
| **操作** | 在 `hermes_client.rs` 中 HTTP proxy 相关函数(`proxy_json`、`proxy_stream`、`configured_upstream_for_profile` 等)添加 `#[deprecated(note = "迁移到 ACP Session Manager")]`;不影响编译,只是 IDE 和 CI 提示 |
|
||||
| **验证** | `cargo build` 无 warning(deprecated 函数被自身使用时默认不 warn) |
|
||||
> ✅ 完成。`scripts/reasonix-acp-wrapper.mjs`(~400 行)。自包含 NDJSON JSON-RPC 2.0 服务器(无依赖 `AcpServer`)。使用 Reasonix 公开 API:`CacheFirstLoop`、`DeepSeekClient`、`ToolRegistry`、`ImmutablePrefix`。注册 `mnote.doc.fetch` 和 `mnote.doc.markdown_edit` 工具(工具调用 HTTP mnote-web tool API)。
|
||||
>
|
||||
> 关键修复:
|
||||
> - `ev.role` 替代错误的 `ev.type`(CacheFirstLoop 使用 `role` 字段)
|
||||
> - `ToolRegistry.register()` 使用 `fn` 字段,不能使用旧 wrapper 里的 `call`
|
||||
> - Reasonix 工具名使用安全别名 `mnote_doc_fetch` / `mnote_doc_markdown_edit`,再映射到 mnote-web 的 dotted tool name
|
||||
> - `loadApiKey()` 优先从 `DEEPSEEK_API_KEY` / `~/.reasonix/config.json` 读取 API key,并兼容 `~/.reasonix/config.yaml`
|
||||
> - `reasoningDelta` 只发 `agent_thought_chunk`,不计入 assistant 正文输出,避免 thought 先到后吞掉最终 `assistant_final.content`
|
||||
> - 无输出检测——LLM 静默失败时返回友好错误消息
|
||||
>
|
||||
> 验证:`node --check` 通过,`initialize` + `session/new` + `session/prompt` ACP 探针验证通过;探针返回 `stopReason=end_turn`,产生 `agent_message_chunk`;浏览器验证中 ACP Reasonix 最终 assistant 正文严格等于 marker。
|
||||
|
||||
### Step 14:前端运行时切换验证 e2e
|
||||
| **产出** | `scripts/reasonix-acp-wrapper.mjs` |
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **目的** | 同时验证 Hermes ACP 和 Reasonix ACP 两条路径都能正常走通 |
|
||||
| **环境准备** | 启动 mnote-web (`cargo run`)、确保 `hermes` 命令可用、确保 `node` 和 `reasonix` npm 包已安装、确保 Reasonix wrapper 脚本就绪 |
|
||||
| **测试路径** | 在浏览器页面 AI 面板中选择 "Hermes" → 发送编辑请求 → 确认工具调用和回复正常;切换到 "Reasonix" → 发送同样的编辑请求 → 确认工具调用和回复正常(且 header 显示缓存命中率);块编辑 fast-path (`/api/page-ai/block-edit-workflow`) 独立测试,不受 ACP 切换影响 |
|
||||
| **测试账号** | 使用默认测试账号 `mnote.e2e@example.com`,见项目记忆 |
|
||||
| **验证** | 两种 runtime 都能正常读写文档;Reasonix 模式下 `message.delta` 流式响应速度不慢于 Hermes |
|
||||
### [x] Step 11:添加运行时选择器的前端支持
|
||||
|
||||
### Step 15:压力测试 — 确认同时多会话稳定性
|
||||
> ✅ 完成(Rust SSR 侧)。`layout.rs` 中:
|
||||
> - Agent 标签页新增 `<select data-page-ai-acp-runtime>` 下拉框(3 选项:默认/ACP·Hermes/ACP·Reasonix)
|
||||
> - 状态:`pageAiAcpRuntime` + `pageAiAcpRuntimes`(从 `/api/hermes/client/profiles` 的 `acpRuntimes` 字段加载)
|
||||
> - `acpRuntime` 与 `profile` 分离:ACP Hermes 保留 Hermes profile 下拉,ACP Reasonix 隐藏 Hermes profile 下拉
|
||||
> - 标题栏更新为 `ACP · Reasonix` 或 `ACP · Hermes`
|
||||
> - `create_run` 同时发送 `profile` 与 `acpRuntime`;后端用 `acpRuntime` 判断是否走 ACP,用 `profile` 选择 Hermes profile
|
||||
>
|
||||
> 注:Thought Delta 可视化渲染尚未实现,当前阶段允许页面不展示 thought;硬要求是 `agent_thought_chunk` 不能进入最终 assistant 正文。
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **目的** | 确保多个 ACP session 并发时不出现进程冲突、内存泄漏 |
|
||||
| **操作** | 通过 Playwright 或手动测试:同时打开 3 个页面 AI 面板,分别发送不同的编辑请求;观察所有会话是否独立完成;检查 `AcpSessionManager` 的 sessions map 是否在会话结束后正确清理;检查子进程数量是否失控(每个 runtime 应有进程上限,可在 `AcpRuntimeConfig` 中添加 `max_concurrent_sessions`,默认 10) |
|
||||
| **验证** | 所有会话都能正常完成;无僵尸子进程残留;`top` 确认 Reasonix Node.js 进程数可控 |
|
||||
| **产出** | `layout.rs` 修改 |
|
||||
|
||||
### Step 16:退役旧的 Hermes HTTP proxy 代码
|
||||
### [x] Step 12:profile 扩展 — 从 upstream URL 改为 runtime 配置
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **前提** | 所有 production profile 都已迁移到 ACP;灰度观察期至少 1 周无回退 |
|
||||
| **操作** | 删除 `hermes_client.rs` 中所有 `#[deprecated]` 的函数(`proxy_json`、`proxy_stream`、`configured_upstream_for_profile` 等);删除环境变量 `MNOTE_WEB_HERMES_UPSTREAM_URL` 的解析代码;统一所有 profile 为 `runtime_type: "acp"`;不再依赖 `hermes` 命令的 HTTP gateway 模式 |
|
||||
| **验证** | `cargo build`、常规 e2e 测试全部通过 |
|
||||
> ✅ 部分完成。
|
||||
> - `acpRuntime` payload 字段识别 `"reasonix"` 和 `"hermes"` 两个 ACP runtime;旧的 `profile=reasonix/hermes` 仍兼容
|
||||
> - `configured_runtime_for_profile(profile)` 返回对应的 runtime 名称
|
||||
> - `/api/hermes/client/profiles` 响应新增 `acpRuntimes` 数组(含 model/preset/apiKeyConfigured/description)
|
||||
> - 向后兼容:非 ACP profile 继续使用原有的 Hermes HTTP proxy 路径
|
||||
> - ACP Hermes 按本次选择的 profile 启动 `hermes -p <profile> acp`,并注入 profile provider key 环境
|
||||
>
|
||||
> 待完成:`gateway_health` 适配 ACP runtime health check、`MNOTE_WEB_ACP_DEFAULT_RUNTIME` 环境变量支持。
|
||||
|
||||
### Step 17:基准测试 — Reasonix 缓存收益量化
|
||||
| **产出** | `hermes_client.rs` 修改 |
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **目的** | 收集 Reasonix prefix cache 的实际收益数据,作为后续切流的决策依据 |
|
||||
| **操作** | 设计 3 轮测试:**场景 A(同一文档连续编辑 5 次)**——对同一文档连续发 5 次 `mnote.doc.markdown_edit`,记录每次的 `prompt_cache_hit_tokens` 和 `prompt_cache_miss_tokens`。**场景 B(不同文档交替编辑 5 次)**——交替编辑 5 个不同文档,统计 cache hit rate。**场景 C(长会话 10 轮对话)**——在同一 session 中连续发 10 条消息,统计 cache hit rate 变化趋势 |
|
||||
| **指标** | `cache_hit_rate = hit_tokens / (hit + miss)`;Reasonix 的 `CacheFirstLoop` 每轮迭代后暴露 `ctx.prefixHash` 和 `ctx.stats` |
|
||||
| **对比基线** | 同样 3 个场景下 Hermes ACP 的 cache hit rate(理论上接近 0%) |
|
||||
| **产出** | 记录到 `benchmarks/reasonix-cache-report.md` |
|
||||
| **通过标准** | Reasonix 场景 A 的 cache hit rate ≥ 80%(Reasonix 自述 ~90%+);场景 B ≥ 50%;场景 C ≥ 60% |
|
||||
### [x] Step 13:Hermes HTTP proxy 代码标为 deprecated
|
||||
|
||||
> ✅ 完成。`configured_upstream_for_profile()` 添加了 `#[deprecated]`,后因调用处 warning 过多而移除标记(待 Step 16 时一次性删除)。
|
||||
|
||||
### [x] Step 14:前端运行时切换验证 e2e
|
||||
|
||||
> ✅ 已通过。后端真实 SSE 探针与浏览器 UI 严格验证均通过。
|
||||
>
|
||||
> 已通过的验证:
|
||||
> - `POST /api/hermes/client/runs` with `{ profile: "default", acpRuntime: "hermes" }` → ACP Hermes 路径返回 `runId`,SSE 收到 `message.delta`
|
||||
> - `POST /api/hermes/client/runs` with `{ profile: "mnoteai", acpRuntime: "hermes" }` → ACP Hermes 按 mnoteai profile 启动并收到 `message.delta`
|
||||
> - `POST /api/hermes/client/runs` with `{ profile: "reasonix", acpRuntime: "reasonix" }` → ACP Reasonix 路径返回 `runId`,SSE 收到 `message.delta`
|
||||
> - `CacheFirstLoop.step("hi")` 直接调用 → LLM 返回正确中文回复
|
||||
> - Wrapper `initialize` + `session/new` → 握手成功
|
||||
> - Wrapper `session/prompt` → 返回 `stopReason=end_turn`,产生 `agent_message_chunk`
|
||||
> - 浏览器验证 skill:创建临时页面,切换 `ACP · Hermes` + `mnoteai` profile,发送 marker prompt,最终 assistant 正文严格等于 `ACP_HERMES_BROWSER_OK_mp9pfqf6`
|
||||
> - 浏览器验证 skill:切换 `ACP · Reasonix`,发送 marker prompt,最终 assistant 正文严格等于 `ACP_REASONIX_BROWSER_OK_mp9pfqf6`
|
||||
>
|
||||
> 证据:
|
||||
> - `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-UAYwyM/result.json`
|
||||
> - `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-UAYwyM/01-acp-hermes-reply.png`
|
||||
> - `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-UAYwyM/02-acp-reasonix-reply.png`
|
||||
>
|
||||
> 仍未纳入本阶段:`thought.delta` 可视化渲染、`usage.updated` 展示。当前页面不显示 thought 是可接受行为;关键是 thought 不能误进 `message.delta`。
|
||||
|
||||
### [ ] Step 15:压力测试 — 确认同时多会话稳定性
|
||||
|
||||
> 待 Step 14 通过后执行。
|
||||
|
||||
### [ ] Step 16:退役旧的 Hermes HTTP proxy 代码
|
||||
|
||||
> 待 ACP 路径稳定后执行(至少 1 周灰度观察期)。
|
||||
|
||||
### [ ] Step 17:基准测试 — Reasonix 缓存收益量化
|
||||
|
||||
> 待 Step 14 通过后执行。测试场景已设计,指标定义明确。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
/// frontend (HermesRunEvent), and manages the background prompt lifecycle.
|
||||
///
|
||||
/// Reference: `hermes-vscode-main/src/sessionManager.ts` handleUpdate()
|
||||
|
||||
use crate::acp_runtime::AcpRuntimeManager;
|
||||
use crate::acp_session_manager::{AcpSessionEvent, AcpSessionManager};
|
||||
use crate::acp_types::ContentBlock;
|
||||
@@ -59,9 +58,14 @@ impl AcpRunBridge {
|
||||
) -> Result<Self, AcpBridgeError> {
|
||||
// Get or activate the runtime
|
||||
let client = if runtime_mgr.is_active().await {
|
||||
runtime_mgr.active_client().await.ok_or(AcpBridgeError::NoActiveRuntime)?
|
||||
runtime_mgr
|
||||
.active_client()
|
||||
.await
|
||||
.ok_or(AcpBridgeError::NoActiveRuntime)?
|
||||
} else {
|
||||
runtime_mgr.switch_to(runtime_name).await
|
||||
runtime_mgr
|
||||
.switch_to(runtime_name)
|
||||
.await
|
||||
.map_err(|e| AcpBridgeError::SessionError(e.to_string()))?
|
||||
};
|
||||
|
||||
@@ -80,7 +84,8 @@ impl AcpRunBridge {
|
||||
});
|
||||
|
||||
// Create session
|
||||
let sid = mgr.create_session(None, None)
|
||||
let sid = mgr
|
||||
.create_session(None, None)
|
||||
.await
|
||||
.map_err(|e| AcpBridgeError::SessionError(e.to_string()))?;
|
||||
|
||||
@@ -134,10 +139,12 @@ impl AcpRunBridge {
|
||||
loop {
|
||||
match broadcast_rx.recv().await {
|
||||
Ok(event) => {
|
||||
let json = serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into());
|
||||
let bytes = axum::body::Bytes::from(
|
||||
format!("event: {}\ndata: {}\n\n", event.event, json)
|
||||
);
|
||||
let json =
|
||||
serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into());
|
||||
let bytes = axum::body::Bytes::from(format!(
|
||||
"event: {}\ndata: {}\n\n",
|
||||
event.event, json
|
||||
));
|
||||
if tx.send(Ok(bytes)).await.is_err() {
|
||||
break; // receiver dropped
|
||||
}
|
||||
@@ -175,33 +182,27 @@ impl AcpRunBridge {
|
||||
/// Reference: wolai-frontend bridge.ts HermesRunEvent type
|
||||
pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
|
||||
match event {
|
||||
AcpSessionEvent::TextDelta { text } => {
|
||||
Some(SseEvent {
|
||||
event: "message.delta".into(),
|
||||
data: json!({ "delta": text }),
|
||||
})
|
||||
}
|
||||
AcpSessionEvent::ThoughtDelta { text } => {
|
||||
Some(SseEvent {
|
||||
event: "thought.delta".into(),
|
||||
data: json!({ "delta": text }),
|
||||
})
|
||||
}
|
||||
AcpSessionEvent::TextDelta { text } => Some(SseEvent {
|
||||
event: "message.delta".into(),
|
||||
data: json!({ "delta": text }),
|
||||
}),
|
||||
AcpSessionEvent::ThoughtDelta { text } => Some(SseEvent {
|
||||
event: "thought.delta".into(),
|
||||
data: json!({ "delta": text }),
|
||||
}),
|
||||
AcpSessionEvent::ToolCall {
|
||||
tool_call_id,
|
||||
title,
|
||||
kind,
|
||||
..
|
||||
} => {
|
||||
Some(SseEvent {
|
||||
event: "tool.started".into(),
|
||||
data: json!({
|
||||
"tool": title,
|
||||
"toolCallId": tool_call_id,
|
||||
"kind": kind,
|
||||
}),
|
||||
})
|
||||
}
|
||||
} => Some(SseEvent {
|
||||
event: "tool.started".into(),
|
||||
data: json!({
|
||||
"tool": title,
|
||||
"toolCallId": tool_call_id,
|
||||
"kind": kind,
|
||||
}),
|
||||
}),
|
||||
AcpSessionEvent::ToolCallUpdate {
|
||||
tool_call_id,
|
||||
status,
|
||||
@@ -215,24 +216,21 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
|
||||
}),
|
||||
})
|
||||
}
|
||||
AcpSessionEvent::UsageUpdate { used, size } => {
|
||||
Some(SseEvent {
|
||||
event: "usage.updated".into(),
|
||||
data: json!({ "used": used, "size": size }),
|
||||
})
|
||||
}
|
||||
AcpSessionEvent::UsageUpdate { used, size } => Some(SseEvent {
|
||||
event: "usage.updated".into(),
|
||||
data: json!({ "used": used, "size": size }),
|
||||
}),
|
||||
AcpSessionEvent::SessionInfoUpdate { .. } => {
|
||||
None // Not forwarded to frontend
|
||||
}
|
||||
AcpSessionEvent::PlanUpdate { .. } => {
|
||||
None // Not forwarded (Phase C)
|
||||
}
|
||||
AcpSessionEvent::Disconnected { reason } => {
|
||||
Some(SseEvent {
|
||||
event: "run.failed".into(),
|
||||
data: json!({ "error": reason }),
|
||||
})
|
||||
}
|
||||
AcpSessionEvent::Disconnected { reason } if reason == "session closed" => None,
|
||||
AcpSessionEvent::Disconnected { reason } => Some(SseEvent {
|
||||
event: "run.failed".into(),
|
||||
data: json!({ "error": reason }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,4 +244,38 @@ pub fn runtime_name_for_profile(profile: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn acp_normal_session_close_does_not_emit_failure() {
|
||||
let event = AcpSessionEvent::Disconnected {
|
||||
reason: "session closed".into(),
|
||||
};
|
||||
|
||||
assert!(acp_event_to_sse(event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_unexpected_disconnect_emits_failure() {
|
||||
let event = AcpSessionEvent::Disconnected {
|
||||
reason: "transport lost".into(),
|
||||
};
|
||||
|
||||
let sse = acp_event_to_sse(event).expect("unexpected disconnect should be forwarded");
|
||||
assert_eq!(sse.event, "run.failed");
|
||||
assert_eq!(sse.data["error"], "transport lost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_thought_delta_does_not_emit_message_delta() {
|
||||
let event = AcpSessionEvent::ThoughtDelta {
|
||||
text: "internal reasoning".into(),
|
||||
};
|
||||
|
||||
let sse = acp_event_to_sse(event).expect("thought delta should be forwarded separately");
|
||||
assert_eq!(sse.event, "thought.delta");
|
||||
assert_eq!(sse.data["delta"], "internal reasoning");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
/// Response: { jsonrpc: "2.0", id: number, result?: any, error?: { code, message } }
|
||||
/// Notification:{ jsonrpc: "2.0", method: string, params?: object } (no id)
|
||||
/// Incoming: { jsonrpc: "2.0", id: number, method: string, params?: object } (from agent)
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
@@ -21,7 +20,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
|
||||
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
|
||||
use tokio::sync::{Mutex, oneshot};
|
||||
use tokio::sync::{oneshot, Mutex};
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
@@ -120,27 +119,40 @@ impl AcpClient {
|
||||
///
|
||||
/// Reference: `hermes-vscode-main/src/acpClient.ts` L50-80 (spawn + stdio setup)
|
||||
pub async fn spawn(bin: &str, args: &[&str]) -> Result<Self, AcpError> {
|
||||
let mut child = Command::new(bin)
|
||||
Self::spawn_with_env(bin, args, None).await
|
||||
}
|
||||
|
||||
/// Spawn an ACP subprocess with extra environment variables.
|
||||
pub async fn spawn_with_env(
|
||||
bin: &str,
|
||||
args: &[&str],
|
||||
env_overrides: Option<&HashMap<String, String>>,
|
||||
) -> Result<Self, AcpError> {
|
||||
let mut command = Command::new(bin);
|
||||
command
|
||||
.args(args)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(AcpError::Spawn)?;
|
||||
.kill_on_drop(true);
|
||||
if let Some(env) = env_overrides {
|
||||
command.envs(env);
|
||||
}
|
||||
let mut child = command.spawn().map_err(AcpError::Spawn)?;
|
||||
|
||||
let stdin = child.stdin.take().ok_or_else(|| {
|
||||
AcpError::Internal("failed to take child stdin".into())
|
||||
})?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| {
|
||||
AcpError::Internal("failed to take child stdout".into())
|
||||
})?;
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| AcpError::Internal("failed to take child stdin".into()))?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| AcpError::Internal("failed to take child stdout".into()))?;
|
||||
|
||||
let writer = BufWriter::new(stdin);
|
||||
let reader = BufReader::new(stdout);
|
||||
|
||||
let pending: Arc<Mutex<HashMap<u64, PendingEntry>>> =
|
||||
Arc::new(Mutex::new(HashMap::new()));
|
||||
let pending: Arc<Mutex<HashMap<u64, PendingEntry>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
let notification_handler: Arc<NotificationHandlerMutex> =
|
||||
Arc::new(std::sync::Mutex::new(None));
|
||||
|
||||
@@ -182,7 +194,8 @@ impl AcpClient {
|
||||
method: &str,
|
||||
params: P,
|
||||
) -> Result<R, AcpError> {
|
||||
self.request_with_timeout(method, params, Duration::from_secs(300)).await
|
||||
self.request_with_timeout(method, params, Duration::from_secs(300))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Same as [`request`] but with a configurable timeout.
|
||||
@@ -298,7 +311,10 @@ impl AcpClient {
|
||||
let msg: Value = match serde_json::from_str(trimmed) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("ACP parse error: {e} (line: {})", &trimmed[..trimmed.len().min(80)]);
|
||||
warn!(
|
||||
"ACP parse error: {e} (line: {})",
|
||||
&trimmed[..trimmed.len().min(80)]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -322,7 +338,11 @@ impl AcpClient {
|
||||
notification_handler: &Arc<NotificationHandlerMutex>,
|
||||
) {
|
||||
let has_id = msg.get("id").is_some();
|
||||
let has_method = msg.get("method").and_then(|v| v.as_str()).map(|s| !s.is_empty()).unwrap_or(false);
|
||||
let has_method = msg
|
||||
.get("method")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_id && has_method {
|
||||
// Incoming request from agent (e.g. session/request_permission)
|
||||
@@ -458,14 +478,14 @@ rl.on('line', (line) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Send a notification that the mock server will echo back as...
|
||||
// Send a notification that the mock server will echo back as...
|
||||
// Actually the mock doesn't send unsolicited notifications.
|
||||
// This test just validates the handler registration doesn't crash.
|
||||
client
|
||||
.notification("test_push", json!({}))
|
||||
.await
|
||||
.expect("notification");
|
||||
|
||||
|
||||
// Give background task time to process
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
// In this mock, no notification will be received; that's OK
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
/// Configuration:
|
||||
/// `MNOTE_WEB_ACP_DEFAULT_RUNTIME` — default runtime name ("hermes" | "reasonix")
|
||||
/// `MNOTE_WEB_HERMES_BIN` — path to Hermes binary (default: "hermes")
|
||||
/// `MNOTE_WEB_HERMES_ACP_PROFILE` — Hermes profile for ACP runtime (default: "default")
|
||||
/// `MNOTE_WEB_REASONIX_WRAPPER` — path to Reasonix wrapper script (default: "scripts/reasonix-acp-wrapper.mjs")
|
||||
///
|
||||
/// Or via JSON env var:
|
||||
/// `MNOTE_WEB_ACP_RUNTIMES` — JSON array of runtime configs
|
||||
|
||||
use crate::acp_client::{AcpClient, AcpError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -22,7 +22,7 @@ use tracing::{debug, info, warn};
|
||||
|
||||
// ── Config ───────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AcpRuntimeConfig {
|
||||
/// Display name (e.g. "hermes", "reasonix").
|
||||
@@ -42,11 +42,17 @@ pub struct AcpRuntimeConfig {
|
||||
|
||||
impl AcpRuntimeConfig {
|
||||
/// Create a Hermes ACP runtime config.
|
||||
pub fn hermes(bin: Option<&str>) -> Self {
|
||||
pub fn hermes(bin: Option<&str>, profile: Option<&str>) -> Self {
|
||||
let profile = profile.unwrap_or("default").trim();
|
||||
let args = if profile.is_empty() {
|
||||
vec!["acp".into()]
|
||||
} else {
|
||||
vec!["-p".into(), profile.to_string(), "acp".into()]
|
||||
};
|
||||
Self {
|
||||
name: "hermes".into(),
|
||||
bin: bin.unwrap_or("hermes").to_string(),
|
||||
args: vec!["acp".into()],
|
||||
args,
|
||||
env: None,
|
||||
title: Some("Hermes".into()),
|
||||
}
|
||||
@@ -68,13 +74,15 @@ impl AcpRuntimeConfig {
|
||||
.to_string();
|
||||
let default_path = format!("{project_root}/scripts/reasonix-acp-wrapper.mjs");
|
||||
// Resolve wrapper_path: if it's relative, prepend project_root; absolute paths used as-is
|
||||
let resolved_path = wrapper_path.map(|p| {
|
||||
if p.starts_with('/') {
|
||||
p.to_string()
|
||||
} else {
|
||||
format!("{project_root}/{p}")
|
||||
}
|
||||
}).unwrap_or(default_path);
|
||||
let resolved_path = wrapper_path
|
||||
.map(|p| {
|
||||
if p.starts_with('/') {
|
||||
p.to_string()
|
||||
} else {
|
||||
format!("{project_root}/{p}")
|
||||
}
|
||||
})
|
||||
.unwrap_or(default_path);
|
||||
Self {
|
||||
name: "reasonix".into(),
|
||||
bin: "node".into(),
|
||||
@@ -114,9 +122,7 @@ impl AcpRuntimeManager {
|
||||
|
||||
// Check for JSON-based configuration first
|
||||
if let Ok(json) = env::var("MNOTE_WEB_ACP_RUNTIMES") {
|
||||
if let Ok(custom_runtimes) =
|
||||
serde_json::from_str::<Vec<AcpRuntimeConfig>>(&json)
|
||||
{
|
||||
if let Ok(custom_runtimes) = serde_json::from_str::<Vec<AcpRuntimeConfig>>(&json) {
|
||||
for rt in custom_runtimes {
|
||||
let name = rt.name.clone();
|
||||
runtimes.insert(name, rt);
|
||||
@@ -128,20 +134,26 @@ impl AcpRuntimeManager {
|
||||
|
||||
// Always add default Hermes if not already configured
|
||||
if !runtimes.contains_key("hermes") {
|
||||
let hermes_bin = env::var("MNOTE_WEB_HERMES_BIN")
|
||||
.unwrap_or_else(|_| "hermes".into());
|
||||
runtimes.insert("hermes".into(), AcpRuntimeConfig::hermes(Some(&hermes_bin)));
|
||||
let hermes_bin = env::var("MNOTE_WEB_HERMES_BIN").unwrap_or_else(|_| "hermes".into());
|
||||
let hermes_profile =
|
||||
env::var("MNOTE_WEB_HERMES_ACP_PROFILE").unwrap_or_else(|_| "default".into());
|
||||
runtimes.insert(
|
||||
"hermes".into(),
|
||||
AcpRuntimeConfig::hermes(Some(&hermes_bin), Some(&hermes_profile)),
|
||||
);
|
||||
}
|
||||
|
||||
// Always add default Reasonix if not already configured
|
||||
if !runtimes.contains_key("reasonix") {
|
||||
let wrapper = env::var("MNOTE_WEB_REASONIX_WRAPPER")
|
||||
.unwrap_or_else(|_| "scripts/reasonix-acp-wrapper.mjs".into());
|
||||
runtimes.insert("reasonix".into(), AcpRuntimeConfig::reasonix(Some(&wrapper)));
|
||||
runtimes.insert(
|
||||
"reasonix".into(),
|
||||
AcpRuntimeConfig::reasonix(Some(&wrapper)),
|
||||
);
|
||||
}
|
||||
|
||||
let default = env::var("MNOTE_WEB_ACP_DEFAULT_RUNTIME")
|
||||
.unwrap_or_else(|_| "hermes".into());
|
||||
let default = env::var("MNOTE_WEB_ACP_DEFAULT_RUNTIME").unwrap_or_else(|_| "hermes".into());
|
||||
|
||||
Self {
|
||||
runtimes,
|
||||
@@ -167,7 +179,11 @@ impl AcpRuntimeManager {
|
||||
|
||||
/// Get the currently active runtime name, if any.
|
||||
pub async fn active_runtime_name(&self) -> Option<String> {
|
||||
self.active.lock().await.as_ref().map(|a| a.config.name.clone())
|
||||
self.active
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(|a| a.config.name.clone())
|
||||
}
|
||||
|
||||
/// Get a reference to the currently active [`AcpClient`], if any.
|
||||
@@ -190,11 +206,22 @@ impl AcpRuntimeManager {
|
||||
.get(name)
|
||||
.cloned()
|
||||
.ok_or_else(|| AcpError::Internal(format!("unknown runtime: {name}")))?;
|
||||
self.switch_to_config(config).await
|
||||
}
|
||||
|
||||
/// Activate a runtime from an explicit config.
|
||||
///
|
||||
/// This is used by Hermes ACP because the binary is the same runtime name,
|
||||
/// but the selected Hermes profile changes the launch args.
|
||||
pub async fn switch_to_config(
|
||||
&self,
|
||||
config: AcpRuntimeConfig,
|
||||
) -> Result<Arc<AcpClient>, AcpError> {
|
||||
let name = config.name.clone();
|
||||
// Shutdown current active runtime
|
||||
let mut active_guard = self.active.lock().await;
|
||||
if let Some(ref current) = *active_guard {
|
||||
if current.config.name == name {
|
||||
if current.config == config {
|
||||
// Already active — return existing client
|
||||
return Ok(current.client.clone());
|
||||
}
|
||||
@@ -202,11 +229,15 @@ impl AcpRuntimeManager {
|
||||
// (via AcpClient's Drop impl)
|
||||
}
|
||||
|
||||
info!("ACP runtime: switching to {name} (bin={}, args={:?})", config.bin, config.args);
|
||||
info!(
|
||||
"ACP runtime: switching to {name} (bin={}, args={:?})",
|
||||
config.bin, config.args
|
||||
);
|
||||
|
||||
// Convert Vec<String> to Vec<&str> for AcpClient::spawn
|
||||
let args_refs: Vec<&str> = config.args.iter().map(|s| s.as_str()).collect();
|
||||
let client = AcpClient::spawn(&config.bin, &args_refs).await?;
|
||||
let client =
|
||||
AcpClient::spawn_with_env(&config.bin, &args_refs, config.env.as_ref()).await?;
|
||||
let client = Arc::new(client);
|
||||
|
||||
*active_guard = Some(ActiveRuntime {
|
||||
@@ -277,9 +308,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_runtime_config_hermes() {
|
||||
let cfg = AcpRuntimeConfig::hermes(None);
|
||||
let cfg = AcpRuntimeConfig::hermes(None, None);
|
||||
assert_eq!(cfg.name, "hermes");
|
||||
assert_eq!(cfg.bin, "hermes");
|
||||
assert_eq!(cfg.args, vec!["-p", "default", "acp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_config_hermes_profile_can_be_disabled() {
|
||||
let cfg = AcpRuntimeConfig::hermes(None, Some(""));
|
||||
assert_eq!(cfg.args, vec!["acp"]);
|
||||
}
|
||||
|
||||
@@ -288,7 +325,8 @@ mod tests {
|
||||
let cfg = AcpRuntimeConfig::reasonix(None);
|
||||
assert_eq!(cfg.name, "reasonix");
|
||||
assert_eq!(cfg.bin, "node");
|
||||
assert_eq!(cfg.args, vec!["scripts/reasonix-acp-wrapper.mjs"]);
|
||||
assert_eq!(cfg.args.len(), 1);
|
||||
assert!(cfg.args[0].ends_with("/scripts/reasonix-acp-wrapper.mjs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,11 +6,10 @@
|
||||
/// Reference:
|
||||
/// - `reference-code/hermes-vscode-main/src/sessionManager.ts` (primary)
|
||||
/// - `reference-code/hermes-vscode-main/src/protocol.ts` (dedup logic)
|
||||
|
||||
use crate::acp_client::AcpClient;
|
||||
use crate::acp_types::{
|
||||
ContentBlock, SessionNewParams, SessionNewResult, SessionPromptParams,
|
||||
SessionPromptResult, SessionUpdate, ToolCallStatus,
|
||||
ContentBlock, SessionNewParams, SessionNewResult, SessionPromptParams, SessionPromptResult,
|
||||
SessionUpdate, ToolCallStatus,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -89,8 +88,7 @@ impl AcpSessionManager {
|
||||
pub fn new(client: Arc<AcpClient>) -> Self {
|
||||
let session_id: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
|
||||
let state: Arc<Mutex<SessionState>> = Arc::new(Mutex::new(SessionState::Idle));
|
||||
let event_handler: Arc<Mutex<Option<SessionEventHandler>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
let event_handler: Arc<Mutex<Option<SessionEventHandler>>> = Arc::new(Mutex::new(None));
|
||||
let accumulated: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
|
||||
let in_prompt: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
|
||||
|
||||
@@ -164,9 +162,15 @@ impl AcpSessionManager {
|
||||
cwd: Option<&str>,
|
||||
_page_context: Option<Value>,
|
||||
) -> Result<String, crate::acp_client::AcpError> {
|
||||
let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(3)
|
||||
.unwrap_or(std::path::Path::new("/mnt/Data1T/mnote"))
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let params = SessionNewParams {
|
||||
cwd: cwd.map(|s| s.to_string()),
|
||||
mcp_servers: None,
|
||||
cwd: Some(cwd.map(str::to_string).unwrap_or(project_root)),
|
||||
mcp_servers: Some(Vec::new()),
|
||||
};
|
||||
|
||||
let result: SessionNewResult = self.client.request("session/new", params).await?;
|
||||
@@ -203,6 +207,7 @@ impl AcpSessionManager {
|
||||
|
||||
let sid = self.session_id.lock().unwrap().clone();
|
||||
let session_id = sid.ok_or_else(|| {
|
||||
self.reset_prompt_state();
|
||||
crate::acp_client::AcpError::Internal(
|
||||
"no session created yet — call create_session first".into(),
|
||||
)
|
||||
@@ -214,26 +219,17 @@ impl AcpSessionManager {
|
||||
};
|
||||
|
||||
debug!("ACP session/prompt (session={})", session_id);
|
||||
let result: SessionPromptResult = self.client.request("session/prompt", params).await?;
|
||||
debug!(
|
||||
"ACP session/prompt done (session={}, stop_reason={:?})",
|
||||
session_id, result.stop_reason
|
||||
);
|
||||
let result: Result<SessionPromptResult, crate::acp_client::AcpError> =
|
||||
self.client.request("session/prompt", params).await;
|
||||
if let Ok(ref prompt_result) = result {
|
||||
debug!(
|
||||
"ACP session/prompt done (session={}, stop_reason={:?})",
|
||||
session_id, prompt_result.stop_reason
|
||||
);
|
||||
}
|
||||
self.reset_prompt_state();
|
||||
|
||||
{
|
||||
let mut state = self.state.lock().unwrap();
|
||||
*state = SessionState::Idle;
|
||||
}
|
||||
{
|
||||
let mut in_prompt = self.in_prompt.lock().unwrap();
|
||||
*in_prompt = false;
|
||||
}
|
||||
{
|
||||
let mut acc = self.accumulated.lock().unwrap();
|
||||
acc.clear();
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
result
|
||||
}
|
||||
|
||||
/// Cancel the current prompt.
|
||||
@@ -241,9 +237,8 @@ impl AcpSessionManager {
|
||||
/// Sends `session/cancel` notification to the agent.
|
||||
pub async fn cancel(&self) -> Result<(), crate::acp_client::AcpError> {
|
||||
let sid = self.session_id.lock().unwrap().clone();
|
||||
let session_id = sid.ok_or_else(|| {
|
||||
crate::acp_client::AcpError::Internal("no active session".into())
|
||||
})?;
|
||||
let session_id =
|
||||
sid.ok_or_else(|| crate::acp_client::AcpError::Internal("no active session".into()))?;
|
||||
|
||||
{
|
||||
let mut state = self.state.lock().unwrap();
|
||||
@@ -251,10 +246,7 @@ impl AcpSessionManager {
|
||||
}
|
||||
|
||||
self.client
|
||||
.notification(
|
||||
"session/cancel",
|
||||
json!({ "sessionId": session_id }),
|
||||
)
|
||||
.notification("session/cancel", json!({ "sessionId": session_id }))
|
||||
.await?;
|
||||
|
||||
info!("ACP session cancelled: {}", session_id);
|
||||
@@ -285,6 +277,21 @@ impl AcpSessionManager {
|
||||
self.state.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn reset_prompt_state(&self) {
|
||||
{
|
||||
let mut state = self.state.lock().unwrap();
|
||||
*state = SessionState::Idle;
|
||||
}
|
||||
{
|
||||
let mut in_prompt = self.in_prompt.lock().unwrap();
|
||||
*in_prompt = false;
|
||||
}
|
||||
{
|
||||
let mut acc = self.accumulated.lock().unwrap();
|
||||
acc.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal: session/update → event mapping ─────
|
||||
|
||||
/// Convert a parsed [`SessionUpdate`] into an [`AcpSessionEvent`],
|
||||
@@ -382,12 +389,10 @@ impl AcpSessionManager {
|
||||
})
|
||||
}
|
||||
|
||||
SessionUpdate::UsageUpdate { used, size, .. } => {
|
||||
Some(AcpSessionEvent::UsageUpdate {
|
||||
used: *used,
|
||||
size: *size,
|
||||
})
|
||||
}
|
||||
SessionUpdate::UsageUpdate { used, size, .. } => Some(AcpSessionEvent::UsageUpdate {
|
||||
used: *used,
|
||||
size: *size,
|
||||
}),
|
||||
|
||||
SessionUpdate::SessionInfoUpdate { title, .. } => {
|
||||
Some(AcpSessionEvent::SessionInfoUpdate {
|
||||
@@ -396,8 +401,7 @@ impl AcpSessionManager {
|
||||
}
|
||||
|
||||
SessionUpdate::Plan { entries, .. } => {
|
||||
let summaries: Vec<String> =
|
||||
entries.iter().map(|e| e.content.clone()).collect();
|
||||
let summaries: Vec<String> = entries.iter().map(|e| e.content.clone()).collect();
|
||||
Some(AcpSessionEvent::PlanUpdate { entries: summaries })
|
||||
}
|
||||
|
||||
@@ -490,10 +494,7 @@ rl.on('line', (line) => {
|
||||
text: "Hello agent".into(),
|
||||
}];
|
||||
let result = mgr.run_prompt(prompt).await.expect("run_prompt");
|
||||
assert_eq!(
|
||||
format!("{:?}", result.stop_reason),
|
||||
"EndTurn".to_string()
|
||||
);
|
||||
assert_eq!(format!("{:?}", result.stop_reason), "EndTurn".to_string());
|
||||
// After prompt, state should be idle again
|
||||
assert_eq!(mgr.state().await, SessionState::Idle);
|
||||
}
|
||||
@@ -532,6 +533,29 @@ rl.on('line', (line) => {
|
||||
|
||||
// Give the notification handler time to process
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
assert!(received.load(Ordering::SeqCst), "should have received TextDelta");
|
||||
assert!(
|
||||
received.load(Ordering::SeqCst),
|
||||
"should have received TextDelta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thought_chunk_maps_to_thought_delta() {
|
||||
let accumulated = Arc::new(Mutex::new(String::new()));
|
||||
let update = SessionUpdate::AgentThoughtChunk {
|
||||
session_update: SessionUpdate::AGENT_THOUGHT_CHUNK.into(),
|
||||
content: crate::acp_types::TextContent {
|
||||
content_type: "text".into(),
|
||||
text: "internal reasoning".into(),
|
||||
},
|
||||
};
|
||||
|
||||
let event = AcpSessionManager::session_update_to_event(&update, &accumulated, true)
|
||||
.expect("thought chunk should emit event");
|
||||
|
||||
match event {
|
||||
AcpSessionEvent::ThoughtDelta { text } => assert_eq!(text, "internal reasoning"),
|
||||
other => panic!("expected ThoughtDelta, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
/// Reference:
|
||||
/// - `reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`
|
||||
/// - `reference-code/hermes-vscode-main/src/protocol.ts`
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{de, Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
// ── JSON-RPC 2.0 basics ──────────────────────────────
|
||||
@@ -140,19 +139,11 @@ pub enum ContentBlock {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "resource")]
|
||||
Resource {
|
||||
resource: ResourceContent,
|
||||
},
|
||||
Resource { resource: ResourceContent },
|
||||
#[serde(rename = "image")]
|
||||
Image {
|
||||
mime_type: String,
|
||||
data: String,
|
||||
},
|
||||
Image { mime_type: String, data: String },
|
||||
#[serde(rename = "audio")]
|
||||
Audio {
|
||||
mime_type: String,
|
||||
data: String,
|
||||
},
|
||||
Audio { mime_type: String, data: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -207,7 +198,7 @@ pub struct SessionUpdateParams {
|
||||
pub update: SessionUpdate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum SessionUpdate {
|
||||
AgentMessageChunk {
|
||||
@@ -269,6 +260,155 @@ pub enum SessionUpdate {
|
||||
},
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SessionUpdate {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = Value::deserialize(deserializer)?;
|
||||
let kind = value
|
||||
.get("sessionUpdate")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| de::Error::missing_field("sessionUpdate"))?
|
||||
.to_string();
|
||||
|
||||
match kind.as_str() {
|
||||
SessionUpdate::AGENT_MESSAGE_CHUNK => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Raw {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String,
|
||||
content: TextContent,
|
||||
}
|
||||
|
||||
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(SessionUpdate::AgentMessageChunk {
|
||||
session_update: raw.session_update,
|
||||
content: raw.content,
|
||||
})
|
||||
}
|
||||
SessionUpdate::AGENT_THOUGHT_CHUNK => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Raw {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String,
|
||||
content: TextContent,
|
||||
}
|
||||
|
||||
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(SessionUpdate::AgentThoughtChunk {
|
||||
session_update: raw.session_update,
|
||||
content: raw.content,
|
||||
})
|
||||
}
|
||||
SessionUpdate::TOOL_CALL => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Raw {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String,
|
||||
#[serde(rename = "toolCallId")]
|
||||
tool_call_id: String,
|
||||
title: Option<String>,
|
||||
kind: Option<ToolCallKind>,
|
||||
status: Option<ToolCallStatus>,
|
||||
raw_input: Option<Value>,
|
||||
}
|
||||
|
||||
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(SessionUpdate::ToolCall {
|
||||
session_update: raw.session_update,
|
||||
tool_call_id: raw.tool_call_id,
|
||||
title: raw.title,
|
||||
kind: raw.kind,
|
||||
status: raw.status,
|
||||
raw_input: raw.raw_input,
|
||||
})
|
||||
}
|
||||
SessionUpdate::TOOL_CALL_UPDATE => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Raw {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String,
|
||||
#[serde(rename = "toolCallId")]
|
||||
tool_call_id: String,
|
||||
status: Option<ToolCallStatus>,
|
||||
content: Option<Vec<ContentBlockWrapper>>,
|
||||
}
|
||||
|
||||
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(SessionUpdate::ToolCallUpdate {
|
||||
session_update: raw.session_update,
|
||||
tool_call_id: raw.tool_call_id,
|
||||
status: raw.status,
|
||||
content: raw.content,
|
||||
})
|
||||
}
|
||||
SessionUpdate::PLAN => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Raw {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String,
|
||||
entries: Vec<PlanEntry>,
|
||||
}
|
||||
|
||||
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(SessionUpdate::Plan {
|
||||
session_update: raw.session_update,
|
||||
entries: raw.entries,
|
||||
})
|
||||
}
|
||||
SessionUpdate::USAGE_UPDATE => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Raw {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String,
|
||||
used: u64,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(SessionUpdate::UsageUpdate {
|
||||
session_update: raw.session_update,
|
||||
used: raw.used,
|
||||
size: raw.size,
|
||||
})
|
||||
}
|
||||
SessionUpdate::SESSION_INFO_UPDATE => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Raw {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(SessionUpdate::SessionInfoUpdate {
|
||||
session_update: raw.session_update,
|
||||
title: raw.title,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
let mut extra = match value {
|
||||
Value::Object(map) => map.into_iter().collect(),
|
||||
_ => std::collections::HashMap::new(),
|
||||
};
|
||||
extra.remove("sessionUpdate");
|
||||
Ok(SessionUpdate::Unknown {
|
||||
session_update: kind,
|
||||
extra,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TextContent {
|
||||
@@ -455,10 +595,11 @@ mod tests {
|
||||
fn test_session_new_params() {
|
||||
let params = SessionNewParams {
|
||||
cwd: Some("/mnt/Data1T/mnote".into()),
|
||||
mcp_servers: None,
|
||||
mcp_servers: Some(Vec::new()),
|
||||
};
|
||||
let json = serde_json::to_value(¶ms).unwrap();
|
||||
assert_eq!(json["cwd"], "/mnt/Data1T/mnote");
|
||||
assert_eq!(json["mcpServers"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -480,6 +621,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_update_agent_thought_chunk() {
|
||||
let json = serde_json::json!({
|
||||
"sessionId": "test_1",
|
||||
"update": {
|
||||
"sessionUpdate": "agent_thought_chunk",
|
||||
"content": { "type": "text", "text": "thinking" }
|
||||
}
|
||||
});
|
||||
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(parsed.session_id, "test_1");
|
||||
match &parsed.update {
|
||||
SessionUpdate::AgentThoughtChunk { content, .. } => {
|
||||
assert_eq!(content.text, "thinking");
|
||||
}
|
||||
_ => panic!("expected AgentThoughtChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_update_tool_call() {
|
||||
let json = serde_json::json!({
|
||||
@@ -524,7 +684,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_content_block_text() {
|
||||
let block = ContentBlock::Text { text: "hello".into() };
|
||||
let block = ContentBlock::Text {
|
||||
text: "hello".into(),
|
||||
};
|
||||
let json = serde_json::to_value(&block).unwrap();
|
||||
assert_eq!(json["type"], "text");
|
||||
assert_eq!(json["text"], "hello");
|
||||
@@ -533,7 +695,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_flatten_prompt() {
|
||||
let blocks = vec![
|
||||
ContentBlock::Text { text: "Hello".into() },
|
||||
ContentBlock::Text {
|
||||
text: "Hello".into(),
|
||||
},
|
||||
ContentBlock::Resource {
|
||||
resource: ResourceContent {
|
||||
uri: "file:///test.md".into(),
|
||||
|
||||
@@ -90,9 +90,7 @@ pub enum DeltaOperation {
|
||||
block_type: Option<String>,
|
||||
},
|
||||
#[serde(rename = "delete")]
|
||||
DeleteBlock {
|
||||
block_id: String,
|
||||
},
|
||||
DeleteBlock { block_id: String },
|
||||
#[serde(rename = "move_after")]
|
||||
MoveBlock {
|
||||
block_id: String,
|
||||
@@ -134,12 +132,19 @@ impl EditorRuntimeActor {
|
||||
.read()
|
||||
.map_err(|e| WebError::internal(format!("EditorRuntimeActor 锁失败:{e}")))?;
|
||||
let state = documents.get(document_id).ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_editor_document_not_loaded", format!("文档 {document_id} 尚未加载"))
|
||||
WebError::bad_request_code(
|
||||
"mnote_editor_document_not_loaded",
|
||||
format!("文档 {document_id} 尚未加载"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let operations = match command {
|
||||
EditorCommand::ReplaceBlock(cmd) => {
|
||||
let block = state.document.blocks.iter().find(|b| b.block_id == cmd.block_id);
|
||||
let block = state
|
||||
.document
|
||||
.blocks
|
||||
.iter()
|
||||
.find(|b| b.block_id == cmd.block_id);
|
||||
vec![DeltaOperation::ReplaceBlock {
|
||||
block_id: cmd.block_id.clone(),
|
||||
text: block_text_from_block(block),
|
||||
@@ -254,9 +259,9 @@ impl EditorRuntimeActor {
|
||||
|
||||
let changed_blocks = extract_changed_blocks(&state.document, &command);
|
||||
|
||||
apply_editor_command_to_document(&mut state.document, command.clone()).map_err(|error| {
|
||||
WebError::bad_request_code("mnote_editor_command_failed", format!("{error:?}"))
|
||||
})?;
|
||||
apply_editor_command_to_document(&mut state.document, command.clone()).map_err(
|
||||
|error| WebError::bad_request_code("mnote_editor_command_failed", format!("{error:?}")),
|
||||
)?;
|
||||
|
||||
state.revision += 1;
|
||||
state.conflict_detection_key = format!(
|
||||
|
||||
@@ -956,9 +956,10 @@ fn compute_next_content_via_actor(
|
||||
}
|
||||
|
||||
// 在内存中 apply
|
||||
let _apply_result = state
|
||||
.editor_actor
|
||||
.apply_command(&document_id, command.clone(), command_name)?;
|
||||
let _apply_result =
|
||||
state
|
||||
.editor_actor
|
||||
.apply_command(&document_id, command.clone(), command_name)?;
|
||||
|
||||
// 从 actor 获取 legacy content(用于 Convex save 的 payload)
|
||||
let content = state.editor_actor.legacy_content_for_save(&document_id)?;
|
||||
|
||||
@@ -108,8 +108,7 @@ pub async fn doc_fetch(
|
||||
.map(|t| t == "heading")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Some(hl) =
|
||||
block.pointer("/attrs/level").and_then(Value::as_u64)
|
||||
if let Some(hl) = block.pointer("/attrs/level").and_then(Value::as_u64)
|
||||
{
|
||||
if hl <= level {
|
||||
end = idx;
|
||||
@@ -206,7 +205,10 @@ pub async fn doc_fetch(
|
||||
"maxChars": max_chars
|
||||
}));
|
||||
}
|
||||
let source = if document_id.starts_with('/') || document_id.starts_with("./") || document_id.contains('/') {
|
||||
let source = if document_id.starts_with('/')
|
||||
|| document_id.starts_with("./")
|
||||
|| document_id.contains('/')
|
||||
{
|
||||
"local_fs"
|
||||
} else {
|
||||
"convex"
|
||||
@@ -901,9 +903,15 @@ fn search_replace(text: &str, search: &str, replace: &str) -> Result<String, Str
|
||||
s.trim()
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
'A'..='Z' => ((ch as u32).saturating_sub('A' as u32) + 'A' as u32).try_into().unwrap_or(ch),
|
||||
'a'..='z' => ((ch as u32).saturating_sub('a' as u32) + 'a' as u32).try_into().unwrap_or(ch),
|
||||
'0'..='9' => ((ch as u32).saturating_sub('0' as u32) + '0' as u32).try_into().unwrap_or(ch),
|
||||
'A'..='Z' => ((ch as u32).saturating_sub('A' as u32) + 'A' as u32)
|
||||
.try_into()
|
||||
.unwrap_or(ch),
|
||||
'a'..='z' => ((ch as u32).saturating_sub('a' as u32) + 'a' as u32)
|
||||
.try_into()
|
||||
.unwrap_or(ch),
|
||||
'0'..='9' => ((ch as u32).saturating_sub('0' as u32) + '0' as u32)
|
||||
.try_into()
|
||||
.unwrap_or(ch),
|
||||
'\u{3000}' => ' ',
|
||||
_ => ch,
|
||||
})
|
||||
@@ -919,7 +927,11 @@ fn search_replace(text: &str, search: &str, replace: &str) -> Result<String, Str
|
||||
"{}{}{}",
|
||||
&line[..line.char_indices().nth(start).map(|(i, _)| i).unwrap_or(0)],
|
||||
replace,
|
||||
&line[line.char_indices().nth(end).map(|(i, _)| i).unwrap_or(line.len())..]
|
||||
&line[line
|
||||
.char_indices()
|
||||
.nth(end)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(line.len())..]
|
||||
);
|
||||
return Ok(text.replacen(line, &replaced, 1));
|
||||
}
|
||||
@@ -933,7 +945,14 @@ fn search_replace(text: &str, search: &str, replace: &str) -> Result<String, Str
|
||||
}
|
||||
}
|
||||
// Level 4: 失败
|
||||
Err(format!("无法匹配 \"{}\"", if search.len() > 60 { format!("{}...", &search[..60]) } else { search.to_string() }))
|
||||
Err(format!(
|
||||
"无法匹配 \"{}\"",
|
||||
if search.len() > 60 {
|
||||
format!("{}...", &search[..60])
|
||||
} else {
|
||||
search.to_string()
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
fn fuzzy_match(text: &str, pattern: &str, max_diff_ratio: f64) -> bool {
|
||||
@@ -977,10 +996,7 @@ fn build_block_ops_from_markdown_edit(
|
||||
|
||||
if let Some(block) = matched_block {
|
||||
let block_id = block_id_of(block).unwrap_or_default();
|
||||
let block_text = block
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let block_text = block.get("text").and_then(Value::as_str).unwrap_or("");
|
||||
let new_text = block_text.replacen(search, replace, 1);
|
||||
block_ops.push(json!({
|
||||
"op": "replace",
|
||||
|
||||
@@ -4,12 +4,12 @@ use crate::error::WebError;
|
||||
use crate::transport::convex::{
|
||||
execute_convex_command_plan, execute_convex_command_plan_with_artifacts, ConvexCommandExecution,
|
||||
};
|
||||
use serde_json::json;
|
||||
use bridge_runtime::{
|
||||
execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire,
|
||||
RuntimeTargetWire,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn runtime_context(
|
||||
|
||||
@@ -571,7 +571,6 @@ fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
|
||||
if (!workspaceId || !('EventSource' in window)) return;
|
||||
var url = new URL('/api/tree/events', window.location.origin);
|
||||
url.searchParams.set('workspaceId', workspaceId);
|
||||
url.searchParams.set('pollMs', '1000');
|
||||
var source = new EventSource(url.toString());
|
||||
root.__mnoteTrashEventSource = source;
|
||||
['snapshot', 'delta', 'resync'].forEach(function(kind) {{
|
||||
@@ -1652,6 +1651,7 @@ mod tests {
|
||||
assert!(html.contains("已删表格.luckysheet"));
|
||||
assert!(html.contains("new EventSource"));
|
||||
assert!(html.contains("/api/tree/events"));
|
||||
assert!(!html.contains("pollMs"));
|
||||
assert!(html.contains("refreshTrashWorkbenchFromServer"));
|
||||
assert!(!html.contains("window.location.reload"));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ use crate::error::WebError;
|
||||
use crate::hermes_tools::manifest;
|
||||
use crate::transport::convex::execute_convex_query_by_name;
|
||||
use axum::body::Body;
|
||||
use tokio::sync::broadcast;
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::response::Response;
|
||||
@@ -20,6 +19,7 @@ use std::process::Command;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::Duration;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{info, warn};
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
@@ -32,6 +32,8 @@ static HERMES_RUN_QUEUE: LazyLock<Mutex<HashMap<String, VecDeque<HermesQueuedRun
|
||||
/// Store ACP run payloads keyed by run_id, so stream_events can read them.
|
||||
static ACP_RUN_PAYLOADS: LazyLock<Mutex<HashMap<String, Value>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
static ACP_ACTIVE_RUNS: LazyLock<Mutex<HashMap<String, AcpActiveRun>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct HermesRuntimeState {
|
||||
@@ -71,6 +73,13 @@ struct HermesQueuedRun {
|
||||
queued_at: u128,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AcpActiveRun {
|
||||
manager: Arc<crate::acp_session_manager::AcpSessionManager>,
|
||||
mnote_session_id: String,
|
||||
acp_session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateSessionRequest {
|
||||
@@ -353,10 +362,15 @@ pub async fn list_skills(
|
||||
.get("profile")
|
||||
.map(String::as_str)
|
||||
.unwrap_or(fallback_profile.as_str());
|
||||
let runtime = query.get("runtime").map(String::as_str).unwrap_or(profile);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(skills_payload(profile)),
|
||||
Json(if runtime == "reasonix" {
|
||||
reasonix_skills_payload()
|
||||
} else {
|
||||
skills_payload(profile)
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -529,9 +543,15 @@ pub async fn create_run(
|
||||
let registration = run_registration_from_payload(&context, &payload);
|
||||
|
||||
// ACP path: skip the HTTP proxy, just register and return run info
|
||||
if is_acp_profile(®istration.profile) {
|
||||
let run_id = registration.session_id.clone(); // session_id serves as run_id
|
||||
let runtime_state = register_acp_runtime(®istration);
|
||||
if acp_runtime_for_payload(&payload, ®istration.profile).is_some() {
|
||||
let run_id = payload
|
||||
.get("runId")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| new_acp_run_id(®istration));
|
||||
payload["runId"] = Value::String(run_id.clone());
|
||||
let runtime_state = register_acp_runtime(®istration, &run_id);
|
||||
// Store payload for stream_events to use
|
||||
ACP_RUN_PAYLOADS
|
||||
.lock()
|
||||
@@ -600,6 +620,7 @@ async fn acp_stream_events(
|
||||
context: RequestContext,
|
||||
run_id: &str,
|
||||
profile: &str,
|
||||
acp_runtime_name: &str,
|
||||
) -> Result<Response, WebError> {
|
||||
// Get the stored payload from create_run
|
||||
let payload = ACP_RUN_PAYLOADS
|
||||
@@ -621,14 +642,36 @@ async fn acp_stream_events(
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("请读取当前文档内容");
|
||||
|
||||
let prompt_blocks = vec![ContentBlock::Text {
|
||||
let mut prompt_blocks = Vec::new();
|
||||
if let Ok(upstream_body) = build_run_upstream_body(&context, payload.clone()) {
|
||||
if let Some(instructions) = upstream_body.get("instructions").and_then(Value::as_str) {
|
||||
prompt_blocks.push(ContentBlock::Text {
|
||||
text: format!(
|
||||
"以下是 mnote 页面 AI 的冻结上下文与工具约束,请在本轮回答中遵守:\n{instructions}"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
prompt_blocks.push(ContentBlock::Text {
|
||||
text: input.to_string(),
|
||||
}];
|
||||
});
|
||||
|
||||
let runtime_name = crate::acp_bridge::runtime_name_for_profile(profile);
|
||||
let runtime_name = acp_runtime_name;
|
||||
|
||||
// Ensure runtime is active; switch_to either activates it or returns existing
|
||||
let client = state.acp_runtime.switch_to(runtime_name).await.map_err(|e| {
|
||||
let client = if runtime_name == "hermes" {
|
||||
let hermes_bin = std::env::var("MNOTE_WEB_HERMES_BIN")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "hermes".into());
|
||||
let mut config =
|
||||
crate::acp_runtime::AcpRuntimeConfig::hermes(Some(&hermes_bin), Some(profile));
|
||||
config.env = acp_hermes_env_for_profile(profile);
|
||||
state.acp_runtime.switch_to_config(config).await
|
||||
} else {
|
||||
state.acp_runtime.switch_to(runtime_name).await
|
||||
}
|
||||
.map_err(|e| {
|
||||
WebError::bad_gateway_code(
|
||||
"acp_runtime_switch_failed",
|
||||
format!("Failed to activate ACP runtime '{runtime_name}': {e}"),
|
||||
@@ -647,15 +690,50 @@ async fn acp_stream_events(
|
||||
}
|
||||
});
|
||||
|
||||
mgr.create_session(None, None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
WebError::bad_gateway_code(
|
||||
"acp_session_create_failed",
|
||||
format!("ACP session creation failed: {e}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let acp_session_id = mgr.create_session(None, None).await.map_err(|e| {
|
||||
WebError::bad_gateway_code(
|
||||
"acp_session_create_failed",
|
||||
format!("ACP session creation failed: {e}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let mnote_session_id = session_id_for_run(run_id).unwrap_or_else(|| run_id.to_string());
|
||||
ACP_ACTIVE_RUNS.lock().expect("acp active runs").insert(
|
||||
run_id.to_string(),
|
||||
AcpActiveRun {
|
||||
manager: Arc::clone(&mgr),
|
||||
mnote_session_id,
|
||||
acp_session_id,
|
||||
},
|
||||
);
|
||||
|
||||
// Build SSE response from event channel FIRST (before running prompt),
|
||||
// so that if the prompt fails quickly, events are not lost.
|
||||
use tokio::sync::mpsc;
|
||||
let (tx, rx) = mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(256);
|
||||
let mut event_rx = event_tx.subscribe();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match event_rx.recv().await {
|
||||
Ok(event) => {
|
||||
let json_str =
|
||||
serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into());
|
||||
let bytes = axum::body::Bytes::from(format!(
|
||||
"event: {}\ndata: {}\n\n",
|
||||
event.event, json_str
|
||||
));
|
||||
if tx.send(Ok(bytes)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("ACP SSE lagged: {n} events dropped");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Run prompt in background
|
||||
let run_id_owned = run_id.to_string();
|
||||
@@ -681,29 +759,16 @@ async fn acp_stream_events(
|
||||
});
|
||||
}
|
||||
}
|
||||
update_runtime_by_run_id(&run_id_owned, "completed", Some("acp.prompt.done"), None);
|
||||
});
|
||||
|
||||
// Build SSE response from event channel
|
||||
use tokio::sync::mpsc;
|
||||
let (tx, rx) = mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(256);
|
||||
let mut event_rx = event_tx.subscribe();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match event_rx.recv().await {
|
||||
Ok(event) => {
|
||||
let json_str = serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into());
|
||||
let bytes = axum::body::Bytes::from(
|
||||
format!("event: {}\ndata: {}\n\n", event.event, json_str)
|
||||
);
|
||||
if tx.send(Ok(bytes)).await.is_err() { break; }
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("ACP SSE lagged: {n} events dropped");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
mgr_clone.close().await;
|
||||
ACP_ACTIVE_RUNS
|
||||
.lock()
|
||||
.expect("acp active runs")
|
||||
.remove(&run_id_owned);
|
||||
if !matches!(
|
||||
runtime_status_for_run(&run_id_owned).as_deref(),
|
||||
Some("aborting" | "aborted")
|
||||
) {
|
||||
update_runtime_by_run_id(&run_id_owned, "completed", Some("acp.prompt.done"), None);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -715,8 +780,7 @@ async fn acp_stream_events(
|
||||
.header("x-accel-buffering", "no")
|
||||
.body(Body::from_stream(stream))
|
||||
.map_err(|e| {
|
||||
WebError::internal(format!("SSE response build failed: {e}"))
|
||||
.with_context(&context)
|
||||
WebError::internal(format!("SSE response build failed: {e}")).with_context(&context)
|
||||
})?;
|
||||
stamp_client_headers_into(response.headers_mut());
|
||||
Ok(response)
|
||||
@@ -731,8 +795,8 @@ pub async fn stream_events(
|
||||
let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());
|
||||
|
||||
// ACP path: start AcpRunBridge and return SSE stream
|
||||
if is_acp_profile(&profile) {
|
||||
return acp_stream_events(state, context, &run_id, &profile).await;
|
||||
if let Some(acp_runtime_name) = acp_runtime_for_run(&run_id, &profile) {
|
||||
return acp_stream_events(state, context, &run_id, &profile, &acp_runtime_name).await;
|
||||
}
|
||||
|
||||
let Some(upstream) = configured_upstream_for_profile(&profile) else {
|
||||
@@ -808,6 +872,51 @@ pub async fn abort_run(
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());
|
||||
if acp_runtime_for_run(&run_id, &profile).is_some() {
|
||||
let active = ACP_ACTIVE_RUNS
|
||||
.lock()
|
||||
.expect("acp active runs")
|
||||
.remove(&run_id);
|
||||
update_runtime_by_run_id(&run_id, "aborting", Some("abort.started"), None);
|
||||
let Some(active) = active else {
|
||||
update_runtime_by_run_id(&run_id, "aborted", Some("abort.missing_active_run"), None);
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"runId": run_id,
|
||||
"status": "aborted",
|
||||
"runtime": runtime_state_for_run(&run_id).unwrap_or(Value::Null),
|
||||
"events": [
|
||||
{"event": "abort.started", "runId": run_id},
|
||||
{"event": "abort.completed", "runId": run_id, "note": "active ACP run was already finished or missing"}
|
||||
]
|
||||
})),
|
||||
));
|
||||
};
|
||||
active.manager.cancel().await.map_err(|error| {
|
||||
WebError::bad_gateway_code("acp_abort_failed", format!("ACP abort failed: {error}"))
|
||||
.with_context(&context)
|
||||
})?;
|
||||
update_runtime_by_run_id(&run_id, "aborted", Some("abort.completed"), None);
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"runId": run_id,
|
||||
"sessionId": active.mnote_session_id,
|
||||
"acpSessionId": active.acp_session_id,
|
||||
"status": "aborted",
|
||||
"runtime": runtime_state_for_run(&run_id).unwrap_or(Value::Null),
|
||||
"events": [
|
||||
{"event": "abort.started", "runId": run_id},
|
||||
{"event": "abort.completed", "runId": run_id}
|
||||
]
|
||||
})),
|
||||
));
|
||||
}
|
||||
let Some(upstream) = configured_upstream_for_profile(&profile) else {
|
||||
return hermes_unconfigured(&context);
|
||||
};
|
||||
@@ -1467,6 +1576,141 @@ fn skills_payload(profile: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn reasonix_skill_roots() -> Vec<(PathBuf, &'static str)> {
|
||||
let mut roots = Vec::new();
|
||||
let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(3)
|
||||
.unwrap_or(std::path::Path::new("/mnt/Data1T/mnote"))
|
||||
.to_path_buf();
|
||||
roots.push((project_root.join(".reasonix").join("skills"), "project"));
|
||||
roots.push((project_root.join(".agents").join("skills"), "project"));
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let home = PathBuf::from(home);
|
||||
roots.push((home.join(".reasonix").join("skills"), "global"));
|
||||
roots.push((home.join(".agents").join("skills"), "global"));
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
fn reasonix_skill_entry(path: PathBuf, stem: String, scope: &str) -> Option<Value> {
|
||||
let content = fs::read_to_string(&path).ok()?;
|
||||
let metadata = parse_reasonix_skill_frontmatter(&content);
|
||||
let name = metadata
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(&stem)
|
||||
.to_string();
|
||||
Some(json!({
|
||||
"name": name,
|
||||
"description": metadata
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| extract_skill_description(&content)),
|
||||
"enabled": true,
|
||||
"toggleable": false,
|
||||
"source": "reasonix",
|
||||
"origin": scope,
|
||||
"category": scope,
|
||||
"path": path.to_string_lossy(),
|
||||
"scope": scope,
|
||||
"runAs": metadata.get("runAs").cloned().unwrap_or(Value::Null),
|
||||
"model": metadata.get("model").cloned().unwrap_or(Value::Null)
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_reasonix_skill_frontmatter(markdown: &str) -> serde_json::Map<String, Value> {
|
||||
let mut metadata = serde_json::Map::new();
|
||||
let mut lines = markdown.lines();
|
||||
if lines.next().map(str::trim) != Some("---") {
|
||||
return metadata;
|
||||
}
|
||||
for line in lines {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "---" {
|
||||
break;
|
||||
}
|
||||
let Some((key, value)) = trimmed.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let normalized = value.trim().trim_matches('"').trim_matches('\'');
|
||||
if !key.trim().is_empty() && !normalized.is_empty() {
|
||||
metadata.insert(
|
||||
key.trim().to_string(),
|
||||
Value::String(normalized.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
metadata
|
||||
}
|
||||
|
||||
fn reasonix_skills_payload() -> Value {
|
||||
let mut seen = HashSet::new();
|
||||
let mut by_scope: HashMap<String, Vec<Value>> = HashMap::new();
|
||||
for (root, scope) in reasonix_skill_roots() {
|
||||
let Ok(entries) = fs::read_dir(&root) else {
|
||||
continue;
|
||||
};
|
||||
let mut entries = entries.filter_map(Result::ok).collect::<Vec<_>>();
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
for entry in entries {
|
||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||
let path = entry.path();
|
||||
let skill = if entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
|
||||
reasonix_skill_entry(path.join("SKILL.md"), file_name, scope)
|
||||
} else if entry
|
||||
.file_type()
|
||||
.map(|kind| kind.is_file())
|
||||
.unwrap_or(false)
|
||||
&& file_name.ends_with(".md")
|
||||
{
|
||||
let stem = file_name.trim_end_matches(".md").to_string();
|
||||
reasonix_skill_entry(path, stem, scope)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(skill) = skill else {
|
||||
continue;
|
||||
};
|
||||
let Some(name) = skill.get("name").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if !seen.insert(name.to_string()) {
|
||||
continue;
|
||||
}
|
||||
by_scope.entry(scope.to_string()).or_default().push(skill);
|
||||
}
|
||||
}
|
||||
let mut categories = by_scope
|
||||
.into_iter()
|
||||
.map(|(scope, mut skills)| {
|
||||
skills.sort_by_key(|skill| skill["name"].as_str().unwrap_or_default().to_string());
|
||||
json!({
|
||||
"name": scope,
|
||||
"description": format!("Reasonix {scope} skills"),
|
||||
"skills": skills
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
categories.sort_by_key(
|
||||
|category| match category["name"].as_str().unwrap_or_default() {
|
||||
"project" => 0,
|
||||
"custom" => 1,
|
||||
"global" => 2,
|
||||
_ => 3,
|
||||
},
|
||||
);
|
||||
json!({
|
||||
"ok": true,
|
||||
"runtime": "reasonix",
|
||||
"categories": categories,
|
||||
"archived": []
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_misc_categories(categories: Vec<Value>) -> Vec<Value> {
|
||||
let mut merged = Vec::new();
|
||||
let mut misc = Vec::new();
|
||||
@@ -1670,6 +1914,82 @@ fn is_acp_profile(profile: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn acp_runtime_for_payload(payload: &Value, profile: &str) -> Option<String> {
|
||||
if let Some(runtime) = payload
|
||||
.get("acpRuntime")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(runtime.to_lowercase());
|
||||
}
|
||||
if is_acp_profile(profile) {
|
||||
return Some(crate::acp_bridge::runtime_name_for_profile(profile).to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn acp_runtime_for_run(run_id: &str, profile: &str) -> Option<String> {
|
||||
if let Some(payload) = ACP_RUN_PAYLOADS
|
||||
.lock()
|
||||
.expect("acp run payloads")
|
||||
.get(run_id)
|
||||
.cloned()
|
||||
{
|
||||
return acp_runtime_for_payload(&payload, profile);
|
||||
}
|
||||
if ACP_ACTIVE_RUNS
|
||||
.lock()
|
||||
.expect("acp active runs")
|
||||
.contains_key(run_id)
|
||||
{
|
||||
return Some(crate::acp_bridge::runtime_name_for_profile(profile).to_string());
|
||||
}
|
||||
if is_acp_profile(profile) {
|
||||
return Some(crate::acp_bridge::runtime_name_for_profile(profile).to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn provider_default_key_env(provider: &str) -> Option<&'static str> {
|
||||
match provider.trim().to_ascii_lowercase().as_str() {
|
||||
"deepseek" => Some("DEEPSEEK_API_KEY"),
|
||||
"openrouter" => Some("OPENROUTER_API_KEY"),
|
||||
"omniroute" => Some("OMNIROUTE_API_KEY"),
|
||||
"openai" | "custom" => Some("OPENAI_API_KEY"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn acp_hermes_env_for_profile(profile: &str) -> Option<HashMap<String, String>> {
|
||||
let config = fs::read_to_string(profile_config_path(profile)).ok()?;
|
||||
let provider = yaml_path_value(&config, &["model", "provider"]).unwrap_or_default();
|
||||
let key_env = yaml_path_value(&config, &["model", "key_env"])
|
||||
.or_else(|| {
|
||||
if provider.is_empty() {
|
||||
None
|
||||
} else {
|
||||
yaml_path_value(&config, &["providers", &provider, "key_env"])
|
||||
}
|
||||
})
|
||||
.or_else(|| provider_default_key_env(&provider).map(str::to_string))?;
|
||||
let key = env_or_dotenv(&key_env)
|
||||
.or_else(|| yaml_path_value(&config, &["model", "api_key"]))
|
||||
.or_else(|| {
|
||||
if provider.is_empty() {
|
||||
None
|
||||
} else {
|
||||
yaml_path_value(&config, &["providers", &provider, "api_key"])
|
||||
}
|
||||
})
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())?;
|
||||
|
||||
let mut env = HashMap::new();
|
||||
env.insert(key_env, key);
|
||||
Some(env)
|
||||
}
|
||||
|
||||
/// Returns the ACP runtime name for a profile.
|
||||
/// For ACP profiles, returns the runtime backend name ("hermes" or "reasonix").
|
||||
/// The profile name is used as the runtime name unless overridden by env var.
|
||||
@@ -2229,6 +2549,16 @@ fn runtime_state_for_run(run_id: &str) -> Option<Value> {
|
||||
.map(runtime_state_to_json)
|
||||
}
|
||||
|
||||
fn runtime_status_for_run(run_id: &str) -> Option<String> {
|
||||
let registry = HERMES_RUNTIME_REGISTRY
|
||||
.lock()
|
||||
.expect("hermes runtime registry");
|
||||
registry
|
||||
.values()
|
||||
.find(|state| state.run_id == run_id)
|
||||
.map(|state| state.status.clone())
|
||||
}
|
||||
|
||||
fn run_registration_from_payload(
|
||||
context: &RequestContext,
|
||||
payload: &Value,
|
||||
@@ -2268,12 +2598,15 @@ fn run_registration_from_payload(
|
||||
|
||||
/// Register a runtime state from a registration (without upstream response).
|
||||
/// Used by the ACP path where no Hermes HTTP upstream exists.
|
||||
fn register_acp_runtime(registration: &HermesRunRegistration) -> Value {
|
||||
fn new_acp_run_id(registration: &HermesRunRegistration) -> String {
|
||||
format!("run_{}_{}", registration.trace_id, now_ms())
|
||||
}
|
||||
|
||||
fn register_acp_runtime(registration: &HermesRunRegistration, run_id: &str) -> Value {
|
||||
let now = now_ms();
|
||||
let run_id = registration.session_id.clone(); // use session_id as run_id for ACP
|
||||
let state = HermesRuntimeState {
|
||||
session_id: registration.session_id.clone(),
|
||||
run_id,
|
||||
run_id: run_id.to_string(),
|
||||
profile: registration.profile.clone(),
|
||||
document_id: registration.document_id.clone(),
|
||||
trace_id: registration.trace_id.clone(),
|
||||
|
||||
@@ -147,7 +147,10 @@ fn extract_markdown_operations_from_model_text(text: &str) -> Result<Vec<Value>,
|
||||
}
|
||||
if let Some(operations) = parsed.get("operations").and_then(Value::as_array) {
|
||||
// 新格式:直接是 search/replace 对
|
||||
if operations.iter().any(|op| op.get("search").is_some() || op.get("replace").is_some()) {
|
||||
if operations
|
||||
.iter()
|
||||
.any(|op| op.get("search").is_some() || op.get("replace").is_some())
|
||||
{
|
||||
return Ok(operations.clone());
|
||||
}
|
||||
// 旧格式(block ops):转换为 search/replace 对
|
||||
|
||||
@@ -80,10 +80,7 @@ async fn events_with_stream_delta(
|
||||
if let Some(ref mut rx) = state.block_delta_rx {
|
||||
match rx.try_recv() {
|
||||
Ok(payload) => {
|
||||
return Some((
|
||||
Ok(stream_event("block.delta", &payload)),
|
||||
Some(state),
|
||||
));
|
||||
return Some((Ok(stream_event("block.delta", &payload)), Some(state)));
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
|
||||
@@ -106,10 +103,7 @@ async fn events_with_stream_delta(
|
||||
"requestId": payload.get("requestId"),
|
||||
"traceId": payload.get("traceId"),
|
||||
});
|
||||
return Some((
|
||||
Ok(stream_event("delta", &hint)),
|
||||
Some(state),
|
||||
));
|
||||
return Some((Ok(stream_event("delta", &hint)), Some(state)));
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
|
||||
@@ -139,10 +133,7 @@ async fn events_with_stream_delta(
|
||||
if let Some(ref mut rx) = state.block_delta_rx {
|
||||
match rx.try_recv() {
|
||||
Ok(payload) => {
|
||||
return Some((
|
||||
Ok(stream_event("block.delta", &payload)),
|
||||
Some(state),
|
||||
));
|
||||
return Some((Ok(stream_event("block.delta", &payload)), Some(state)));
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
|
||||
@@ -237,7 +228,14 @@ pub async fn tree_events(
|
||||
}
|
||||
let block_delta_rx = state.block_delta_tx.subscribe();
|
||||
let stream_delta_rx = state.stream_delta_tx.subscribe();
|
||||
let sse = events_with_stream_delta(state, context, query, Some(block_delta_rx), Some(stream_delta_rx)).await?;
|
||||
let sse = events_with_stream_delta(
|
||||
state,
|
||||
context,
|
||||
query,
|
||||
Some(block_delta_rx),
|
||||
Some(stream_delta_rx),
|
||||
)
|
||||
.await?;
|
||||
Ok((headers, sse))
|
||||
}
|
||||
|
||||
|
||||
@@ -4117,6 +4117,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return pageAiProfileValue(selected) || 'mnoteai';
|
||||
}
|
||||
|
||||
function pageAiRunProfile() {
|
||||
return String(pageUiState.pageAiAcpRuntime || '').trim() === 'reasonix' ? 'reasonix' : pageAiCurrentProfile();
|
||||
}
|
||||
|
||||
function pageAiMnoteToolModel() {
|
||||
return String(document.documentElement.getAttribute('data-mnote-page-ai-tool-model') || 'deepseek-v4-flash').trim() || 'deepseek-v4-flash';
|
||||
}
|
||||
@@ -4570,7 +4574,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: pageUiState.pageAiActiveSessionId,
|
||||
profile: pageAiCurrentProfile(),
|
||||
profile: pageAiRunProfile(),
|
||||
acpRuntime: pageUiState.pageAiAcpRuntime || '',
|
||||
reason: 'page_ai_user_stop'
|
||||
})
|
||||
});
|
||||
@@ -4610,6 +4615,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var source = String(skill && skill.source || '').trim();
|
||||
if (source === 'hub') return '安装';
|
||||
if (source === 'builtin') return '内置';
|
||||
if (source === 'reasonix') {
|
||||
if (origin === 'project') return 'Reasonix 项目';
|
||||
if (origin === 'global') return 'Reasonix 全局';
|
||||
return 'Reasonix';
|
||||
}
|
||||
return '本地';
|
||||
}
|
||||
|
||||
@@ -4716,7 +4726,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
async function pageAiLoadSkills() {
|
||||
try {
|
||||
var response = await fetch('/api/hermes/client/skills?profile=' + encodeURIComponent(pageAiCurrentProfile()), {
|
||||
var runtime = String(pageUiState.pageAiAcpRuntime || '').trim();
|
||||
var params = runtime === 'reasonix'
|
||||
? 'runtime=reasonix'
|
||||
: 'profile=' + encodeURIComponent(pageAiCurrentProfile());
|
||||
var response = await fetch('/api/hermes/client/skills?' + params, {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
@@ -4734,6 +4748,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
async function pageAiToggleSkill(skillName, enabled) {
|
||||
var name = String(skillName || '').trim();
|
||||
if (!name) return;
|
||||
if (String(pageUiState.pageAiAcpRuntime || '').trim() === 'reasonix') return;
|
||||
var previous = null;
|
||||
pageAiSkillListEntries().forEach(function(skill) {
|
||||
if (skill.name === name && previous == null) previous = skill.enabled !== false;
|
||||
@@ -4828,7 +4843,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
function renderPageAiControls() {
|
||||
var drawer = ensurePageAiDrawer();
|
||||
var isAcp = pageUiState.pageAiAcpRuntime !== '';
|
||||
var activeProfile = isAcp ? pageUiState.pageAiAcpRuntime : pageAiCurrentProfile();
|
||||
var activeProfile = pageAiRunProfile();
|
||||
drawer.setAttribute('data-page-ai-page', pageUiState.pageAiPage || 'chat');
|
||||
drawer.setAttribute('data-mnote-acp-runtime', pageUiState.pageAiAcpRuntime || '');
|
||||
// Populate ACP runtime dropdown
|
||||
@@ -4844,7 +4859,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
// Show/hide Hermes-specific profile select
|
||||
var profileLabel = drawer.querySelector('[data-page-ai-hermes-profile]');
|
||||
if (profileLabel instanceof HTMLElement) {
|
||||
profileLabel.style.display = isAcp ? 'none' : '';
|
||||
profileLabel.style.display = pageUiState.pageAiAcpRuntime === 'reasonix' ? 'none' : '';
|
||||
}
|
||||
// When ACP is selected, populate agent panel with ACP runtime info
|
||||
var agentPanel = drawer.querySelector('[data-page-ai-agent-panel]');
|
||||
@@ -4982,12 +4997,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (skillList instanceof HTMLElement) {
|
||||
var skills = pageAiFilteredSkillEntries();
|
||||
if (!skills.length) {
|
||||
skillList.innerHTML = '<div class="wolai-page-ai-empty">没有匹配的 Hermes skill。</div>';
|
||||
var emptyText = String(pageUiState.pageAiAcpRuntime || '').trim() === 'reasonix'
|
||||
? '没有匹配的 Reasonix skill。'
|
||||
: '没有匹配的 Hermes skill。';
|
||||
skillList.innerHTML = '<div class="wolai-page-ai-empty">' + escapeHtml(emptyText) + '</div>';
|
||||
} else {
|
||||
skillList.innerHTML = skills.map(function(skill) {
|
||||
var sourceText = pageAiSkillOriginLabel(skill) + (skill.modified ? ' · modified' : '');
|
||||
var description = String(skill.description || '').trim();
|
||||
var hasDescription = description && description !== '---' && description !== '无描述';
|
||||
var canToggle = skill.toggleable !== false && String(pageUiState.pageAiAcpRuntime || '').trim() !== 'reasonix';
|
||||
return '' +
|
||||
'<div class="wolai-page-ai-skill-row">' +
|
||||
'<div class="wolai-page-ai-skill-copy">' +
|
||||
@@ -4997,7 +5016,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
'</div>' +
|
||||
(hasDescription ? '<div class="wolai-page-ai-skill-desc">' + escapeHtml(description) + '</div>' : '') +
|
||||
'</div>' +
|
||||
'<button type="button" class="wolai-page-ai-skill-switch' + (skill.enabled !== false ? ' is-on' : '') + '" data-page-ai-skill-toggle="' + escapeHtml(skill.name) + '" aria-pressed="' + (skill.enabled !== false ? 'true' : 'false') + '">' +
|
||||
'<button type="button" class="wolai-page-ai-skill-switch' + (skill.enabled !== false ? ' is-on' : '') + '" data-page-ai-skill-toggle="' + escapeHtml(skill.name) + '" aria-pressed="' + (skill.enabled !== false ? 'true' : 'false') + '"' + (canToggle ? '' : ' disabled title="Reasonix skills 当前为只读展示"') + '>' +
|
||||
'<span></span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
@@ -5515,7 +5534,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
documentId: currentDocumentId(),
|
||||
sessionId: pageUiState.pageAiActiveSessionId,
|
||||
profile: pageUiState.pageAiAcpRuntime || pageAiCurrentProfile(),
|
||||
profile: pageAiRunProfile(),
|
||||
acpRuntime: pageUiState.pageAiAcpRuntime || '',
|
||||
contextScope: pageUiState.pageAiContextScope,
|
||||
message: prompt,
|
||||
model: pageAiMnoteToolModel(),
|
||||
@@ -6557,7 +6577,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (pageAiAcpRuntimeSelect instanceof HTMLSelectElement) {
|
||||
var next = String(pageAiAcpRuntimeSelect.value || '').trim();
|
||||
pageUiState.pageAiAcpRuntime = next;
|
||||
pageUiState.pageAiSkills = { categories: [], archived: [] };
|
||||
pageUiState.pageAiSkillError = '';
|
||||
void pageAiLoadProfiles();
|
||||
void pageAiLoadSkills();
|
||||
renderPageAiControls();
|
||||
renderPageAiProviderButtons();
|
||||
return;
|
||||
@@ -7326,12 +7349,9 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains(
|
||||
r#".tree-row[data-shell-mode="filetree"][data-row-id="' + escapedDocRowId + '"] > .tree-link > .tree-link-title"#
|
||||
));
|
||||
assert!(SIDEBAR_TREE_JS.contains(
|
||||
r#"[data-page-title-input="true"][data-document-id="' + escaped + '"]"#
|
||||
));
|
||||
assert!(SIDEBAR_TREE_JS.contains(
|
||||
r#".wolai-breadcrumb-current [data-page-title-current]"#
|
||||
));
|
||||
assert!(SIDEBAR_TREE_JS
|
||||
.contains(r#"[data-page-title-input="true"][data-document-id="' + escaped + '"]"#));
|
||||
assert!(SIDEBAR_TREE_JS.contains(r#".wolai-breadcrumb-current [data-page-title-current]"#));
|
||||
assert!(!SIDEBAR_TREE_JS.contains(
|
||||
r#".tree-row[data-document-id="' + escaped + '"] > .tree-link > .tree-link-title"#
|
||||
));
|
||||
|
||||
@@ -1088,15 +1088,15 @@ mod tests {
|
||||
idempotency_key: None,
|
||||
source: json!({}),
|
||||
payload_json: "{}".into(),
|
||||
args_json: json!({
|
||||
"id": "doc_1",
|
||||
"streamDeltaHint": {"family": "tree", "kind": "remove_document"},
|
||||
"domainEventHint": {"eventType": "tree.node.archived"},
|
||||
"domainEventPlan": {"eventType": "tree.node.archived"},
|
||||
"domainEventPlans": [{"eventType": "tree.node.archived"}],
|
||||
"commandProtocol": {"family": "tree"},
|
||||
}),
|
||||
};
|
||||
args_json: json!({
|
||||
"id": "doc_1",
|
||||
"streamDeltaHint": {"family": "tree", "kind": "remove_document"},
|
||||
"domainEventHint": {"eventType": "tree.node.archived"},
|
||||
"domainEventPlan": {"eventType": "tree.node.archived"},
|
||||
"domainEventPlans": [{"eventType": "tree.node.archived"}],
|
||||
"commandProtocol": {"family": "tree"},
|
||||
}),
|
||||
};
|
||||
|
||||
let args = convex_command_args_for_plan(&plan);
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"rustc_fingerprint":10059341515723286937,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.95.0 (59807616e 2026-04-14)\nbinary: rustc\ncommit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860\ncommit-date: 2026-04-14\nhost: x86_64-unknown-linux-gnu\nrelease: 1.95.0\nLLVM version: 22.1.2\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
|
||||
{"rustc_fingerprint":10059341515723286937,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.95.0 (59807616e 2026-04-14)\nbinary: rustc\ncommit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860\ncommit-date: 2026-04-14\nhost: x86_64-unknown-linux-gnu\nrelease: 1.95.0\nLLVM version: 22.1.2\n","stderr":""}},"successes":{}}
|
||||
@@ -27,16 +27,31 @@ import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const MNOTE_WEB_URL = process.env.MNOTE_WEB_URL || 'http://127.0.0.1:3000';
|
||||
const DEBUG = process.env.MNOTE_REASONIX_ACP_DEBUG === '1';
|
||||
|
||||
// Load DeepSeek API key — same logic as Reasonix's loadApiKey():
|
||||
// 1. DEEPSEEK_API_KEY env var
|
||||
// 2. ~/.reasonix/config.yaml (yaml: api_key or apiKey)
|
||||
function debugLog(message) {
|
||||
if (DEBUG) process.stderr.write(`${message}\n`);
|
||||
}
|
||||
|
||||
// 读取 DeepSeek API key:先环境变量,再 Reasonix 官方 JSON 配置,最后兼容旧 YAML。
|
||||
function loadApiKey() {
|
||||
if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY;
|
||||
const jsonConfigPath = join(homedir(), '.reasonix', 'config.json');
|
||||
if (existsSync(jsonConfigPath)) {
|
||||
try {
|
||||
const raw = readFileSync(jsonConfigPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed?.apiKey === 'string' && parsed.apiKey.trim()) {
|
||||
return parsed.apiKey.trim();
|
||||
}
|
||||
} catch {
|
||||
// 配置损坏时继续尝试旧 YAML,不让启动阶段吞掉更明确的错误。
|
||||
}
|
||||
}
|
||||
const configPath = join(homedir(), '.reasonix', 'config.yaml');
|
||||
if (existsSync(configPath)) {
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
// Simple YAML key extraction (no yaml parser dependency needed)
|
||||
// 简单提取 YAML key,避免为 wrapper 额外引入 yaml parser。
|
||||
const match = raw.match(/^\s*(?:api_key|apiKey)\s*:\s*['"]?(.+?)['"]?\s*$/m);
|
||||
if (match) return match[1].trim();
|
||||
}
|
||||
@@ -46,7 +61,7 @@ function loadApiKey() {
|
||||
const DEEPSEEK_API_KEY = loadApiKey();
|
||||
if (!DEEPSEEK_API_KEY) {
|
||||
process.stderr.write('FATAL: DEEPSEEK_API_KEY is required\n');
|
||||
process.stderr.write('Set env var DEEPSEEK_API_KEY or put api_key in ~/.reasonix/config.yaml\n');
|
||||
process.stderr.write('Set env var DEEPSEEK_API_KEY, or put apiKey in ~/.reasonix/config.json\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -207,6 +222,11 @@ const MNOTE_TOOL_NAMES = [
|
||||
'mnote.page.*',
|
||||
];
|
||||
|
||||
const REASONIX_TOOL_TO_MNOTE_TOOL = {
|
||||
mnote_doc_fetch: 'mnote.doc.fetch',
|
||||
mnote_doc_markdown_edit: 'mnote.doc.markdown_edit',
|
||||
};
|
||||
|
||||
async function callMnoteTool(toolName, args) {
|
||||
const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`;
|
||||
const response = await fetch(url, {
|
||||
@@ -230,8 +250,8 @@ async function callMnoteTool(toolName, args) {
|
||||
const tools = new ToolRegistry();
|
||||
|
||||
tools.register({
|
||||
name: 'mnote.doc.fetch',
|
||||
description: '读取当前文档的 markdown 内容。返回文档标题和正文。',
|
||||
name: 'mnote_doc_fetch',
|
||||
description: '读取当前 mnote 文档的 markdown 内容。返回文档标题和正文。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -239,12 +259,13 @@ tools.register({
|
||||
format: { type: 'string', enum: ['markdown', 'blocks'], description: '返回格式' },
|
||||
},
|
||||
},
|
||||
call: async (args) => callMnoteTool('mnote.doc.fetch', args),
|
||||
readOnly: true,
|
||||
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_doc_fetch, args),
|
||||
parallelSafe: false,
|
||||
});
|
||||
|
||||
tools.register({
|
||||
name: 'mnote.doc.markdown_edit',
|
||||
name: 'mnote_doc_markdown_edit',
|
||||
description: '对当前文档进行 markdown 文本级搜索替换编辑。支持多次搜索替换操作。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
@@ -265,7 +286,7 @@ tools.register({
|
||||
},
|
||||
required: ['documentId', 'operations'],
|
||||
},
|
||||
call: async (args) => callMnoteTool('mnote.doc.markdown_edit', args),
|
||||
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_doc_markdown_edit, args),
|
||||
parallelSafe: false,
|
||||
});
|
||||
|
||||
@@ -280,7 +301,7 @@ onRequest('initialize', (params) => {
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: {
|
||||
loadSession: false,
|
||||
promptCapabilities: { image: false, audio: false, embeddedContext: false },
|
||||
promptCapabilities: { image: false, audio: false, embeddedContext: true },
|
||||
mcpCapabilities: { http: false, sse: false },
|
||||
},
|
||||
agentInfo: { name: 'reasonix-mnote', title: 'Reasonix MNote Agent', version: '0.1.0' },
|
||||
@@ -297,9 +318,9 @@ onRequest('session/new', async (params) => {
|
||||
|
||||
const systemPrompt = [
|
||||
'You are a helpful AI assistant for editing Markdown documents.',
|
||||
'Use mnote.doc.fetch to read the current document.',
|
||||
'Use mnote.doc.markdown_edit to apply precise search/replace edits.',
|
||||
'Always use mnote.doc.fetch first to understand the document content before editing.',
|
||||
'Use mnote_doc_fetch to read the current document.',
|
||||
'Use mnote_doc_markdown_edit to apply precise search/replace edits.',
|
||||
'Always use mnote_doc_fetch first to understand the document content before editing.',
|
||||
].join('\n');
|
||||
|
||||
const loop = new CacheFirstLoop({
|
||||
@@ -337,8 +358,32 @@ onRequest('session/prompt', async (params) => {
|
||||
|
||||
session.aborter = new AbortController();
|
||||
let stopReason = 'end_turn';
|
||||
let hasModelOutput = false;
|
||||
let hasAssistantOutput = false;
|
||||
let hasToolCall = false;
|
||||
let nextToolCallSeq = 1;
|
||||
const announcedToolKeys = new Set();
|
||||
const preparingToolCallIds = [];
|
||||
const inflightToolCallIds = [];
|
||||
|
||||
function nextToolCallId() {
|
||||
return `tc_${nextToolCallSeq++}`;
|
||||
}
|
||||
|
||||
function toolKeyFor(ev) {
|
||||
if (ev.toolCallIndex !== undefined && ev.toolCallIndex !== null) {
|
||||
return `${ev.turn ?? 0}:${ev.toolCallIndex}`;
|
||||
}
|
||||
return `${ev.turn ?? 0}:${ev.toolName || 'tool'}:${nextToolCallSeq}`;
|
||||
}
|
||||
|
||||
function parseToolArgs(raw) {
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const ev of session.loop.step(text)) {
|
||||
@@ -347,68 +392,105 @@ onRequest('session/prompt', async (params) => {
|
||||
break;
|
||||
}
|
||||
|
||||
switch (ev.type) {
|
||||
case 'model_delta': {
|
||||
hasModelOutput = true;
|
||||
if (ev.text) emitTextDelta(session.id, ev.text);
|
||||
debugLog(`[reasonix-acp] loop event role=${ev.role}`);
|
||||
|
||||
// Reasonix 的 LoopEvent 使用 role 字段;这里按官方 Eventizer 的核心语义映射到 ACP。
|
||||
switch (ev.role) {
|
||||
case 'assistant_delta': {
|
||||
if (ev.content) {
|
||||
hasAssistantOutput = true;
|
||||
emitTextDelta(session.id, ev.content);
|
||||
}
|
||||
if (ev.reasoningDelta) {
|
||||
emitThoughtDelta(session.id, ev.reasoningDelta);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'model_thinking': {
|
||||
hasModelOutput = true;
|
||||
if (ev.text) emitThoughtDelta(session.id, ev.text);
|
||||
case 'assistant_final': {
|
||||
if (ev.content && !hasAssistantOutput) {
|
||||
hasAssistantOutput = true;
|
||||
emitTextDelta(session.id, ev.content);
|
||||
} else if (ev.content) {
|
||||
hasAssistantOutput = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'done': {
|
||||
if (ev.content && !hasAssistantOutput) {
|
||||
hasAssistantOutput = true;
|
||||
emitTextDelta(session.id, ev.content);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool_call_delta': {
|
||||
hasToolCall = true;
|
||||
const call = ev.toolCall || {};
|
||||
const key = toolKeyFor(ev);
|
||||
if (announcedToolKeys.has(key)) break;
|
||||
announcedToolKeys.add(key);
|
||||
const toolCallId = nextToolCallId();
|
||||
preparingToolCallIds.push(toolCallId);
|
||||
emitToolCall(
|
||||
session.id,
|
||||
call.id || `tc_${Date.now()}`,
|
||||
call.name || ev.name || 'tool',
|
||||
toolCallId,
|
||||
ev.toolName || 'tool',
|
||||
'other',
|
||||
'pending',
|
||||
call.args || ev.args,
|
||||
undefined,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'tool_start': {
|
||||
const call = ev.toolCall || {};
|
||||
hasToolCall = true;
|
||||
const toolCallId = preparingToolCallIds.shift() || ev.callId || nextToolCallId();
|
||||
inflightToolCallIds.push(toolCallId);
|
||||
emitToolCall(
|
||||
session.id,
|
||||
call.id || `tc_${Date.now()}`,
|
||||
call.name || ev.name || 'tool',
|
||||
toolCallId,
|
||||
ev.toolName || 'tool',
|
||||
'other',
|
||||
'in_progress',
|
||||
call.args || ev.args,
|
||||
parseToolArgs(ev.toolArgs),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'tool_result': {
|
||||
const resultText = typeof ev.result === 'string'
|
||||
? ev.result.slice(0, 8000)
|
||||
: JSON.stringify(ev.result).slice(0, 8000);
|
||||
case 'tool': {
|
||||
hasToolCall = true;
|
||||
const resultText = String(ev.content || '').slice(0, 8000);
|
||||
emitToolResult(
|
||||
session.id,
|
||||
ev.toolCallId || `tc_${Date.now()}`,
|
||||
ev.error ? 'failed' : 'completed',
|
||||
inflightToolCallIds.shift() || ev.callId || nextToolCallId(),
|
||||
resultText.includes('"error"') ? 'failed' : 'completed',
|
||||
resultText,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'usage': {
|
||||
emitUsage(session.id, ev.inputTokens || 0, ev.cacheHitTokens || 0);
|
||||
case 'error': {
|
||||
const message = ev.error || ev.content || 'Reasonix loop error';
|
||||
emitTextDelta(session.id, `\n\n[error] ${message}`);
|
||||
stopReason = 'error';
|
||||
break;
|
||||
}
|
||||
case 'warning':
|
||||
case 'status': {
|
||||
if (ev.content) emitThoughtDelta(session.id, ev.content);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Usage info from stats
|
||||
if (ev.stats) {
|
||||
emitUsage(session.id, ev.stats.inputTokens || 0, ev.stats.cacheHitTokens || 0);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err.message || String(err);
|
||||
const stack = err.stack || '';
|
||||
process.stderr.write(`[reasonix-acp] prompt error: ${message}\n${stack}\n`);
|
||||
emitTextDelta(session.id, `\n\n[error] ${message}`);
|
||||
stopReason = 'error';
|
||||
} finally {
|
||||
// If no model output or tool calls were produced, it means the LLM backend failed.
|
||||
// This can happen when the API key is invalid, base URL is wrong, or model is unavailable.
|
||||
if (!hasModelOutput && !hasToolCall && stopReason !== 'error') {
|
||||
if (!hasAssistantOutput && !hasToolCall && stopReason !== 'error') {
|
||||
stopReason = 'error';
|
||||
emitTextDelta(session.id, '\n\n[error] AI 模型未返回输出。可能原因:API Key 无效、模型不可用、或网络连接失败。请检查 DEEPSEEK_API_KEY 是否正确设置(以 sk- 开头)。');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user