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:
lix-2026
2026-05-17 20:11:39 +08:00
parent 2ea559beaa
commit bb2f190f50
19 changed files with 1307 additions and 451 deletions
@@ -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 profileReasonix 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
#### 问题 1ACP Reasonix SSE 流返回空(`stop_reason=Error`
| 项 | 详情 |
|---|---|
| **状态** | ✅ 已修复;后端 SSE 探针与前端真实浏览器验证均通过 |
| **原现象** | `ACP prompt completed: stop_reason=Error`,耗时 ~300msSSE 流无任何 `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/` |
#### 问题 1bACP 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 作为通过条件 |
#### 问题 2Hermes 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 层硬编码端口 |
#### 问题 3ACP 模式 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 9AppState 改造 — 加入 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 12profile 扩展 — 从 upstream URL 改为 runtime 配置
### [x] Step 9AppState 改造 — 加入 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 13Hermes 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` 无 warningdeprecated 函数被自身使用时默认不 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 12profile 扩展 — 从 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 13Hermes 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 通过后执行。测试场景已设计,指标定义明确。
---
+74 -42
View File
@@ -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");
}
}
+39 -19
View File
@@ -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
+64 -26
View File
@@ -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:?}"),
}
}
}
+181 -17
View File
@@ -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(&params).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(),
+13 -8
View File
@@ -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)?;
+28 -12
View File
@@ -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 {
''..='' => ((ch as u32).saturating_sub('' as u32) + 'A' as u32).try_into().unwrap_or(ch),
''..='' => ((ch as u32).saturating_sub('' as u32) + 'a' as u32).try_into().unwrap_or(ch),
''..='' => ((ch as u32).saturating_sub('' as u32) + '0' as u32).try_into().unwrap_or(ch),
''..='' => ((ch as u32).saturating_sub('' as u32) + 'A' as u32)
.try_into()
.unwrap_or(ch),
''..='' => ((ch as u32).saturating_sub('' as u32) + 'a' as u32)
.try_into()
.unwrap_or(ch),
''..='' => ((ch as u32).saturating_sub('' 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(
+1 -1
View File
@@ -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"));
}
+381 -48
View File
@@ -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(&registration.profile) {
let run_id = registration.session_id.clone(); // session_id serves as run_id
let runtime_state = register_acp_runtime(&registration);
if acp_runtime_for_payload(&payload, &registration.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(&registration));
payload["runId"] = Value::String(run_id.clone());
let runtime_state = register_acp_runtime(&registration, &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 对
+11 -13
View File
@@ -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))
}
+33 -13
View File
@@ -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
View File
@@ -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":{}}
+121 -39
View File
@@ -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- 开头)。');
}