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

@@ -7194,6 +7194,44 @@ const handlers = {
return { ok: false, error: `Web 模式下无法预装依赖。请在桌面端 ClawPanel 完成 ${feature} 安装。` }
},
// Batch 1 §D: 真正中断 — POST /v1/runs/{run_id}/stop
async hermes_run_stop({ runId } = {}) {
if (!runId) throw new Error('run_id 不能为空')
const url = `${hermesGatewayUrl()}/v1/runs/${encodeURIComponent(runId)}/stop`
const apiKey = _readHermesApiServerKey()
const headers = { 'User-Agent': 'ClawPanel-Web' }
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
const resp = await globalThis.fetch(url, { method: 'POST', headers, signal: AbortSignal.timeout(5000) })
if (!resp.ok) {
const body = await resp.text().catch(() => '')
throw new Error(`stop 失败 HTTP ${resp.status}: ${body}`)
}
return await resp.json().catch(() => ({ ok: true }))
},
// Batch 1 §C-bis: Approval Flow — POST /v1/runs/{run_id}/approval { choice }
async hermes_run_approval({ runId, choice } = {}) {
if (!runId) throw new Error('run_id 不能为空')
if (!['once', 'session', 'always', 'deny'].includes(choice)) {
throw new Error(`approval choice 必须是 once/session/always/deny收到 ${choice}`)
}
const url = `${hermesGatewayUrl()}/v1/runs/${encodeURIComponent(runId)}/approval`
const apiKey = _readHermesApiServerKey()
const headers = { 'User-Agent': 'ClawPanel-Web', 'Content-Type': 'application/json' }
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
const resp = await globalThis.fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ choice }),
signal: AbortSignal.timeout(5000),
})
if (!resp.ok) {
const body = await resp.text().catch(() => '')
throw new Error(`approval 失败 HTTP ${resp.status}: ${body}`)
}
return await resp.json().catch(() => ({ ok: true }))
},
// P1-4完整解析 config.yaml让前端能读 14+ 高价值字段
// Web 模式不引入 yaml 依赖,简单返回 raw + null highlights前端按需渲染
hermes_read_config_full() {

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,

View File

@@ -228,6 +228,12 @@ function createStore() {
// Live tool calls for the current run (shown in the streaming indicator).
liveTools: [], // [{ id, name, status, preview, args, result }]
// Batch 1 §C/§D/§C-bis: run-level 状态
currentRunId: null, // 当前 run 的 id来自 hermes-run-started 事件)
pendingApproval: null, // 待批准工具调用 { tool, args, request_id, choices, run_id }
hasReasoning: false, // 本轮有推理链可读reasoning.available 触发)
aborting: false, // 用户点 Stop 后等 run.cancelled 期间
// UI prefs (persisted).
pinned: new Set(loadJson(STORAGE_PINNED_PREFIX + profileKey(safeGet(STORAGE_PROFILE) || 'default')) || []),
collapsed: new Set(loadJson(STORAGE_COLLAPSED_PREFIX + profileKey(safeGet(STORAGE_PROFILE) || 'default')) || []),
@@ -736,7 +742,60 @@ function createStore() {
}
cleanupAfterRun()
})
unlisteners.push(u1, u2, u3, u4)
// Batch 1 §C/§D/§C-bis: 监听新事件
const u5 = await safeTauriListen('hermes-run-started', (e) => {
const rid = e?.payload?.run_id
if (rid) state.currentRunId = rid
notify()
})
const u6 = await safeTauriListen('hermes-run-approval-request', (e) => {
const evt = e?.payload || {}
state.pendingApproval = {
runId: evt.run_id || state.currentRunId,
tool: evt.tool || evt.tool_name || 'tool',
args: evt.args || evt.arguments || evt.parameters || evt.input || null,
requestId: evt.request_id || evt.id || null,
choices: Array.isArray(evt.choices) && evt.choices.length ? evt.choices : ['once', 'session', 'always', 'deny'],
rawEvent: evt,
}
notify()
})
const u7 = await safeTauriListen('hermes-run-approval-responded', () => {
state.pendingApproval = null
notify()
})
const u8 = await safeTauriListen('hermes-run-cancelled', () => {
// 用户主动中断或服务端取消 — 把 pending assistant 标记为 (stopped) 并清状态
const s = runSession()
if (s) {
const msg = s.messages.find(m => m.id === state.pendingAssistantId)
if (msg) {
delete msg.isStreaming
if (!msg.content.trim()) msg.content = '_(stopped)_'
else if (!msg.content.endsWith('(stopped)')) msg.content = msg.content.trimEnd() + ' _(stopped)_'
}
// 提交已知工具调用
for (const t of state.liveTools) {
if (t.status === 'done' || t.status === 'error') {
s.messages.push({
id: uid(), role: 'tool', content: '', timestamp: Date.now(),
toolName: t.name, toolPreview: t.preview || undefined,
toolArgs: stringifyMaybe(t.args), toolResult: stringifyMaybe(t.result),
toolStatus: t.error ? 'error' : 'done',
})
}
}
s.updatedAt = Date.now()
persistSessionMessages(s.id)
persistSessions()
}
cleanupAfterRun()
})
const u9 = await safeTauriListen('hermes-run-reasoning', () => {
state.hasReasoning = true
notify()
})
unlisteners.push(u1, u2, u3, u4, u5, u6, u7, u8, u9)
}
function detachStreamListeners() {
@@ -868,6 +927,40 @@ function createStore() {
} else if (eventType === 'run.failed') {
failStreamRun(runSessionId, evt.error || 'unknown error')
}
// Batch 1 §C/§D/§C-bis: Web 模式同步处理新事件
else if (eventType === 'run.cancelled') {
const s = state.sessions.find(x => x.id === runSessionId)
if (s) {
const msg = s.messages.find(m => m.id === state.pendingAssistantId)
if (msg) {
delete msg.isStreaming
if (!msg.content.trim()) msg.content = '_(stopped)_'
else if (!msg.content.endsWith('(stopped)')) msg.content = msg.content.trimEnd() + ' _(stopped)_'
}
s.updatedAt = Date.now()
persistSessionMessages(s.id)
}
cleanupAfterRun()
}
else if (eventType === 'approval.request') {
state.pendingApproval = {
runId: evt.run_id || state.currentRunId,
tool: evt.tool || evt.tool_name || 'tool',
args: evt.args || evt.arguments || evt.parameters || evt.input || null,
requestId: evt.request_id || evt.id || null,
choices: Array.isArray(evt.choices) && evt.choices.length ? evt.choices : ['once', 'session', 'always', 'deny'],
rawEvent: evt,
}
notify()
}
else if (eventType === 'approval.responded') {
state.pendingApproval = null
notify()
}
else if (eventType === 'reasoning.available') {
state.hasReasoning = true
notify()
}
}
function cleanupAfterRun() {
@@ -875,6 +968,11 @@ function createStore() {
state.runningSessionId = null
state.pendingAssistantId = null
state.liveTools = []
// Batch 1 §C/§D/§C-bis: 重置 run-level 字段
state.currentRunId = null
state.pendingApproval = null
state.aborting = false
// hasReasoning 保留到下次 run 开始(让用户看完上一轮思考链)
streamAbortController = null
detachStreamListeners()
notify()
@@ -902,6 +1000,14 @@ function createStore() {
*/
function stopStreaming() {
if (!state.streaming) return
state.aborting = true
notify()
// Batch 1 §D: 走真实端点中断(用 run_id 不是 session_id
if (state.currentRunId) {
api.hermesRunStop(state.currentRunId).catch(err => {
console.warn('[chat-store] hermes_run_stop failed:', err)
})
}
if (streamAbortController) {
try { streamAbortController.abort() } catch {}
}
@@ -1090,6 +1196,22 @@ function createStore() {
toggleCollapsed,
sendMessage,
stopStreaming,
// Batch 1 §C-bis: Approval Flow — 让 UI 调用回复批准
respondApproval(choice) {
const pending = state.pendingApproval
if (!pending || !pending.runId) return Promise.reject(new Error('no pending approval'))
const normalized = ['once', 'session', 'always', 'deny'].includes(choice) ? choice : 'once'
// 乐观清掉 — 等服务端 approval.responded 事件作权威清理
state.pendingApproval = null
notify()
return api.hermesRunApproval(pending.runId, normalized).catch(err => {
console.warn('[chat-store] hermes_run_approval failed:', err)
// 失败时恢复
state.pendingApproval = pending
notify()
throw err
})
},
pushLocalAssistant,
pushLocalUser,
clearActive,

View File

@@ -1120,6 +1120,20 @@ export function render() {
toast(t('engine.chatStopped'), 'success')
})
// Batch 1 §C-bis: Approval Flow 按钮点击
el.querySelectorAll('.hm-chat-approval-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const choice = btn.dataset.approvalChoice
if (!choice) return
btn.disabled = true
try {
await store.respondApproval(choice)
} catch (err) {
toast(t('engine.chatApprovalFailed'), 'error')
}
})
})
el.querySelectorAll('.hm-chat-slash-item').forEach(item => {
item.addEventListener('click', () => {
const cmd = item.dataset.cmd

View File

@@ -5039,6 +5039,65 @@ body[data-active-engine="hermes"][data-theme="dark"] {
border-radius: 50%;
animation: hm-chat-live-pulse 1.6s ease-in-out infinite;
}
/* ---- Batch 1 §C-bis: Approval Flow 气泡 ---- */
[data-engine="hermes"] .hm-chat-approval {
margin: 12px 0 16px 42px;
padding: 16px;
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.35);
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 720px;
}
[data-engine="hermes"] .hm-chat-approval-head {
display: flex;
align-items: center;
gap: 10px;
}
[data-engine="hermes"] .hm-chat-approval-emoji {
font-size: 22px;
line-height: 1;
}
[data-engine="hermes"] .hm-chat-approval-title {
font-size: 14px;
font-weight: 600;
color: var(--hm-text-primary);
line-height: 1.4;
}
[data-engine="hermes"] .hm-chat-approval-args {
background: rgba(0, 0, 0, 0.06);
border-radius: 8px;
padding: 10px 12px;
font-size: 12px;
font-family: var(--hm-font-mono, monospace);
color: var(--hm-text-secondary);
margin: 0;
max-height: 180px;
overflow: auto;
word-break: break-all;
white-space: pre-wrap;
}
[data-engine="hermes"] .hm-chat-approval-hint {
font-size: 12px;
color: var(--hm-text-tertiary);
line-height: 1.5;
}
[data-engine="hermes"] .hm-chat-approval-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
[data-theme="dark"] [data-engine="hermes"] .hm-chat-approval {
background: rgba(245, 158, 11, 0.10);
border-color: rgba(245, 158, 11, 0.45);
}
[data-theme="dark"] [data-engine="hermes"] .hm-chat-approval-args {
background: rgba(255, 255, 255, 0.04);
}
[data-engine="hermes"] .hm-chat-live-tools {
display: flex;
flex-direction: column;

View File

@@ -475,6 +475,9 @@ export const api = {
hermesApiProxy: (method, path, body, headers) => invoke('hermes_api_proxy', { method, path, body: body || null, headers: headers || null }),
hermesAgentRun: (input, sessionId, conversationHistory, instructions) => invoke('hermes_agent_run', { input, sessionId: sessionId || null, conversationHistory: conversationHistory || null, instructions: instructions || null }),
hermesAgentRunStream: (input, sessionId, conversationHistory, instructions, onEvent, options) => webStreamInvoke('hermes_agent_run_stream', { input, sessionId: sessionId || null, conversationHistory: conversationHistory || null, instructions: instructions || null }, onEvent, options),
// Batch 1 §D + §C-bis: 真正中断 + Approval Flow用 run_id
hermesRunStop: (runId) => invoke('hermes_run_stop', { runId }),
hermesRunApproval: (runId, choice) => invoke('hermes_run_approval', { runId, choice }),
hermesReadConfig: () => invoke('hermes_read_config'),
hermesReadConfigFull: () => invoke('hermes_read_config_full'),
hermesLazyDepsFeatures: () => cachedInvoke('hermes_lazy_deps_features', {}, 600000),

View File

@@ -432,6 +432,15 @@ export default {
// 停止流式
chatStop: _('停止', 'Stop', '停止'),
chatStopped: _('已停止当前回复', 'Run stopped', '已停止目前回覆'),
chatAborting: _('正在中断…', 'Aborting…', '正在中斷…'),
// Batch 1 §C-bis: Approval Flow 工具调用批准
chatApprovalTitle: _('Agent 想调用工具 "{tool}",是否批准?', 'Agent wants to call tool "{tool}". Approve?', 'Agent 想呼叫工具 "{tool}",是否批准?'),
chatApprovalHint: _('「一次性」最安全;「本次会话」让 Agent 自由用;「永久」全局信任此工具。', '"Once" is safest. "Session" lets the agent use it freely this session. "Always" trusts this tool globally.', '「一次性」最安全;「本次會話」讓 Agent 自由用;「永久」全局信任此工具。'),
chatApprovalOnce: _('一次性批准', 'Approve once', '一次性批准'),
chatApprovalSession: _('本次会话', 'This session', '本次會話'),
chatApprovalAlways: _('永久信任', 'Always', '永久信任'),
chatApprovalDeny: _('拒绝', 'Deny', '拒絕'),
chatApprovalFailed: _('批准失败', 'Approval failed', '批准失敗'),
// Web 模式(远程浏览器)下流式聊天暂不可用
chatWebModeStreamingUnsupported: _(
'Web 模式暂不支持 Hermes 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',