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

@@ -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 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',