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:
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',
|
||||
|
||||
Reference in New Issue
Block a user