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:
晴天
2026-05-14 05:30:18 +08:00
parent b656ce74f8
commit 0d6c4614e4
7 changed files with 620 additions and 0 deletions

View File

@@ -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') },

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
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
}

View File

@@ -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),

View File

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