From 0d6c4614e43b50ca1343b86946a60b199dac1394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Thu, 14 May 2026 05:30:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(hermes):=20Batch=202=20=C2=A7G=20-=20?= =?UTF-8?q?=E5=A4=9A=20Gateway=20=E7=9C=8B=E6=9D=BF=EF=BC=88=E5=90=8C?= =?UTF-8?q?=E6=97=B6=E8=BF=90=E8=A1=8C=E5=A4=9A=20profile=20Gateway?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计:让用户给多个 profile 各自配 Gateway 实例,同时运行。 端口完全由 profile 的 config.yaml model.gateway.port 决定,ClawPanel 只负责 spawn + PID 跟踪。 ## Rust 后端(~350 行) ### 数据结构 - MULTI_GW_PIDS: Mutex>> 全局 PID 表 - 持久化在 panelConfig.hermes.multiGateways: [{name, profile}] ### Helper 函数 - multi_gw_pids_get/set/remove: 线程安全 PID 表读写 - read_multi_gateways_config / write_multi_gateways_config: panel config R/W(保留其他字段) - read_profile_gateway_port(profile): 缩进感知解析 profile config.yaml 的 model.gateway.port - pid_is_alive(pid): Windows 用 tasklist /FI,Unix 用 kill -0 检测 ### 5 个新 Tauri 命令 - hermes_multi_gateway_list() → [{name, profile, port, running, pid, owned}] · running = PID 存活 || TCP 探测可达 · owned = ClawPanel spawn(可控制)vs 外部进程占着端口 - hermes_multi_gateway_add(name, profile) - 写入 panel config,名称合法性检查 - hermes_multi_gateway_remove(name) - 先停掉,再从配置删除 - hermes_multi_gateway_start(name) - spawn `hermes --profile gateway run` · 注入 profile 的 .env · 等待端口可达(8 秒超时) · 记 PID - hermes_multi_gateway_stop(name) - taskkill /F /PID (Windows) 或 kill -TERM (Unix) ### 发射 hermes-multi-gateway-changed 事件(前端可监听刷新) ## 前端(~230 行) ### /h/gateways 页面 - 顶部 + 添加 按钮 - 卡片网格(复用 .lazy-deps-grid)显示每个 Gateway: · name + status badge (运行中/已停止) · profile + port + PID + owned 提示 · 启动 / 停止 / 删除 按钮 - 添加弹窗:name 输入 + profile 下拉(拉自 hermesProfilesList) - 停止 / 删除均有 showConfirm(带 in-flight 警告) - 外部进程占端口时 stop 按钮 disabled + tooltip ### sidebar - 「管理」section 加 Gateways 入口(gateway icon) - /h/gateways 路由注册 ### dev-api.js - Web 模式 fallback: 多 Gateway 不支持本地进程管理(throw friendly error) ### i18n - 26 个新键 × 3 语言(hermesGateway*) ## 累计 - Rust ~350 行 + 前端 ~230 行 + i18n 78 字符串 + 路由/sidebar - cargo check ✓ + npm build ✓ --- scripts/dev-api.js | 7 + src-tauri/src/commands/hermes.rs | 341 +++++++++++++++++++++++++++ src-tauri/src/lib.rs | 5 + src/engines/hermes/index.js | 2 + src/engines/hermes/pages/gateways.js | 231 ++++++++++++++++++ src/lib/tauri-api.js | 6 + src/locales/modules/engine.js | 28 +++ 7 files changed, 620 insertions(+) create mode 100644 src/engines/hermes/pages/gateways.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 8bb4200..19f0714 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -7225,6 +7225,13 @@ const handlers = { return await resp.json().catch(() => ({ ok: true })) }, + // Batch 2 §G: 多 Gateway(Web 模式不支持本地进程管理) + hermes_multi_gateway_list() { return [] }, + hermes_multi_gateway_add() { throw new Error('Web 模式不支持多 Gateway 管理(请使用桌面客户端)') }, + hermes_multi_gateway_remove() { throw new Error('Web 模式不支持多 Gateway 管理') }, + hermes_multi_gateway_start() { throw new Error('Web 模式不支持多 Gateway 管理') }, + hermes_multi_gateway_stop() { throw new Error('Web 模式不支持多 Gateway 管理') }, + // Batch 2 §H 基础设施: 通用 Dashboard 9119 HTTP 代理(含 session token 注入) // _dashboardToken 模块级缓存;401 时刷新重试 async _fetchDashboardToken(port) { diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 5825545..4b44d6a 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -6090,6 +6090,347 @@ pub fn hermes_cron_jobs_list() -> Result { serde_json::from_str::(&raw).map_err(|e| format!("Failed to parse cron jobs: {e}")) } +// ============================================================================ +// Batch 2 §G: 多 Gateway 看板 +// +// 让用户同时运行多个 Hermes Gateway 实例(每个绑不同 profile)。 +// 用 `hermes --profile gateway run` 启动,PID 跟踪在内存里。 +// +// 持久化:~/.openclaw/clawpanel.json 的 hermes.multiGateways 数组 +// [{ name: "main", profile: "default" }, { name: "coder", profile: "coder" }] +// +// 端口:从 profile 的 config.yaml 读 model.gateway.port(每个 profile 独立配置)。 +// +// 状态:TCP 探测每个端口 + 检查 PID 是否仍活着。 +// ============================================================================ + +use std::collections::HashMap; +static MULTI_GW_PIDS: Mutex>> = Mutex::new(None); + +fn multi_gw_pids_get(name: &str) -> Option { + MULTI_GW_PIDS + .lock() + .ok() + .and_then(|guard| guard.as_ref()?.get(name).copied()) +} + +fn multi_gw_pids_set(name: &str, pid: u32) { + if let Ok(mut guard) = MULTI_GW_PIDS.lock() { + guard.get_or_insert_with(HashMap::new).insert(name.to_string(), pid); + } +} + +fn multi_gw_pids_remove(name: &str) { + if let Ok(mut guard) = MULTI_GW_PIDS.lock() { + if let Some(map) = guard.as_mut() { + map.remove(name); + } + } +} + +/// 读取 panel config 的 multiGateways 列表 +fn read_multi_gateways_config() -> Vec { + super::read_panel_config_value() + .and_then(|v| v.get("hermes")?.get("multiGateways").cloned()) + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default() +} + +/// 写入 panel config 的 multiGateways 列表(保留其他字段) +fn write_multi_gateways_config(gateways: Vec) -> Result<(), String> { + let config_path = super::panel_config_path(); + if let Some(parent) = config_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let mut root: serde_json::Map = if config_path.exists() { + let content = std::fs::read_to_string(&config_path) + .map_err(|e| format!("读取 panel 配置失败: {e}"))?; + serde_json::from_str(&content).unwrap_or_default() + } else { + serde_json::Map::new() + }; + // root.hermes.multiGateways = gateways + let mut hermes_obj = root + .get("hermes") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + hermes_obj.insert("multiGateways".into(), Value::Array(gateways)); + root.insert("hermes".into(), Value::Object(hermes_obj)); + let json = serde_json::to_string_pretty(&Value::Object(root)) + .map_err(|e| format!("序列化失败: {e}"))?; + std::fs::write(&config_path, json).map_err(|e| format!("写入失败: {e}"))?; + Ok(()) +} + +/// 读 profile config.yaml 的 model.gateway.port(缩进感知) +fn read_profile_gateway_port(profile: &str) -> u16 { + let home = if profile == "default" { + hermes_home() + } else { + hermes_home().join("profiles").join(profile) + }; + let config_path = home.join("config.yaml"); + let Ok(content) = std::fs::read_to_string(&config_path) else { + return 8642; + }; + // 简单缩进感知解析:model: → gateway: → port: + let mut in_model = false; + let mut in_gateway = false; + for line in content.lines() { + let raw_indent = line.len() - line.trim_start().len(); + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if raw_indent == 0 { + in_model = trimmed.starts_with("model:"); + in_gateway = false; + } else if in_model && raw_indent == 2 { + in_gateway = trimmed.starts_with("gateway:"); + } else if in_model && in_gateway && raw_indent == 4 { + if let Some(p) = trimmed.strip_prefix("port:") { + if let Ok(n) = p.trim().parse::() { + return n; + } + } + } + } + 8642 +} + +/// 检测 PID 是否仍然存活 +fn pid_is_alive(pid: u32) -> bool { + if pid == 0 { + return false; + } + #[cfg(target_os = "windows")] + { + let out = std::process::Command::new("tasklist") + .args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"]) + .creation_flags(CREATE_NO_WINDOW) + .output(); + match out { + Ok(o) => { + let s = String::from_utf8_lossy(&o.stdout); + s.lines().any(|l| l.contains(&pid.to_string())) + } + Err(_) => false, + } + } + #[cfg(not(target_os = "windows"))] + { + // kill -0 signal 0 不杀进程,只检查存在性 + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } +} + +#[tauri::command] +pub async fn hermes_multi_gateway_list() -> Result { + let configs = read_multi_gateways_config(); + let mut result = Vec::new(); + for cfg in configs { + let name = cfg.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let profile = cfg.get("profile").and_then(|v| v.as_str()).unwrap_or("default").to_string(); + if name.is_empty() { + continue; + } + let port = read_profile_gateway_port(&profile); + // PID-based liveness + let pid_opt = multi_gw_pids_get(&name); + let pid_alive = pid_opt.map(pid_is_alive).unwrap_or(false); + // TCP probe(即使 PID 死了,也可能其他进程占着端口) + let addr = format!("127.0.0.1:{port}"); + let tcp_running = addr.parse::().ok() + .and_then(|sa| std::net::TcpStream::connect_timeout(&sa, std::time::Duration::from_millis(300)).ok()) + .is_some(); + result.push(serde_json::json!({ + "name": name, + "profile": profile, + "port": port, + "running": pid_alive || tcp_running, + "pid": pid_opt.unwrap_or(0), + "owned": pid_alive, // 是否是 ClawPanel spawn 的 + })); + } + Ok(Value::Array(result)) +} + +#[tauri::command] +pub async fn hermes_multi_gateway_add( + name: String, + profile: String, +) -> Result { + let name = name.trim().to_string(); + let profile = profile.trim().to_string(); + if name.is_empty() { + return Err("名称不能为空".into()); + } + if profile.is_empty() { + return Err("Profile 不能为空".into()); + } + // 名称合法性检查(同 hermes profile 规则) + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { + return Err("名称只能含字母/数字/下划线/连字符".into()); + } + let mut configs = read_multi_gateways_config(); + if configs.iter().any(|c| c.get("name").and_then(|v| v.as_str()) == Some(&name)) { + return Err(format!("名称 \"{name}\" 已存在")); + } + configs.push(serde_json::json!({ "name": name, "profile": profile })); + write_multi_gateways_config(configs)?; + Ok(serde_json::json!({ "ok": true })) +} + +#[tauri::command] +pub async fn hermes_multi_gateway_remove(name: String) -> Result { + let name = name.trim().to_string(); + if name.is_empty() { + return Err("名称不能为空".into()); + } + // 先停掉(如果在跑) + let _ = hermes_multi_gateway_stop(name.clone()).await; + let configs: Vec = read_multi_gateways_config() + .into_iter() + .filter(|c| c.get("name").and_then(|v| v.as_str()) != Some(&name)) + .collect(); + write_multi_gateways_config(configs)?; + Ok(serde_json::json!({ "ok": true })) +} + +#[tauri::command] +pub async fn hermes_multi_gateway_start( + app: tauri::AppHandle, + name: String, +) -> Result { + let name = name.trim().to_string(); + let configs = read_multi_gateways_config(); + let cfg = configs + .iter() + .find(|c| c.get("name").and_then(|v| v.as_str()) == Some(&name)) + .ok_or_else(|| format!("Gateway \"{name}\" 未配置"))?; + let profile = cfg + .get("profile") + .and_then(|v| v.as_str()) + .unwrap_or("default") + .to_string(); + let port = read_profile_gateway_port(&profile); + + // 已运行? + if let Some(pid) = multi_gw_pids_get(&name) { + if pid_is_alive(pid) { + return Ok(serde_json::json!({ + "started": true, "already_running": true, "pid": pid, "port": port + })); + } + } + let addr = format!("127.0.0.1:{port}"); + if let Ok(sa) = addr.parse::() { + if std::net::TcpStream::connect_timeout(&sa, std::time::Duration::from_millis(300)).is_ok() { + return Err(format!( + "端口 {port} 已被占用(非 ClawPanel spawn 的进程,无法接管。请用 services 页停掉默认 Gateway 后重试)" + )); + } + } + + let enhanced = hermes_enhanced_path(); + let home = hermes_home(); + let log_path = home.join(format!("gateway-{name}-run.log")); + let log_file = std::fs::File::create(&log_path) + .map_err(|e| format!("创建日志文件失败: {e}"))?; + let log_err = log_file + .try_clone() + .map_err(|e| format!("克隆日志句柄失败: {e}"))?; + + let mut cmd = std::process::Command::new("hermes"); + cmd.args(["--profile", &profile, "gateway", "run"]) + .current_dir(&home) + .env("PATH", &enhanced) + .stdin(std::process::Stdio::null()) + .stdout(log_file) + .stderr(log_err); + #[cfg(target_os = "windows")] + cmd.creation_flags(CREATE_NO_WINDOW); + + // 注入 profile 的 .env + let profile_env = if profile == "default" { + home.join(".env") + } else { + home.join("profiles").join(&profile).join(".env") + }; + if let Ok(env_content) = std::fs::read_to_string(&profile_env) { + for line in env_content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((k, v)) = line.split_once('=') { + cmd.env(k.trim(), v.trim()); + } + } + } + + let child = cmd.spawn().map_err(|e| format!("启动失败: {e}"))?; + let pid = child.id(); + std::mem::forget(child); // 不等待进程,由 PID 跟踪 + multi_gw_pids_set(&name, pid); + + let _ = app.emit("hermes-multi-gateway-changed", serde_json::json!({ "name": &name, "action": "started" })); + + // 等端口起来(最多 8 秒) + for _ in 0..40 { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + if let Ok(sa) = addr.parse::() { + if std::net::TcpStream::connect_timeout(&sa, std::time::Duration::from_millis(200)).is_ok() { + return Ok(serde_json::json!({ + "started": true, "pid": pid, "port": port + })); + } + } + } + Ok(serde_json::json!({ + "started": true, "pid": pid, "port": port, "warning": "端口未在 8 秒内可达,可能仍在初始化" + })) +} + +#[tauri::command] +pub async fn hermes_multi_gateway_stop( + name: String, +) -> Result { + let name = name.trim().to_string(); + let pid = multi_gw_pids_get(&name); + if pid.is_none() || !pid_is_alive(pid.unwrap()) { + multi_gw_pids_remove(&name); + return Ok(serde_json::json!({ "stopped": true, "was_running": false })); + } + let pid = pid.unwrap(); + #[cfg(target_os = "windows")] + { + let _ = std::process::Command::new("taskkill") + .args(["/F", "/PID", &pid.to_string()]) + .creation_flags(CREATE_NO_WINDOW) + .output(); + } + #[cfg(not(target_os = "windows"))] + { + let _ = std::process::Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .output(); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + if pid_is_alive(pid) { + let _ = std::process::Command::new("kill") + .args(["-9", &pid.to_string()]) + .output(); + } + } + multi_gw_pids_remove(&name); + Ok(serde_json::json!({ "stopped": true, "was_running": true, "pid": pid })) +} + // ============================================================================ // Unit tests for the pure YAML helpers (no filesystem I/O). // ============================================================================ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2258c31..10d2af3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -244,6 +244,11 @@ pub fn run() { hermes::hermes_run_status, hermes::hermes_session_export, hermes::hermes_dashboard_api_proxy, + hermes::hermes_multi_gateway_list, + hermes::hermes_multi_gateway_add, + hermes::hermes_multi_gateway_remove, + hermes::hermes_multi_gateway_start, + hermes::hermes_multi_gateway_stop, hermes::hermes_read_config, hermes::hermes_read_config_full, hermes::hermes_lazy_deps_features, diff --git a/src/engines/hermes/index.js b/src/engines/hermes/index.js index 087206d..dee5545 100644 --- a/src/engines/hermes/index.js +++ b/src/engines/hermes/index.js @@ -87,6 +87,7 @@ export default { { route: '/h/memory', label: t('sidebar.memory'), icon: 'memory' }, { route: '/h/cron', label: t('sidebar.cron'), icon: 'clock' }, { route: '/h/profiles', label: t('engine.hermesProfilesTitle'), icon: 'agents' }, + { route: '/h/gateways', label: t('engine.hermesGatewaysTitle'), icon: 'gateway' }, { route: '/h/kanban', label: t('engine.hermesKanbanTitle'), icon: 'inbox' }, { route: '/h/oauth', label: t('engine.hermesOAuthTitle'), icon: 'memory' }, { route: '/h/lazy-deps', label: t('hermesLazyDeps.title'), icon: 'package' }, @@ -116,6 +117,7 @@ export default { { path: '/h/memory', loader: () => import('./pages/memory.js') }, { path: '/h/cron', loader: () => import('./pages/cron.js') }, { path: '/h/extensions', loader: () => import('./pages/extensions.js') }, + { path: '/h/gateways', loader: () => import('./pages/gateways.js') }, { path: '/h/profiles', loader: () => import('./pages/profiles.js') }, { path: '/h/kanban', loader: () => import('./pages/kanban.js') }, { path: '/h/lazy-deps', loader: () => import('./pages/lazy-deps.js') }, diff --git a/src/engines/hermes/pages/gateways.js b/src/engines/hermes/pages/gateways.js new file mode 100644 index 0000000..7ef1fbb --- /dev/null +++ b/src/engines/hermes/pages/gateways.js @@ -0,0 +1,231 @@ +/** + * Hermes 多 Gateway 看板(Batch 2 §G) + * + * 让用户同时跑多个 Hermes Gateway 实例(每个绑不同 profile)。 + * 端口完全由 profile 的 config.yaml 决定,ClawPanel 只负责 spawn + PID 跟踪。 + * + * 后端 Tauri 命令: + * - hermesMultiGatewayList() → [{name, profile, port, running, pid, owned}] + * - hermesMultiGatewayAdd(name, profile) + * - hermesMultiGatewayRemove(name) + * - hermesMultiGatewayStart(name) + * - hermesMultiGatewayStop(name) + * + * 持久化在 panelConfig.hermes.multiGateways + */ +import { t } from '../../../lib/i18n.js' +import { api } from '../../../lib/tauri-api.js' +import { toast } from '../../../components/toast.js' +import { showModal, showConfirm } from '../../../components/modal.js' +import { humanizeError } from '../../../lib/humanize-error.js' + +function escHtml(s) { + return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} +function escAttr(s) { return escHtml(s) } + +export function render() { + const el = document.createElement('div') + el.className = 'page' + el.dataset.engine = 'hermes' + + let gateways = [] + let profiles = [] + let loading = true + let error = '' + let busyName = '' // 操作中的 gateway 名 + + function draw() { + el.innerHTML = ` + +
+ ${loading ? `
${escHtml(t('common.loading'))}…
` : ''} + ${error ? `
${escHtml(error)}
` : ''} + ${(!loading && !error && !gateways.length) ? ` +
+
⚙️
+
${escHtml(t('engine.hermesGatewaysEmpty'))}
+
${escHtml(t('engine.hermesGatewaysEmptyHint'))}
+
` : ''} + ${(!loading && gateways.length) ? ` +
+ ${gateways.map(renderCard).join('')} +
` : ''} +
+ ` + bind() + } + + function renderCard(g) { + const isBusy = busyName === g.name + const isOwned = !!g.owned + const isRunning = !!g.running + const stateBadge = isRunning + ? `${escHtml(t('engine.hermesGatewayRunning'))}` + : `${escHtml(t('engine.hermesGatewayStopped'))}` + const ownedHint = isRunning && !isOwned + ? `
${escHtml(t('engine.hermesGatewayForeign'))}
` + : '' + return ` +
+
+
${escHtml(g.name)}
+ ${stateBadge} +
+
Profile: ${escHtml(g.profile)}
+
Port: :${g.port}
+ ${g.pid ? `
PID ${g.pid}
` : ''} + ${ownedHint} +
+ ${isRunning + ? `` + : ``} + +
+
+ ` + } + + function bind() { + el.querySelector('#hm-gws-refresh')?.addEventListener('click', load) + el.querySelector('#hm-gws-add')?.addEventListener('click', onAdd) + el.querySelectorAll('[data-action]').forEach(btn => { + const action = btn.dataset.action + const name = btn.dataset.name + btn.addEventListener('click', () => { + if (action === 'start') onStart(name) + else if (action === 'stop') onStop(name) + else if (action === 'remove') onRemove(name) + }) + }) + } + + async function load() { + loading = true + error = '' + draw() + try { + const [gws, profileList] = await Promise.all([ + api.hermesMultiGatewayList(), + api.hermesProfilesList().catch(() => ({ profiles: [] })), + ]) + gateways = Array.isArray(gws) ? gws : [] + const arr = Array.isArray(profileList) ? profileList : (profileList?.profiles || []) + profiles = arr.map(p => (typeof p === 'string' ? p : (p.name || ''))).filter(Boolean) + if (!profiles.includes('default')) profiles.unshift('default') + } catch (e) { + error = String(e?.message || e) + } finally { + loading = false + draw() + } + } + + function onAdd() { + showModal({ + title: t('engine.hermesGatewayAddTitle'), + fields: [ + { + name: 'name', + label: t('engine.hermesGatewayNameLabel'), + value: '', + placeholder: 'main, coder, ...', + hint: t('engine.hermesGatewayNameHint'), + }, + { + name: 'profile', + label: t('engine.hermesGatewayProfileLabel'), + type: 'select', + options: profiles.map(p => ({ value: p, label: p })), + value: profiles[0] || 'default', + hint: t('engine.hermesGatewayProfileHint'), + }, + ], + onConfirm: async (data) => { + const name = (data.name || '').trim() + const profile = (data.profile || '').trim() + if (!name) { + toast(t('engine.hermesGatewayNameRequired'), 'error') + return + } + try { + await api.hermesMultiGatewayAdd(name, profile) + toast(t('engine.hermesGatewayAdded', { name }), 'success') + await load() + } catch (e) { + toast(humanizeError(e, t('engine.hermesGatewayAddFailed')), 'error') + } + }, + }) + } + + async function onStart(name) { + busyName = name + draw() + try { + const result = await api.hermesMultiGatewayStart(name) + if (result?.warning) { + toast(t('engine.hermesGatewayStartedWarning', { warning: result.warning }), 'warning') + } else { + toast(t('engine.hermesGatewayStarted', { name }), 'success') + } + await load() + } catch (e) { + toast(humanizeError(e, t('engine.hermesGatewayStartFailed')), 'error') + } finally { + busyName = '' + draw() + } + } + + async function onStop(name) { + const ok = await showConfirm({ + message: t('engine.hermesGatewayStopConfirm', { name }), + impact: [t('engine.servicesImpactInflight')], + confirmText: t('engine.dashStopGw'), + variant: 'danger', + }) + if (!ok) return + busyName = name + draw() + try { + await api.hermesMultiGatewayStop(name) + toast(t('engine.hermesGatewayStopped', { name }), 'success') + await load() + } catch (e) { + toast(humanizeError(e, t('engine.hermesGatewayStopFailed')), 'error') + } finally { + busyName = '' + draw() + } + } + + async function onRemove(name) { + const ok = await showConfirm({ + message: t('engine.hermesGatewayRemoveConfirm', { name }), + confirmText: t('engine.hermesGatewayRemove'), + variant: 'danger', + }) + if (!ok) return + try { + await api.hermesMultiGatewayRemove(name) + toast(t('engine.hermesGatewayRemoved', { name }), 'success') + await load() + } catch (e) { + toast(humanizeError(e, t('engine.hermesGatewayRemoveFailed')), 'error') + } + } + + draw() + load() + return el +} diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 7dfabd9..8502095 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -488,6 +488,12 @@ export const api = { body: body == null ? null : (typeof body === 'string' ? body : JSON.stringify(body)), headers: headers || null, }), + // Batch 2 §G: 多 Gateway 看板 + hermesMultiGatewayList: () => invoke('hermes_multi_gateway_list'), + hermesMultiGatewayAdd: (name, profile) => invoke('hermes_multi_gateway_add', { name, profile }), + hermesMultiGatewayRemove: (name) => invoke('hermes_multi_gateway_remove', { name }), + hermesMultiGatewayStart: (name) => invoke('hermes_multi_gateway_start', { name }), + hermesMultiGatewayStop: (name) => invoke('hermes_multi_gateway_stop', { name }), 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 1bce34e..8ac6abe 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -559,6 +559,34 @@ export default { hermesOAuthDisconnectFailed: _('断开失败', 'Disconnect failed', '中斷失敗'), // Hermes UX 小白化:chat profile 切换错误 chatProfileSwitchFailed: _('切换 Profile 失败', 'Switch profile failed', '切換 Profile 失敗'), + // Batch 2 §G: 多 Gateway 看板 + hermesGatewaysTitle: _('多 Gateway 看板', 'Multi Gateway', '多 Gateway 看板'), + hermesGatewaysDesc: _('同时运行多个 Hermes Gateway(每个绑不同 Profile),方便切换工作环境', 'Run multiple Hermes Gateways simultaneously (each bound to its own profile)', '同時執行多個 Hermes Gateway(每個綁不同 Profile),方便切換工作環境'), + hermesGatewaysEmpty: _('尚未配置任何 Gateway', 'No gateways configured', '尚未設定任何 Gateway'), + hermesGatewaysEmptyHint: _('点击「+ 添加」给一个 Profile 配置 Gateway 实例', 'Click "+ Add" to configure a gateway instance for a profile', '點擊「+ 新增」給一個 Profile 設定 Gateway 實例'), + hermesGatewayAdd: _('添加', 'Add', '新增'), + hermesGatewayAddTitle: _('添加 Gateway', 'Add Gateway', '新增 Gateway'), + hermesGatewayNameLabel: _('Gateway 名称', 'Gateway name', 'Gateway 名稱'), + hermesGatewayNameHint: _('用于在面板里识别(不影响 Hermes 内部)', 'Just for panel display (does not affect Hermes internals)', '用於在面板中識別(不影響 Hermes 內部)'), + hermesGatewayNameRequired: _('名称不能为空', 'Name is required', '名稱不能為空'), + hermesGatewayProfileLabel: _('绑定 Profile', 'Bind to profile', '綁定 Profile'), + hermesGatewayProfileHint: _('端口从该 Profile 的 config.yaml model.gateway.port 读取', 'Port is read from the profile\'s config.yaml model.gateway.port', '埠號從該 Profile 的 config.yaml model.gateway.port 讀取'), + hermesGatewayAdded: _('Gateway "{name}" 已添加', 'Gateway "{name}" added', 'Gateway "{name}" 已新增'), + hermesGatewayAddFailed: _('添加 Gateway 失败', 'Add gateway failed', '新增 Gateway 失敗'), + hermesGatewayRunning: _('运行中', 'Running', '執行中'), + hermesGatewayStopped: _('已停止', 'Stopped', '已停止'), + hermesGatewayForeign: _('端口已被外部进程占用(非 ClawPanel spawn)', 'Port owned by external process (not spawned by ClawPanel)', '埠號已被外部行程佔用'), + hermesGatewayForeignTip: _('只能停止 ClawPanel spawn 的实例', 'Can only stop instances spawned by ClawPanel', '只能停止 ClawPanel spawn 的實例'), + hermesGatewayStarted: _('Gateway "{name}" 已启动', 'Gateway "{name}" started', 'Gateway "{name}" 已啟動'), + hermesGatewayStartedWarning: _('已启动,但 {warning}', 'Started, but {warning}', '已啟動,但 {warning}'), + hermesGatewayStartFailed: _('启动 Gateway 失败', 'Start gateway failed', '啟動 Gateway 失敗'), + hermesGatewayStopConfirm: _('确认停止 Gateway "{name}"?', 'Stop Gateway "{name}"?', '確認停止 Gateway "{name}"?'), + hermesGatewayStopped2: _('Gateway "{name}" 已停止', 'Gateway "{name}" stopped', 'Gateway "{name}" 已停止'), + hermesGatewayStopFailed: _('停止 Gateway 失败', 'Stop gateway failed', '停止 Gateway 失敗'), + hermesGatewayRemove: _('删除', 'Remove', '刪除'), + hermesGatewayRemoveConfirm: _('确认从面板删除 Gateway "{name}"?(不会影响该 Profile 配置)', 'Remove Gateway "{name}" from panel? (Does not affect profile config)', '確認從面板刪除 Gateway "{name}"?(不會影響該 Profile 設定)'), + hermesGatewayRemoved: _('Gateway "{name}" 已删除', 'Gateway "{name}" removed', 'Gateway "{name}" 已刪除'), + hermesGatewayRemoveFailed: _('删除 Gateway 失败', 'Remove gateway failed', '刪除 Gateway 失敗'), // Web 模式(远程浏览器)下流式聊天暂不可用 chatWebModeStreamingUnsupported: _( 'Web 模式暂不支持 Hermes 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',