mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 20:30:00 +08:00
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:
@@ -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 — 真正中断 run(POST /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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user