diff --git a/scripts/dev-api.js b/scripts/dev-api.js index b91856a..773e965 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -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() { diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 3ca1e28..211e883 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -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 { + 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::().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 { + 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::().await.unwrap_or(serde_json::json!({ "ok": true }))) +} + #[tauri::command] pub async fn hermes_agent_run( app: tauri::AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8762c49..b804f3e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/engines/hermes/lib/chat-store.js b/src/engines/hermes/lib/chat-store.js index 83d750e..dac8d3c 100644 --- a/src/engines/hermes/lib/chat-store.js +++ b/src/engines/hermes/lib/chat-store.js @@ -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, diff --git a/src/engines/hermes/pages/chat.js b/src/engines/hermes/pages/chat.js index 946598e..fdd108f 100644 --- a/src/engines/hermes/pages/chat.js +++ b/src/engines/hermes/pages/chat.js @@ -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 diff --git a/src/engines/hermes/style/hermes.css b/src/engines/hermes/style/hermes.css index 8ea93ff..410ec06 100644 --- a/src/engines/hermes/style/hermes.css +++ b/src/engines/hermes/style/hermes.css @@ -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; diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 4531577..b472e1b 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -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), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 54505fd..2e0f3fe 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -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 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',