mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-30 12:50:14 +08:00
feat(hermes): Batch 2 §G - 多 Gateway 看板(同时运行多 profile Gateway)
设计:让用户给多个 profile 各自配 Gateway 实例,同时运行。
端口完全由 profile 的 config.yaml model.gateway.port 决定,ClawPanel 只负责 spawn + PID 跟踪。
## Rust 后端(~350 行)
### 数据结构
- MULTI_GW_PIDS: Mutex<Option<HashMap<String, u32>>> 全局 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 <name> 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 ✓
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -6090,6 +6090,347 @@ pub fn hermes_cron_jobs_list() -> Result<Value, String> {
|
||||
serde_json::from_str::<Value>(&raw).map_err(|e| format!("Failed to parse cron jobs: {e}"))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Batch 2 §G: 多 Gateway 看板
|
||||
//
|
||||
// 让用户同时运行多个 Hermes Gateway 实例(每个绑不同 profile)。
|
||||
// 用 `hermes --profile <name> 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<Option<HashMap<String, u32>>> = Mutex::new(None);
|
||||
|
||||
fn multi_gw_pids_get(name: &str) -> Option<u32> {
|
||||
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<Value> {
|
||||
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<Value>) -> 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<String, Value> = 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::<u16>() {
|
||||
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<Value, String> {
|
||||
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::<std::net::SocketAddr>().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<Value, String> {
|
||||
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<Value, String> {
|
||||
let name = name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err("名称不能为空".into());
|
||||
}
|
||||
// 先停掉(如果在跑)
|
||||
let _ = hermes_multi_gateway_stop(name.clone()).await;
|
||||
let configs: Vec<Value> = 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<Value, String> {
|
||||
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::<std::net::SocketAddr>() {
|
||||
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::<std::net::SocketAddr>() {
|
||||
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<Value, String> {
|
||||
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).
|
||||
// ============================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') },
|
||||
|
||||
231
src/engines/hermes/pages/gateways.js
Normal file
231
src/engines/hermes/pages/gateways.js
Normal file
@@ -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, '>').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 = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">${escHtml(t('engine.hermesGatewaysTitle'))}</h1>
|
||||
<p class="page-desc">${escHtml(t('engine.hermesGatewaysDesc'))}</p>
|
||||
</div>
|
||||
<div class="config-actions">
|
||||
<button class="btn btn-secondary btn-sm" id="hm-gws-refresh">${escHtml(t('hermesLazyDeps.refresh'))}</button>
|
||||
<button class="btn btn-primary btn-sm" id="hm-gws-add">+ ${escHtml(t('engine.hermesGatewayAdd'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hm-gws-content">
|
||||
${loading ? `<div style="padding:32px;text-align:center;color:var(--text-tertiary)">${escHtml(t('common.loading'))}…</div>` : ''}
|
||||
${error ? `<div style="color:var(--error);padding:20px">${escHtml(error)}</div>` : ''}
|
||||
${(!loading && !error && !gateways.length) ? `
|
||||
<div class="empty-state empty-compact">
|
||||
<div class="empty-icon">⚙️</div>
|
||||
<div class="empty-title">${escHtml(t('engine.hermesGatewaysEmpty'))}</div>
|
||||
<div class="empty-desc" style="margin-top:8px">${escHtml(t('engine.hermesGatewaysEmptyHint'))}</div>
|
||||
</div>` : ''}
|
||||
${(!loading && gateways.length) ? `
|
||||
<div class="lazy-deps-grid">
|
||||
${gateways.map(renderCard).join('')}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
bind()
|
||||
}
|
||||
|
||||
function renderCard(g) {
|
||||
const isBusy = busyName === g.name
|
||||
const isOwned = !!g.owned
|
||||
const isRunning = !!g.running
|
||||
const stateBadge = isRunning
|
||||
? `<span class="lazy-deps-badge ok">${escHtml(t('engine.hermesGatewayRunning'))}</span>`
|
||||
: `<span class="lazy-deps-badge">${escHtml(t('engine.hermesGatewayStopped'))}</span>`
|
||||
const ownedHint = isRunning && !isOwned
|
||||
? `<div class="lazy-deps-card-meta" style="color:var(--warning)">${escHtml(t('engine.hermesGatewayForeign'))}</div>`
|
||||
: ''
|
||||
return `
|
||||
<div class="lazy-deps-card">
|
||||
<div class="lazy-deps-card-head">
|
||||
<div class="lazy-deps-card-title">${escHtml(g.name)}</div>
|
||||
${stateBadge}
|
||||
</div>
|
||||
<div class="lazy-deps-card-meta">Profile: <b>${escHtml(g.profile)}</b></div>
|
||||
<div class="lazy-deps-card-meta">Port: <code style="font-family:var(--font-mono);font-size:12px">:${g.port}</code></div>
|
||||
${g.pid ? `<div class="lazy-deps-card-meta" style="font-family:var(--font-mono);font-size:11px">PID ${g.pid}</div>` : ''}
|
||||
${ownedHint}
|
||||
<div class="lazy-deps-card-actions" style="gap:6px">
|
||||
${isRunning
|
||||
? `<button class="btn btn-secondary btn-sm" data-action="stop" data-name="${escAttr(g.name)}" ${isBusy || !isOwned ? 'disabled' : ''} ${!isOwned ? 'title="' + escAttr(t('engine.hermesGatewayForeignTip')) + '"' : ''}>${escHtml(isBusy ? t('engine.dashStopping') : t('engine.dashStopGw'))}</button>`
|
||||
: `<button class="btn btn-primary btn-sm" data-action="start" data-name="${escAttr(g.name)}" ${isBusy ? 'disabled' : ''}>${escHtml(isBusy ? t('engine.gatewayStarting') : t('engine.gatewayStartBtn'))}</button>`}
|
||||
<button class="btn btn-secondary btn-sm" data-action="remove" data-name="${escAttr(g.name)}" ${isBusy || isRunning ? 'disabled' : ''} style="color:var(--error)">${escHtml(t('engine.hermesGatewayRemove'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',
|
||||
|
||||
Reference in New Issue
Block a user