mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-31 21:29:59 +08:00
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:
114
src/lib/engine-manager.js
Normal file
114
src/lib/engine-manager.js
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user