feat(hermes): Batch 1 §C+§D+§C-bis - Approval Flow + Stop 真中断 + 3 类新事件

校对稿(hermes-source-verified)基于真实 Hermes 源码确认了关键事实:
- 真实 SSE 事件 9 类(设计稿推测的 6 个根本不存在)
- 真实 abort 端点:POST /v1/runs/{run_id}/stop(用 run_id 不是 session_id)
- Approval Flow 是 Hermes 重要原生特性,ClawPanel 0% 接入 → 跑代码工具就崩

本 PR 一次解决 3 个必修硬伤:

## 修复 1: Stop 假停(§D)
- 新 Tauri 命令 hermes_run_stop(run_id) → POST /v1/runs/{run_id}/stop
- chat-store: state 新增 currentRunId,来自 hermes-run-started 事件
- stopStreaming() 改为:先调 hermes_run_stop(currentRunId) 通知后端,再 abort 本地 SSE
- 之前 stopStreaming 只 abort 本地 SSE,后端继续跑完 → 是「假停」

## 修复 2: Approval Flow 接入(§C-bis 新增 — 设计稿原本没写)
- 新 Tauri 命令 hermes_run_approval(run_id, choice) → POST /v1/runs/{run_id}/approval
- choice 枚举校验:once / session / always / deny
- chat-store: state.pendingApproval 存待批准信息(tool, args, choices, run_id)
- chat.js: 新增 renderApprovalPanel() 渲染琥珀色气泡 + 4 按钮 + JSON args 预览
- store.respondApproval(choice) 暴露给 UI(乐观清状态 + 失败回滚)
- 用户跑代码工具(terminal/code_execution)时会触发,没接入就会卡死

## 修复 3: SSE 事件白名单补 3 类(§C 校对版)
- normalize_hermes_stream_event 白名单增加:
  · approval.request   → emit hermes-run-approval-request
  · approval.responded → emit hermes-run-approval-responded
  · run.cancelled      → emit hermes-run-cancelled(终态,返回 Ok(true))
- chat-store 新监听 u5..u9(5 个新事件):
  · hermes-run-started      → 存 currentRunId
  · approval-request        → 设 pendingApproval
  · approval-responded      → 清 pendingApproval
  · cancelled               → 标记 (stopped) + cleanup
  · reasoning               → 标记 hasReasoning(设计稿推测的 reasoning.delta 不存在)
- handleStreamEvent(Web 模式)同步加 4 个新分支

## chat.js UI
- renderApprovalPanel:琥珀色边框 + 🔐 emoji + 工具名 + JSON args 预览 + 4 选项按钮
- "deny" 用 btn-secondary(灰色),其他 btn-primary(蓝色)
- 按钮点击 → store.respondApproval(choice) → 后端 POST + 等服务端 approval.responded 作权威清理
- streaming 中显示 aborting 文案当 state.aborting=true

## CSS (.hm-chat-approval*)
- 琥珀色边框 + 半透明背景(明/暗主题各自适配)
- args 单独 monospace 代码块、max-height: 180px 防过长
- 响应式:max-width: 720px,按钮 flex-wrap

## i18n
- engine.chatAborting / chatApprovalTitle / chatApprovalHint
- chatApprovalOnce / chatApprovalSession / chatApprovalAlways / chatApprovalDeny
- chatApprovalFailed
- 3 语言(zh-CN/en/zh-TW),其它语言走 fallback

## 设计稿对照(保留可信细节,砍掉推测)
-  留:approval.request / approval.responded / run.cancelled / reasoning.available(4 类真实事件)
-  砍:reasoning.delta / thinking.delta / compression.* / abort.* / usage.updated / run.queued(6 类不存在的事件,hermes-web-ui 内部合成)
-  修:abort 端点路径 /v1/runs/{run_id}/stop(用 run_id)

## 累计
- Rust: 1 个 helper(read_hermes_api_key) + 2 个新命令 + emit_hermes_stream_event 加 3 分支
- 前端: chat-store 新 4 个 state 字段 + 5 个监听器 + 4 个 handleStreamEvent 分支 + respondApproval API
- chat.js: renderApprovalPanel + 按钮绑定 + aborting 文案
- i18n: +8 个键 × 3 语言
- cargo check ✓ + npm build ✓
This commit is contained in:
晴天
2026-05-14 04:48:14 +08:00
parent c00b2dbf64
commit efade55f61
8 changed files with 357 additions and 3 deletions

View File

@@ -3400,8 +3400,9 @@ fn normalize_hermes_stream_event(
.map(|s| Value::String(s.to_string()))
.unwrap_or(Value::Null);
match event_type {
"message.delta" | "run.completed" | "run.failed" | "tool.started" | "tool.completed"
| "tool.progress" | "tool.error" => {
"message.delta" | "run.completed" | "run.failed" | "run.cancelled"
| "tool.started" | "tool.completed" | "tool.progress" | "tool.error"
| "reasoning.available" | "approval.request" | "approval.responded" => {
let mut out = evt.clone();
if out.get("run_id").is_none() {
out["run_id"] = Value::String(run_id.to_string());
@@ -3531,6 +3532,18 @@ fn emit_hermes_stream_event(
"reasoning.available" => {
let _ = app.emit("hermes-run-reasoning", evt.clone());
}
// Batch 1 §C 新增Approval Flow 4 类真实事件(已用源码 api_server.py 确认)
"approval.request" => {
let _ = app.emit("hermes-run-approval-request", evt.clone());
}
"approval.responded" => {
let _ = app.emit("hermes-run-approval-responded", evt.clone());
}
"run.cancelled" => {
let _ = app.emit("hermes-run-cancelled", evt.clone());
// 中断也是终态 — 让流循环可以 return Ok(true) 结束读
return Ok(true);
}
"run.completed" => {
if let Some(output) = evt["output"].as_str() {
if !output.is_empty() {
@@ -3676,6 +3689,100 @@ async fn try_hermes_responses_run(
Ok(Some(run_id))
}
/// 读取 Hermes API_SERVER_KEY从 ~/.hermes/.env与 hermes_agent_run 共用。
fn read_hermes_api_key() -> String {
let env_path = hermes_home().join(".env");
let mut key = String::new();
if let Ok(content) = std::fs::read_to_string(&env_path) {
for line in content.lines() {
let line = line.trim();
if let Some(val) = line.strip_prefix("API_SERVER_KEY=") {
key = val.trim().to_string();
break;
}
}
}
key
}
// ---------------------------------------------------------------------------
// Batch 1 §D: hermes_run_stop — 真正中断 runPOST /v1/runs/{run_id}/stop
//
// 原本 chat-store 的 stopStreaming() 只 abort 本地 SSE后端 agent 继续跑完
// 「Stop 假停」问题:从 hermes 源码确认真实端点是 /v1/runs/{run_id}/stop用 run_id 不是 session_id
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn hermes_run_stop(run_id: String) -> Result<Value, String> {
if run_id.is_empty() {
return Err("run_id 不能为空".to_string());
}
let gw_url = hermes_gateway_url();
let url = format!("{gw_url}/v1/runs/{run_id}/stop");
let api_key = read_hermes_api_key();
let client = hermes_gateway_http_client(std::time::Duration::from_secs(5))
.map_err(|e| format!("HTTP 客户端创建失败: {e}"))?;
let mut req = client.post(&url);
if !api_key.is_empty() {
req = req.header("Authorization", format!("Bearer {api_key}"));
}
let resp = req
.send()
.await
.map_err(|e| format!("stop 请求失败: {}", reqwest_error_detail(&e)))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("stop 失败 HTTP {}: {}", status.as_u16(), body));
}
Ok(resp.json::<Value>().await.unwrap_or(serde_json::json!({ "ok": true })))
}
// ---------------------------------------------------------------------------
// Batch 1 §C-bis: hermes_run_approval — 批准/拒绝 Hermes 内核的工具调用
//
// Hermes 跑高危工具terminal / code_execution默认是 ask once 模式,
// 触发 approval.request SSE 事件,前端要弹给用户 4 个选项:
// - "once" 一次性批准(默认)
// - "session" 本 session 内都批准
// - "always" 全局总是批准(极少用)
// - "deny" 拒绝run 会被 cancelled
//
// 端点POST /v1/runs/{run_id}/approval { choice }
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn hermes_run_approval(run_id: String, choice: String) -> Result<Value, String> {
if run_id.is_empty() {
return Err("run_id 不能为空".to_string());
}
let normalized_choice = match choice.as_str() {
"once" | "session" | "always" | "deny" => choice,
other => return Err(format!("approval choice 必须是 once/session/always/deny收到 {other}")),
};
let gw_url = hermes_gateway_url();
let url = format!("{gw_url}/v1/runs/{run_id}/approval");
let api_key = read_hermes_api_key();
let client = hermes_gateway_http_client(std::time::Duration::from_secs(5))
.map_err(|e| format!("HTTP 客户端创建失败: {e}"))?;
let mut req = client
.post(&url)
.json(&serde_json::json!({ "choice": normalized_choice }));
if !api_key.is_empty() {
req = req.header("Authorization", format!("Bearer {api_key}"));
}
let resp = req
.send()
.await
.map_err(|e| format!("approval 请求失败: {}", reqwest_error_detail(&e)))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("approval 失败 HTTP {}: {}", status.as_u16(), body));
}
Ok(resp.json::<Value>().await.unwrap_or(serde_json::json!({ "ok": true })))
}
#[tauri::command]
pub async fn hermes_agent_run(
app: tauri::AppHandle,

View File

@@ -239,6 +239,8 @@ pub fn run() {
hermes::hermes_capabilities,
hermes::hermes_api_proxy,
hermes::hermes_agent_run,
hermes::hermes_run_stop,
hermes::hermes_run_approval,
hermes::hermes_read_config,
hermes::hermes_read_config_full,
hermes::hermes_lazy_deps_features,