feat: Hermes Agent 多引擎架构核心代码

- 新增 src/engines/hermes/ 完整引擎(仪表盘/服务管理/模型配置/Agent管理/对话)
- 新增 src/lib/engine-manager.js 引擎管理器(切换/检测/状态)
- 新增 src-tauri/src/commands/hermes.rs 后端命令(Gateway控制/配置读写/Agent Run SSE)
- sidebar 引擎切换器 UI
- i18n 新增 engine 模块(中/英/繁体)
- 多安装清理工具(gateway-ownership.js)
- 晴辰助手文件访问开关
- Hermes 对话工具调用可视化、SSE 流式输出
- Cargo.lock / dev-api.js 同步更新
This commit is contained in:
晴天
2026-04-13 04:09:00 +08:00
parent 32190c8f27
commit 5575566806
36 changed files with 6694 additions and 424 deletions

114
src/lib/engine-manager.js Normal file
View File

@@ -0,0 +1,114 @@
/**
* 引擎管理器
* 管理多引擎OpenClaw / Hermes Agent / ...)的注册、切换和状态
*/
import { api } from './tauri-api.js'
import { registerRoute, setDefaultRoute } from '../router.js'
const _engines = {}
let _activeEngine = null
let _listeners = []
/** 注册引擎 */
export function registerEngine(engine) {
_engines[engine.id] = engine
}
/** 获取所有已注册引擎 */
export function listEngines() {
return Object.values(_engines).map(e => ({
id: e.id,
name: e.name,
icon: e.icon || '',
description: e.description || '',
}))
}
/** 获取当前激活的引擎 */
export function getActiveEngine() {
return _activeEngine
}
/** 获取引擎 ID */
export function getActiveEngineId() {
return _activeEngine?.id || 'openclaw'
}
/** 按 ID 获取引擎 */
export function getEngine(id) {
return _engines[id] || null
}
/** 监听引擎切换事件 */
export function onEngineChange(fn) {
_listeners.push(fn)
return () => { _listeners = _listeners.filter(cb => cb !== fn) }
}
/**
* 初始化引擎管理器:读取 clawpanel.json 中的 engineMode激活对应引擎
* 在 main.js boot() 中调用
*/
export async function initEngineManager() {
let mode = 'openclaw'
try {
const cfg = await api.readPanelConfig()
if (cfg?.engineMode && _engines[cfg.engineMode]) {
mode = cfg.engineMode
}
} catch {}
await activateEngine(mode, false)
}
/**
* 激活指定引擎(注册路由 + 启动)
* @param {string} id 引擎 ID
* @param {boolean} persist 是否写入 clawpanel.json
*/
export async function activateEngine(id, persist = true) {
const engine = _engines[id]
if (!engine) {
console.error(`[engine-manager] 未知引擎: ${id}`)
return
}
// 清理旧引擎
if (_activeEngine && _activeEngine.id !== id && _activeEngine.cleanup) {
try { _activeEngine.cleanup() } catch {}
}
_activeEngine = engine
// 注册引擎路由 + 设置默认路由
const routes = engine.getRoutes()
for (const r of routes) {
registerRoute(r.path, r.loader)
}
if (engine.getDefaultRoute) {
setDefaultRoute(engine.getDefaultRoute())
}
// 持久化到 clawpanel.json
if (persist) {
try {
const cfg = await api.readPanelConfig()
if (cfg.engineMode !== id) {
cfg.engineMode = id
await api.writePanelConfig(cfg)
}
} catch (e) {
console.warn('[engine-manager] 保存 engineMode 失败:', e)
}
}
// 通知监听者
_listeners.forEach(fn => { try { fn(engine) } catch {} })
}
/**
* 切换引擎(带 UI 跳转)
*/
export async function switchEngine(id) {
if (_activeEngine?.id === id) return
await activateEngine(id, true)
}

View File

@@ -1,5 +1,6 @@
import { api } from './tauri-api.js'
import { showContentModal } from '../components/modal.js'
import { showContentModal, showConfirm } from '../components/modal.js'
import { toast } from '../components/toast.js'
import { t } from './i18n.js'
function escapeHtml(str) {
@@ -207,11 +208,17 @@ export async function showGatewayConflictGuidance({ error = null, service = null
content,
width: 760,
buttons: [
{ id: 'gateway-conflict-open-settings', label: settingsButtonLabel, className: 'btn btn-primary btn-sm' },
{ id: 'gateway-conflict-open-cleanup', label: t('services.cleanupTitle'), className: 'btn btn-primary btn-sm' },
{ id: 'gateway-conflict-open-settings', label: settingsButtonLabel, className: 'btn btn-secondary btn-sm' },
{ id: 'gateway-conflict-refresh', label: t('services.refreshStatus'), className: 'btn btn-secondary btn-sm' },
],
})
overlay.querySelector('#gateway-conflict-open-cleanup')?.addEventListener('click', async () => {
overlay.close()
await showInstallationCleanup({ onRefresh })
})
overlay.querySelector('#gateway-conflict-open-settings')?.addEventListener('click', () => {
overlay.close()
window.location.hash = '#/settings'
@@ -226,3 +233,195 @@ export async function showGatewayConflictGuidance({ error = null, service = null
return overlay
}
/** 根据安装来源返回卸载命令 */
function uninstallCommandForSource(source, path) {
if (source === 'standalone') {
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
const p = escapeHtml(path || '')
return isWin ? `rmdir /s /q "${p}"` : `rm -rf "${p}"`
}
if (source === 'npm-official' || source === 'official') return 'npm uninstall -g openclaw'
// npm-zh, npm-global, and others
return 'npm uninstall -g @qingchencloud/openclaw-zh'
}
/**
* 显示安装清理弹窗
* 列出所有检测到的 OpenClaw 安装,提供逐个卸载命令 + 一键绑定 + 全量卸载
*/
export async function showInstallationCleanup({ onRefresh = null } = {}) {
const [versionInfo, panelConfig] = await Promise.all([
api.getVersionInfo().catch(() => null),
api.readPanelConfig().catch(() => null),
])
const installations = dedupeOpenclawInstallations(Array.isArray(versionInfo?.all_installations) ? versionInfo.all_installations : [])
const boundPath = readBoundCliPath(panelConfig)
const currentPath = versionInfo?.cli_path || ''
const sourceLabel = (src) => cliSourceLabel(src)
// 每个安装的卡片 HTML
const installCards = installations.map((inst, idx) => {
const isActive = !!inst.active
const isBound = boundPath && openclawInstallationIdentity({ path: inst.path }) === openclawInstallationIdentity({ path: boundPath })
const borderColor = isActive ? 'rgba(34,197,94,0.4)' : 'var(--border-light)'
const bgColor = isActive ? 'rgba(34,197,94,0.04)' : 'var(--bg-secondary)'
const badges = []
if (isActive) badges.push(`<span style="display:inline-flex;align-items:center;gap:3px;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600;background:rgba(34,197,94,0.14);color:#16a34a">● ${t('services.cleanupActive')}</span>`)
if (isBound) badges.push(`<span style="display:inline-flex;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600;background:rgba(99,102,241,0.14);color:#6366f1">✓ ${t('services.cleanupBound')}</span>`)
if (inst.version) badges.push(`<span style="display:inline-flex;padding:2px 8px;border-radius:999px;font-size:11px;background:var(--bg-tertiary);color:var(--text-secondary)">${escapeHtml(inst.version)}</span>`)
if (inst.source) badges.push(`<span style="display:inline-flex;padding:2px 8px;border-radius:999px;font-size:11px;background:var(--bg-tertiary);color:var(--text-tertiary)">${escapeHtml(sourceLabel(inst.source))}</span>`)
const uninstallCmd = uninstallCommandForSource(inst.source, inst.path)
// 操作区:非活跃的安装显示卸载命令 + 复制按钮;活跃的显示绑定按钮
let actions = ''
if (isActive && !isBound) {
actions = `<button class="btn btn-primary btn-xs cleanup-bind-btn" data-path="${escapeHtml(inst.path)}" style="margin-top:8px">${t('services.cleanupBindThis')}</button>`
} else if (!isActive) {
actions = `
<div style="margin-top:8px;display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<code style="flex:1;min-width:0;font-size:11px;padding:4px 8px;background:var(--bg-tertiary);border-radius:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;user-select:all" title="${escapeHtml(uninstallCmd)}">${escapeHtml(uninstallCmd)}</code>
<button class="btn btn-secondary btn-xs cleanup-copy-cmd" data-cmd="${escapeHtml(uninstallCmd)}" style="flex-shrink:0">${t('services.cleanupCopyCmd')}</button>
</div>`
}
return `
<div style="padding:12px 14px;border:1px solid ${borderColor};border-radius:10px;background:${bgColor};transition:border-color .15s">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span style="font-size:14px">${isActive ? '✅' : '📦'}</span>
<code style="font-size:12px;word-break:break-all;flex:1;min-width:0">${escapeHtml(inst.path)}</code>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">${badges.join('')}</div>
${actions}
</div>`
}).join('')
const noInstalls = !installations.length
? `<div style="padding:14px;border:1px dashed var(--border-light);border-radius:10px;text-align:center;color:var(--text-tertiary)">${t('services.cleanupNoInstalls')}</div>`
: ''
// 概要提示
const summaryStyle = installations.length > 1
? 'background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.2);color:var(--warning)'
: 'background:rgba(34,197,94,0.08);border:1px solid rgba(34,197,94,0.2);color:var(--success)'
const summaryIcon = installations.length > 1 ? '⚠️' : '✅'
const summaryText = installations.length > 1
? t('services.cleanupMultiSummary', { count: installations.length })
: t('services.cleanupSingleSummary')
const content = `
<div style="display:flex;flex-direction:column;gap:12px;font-size:var(--font-size-sm);color:var(--text-secondary);line-height:1.6">
<div style="display:flex;gap:10px;padding:10px 14px;border-radius:10px;${summaryStyle}">
<span style="font-size:16px;flex-shrink:0">${summaryIcon}</span>
<div style="font-size:13px;line-height:1.5">${escapeHtml(summaryText)}</div>
</div>
${installations.length > 1 ? `
<div style="padding:10px 14px;border-radius:10px;background:var(--bg-secondary);border:1px solid var(--border-light);font-size:12px;line-height:1.6;color:var(--text-tertiary)">
<strong style="color:var(--text-secondary)">${t('services.cleanupHowTo')}</strong><br>
${t('services.cleanupHowToDesc')}
</div>` : ''}
<div style="display:flex;flex-direction:column;gap:8px">
<div style="font-size:13px;font-weight:600;color:var(--text-primary)">${t('services.cleanupInstallationsTitle', { count: installations.length })}</div>
${installCards}${noInstalls}
</div>
<details style="border-radius:10px;background:var(--bg-secondary);border:1px solid var(--border-light);overflow:hidden">
<summary style="padding:10px 14px;cursor:pointer;font-size:13px;font-weight:600;color:var(--error);user-select:none">${t('services.cleanupDangerZone')}</summary>
<div style="padding:0 14px 12px;display:flex;flex-direction:column;gap:8px">
<div style="font-size:12px;color:var(--text-tertiary);line-height:1.6">${t('services.cleanupDangerDesc')}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-secondary btn-xs" id="cleanup-uninstall-all">${t('services.cleanupUninstallAll')}</button>
<button class="btn btn-secondary btn-xs" id="cleanup-uninstall-all-config" style="color:var(--error)">${t('services.cleanupUninstallAllWithConfig')}</button>
</div>
</div>
</details>
</div>
`
const overlay = showContentModal({
title: t('services.cleanupTitle'),
content,
width: 640,
buttons: [
{ id: 'cleanup-goto-settings', label: t('sidebar.settings'), className: 'btn btn-secondary btn-sm' },
{ id: 'cleanup-refresh', label: t('services.refreshStatus'), className: 'btn btn-secondary btn-sm' },
],
})
// 复制命令按钮
overlay.addEventListener('click', async (e) => {
const copyBtn = e.target.closest('.cleanup-copy-cmd')
if (copyBtn) {
const cmd = copyBtn.dataset.cmd
try {
await navigator.clipboard.writeText(cmd)
const orig = copyBtn.textContent
copyBtn.textContent = '✓'
copyBtn.style.color = 'var(--success)'
setTimeout(() => { copyBtn.textContent = orig; copyBtn.style.color = '' }, 1500)
} catch {
toast(t('services.cleanupCopyFailed'), 'warning')
}
return
}
// 绑定 CLI 按钮
const bindBtn = e.target.closest('.cleanup-bind-btn')
if (bindBtn) {
const path = bindBtn.dataset.path
if (!path) return
try {
const cfg = await api.readPanelConfig()
cfg.openclawCliPath = path
await api.writePanelConfig(cfg)
toast(t('services.cleanupBindSuccess'), 'success')
overlay.close()
if (typeof onRefresh === 'function') await onRefresh()
} catch (err) {
toast(t('services.cleanupBindFailed') + ': ' + (err?.message || err), 'error')
}
return
}
})
// 全量卸载按钮
overlay.querySelector('#cleanup-uninstall-all')?.addEventListener('click', async () => {
const ok = await showConfirm(t('services.cleanupConfirmUninstall'))
if (!ok) return
overlay.close()
try {
toast(t('services.cleanupUninstalling'), 'info')
await api.uninstallOpenclaw(false)
} catch (err) {
toast(t('services.cleanupUninstallFailed') + ': ' + (err?.message || err), 'error')
}
})
overlay.querySelector('#cleanup-uninstall-all-config')?.addEventListener('click', async () => {
const ok = await showConfirm(t('services.cleanupConfirmUninstallConfig'))
if (!ok) return
overlay.close()
try {
toast(t('services.cleanupUninstalling'), 'info')
await api.uninstallOpenclaw(true)
} catch (err) {
toast(t('services.cleanupUninstallFailed') + ': ' + (err?.message || err), 'error')
}
})
// 导航按钮
overlay.querySelector('#cleanup-goto-settings')?.addEventListener('click', () => {
overlay.close()
window.location.hash = '#/settings'
})
overlay.querySelector('#cleanup-refresh')?.addEventListener('click', async () => {
overlay.close()
if (typeof onRefresh === 'function') await onRefresh()
})
return overlay
}

View File

@@ -373,4 +373,21 @@ export const api = {
saveImage: (id, data) => invoke('assistant_save_image', { id, data }),
loadImage: (id) => invoke('assistant_load_image', { id }),
deleteImage: (id) => invoke('assistant_delete_image', { id }),
// Hermes Agent 管理
checkPython: () => cachedInvoke('check_python', {}, 60000),
checkHermes: () => cachedInvoke('check_hermes', {}, 30000),
installHermes: (method = 'uv-tool', extras = []) => invoke('install_hermes', { method, extras }),
configureHermes: (provider, apiKey, model, baseUrl) => invoke('configure_hermes', { provider, apiKey, model: model || null, baseUrl: baseUrl || null }),
hermesGatewayAction: (action) => invoke('hermes_gateway_action', { action }),
hermesHealthCheck: () => invoke('hermes_health_check'),
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 }),
hermesReadConfig: () => invoke('hermes_read_config'),
hermesFetchModels: (baseUrl, apiKey, apiType) => invoke('hermes_fetch_models', { baseUrl, apiKey, apiType: apiType || null }),
hermesUpdateModel: (model) => invoke('hermes_update_model', { model }),
hermesDetectEnvironments: () => invoke('hermes_detect_environments'),
hermesSetGatewayUrl: (url) => invoke('hermes_set_gateway_url', { url: url || null }),
updateHermes: () => invoke('update_hermes'),
uninstallHermes: (cleanConfig = false) => invoke('uninstall_hermes', { cleanConfig }),
}