mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-02 22:30:36 +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:
125
src/engines/hermes/index.js
Normal file
125
src/engines/hermes/index.js
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Hermes Agent 引擎
|
||||
*/
|
||||
import { t } from '../../lib/i18n.js'
|
||||
import { api } from '../../lib/tauri-api.js'
|
||||
|
||||
// Hermes 状态
|
||||
let _ready = false
|
||||
let _running = false
|
||||
let _listeners = []
|
||||
let _pollTimer = null
|
||||
|
||||
async function detectHermesStatus() {
|
||||
try {
|
||||
const info = await api.checkHermes()
|
||||
_ready = !!info?.installed && !!info?.configExists
|
||||
_running = !!info?.gatewayRunning
|
||||
} catch (_) {
|
||||
_ready = false
|
||||
_running = false
|
||||
}
|
||||
_listeners.forEach(fn => { try { fn({ ready: _ready, running: _running }) } catch (_) {} })
|
||||
return _ready
|
||||
}
|
||||
|
||||
function startPoll() {
|
||||
if (_pollTimer) return
|
||||
_pollTimer = setInterval(detectHermesStatus, 15000)
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null }
|
||||
}
|
||||
|
||||
export default {
|
||||
id: 'hermes',
|
||||
name: 'Hermes Agent',
|
||||
description: 'Hermes AI Agent with tool-calling capabilities',
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>',
|
||||
|
||||
async detect() {
|
||||
await detectHermesStatus()
|
||||
return { installed: _ready, ready: _ready }
|
||||
},
|
||||
|
||||
async boot() {
|
||||
await detectHermesStatus()
|
||||
startPoll()
|
||||
},
|
||||
|
||||
cleanup() {
|
||||
stopPoll()
|
||||
},
|
||||
|
||||
getNavItems() {
|
||||
// 未就绪时显示 Setup 菜单
|
||||
if (!_ready) {
|
||||
return [{
|
||||
section: '',
|
||||
items: [
|
||||
{ route: '/h/setup', label: t('sidebar.setup'), icon: 'setup' },
|
||||
{ route: '/assistant', label: t('sidebar.assistant'), icon: 'assistant' },
|
||||
]
|
||||
}, {
|
||||
section: '',
|
||||
items: [
|
||||
{ route: '/settings', label: t('sidebar.settings'), icon: 'settings' },
|
||||
{ route: '/about', label: t('sidebar.about'), icon: 'about' },
|
||||
]
|
||||
}]
|
||||
}
|
||||
// 就绪后显示完整菜单
|
||||
// 仅展示已开发的页面,stub 页面暂时隐藏
|
||||
return [{
|
||||
section: t('sidebar.sectionMonitor'),
|
||||
items: [
|
||||
{ route: '/h/dashboard', label: t('sidebar.dashboard'), icon: 'dashboard' },
|
||||
{ route: '/assistant', label: t('sidebar.assistant'), icon: 'assistant' },
|
||||
{ route: '/h/chat', label: t('sidebar.chat'), icon: 'chat' },
|
||||
]
|
||||
}, {
|
||||
section: '',
|
||||
items: [
|
||||
{ route: '/settings', label: t('sidebar.settings'), icon: 'settings' },
|
||||
{ route: '/about', label: t('sidebar.about'), icon: 'about' },
|
||||
]
|
||||
}]
|
||||
},
|
||||
|
||||
getRoutes() {
|
||||
return [
|
||||
// Hermes 专属页面(/h/ 前缀)— Phase 2 实现
|
||||
{ path: '/h/setup', loader: () => import('./pages/setup.js') },
|
||||
{ path: '/h/dashboard', loader: () => import('./pages/dashboard.js') },
|
||||
{ path: '/h/chat', loader: () => import('./pages/chat.js') },
|
||||
{ path: '/h/services', loader: () => import('./pages/services.js') },
|
||||
{ path: '/h/config', loader: () => import('./pages/config.js') },
|
||||
{ path: '/h/channels', loader: () => import('./pages/channels.js') },
|
||||
{ path: '/h/cron', loader: () => import('./pages/cron.js') },
|
||||
{ path: '/h/skills', loader: () => import('./pages/skills.js') },
|
||||
// 共用页面(引擎无关)
|
||||
{ path: '/assistant', loader: () => import('../../pages/assistant.js') },
|
||||
{ path: '/settings', loader: () => import('../../pages/settings.js') },
|
||||
{ path: '/about', loader: () => import('../../pages/about.js') },
|
||||
]
|
||||
},
|
||||
|
||||
getSetupRoute() { return '/h/setup' },
|
||||
getDefaultRoute() { return '/h/dashboard' },
|
||||
|
||||
isReady() { return _ready },
|
||||
isGatewayRunning() { return _running },
|
||||
isGatewayForeign() { return false },
|
||||
|
||||
onStateChange(fn) {
|
||||
_listeners.push(fn)
|
||||
return () => { _listeners = _listeners.filter(cb => cb !== fn) }
|
||||
},
|
||||
onReadyChange(fn) {
|
||||
_listeners.push(fn)
|
||||
return () => { _listeners = _listeners.filter(cb => cb !== fn) }
|
||||
},
|
||||
|
||||
isFeatureAvailable() { return true },
|
||||
}
|
||||
16
src/engines/hermes/pages/channels.js
Normal file
16
src/engines/hermes/pages/channels.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Hermes Agent 渠道配置
|
||||
*/
|
||||
import { t } from '../../../lib/i18n.js'
|
||||
|
||||
export function render() {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'page'
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h1>${t('engine.hermesChannelsTitle')}</h1></div>
|
||||
<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">
|
||||
${t('engine.comingSoonPhase2')}
|
||||
</div></div>
|
||||
`
|
||||
return el
|
||||
}
|
||||
627
src/engines/hermes/pages/chat.js
Normal file
627
src/engines/hermes/pages/chat.js
Normal file
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* Hermes Agent 对话页面
|
||||
* 通过 /v1/runs + SSE 事件流驱动,支持工具调用可视化和流式文本
|
||||
* 支持多会话管理、/xxx 快捷指令
|
||||
*/
|
||||
import { t } from '../../../lib/i18n.js'
|
||||
import { api } from '../../../lib/tauri-api.js'
|
||||
import { PROVIDER_PRESETS } from '../../../lib/model-presets.js'
|
||||
|
||||
const STORAGE_KEY = 'hermes_chat_sessions'
|
||||
const FILE_ACCESS_KEY = 'hermes_chat_file_access'
|
||||
const SLASH_COMMANDS = [
|
||||
{ cmd: '/help', desc: '显示可用命令' },
|
||||
{ cmd: '/status', desc: '查看 Agent 状态' },
|
||||
{ cmd: '/memory', desc: '管理记忆' },
|
||||
{ cmd: '/skills', desc: '查看技能列表' },
|
||||
{ cmd: '/clear', desc: '清空当前会话' },
|
||||
{ cmd: '/new', desc: '新建会话' },
|
||||
]
|
||||
|
||||
const TOOL_ICONS = {
|
||||
web_search: '🔍', browse: '🌐', web_browse: '🌐', google: '🔍',
|
||||
code: '💻', execute_code: '💻', run_code: '💻', python: '🐍',
|
||||
terminal: '⌨️', shell: '⌨️', bash: '⌨️', command: '⌨️',
|
||||
file: '📁', read_file: '📁', write_file: '📝',
|
||||
memory: '🧠', recall: '🧠',
|
||||
default: '🔧',
|
||||
}
|
||||
function toolIcon(name) {
|
||||
const n = (name || '').toLowerCase()
|
||||
for (const [k, v] of Object.entries(TOOL_ICONS)) {
|
||||
if (n.includes(k)) return v
|
||||
}
|
||||
return TOOL_ICONS.default
|
||||
}
|
||||
|
||||
function mdToHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="lang-$1">$2</code></pre>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||
.replace(/\n/g, '<br>')
|
||||
}
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
function genId() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) }
|
||||
|
||||
// Lazy Tauri event listen (avoid top-level await for vite build)
|
||||
let _listenFn = null
|
||||
async function tauriListen(event, cb) {
|
||||
if (!_listenFn) {
|
||||
const mod = await import('@tauri-apps/api/event')
|
||||
_listenFn = mod.listen
|
||||
}
|
||||
return _listenFn(event, cb)
|
||||
}
|
||||
|
||||
// --- Session persistence ---
|
||||
function loadSessions() {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]') } catch { return [] }
|
||||
}
|
||||
function saveSessions(sessions) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions))
|
||||
}
|
||||
function sessionTitle(s) {
|
||||
if (s.title) return s.title
|
||||
const first = s.messages.find(m => m.role === 'user')
|
||||
return first ? first.content.slice(0, 30) : t('engine.chatNewSession')
|
||||
}
|
||||
|
||||
export function render() {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'page hermes-chat-page'
|
||||
|
||||
let sessions = loadSessions()
|
||||
let activeId = sessions[0]?.id || null
|
||||
let streaming = false
|
||||
let gwOnline = false
|
||||
let showSlash = false
|
||||
let slashFilter = ''
|
||||
let currentModel = '' // 当前模型名
|
||||
let modelList = [] // 已获取的模型列表
|
||||
let showModelDropdown = false
|
||||
let fileAccessEnabled = localStorage.getItem(FILE_ACCESS_KEY) === 'true'
|
||||
|
||||
// 流式状态
|
||||
let pendingText = '' // 累积的 delta 文本
|
||||
let activeTools = [] // 当前活跃的工具调用 [{ name, status, detail, input, output, error }]
|
||||
let unlisteners = [] // Tauri 事件监听取消函数
|
||||
|
||||
function active() { return sessions.find(s => s.id === activeId) }
|
||||
|
||||
function newSession() {
|
||||
const s = { id: genId(), title: '', messages: [], createdAt: Date.now() }
|
||||
sessions.unshift(s)
|
||||
activeId = s.id
|
||||
saveSessions(sessions)
|
||||
}
|
||||
|
||||
if (!sessions.length) newSession()
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const info = await api.checkHermes()
|
||||
gwOnline = !!info?.gatewayRunning
|
||||
} catch (_) {}
|
||||
// Load current model config
|
||||
try {
|
||||
const cfg = await api.hermesReadConfig()
|
||||
if (cfg?.model) currentModel = cfg.model
|
||||
if (cfg?.base_url && cfg?.api_key) {
|
||||
// Pre-fetch model list for quick switch
|
||||
try {
|
||||
const base = cfg.base_url.replace(/\/+$/, '').replace(/\/(chat\/completions|completions|responses|messages|models)\/?$/, '')
|
||||
const resp = await fetch(base + '/models', { headers: { 'Authorization': `Bearer ${cfg.api_key}` }, signal: AbortSignal.timeout(8000) })
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
modelList = (data.data || []).map(m => m.id).filter(Boolean).sort()
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
draw()
|
||||
}
|
||||
|
||||
// --- 工具调用卡片渲染 ---
|
||||
function formatToolData(data) {
|
||||
if (!data) return ''
|
||||
if (typeof data === 'string') {
|
||||
// 尝试解析 JSON 以美化显示
|
||||
try { const obj = JSON.parse(data); return JSON.stringify(obj, null, 2) } catch { return data }
|
||||
}
|
||||
return JSON.stringify(data, null, 2)
|
||||
}
|
||||
|
||||
function renderToolCard(t, collapsed = true) {
|
||||
const icon = toolIcon(t.name)
|
||||
const statusCls = t.status === 'complete' ? 'done' : t.status === 'error' ? 'err' : 'active'
|
||||
const statusText = t.status === 'complete' ? '✓ 完成' : t.status === 'error' ? '✗ 失败' : '⟳ 运行中'
|
||||
const detail = t.detail && t.detail !== '失败' && t.detail !== '完成' ? ` — ${escHtml(t.detail)}` : ''
|
||||
const inputStr = formatToolData(t.input)
|
||||
const outputStr = formatToolData(t.output)
|
||||
const errorStr = t.error ? (typeof t.error === 'string' ? t.error : JSON.stringify(t.error)) : ''
|
||||
// fallback: 用 raw 快照显示原始事件数据
|
||||
const rawStr = (!inputStr && !outputStr && !errorStr) ? formatToolData(t._raw || t._rawCompleted) : ''
|
||||
const hasDetails = inputStr || outputStr || errorStr || rawStr
|
||||
const cardId = 'tc-' + genId()
|
||||
let detailsHtml = ''
|
||||
if (hasDetails) {
|
||||
detailsHtml = `<div class="hm-tool-details" id="${cardId}-details" style="${collapsed ? 'display:none' : ''}">
|
||||
${inputStr ? `<div class="hm-tool-section"><div class="hm-tool-section-label">输入</div><pre class="hm-tool-pre">${escHtml(inputStr)}</pre></div>` : ''}
|
||||
${errorStr ? `<div class="hm-tool-section hm-tool-section-err"><div class="hm-tool-section-label">错误</div><pre class="hm-tool-pre">${escHtml(errorStr)}</pre></div>` : ''}
|
||||
${outputStr ? `<div class="hm-tool-section"><div class="hm-tool-section-label">输出</div><pre class="hm-tool-pre">${escHtml(outputStr)}</pre></div>` : ''}
|
||||
${rawStr ? `<div class="hm-tool-section"><div class="hm-tool-section-label">详情</div><pre class="hm-tool-pre">${escHtml(rawStr)}</pre></div>` : ''}
|
||||
</div>`
|
||||
}
|
||||
return `<div class="hm-tool-card ${statusCls}" data-tool-card="${cardId}">
|
||||
<div class="hm-tool-card-header">${icon} <span class="hm-tool-name">${escHtml(t.name)}</span><span class="hm-tool-status">${statusText}${detail}</span>${hasDetails ? `<span class="hm-tool-toggle">▶</span>` : ''}</div>
|
||||
${detailsHtml}
|
||||
</div>`
|
||||
}
|
||||
|
||||
// --- 增量更新流式区域(避免全量 draw 导致闪烁)---
|
||||
function updateStreamArea() {
|
||||
const msgsEl = el.querySelector('#hm-chat-msgs')
|
||||
if (!msgsEl) return
|
||||
let streamEl = msgsEl.querySelector('.hm-stream-area')
|
||||
if (!streaming) {
|
||||
if (streamEl) streamEl.remove()
|
||||
return
|
||||
}
|
||||
if (!streamEl) {
|
||||
streamEl = document.createElement('div')
|
||||
streamEl.className = 'hm-stream-area'
|
||||
msgsEl.appendChild(streamEl)
|
||||
}
|
||||
const toolsHtml = activeTools.map(t => renderToolCard(t, false)).join('')
|
||||
const textHtml = pendingText
|
||||
? `<div class="hermes-chat-msg assistant"><div class="hermes-chat-bubble assistant">${mdToHtml(pendingText)}</div></div>`
|
||||
: (activeTools.length === 0 ? `<div class="hermes-chat-msg assistant"><div class="hermes-chat-bubble assistant"><span class="hermes-chat-typing">${t('engine.chatThinking')}</span></div></div>` : '')
|
||||
streamEl.innerHTML = toolsHtml + textHtml
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight
|
||||
}
|
||||
|
||||
// --- Draw ---
|
||||
function draw() {
|
||||
const cur = active()
|
||||
const msgs = cur?.messages || []
|
||||
el.innerHTML = `
|
||||
<div class="hm-chat-layout">
|
||||
<div class="hm-chat-sidebar">
|
||||
<div class="hm-chat-sidebar-header">
|
||||
<span>${t('engine.hermesChatTitle')}</span>
|
||||
<button class="hm-new-btn" title="${t('engine.chatNewSession')}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="hm-chat-session-list">
|
||||
${sessions.map(s => `
|
||||
<div class="hm-session-item ${s.id === activeId ? 'active' : ''}" data-sid="${s.id}">
|
||||
<span class="hm-session-title">${escHtml(sessionTitle(s))}</span>
|
||||
<button class="hm-session-del" data-del="${s.id}" title="${t('common.delete')}">×</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hm-chat-main">
|
||||
<div class="hm-chat-model-bar">
|
||||
<span class="hm-model-label">${t('engine.configModel')}:</span>
|
||||
<div style="position:relative;flex:1;max-width:240px">
|
||||
<input type="text" id="hm-chat-model" class="hm-model-input" value="${escHtml(currentModel)}" placeholder="QC-B01" readonly>
|
||||
${showModelDropdown && modelList.length ? `<div id="hm-chat-model-dd" class="hm-model-dropdown">${modelList.map(m => `<div class="hm-chat-model-opt${m === currentModel ? ' active' : ''}" data-model="${escHtml(m)}">${escHtml(m)}</div>`).join('')}</div>` : ''}
|
||||
</div>
|
||||
<button class="hm-file-access-toggle ${fileAccessEnabled ? 'active' : ''}" id="hm-file-access-btn" title="${fileAccessEnabled ? t('engine.fileAccessOn') : t('engine.fileAccessOff')}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
<span>${t('engine.fileAccess')}</span>
|
||||
</button>
|
||||
<a href="#/h/dashboard" class="hm-model-link">${t('engine.dashModelConfig')} →</a>
|
||||
</div>
|
||||
<div class="hermes-chat-messages" id="hm-chat-msgs">
|
||||
${msgs.length === 0 ? `<div class="hermes-chat-empty">${t('engine.chatEmptyHint')}</div>` : ''}
|
||||
${msgs.map(m => renderMessage(m)).join('')}
|
||||
</div>
|
||||
<div class="hermes-chat-input-area">
|
||||
${!gwOnline ? `<div class="hm-gw-offline">${t('engine.chatGatewayOffline')}</div>` : ''}
|
||||
<div style="position:relative">
|
||||
${showSlash ? renderSlashMenu() : ''}
|
||||
<div class="hm-chat-input-wrap">
|
||||
<textarea id="hm-chat-input" rows="1" placeholder="${t('engine.chatPlaceholder')}" ${!gwOnline ? 'disabled' : ''}></textarea>
|
||||
<button class="btn btn-primary hm-chat-send" ${!gwOnline || streaming ? 'disabled' : ''}>${streaming ? '...' : t('engine.chatSend')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
bind()
|
||||
if (streaming) updateStreamArea()
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
function renderMessage(m) {
|
||||
const isUser = m.role === 'user'
|
||||
// 工具摘要行(存储在 messages 中的已完成工具记录)
|
||||
if (m.role === 'tool-summary') {
|
||||
return `<div class="hm-tool-summary">${m.tools.map(t => renderToolCard(t, true)).join('')}</div>`
|
||||
}
|
||||
return `<div class="hermes-chat-msg ${isUser ? 'user' : 'assistant'}">
|
||||
<div class="hermes-chat-bubble ${isUser ? 'user' : 'assistant'}">${isUser ? escHtml(m.content) : mdToHtml(m.content)}</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderSlashMenu() {
|
||||
const cmds = SLASH_COMMANDS.filter(c => !slashFilter || c.cmd.includes(slashFilter))
|
||||
if (!cmds.length) return ''
|
||||
return `<div class="hm-slash-menu">${cmds.map(c =>
|
||||
`<div class="hm-slash-item" data-cmd="${c.cmd}"><span class="hm-slash-cmd">${c.cmd}</span><span class="hm-slash-desc">${c.desc}</span></div>`
|
||||
).join('')}</div>`
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const msgsEl = el.querySelector('#hm-chat-msgs')
|
||||
if (msgsEl) msgsEl.scrollTop = msgsEl.scrollHeight
|
||||
}
|
||||
|
||||
// 事件委托:工具卡片展开/折叠(对静态和动态流式卡片都生效)
|
||||
el.addEventListener('click', (e) => {
|
||||
const header = e.target.closest('.hm-tool-card-header')
|
||||
if (!header) return
|
||||
const card = header.closest('.hm-tool-card')
|
||||
const details = card?.querySelector('.hm-tool-details')
|
||||
const toggle = header.querySelector('.hm-tool-toggle')
|
||||
if (details) {
|
||||
const open = details.style.display !== 'none'
|
||||
details.style.display = open ? 'none' : 'block'
|
||||
if (toggle) toggle.textContent = open ? '▶' : '▼'
|
||||
}
|
||||
})
|
||||
|
||||
function bind() {
|
||||
// Model quick-switch
|
||||
el.querySelector('#hm-chat-model')?.addEventListener('click', () => {
|
||||
if (modelList.length) { showModelDropdown = !showModelDropdown; draw() }
|
||||
})
|
||||
el.querySelectorAll('.hm-chat-model-opt').forEach(opt => {
|
||||
opt.addEventListener('click', async () => {
|
||||
const m = opt.dataset.model
|
||||
if (m && m !== currentModel) {
|
||||
try {
|
||||
await api.hermesUpdateModel(m)
|
||||
currentModel = m
|
||||
} catch (_) {}
|
||||
}
|
||||
showModelDropdown = false; draw()
|
||||
})
|
||||
})
|
||||
document.addEventListener('click', (e) => {
|
||||
if (showModelDropdown && !e.target.closest('#hm-chat-model') && !e.target.closest('#hm-chat-model-dd')) {
|
||||
showModelDropdown = false; draw()
|
||||
}
|
||||
})
|
||||
// File access toggle
|
||||
el.querySelector('#hm-file-access-btn')?.addEventListener('click', () => {
|
||||
fileAccessEnabled = !fileAccessEnabled
|
||||
localStorage.setItem(FILE_ACCESS_KEY, fileAccessEnabled ? 'true' : 'false')
|
||||
draw()
|
||||
})
|
||||
|
||||
// Session sidebar
|
||||
el.querySelector('.hm-new-btn')?.addEventListener('click', () => { newSession(); draw() })
|
||||
el.querySelectorAll('.hm-session-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.hm-session-del')) return
|
||||
activeId = item.dataset.sid
|
||||
draw()
|
||||
})
|
||||
})
|
||||
el.querySelectorAll('.hm-session-del').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
const sid = btn.dataset.del
|
||||
sessions = sessions.filter(s => s.id !== sid)
|
||||
if (activeId === sid) {
|
||||
if (!sessions.length) newSession()
|
||||
activeId = sessions[0].id
|
||||
}
|
||||
saveSessions(sessions)
|
||||
draw()
|
||||
})
|
||||
})
|
||||
|
||||
// Slash menu clicks
|
||||
el.querySelectorAll('.hm-slash-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const input = el.querySelector('#hm-chat-input')
|
||||
if (input) { input.value = item.dataset.cmd + ' '; input.focus() }
|
||||
showSlash = false
|
||||
draw()
|
||||
})
|
||||
})
|
||||
|
||||
// Send
|
||||
el.querySelector('.hm-chat-send')?.addEventListener('click', sendMessage)
|
||||
const input = el.querySelector('#hm-chat-input')
|
||||
if (input) {
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() }
|
||||
if (e.key === 'Escape') { showSlash = false; draw() }
|
||||
})
|
||||
input.addEventListener('input', () => {
|
||||
input.style.height = 'auto'
|
||||
input.style.height = Math.min(input.scrollHeight, 120) + 'px'
|
||||
const val = input.value
|
||||
if (val.startsWith('/') && !val.includes(' ')) {
|
||||
showSlash = true; slashFilter = val
|
||||
const parent = input.closest('.hermes-chat-input-area')?.querySelector('[style*="position:relative"]')
|
||||
if (parent) {
|
||||
const existing = parent.querySelector('.hm-slash-menu')
|
||||
if (existing) existing.remove()
|
||||
const cmds = SLASH_COMMANDS.filter(c => c.cmd.includes(val))
|
||||
if (cmds.length) {
|
||||
const div = document.createElement('div')
|
||||
div.className = 'hm-slash-menu'
|
||||
div.innerHTML = cmds.map(c =>
|
||||
`<div class="hm-slash-item" data-cmd="${c.cmd}"><span class="hm-slash-cmd">${c.cmd}</span><span class="hm-slash-desc">${c.desc}</span></div>`
|
||||
).join('')
|
||||
div.querySelectorAll('.hm-slash-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
input.value = item.dataset.cmd + ' '
|
||||
input.focus()
|
||||
showSlash = false
|
||||
div.remove()
|
||||
})
|
||||
})
|
||||
parent.prepend(div)
|
||||
}
|
||||
}
|
||||
} else if (showSlash) {
|
||||
showSlash = false
|
||||
el.querySelector('.hm-slash-menu')?.remove()
|
||||
}
|
||||
})
|
||||
input.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// --- 清理事件监听 ---
|
||||
function cleanupListeners() {
|
||||
for (const fn of unlisteners) fn()
|
||||
unlisteners = []
|
||||
}
|
||||
|
||||
// --- 设置 Tauri 事件监听 ---
|
||||
async function setupRunListeners() {
|
||||
cleanupListeners()
|
||||
const u1 = await tauriListen('hermes-run-delta', (e) => {
|
||||
pendingText += e.payload?.delta || ''
|
||||
updateStreamArea()
|
||||
})
|
||||
const u2 = await tauriListen('hermes-run-tool', (e) => {
|
||||
const evt = e.payload || {}
|
||||
const evtType = evt.event || ''
|
||||
const toolName = evt.tool || evt.tool_name || evt.name || 'tool'
|
||||
const preview = evt.preview || evt.detail || evt.message || ''
|
||||
// 提取 input/output 时兼容多种字段名
|
||||
const extractData = (obj, keys) => {
|
||||
for (const k of keys) {
|
||||
if (obj[k] != null && obj[k] !== '') return obj[k]
|
||||
}
|
||||
return null
|
||||
}
|
||||
// 构建去掉元字段后的 raw 快照,作为 fallback
|
||||
const rawSnapshot = (exclude) => {
|
||||
const copy = {}
|
||||
for (const [k, v] of Object.entries(evt)) {
|
||||
if (!exclude.includes(k) && v != null && v !== '') copy[k] = v
|
||||
}
|
||||
return Object.keys(copy).length ? copy : null
|
||||
}
|
||||
if (evtType === 'tool.started') {
|
||||
const inputData = extractData(evt, ['input', 'args', 'arguments', 'parameters', 'params', 'data'])
|
||||
activeTools.push({ name: toolName, status: 'active', detail: preview, input: inputData, output: null, error: null, _raw: rawSnapshot(['event', 'tool', 'tool_name', 'name']) })
|
||||
} else if (evtType === 'tool.completed') {
|
||||
const t = activeTools.find(t => t.name === toolName && t.status === 'active')
|
||||
if (t) {
|
||||
t.status = evt.error ? 'error' : 'complete'
|
||||
t.detail = evt.error ? '失败' : (evt.duration ? `${evt.duration}s` : '完成')
|
||||
t.output = extractData(evt, ['output', 'result', 'content', 'data', 'response'])
|
||||
if (evt.error) t.error = typeof evt.error === 'string' ? evt.error : JSON.stringify(evt.error)
|
||||
// 合并 started 时可能没有的 input
|
||||
if (!t.input) t.input = extractData(evt, ['input', 'args', 'arguments', 'parameters', 'params'])
|
||||
t._rawCompleted = rawSnapshot(['event', 'tool', 'tool_name', 'name', 'error', 'duration'])
|
||||
}
|
||||
} else if (evtType === 'tool.error') {
|
||||
const t = activeTools.find(t => t.name === toolName && t.status === 'active')
|
||||
if (t) {
|
||||
t.status = 'error'
|
||||
t.detail = preview || '失败'
|
||||
t.error = evt.error || preview || '未知错误'
|
||||
}
|
||||
} else if (evtType === 'tool.progress') {
|
||||
const t = activeTools.find(t => t.name === toolName && t.status === 'active')
|
||||
if (t && preview) t.detail = preview
|
||||
}
|
||||
updateStreamArea()
|
||||
})
|
||||
const u3 = await tauriListen('hermes-run-done', (e) => {
|
||||
const cur = active()
|
||||
if (!cur) return
|
||||
const output = e.payload?.output || pendingText || '(empty)'
|
||||
// 存储工具摘要(含输入输出详情)
|
||||
if (activeTools.length > 0) {
|
||||
cur.messages.push({ role: 'tool-summary', tools: activeTools.map(t => ({
|
||||
name: t.name, status: t.status, detail: t.detail,
|
||||
input: t.input, output: t.output, error: t.error,
|
||||
_raw: t._raw, _rawCompleted: t._rawCompleted
|
||||
})) })
|
||||
}
|
||||
cur.messages.push({ role: 'assistant', content: output })
|
||||
streaming = false
|
||||
pendingText = ''
|
||||
activeTools = []
|
||||
saveSessions(sessions)
|
||||
cleanupListeners()
|
||||
draw()
|
||||
})
|
||||
const u4 = await tauriListen('hermes-run-error', (e) => {
|
||||
const cur = active()
|
||||
if (!cur) return
|
||||
const err = e.payload?.error || 'unknown error'
|
||||
cur.messages.push({ role: 'assistant', content: `⚠️ Agent 运行失败: ${escHtml(err)}` })
|
||||
streaming = false
|
||||
pendingText = ''
|
||||
activeTools = []
|
||||
saveSessions(sessions)
|
||||
cleanupListeners()
|
||||
draw()
|
||||
})
|
||||
unlisteners.push(u1, u2, u3, u4)
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const input = el.querySelector('#hm-chat-input')
|
||||
const text = input?.value?.trim()
|
||||
if (!text || streaming) return
|
||||
|
||||
const cur = active()
|
||||
if (!cur) return
|
||||
|
||||
// 本地命令处理(不走 Gateway)
|
||||
if (text === '/clear') {
|
||||
cur.messages = []; cur.title = ''
|
||||
saveSessions(sessions)
|
||||
input.value = ''; draw(); return
|
||||
}
|
||||
if (text === '/new') {
|
||||
newSession(); input.value = ''; draw(); return
|
||||
}
|
||||
if (text === '/help') {
|
||||
cur.messages.push({ role: 'user', content: text })
|
||||
cur.messages.push({ role: 'assistant', content:
|
||||
'**可用命令:**\n' +
|
||||
'`/help` — 显示此帮助\n' +
|
||||
'`/status` — 查看 Gateway 状态\n' +
|
||||
'`/memory` — 管理 Agent 记忆\n' +
|
||||
'`/skills` — 查看可用技能\n' +
|
||||
'`/clear` — 清空当前会话\n' +
|
||||
'`/new` — 新建会话\n\n' +
|
||||
'直接输入问题即可与 Hermes Agent 对话。'
|
||||
})
|
||||
saveSessions(sessions)
|
||||
input.value = ''; draw(); return
|
||||
}
|
||||
if (text === '/status') {
|
||||
input.value = ''
|
||||
cur.messages.push({ role: 'user', content: text })
|
||||
try {
|
||||
const info = await api.checkHermes()
|
||||
const gw = info?.gatewayRunning ? '✅ 运行中' : '❌ 未运行'
|
||||
const model = info?.model || '-'
|
||||
const port = info?.gatewayPort || 8642
|
||||
cur.messages.push({ role: 'assistant', content:
|
||||
`**Gateway 状态:** ${gw}\n**端口:** ${port}\n**模型:** ${model}`
|
||||
})
|
||||
} catch (e) {
|
||||
cur.messages.push({ role: 'assistant', content: `⚠️ 获取状态失败: ${e}` })
|
||||
}
|
||||
saveSessions(sessions)
|
||||
draw(); return
|
||||
}
|
||||
|
||||
cur.messages.push({ role: 'user', content: text })
|
||||
if (!cur.title && cur.messages.length === 1) {
|
||||
cur.title = text.slice(0, 30)
|
||||
}
|
||||
input.value = ''
|
||||
input.style.height = 'auto'
|
||||
streaming = true
|
||||
pendingText = ''
|
||||
activeTools = []
|
||||
showSlash = false
|
||||
draw()
|
||||
|
||||
try {
|
||||
// 构建会话历史(只取 user/assistant 消息)
|
||||
const history = cur.messages
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.slice(0, -1) // 排除刚加的这条 user 消息
|
||||
.map(m => ({ role: m.role, content: m.content }))
|
||||
|
||||
// 设置事件监听
|
||||
await setupRunListeners()
|
||||
|
||||
// 构建 instructions(文件系统访问开关)
|
||||
let instructions = null
|
||||
if (fileAccessEnabled) {
|
||||
const isWin = navigator.platform?.startsWith('Win') || /windows/i.test(navigator.userAgent)
|
||||
const osHint = isWin
|
||||
? '当前系统是 Windows。请使用 PowerShell 或 cmd 命令(如 dir, type, Get-ChildItem, Get-Content 等),路径用反斜杠。桌面路径: $env:USERPROFILE\\Desktop(PowerShell)或 %USERPROFILE%\\Desktop(cmd)。不要使用 ls/cat 等 Unix 命令。'
|
||||
: '当前系统是 macOS/Linux。桌面路径: ~/Desktop。'
|
||||
instructions = `你拥有完整的文件系统访问权限,用户已明确授权,无需再次确认。${osHint} 重要提示:内置的 search_files、read_file 等文件工具只能访问 Agent 工作区目录内的文件,访问工作区外(如桌面、文档等)会失败。因此请始终优先使用 terminal 工具执行系统命令来访问任意位置的文件和目录。不要说"我无法访问",直接用终端命令操作。`
|
||||
}
|
||||
|
||||
// 通过 /v1/runs 启动 Agent Run
|
||||
await api.hermesAgentRun(text, cur.id, history.length ? history : null, instructions)
|
||||
// run 完成后事件监听会处理结果
|
||||
} catch (e) {
|
||||
const msg = String(e.message || e).replace(/^Error:\s*/, '')
|
||||
cur.messages.push({ role: 'assistant', content: `⚠️ ${t('engine.chatError', { error: msg })}` })
|
||||
streaming = false
|
||||
pendingText = ''
|
||||
activeTools = []
|
||||
saveSessions(sessions)
|
||||
cleanupListeners()
|
||||
draw()
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
// --- Guardian 事件监听:实时响应 Gateway 状态变化 ---
|
||||
let gwStatusUnlisteners = []
|
||||
let gwPollTimer = null
|
||||
|
||||
async function setupGwStatusListeners() {
|
||||
try {
|
||||
const unlisten = await tauriListen('hermes-gateway-status', (evt) => {
|
||||
const wasOnline = gwOnline
|
||||
gwOnline = !!evt.payload?.running
|
||||
if (wasOnline !== gwOnline) draw()
|
||||
})
|
||||
gwStatusUnlisteners.push(unlisten)
|
||||
} catch (_) {}
|
||||
|
||||
// 定期轮询作为补充(10s)
|
||||
gwPollTimer = setInterval(async () => {
|
||||
if (streaming) return
|
||||
try {
|
||||
const info = await api.checkHermes()
|
||||
const wasOnline = gwOnline
|
||||
gwOnline = !!info?.gatewayRunning
|
||||
if (wasOnline !== gwOnline) draw()
|
||||
} catch (_) {}
|
||||
}, 10000)
|
||||
}
|
||||
setupGwStatusListeners()
|
||||
|
||||
// 页面卸载时清理
|
||||
const gwCleanup = () => {
|
||||
gwStatusUnlisteners.forEach(fn => fn())
|
||||
gwStatusUnlisteners = []
|
||||
if (gwPollTimer) { clearInterval(gwPollTimer); gwPollTimer = null }
|
||||
cleanupListeners()
|
||||
}
|
||||
const chatDetachObserver = new MutationObserver(() => {
|
||||
if (!el.isConnected) { gwCleanup(); chatDetachObserver.disconnect() }
|
||||
})
|
||||
requestAnimationFrame(() => {
|
||||
if (el.parentNode) chatDetachObserver.observe(el.parentNode, { childList: true })
|
||||
})
|
||||
|
||||
return el
|
||||
}
|
||||
16
src/engines/hermes/pages/config.js
Normal file
16
src/engines/hermes/pages/config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Hermes Agent 配置编辑
|
||||
*/
|
||||
import { t } from '../../../lib/i18n.js'
|
||||
|
||||
export function render() {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'page'
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h1>${t('engine.hermesConfigTitle')}</h1></div>
|
||||
<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">
|
||||
${t('engine.comingSoonPhase2')}
|
||||
</div></div>
|
||||
`
|
||||
return el
|
||||
}
|
||||
174
src/engines/hermes/pages/cron.js
Normal file
174
src/engines/hermes/pages/cron.js
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Hermes Agent 定时任务管理
|
||||
* 通过 Gateway /api/jobs REST API 管理 cron jobs
|
||||
*/
|
||||
import { t } from '../../../lib/i18n.js'
|
||||
import { api } from '../../../lib/tauri-api.js'
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
export function render() {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'page'
|
||||
|
||||
let jobs = []
|
||||
let gwPort = 8642
|
||||
let gwOnline = false
|
||||
let loading = true
|
||||
let editingJob = null // null = list view, {} = create/edit form
|
||||
let busy = false
|
||||
let errorMsg = ''
|
||||
|
||||
async function gw(path, opts = {}) {
|
||||
const method = (opts.method || 'GET').toUpperCase()
|
||||
return await api.hermesApiProxy(method, path, opts.body || null)
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const info = await api.checkHermes()
|
||||
gwPort = info?.gatewayPort || 8642
|
||||
gwOnline = !!info?.gatewayRunning
|
||||
} catch (_) {}
|
||||
if (gwOnline) await loadJobs()
|
||||
loading = false
|
||||
draw()
|
||||
}
|
||||
|
||||
async function loadJobs() {
|
||||
try {
|
||||
const data = await gw('/api/jobs')
|
||||
jobs = data.jobs || []
|
||||
errorMsg = ''
|
||||
} catch (e) {
|
||||
errorMsg = String(e.message || e)
|
||||
jobs = []
|
||||
}
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (editingJob) { drawForm(); return }
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="page-header" style="display:flex;align-items:center;justify-content:space-between">
|
||||
<h1 style="margin:0">${t('engine.hermesCronTitle')}</h1>
|
||||
<button class="btn btn-primary btn-sm hm-cron-create" ${!gwOnline ? 'disabled' : ''}>${t('engine.cronCreate')}</button>
|
||||
</div>
|
||||
${errorMsg ? `<div style="color:var(--error);font-size:13px;margin-bottom:12px">${escHtml(errorMsg)}</div>` : ''}
|
||||
${!gwOnline ? `<div class="card"><div class="card-body" style="padding:24px;text-align:center;color:var(--text-tertiary)">${t('engine.chatGatewayOffline')}</div></div>` : ''}
|
||||
${gwOnline && jobs.length === 0 && !loading ? `<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">${t('engine.cronNoJobs')}</div></div>` : ''}
|
||||
${gwOnline && jobs.length > 0 ? renderJobList() : ''}
|
||||
`
|
||||
bindList()
|
||||
}
|
||||
|
||||
function renderJobList() {
|
||||
return `<div style="display:flex;flex-direction:column;gap:12px">${jobs.map(j => `
|
||||
<div class="card hm-cron-item" data-id="${escHtml(j.id || j.name)}">
|
||||
<div class="card-body" style="padding:14px 16px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
|
||||
<div style="flex:1;min-width:200px">
|
||||
<div style="font-weight:600;font-size:14px">${escHtml(j.name)}</div>
|
||||
<div style="font-size:12px;color:var(--text-tertiary);margin-top:2px;font-family:var(--font-mono,monospace)">${escHtml(j.schedule || '')}</div>
|
||||
${j.prompt ? `<div style="font-size:12px;color:var(--text-secondary);margin-top:4px;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(j.prompt)}</div>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
|
||||
<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:${j.paused ? 'var(--bg-tertiary)' : 'rgba(34,197,94,0.1)'};color:${j.paused ? 'var(--text-tertiary)' : 'var(--success, #22c55e)'}">${j.paused ? t('engine.cronPaused') : t('engine.cronActive')}</span>
|
||||
<button class="btn btn-sm btn-secondary hm-cron-toggle" data-id="${escHtml(j.id || j.name)}" data-paused="${j.paused ? '1' : '0'}" title="${j.paused ? 'Resume' : 'Pause'}" style="padding:4px 10px;font-size:12px">${j.paused ? '▶' : '⏸'}</button>
|
||||
<button class="btn btn-sm btn-secondary hm-cron-run" data-id="${escHtml(j.id || j.name)}" title="${t('engine.cronRunNow')}" style="padding:4px 10px;font-size:12px">⚡</button>
|
||||
<button class="btn btn-sm btn-secondary hm-cron-edit" data-id="${escHtml(j.id || j.name)}" style="padding:4px 10px;font-size:12px">✎</button>
|
||||
<button class="btn btn-sm hm-cron-del" data-id="${escHtml(j.id || j.name)}" style="padding:4px 10px;font-size:12px;color:var(--error)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}</div>`
|
||||
}
|
||||
|
||||
function bindList() {
|
||||
el.querySelector('.hm-cron-create')?.addEventListener('click', () => {
|
||||
editingJob = { name: '', schedule: '0 9 * * *', prompt: '' }
|
||||
draw()
|
||||
})
|
||||
el.querySelectorAll('.hm-cron-toggle').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.id
|
||||
const paused = btn.dataset.paused === '1'
|
||||
try { await gw(`/api/jobs/${encodeURIComponent(id)}/${paused ? 'resume' : 'pause'}`, { method: 'POST' }) } catch (_) {}
|
||||
await loadJobs(); draw()
|
||||
})
|
||||
})
|
||||
el.querySelectorAll('.hm-cron-run').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
try { await gw(`/api/jobs/${encodeURIComponent(btn.dataset.id)}/run`, { method: 'POST' }) } catch (_) {}
|
||||
})
|
||||
})
|
||||
el.querySelectorAll('.hm-cron-edit').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const job = jobs.find(j => (j.id || j.name) === btn.dataset.id)
|
||||
if (job) { editingJob = { ...job, _editing: true }; draw() }
|
||||
})
|
||||
})
|
||||
el.querySelectorAll('.hm-cron-del').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(t('engine.cronDelete') + '?')) return
|
||||
try { await gw(`/api/jobs/${encodeURIComponent(btn.dataset.id)}`, { method: 'DELETE' }) } catch (_) {}
|
||||
await loadJobs(); draw()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function drawForm() {
|
||||
const isEdit = !!editingJob._editing
|
||||
const id = editingJob.id || editingJob.name
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h1 style="margin:0">${isEdit ? escHtml(editingJob.name) : t('engine.cronCreate')}</h1></div>
|
||||
${errorMsg ? `<div style="color:var(--error);font-size:13px;margin-bottom:12px">${escHtml(errorMsg)}</div>` : ''}
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:20px;display:flex;flex-direction:column;gap:14px">
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px">${t('engine.cronName')}</label>
|
||||
<input class="input" id="hm-cron-name" value="${escHtml(editingJob.name)}" style="width:100%" ${isEdit ? 'disabled' : ''}>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px">${t('engine.cronSchedule')} <span style="font-weight:400;color:var(--text-tertiary)">(cron)</span></label>
|
||||
<input class="input" id="hm-cron-schedule" value="${escHtml(editingJob.schedule || '')}" placeholder="0 9 * * *" style="width:100%">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px">${t('engine.cronPrompt')}</label>
|
||||
<textarea class="input" id="hm-cron-prompt" rows="4" style="width:100%;resize:vertical;font-size:14px">${escHtml(editingJob.prompt || '')}</textarea>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;margin-top:4px">
|
||||
<button class="btn btn-primary btn-sm hm-cron-save" ${busy ? 'disabled' : ''}>${t('engine.cronSave')}</button>
|
||||
<button class="btn btn-secondary btn-sm hm-cron-cancel">${t('engine.cronCancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
el.querySelector('.hm-cron-cancel')?.addEventListener('click', () => { editingJob = null; errorMsg = ''; draw() })
|
||||
el.querySelector('.hm-cron-save')?.addEventListener('click', async () => {
|
||||
const name = el.querySelector('#hm-cron-name')?.value?.trim()
|
||||
const schedule = el.querySelector('#hm-cron-schedule')?.value?.trim()
|
||||
const prompt = el.querySelector('#hm-cron-prompt')?.value?.trim()
|
||||
if (!name || !schedule) { errorMsg = 'Name and schedule are required'; draw(); return }
|
||||
busy = true; errorMsg = ''
|
||||
try {
|
||||
if (isEdit) {
|
||||
await gw(`/api/jobs/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify({ schedule, prompt }) })
|
||||
} else {
|
||||
await gw('/api/jobs', { method: 'POST', body: JSON.stringify({ name, schedule, prompt }) })
|
||||
}
|
||||
editingJob = null
|
||||
await loadJobs()
|
||||
} catch (e) {
|
||||
errorMsg = String(e.message || e)
|
||||
}
|
||||
busy = false; draw()
|
||||
})
|
||||
}
|
||||
|
||||
init()
|
||||
return el
|
||||
}
|
||||
556
src/engines/hermes/pages/dashboard.js
Normal file
556
src/engines/hermes/pages/dashboard.js
Normal file
@@ -0,0 +1,556 @@
|
||||
/**
|
||||
* Hermes Agent 仪表盘
|
||||
*/
|
||||
import { t } from '../../../lib/i18n.js'
|
||||
import { api } from '../../../lib/tauri-api.js'
|
||||
import { PROVIDER_PRESETS } from '../../../lib/model-presets.js'
|
||||
|
||||
const ICONS = {
|
||||
running: `<svg viewBox="0 0 24 24" fill="none" stroke="var(--success, #22c55e)" stroke-width="2.5" width="20" height="20"><circle cx="12" cy="12" r="10"/><polyline points="16 12 12 8 8 12"/><line x1="12" y1="16" x2="12" y2="8"/></svg>`,
|
||||
stopped: `<svg viewBox="0 0 24 24" fill="none" stroke="var(--error, #ef4444)" stroke-width="2.5" width="20" height="20"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`,
|
||||
chat: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>`,
|
||||
cron: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
|
||||
config: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>`,
|
||||
refresh: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>`,
|
||||
}
|
||||
|
||||
const HERMES_PROVIDERS = PROVIDER_PRESETS.filter(p => !p.hidden)
|
||||
|
||||
// Lazy Tauri event listen (avoid top-level await for vite build)
|
||||
let _listenFn = null
|
||||
async function tauriListen(event, cb) {
|
||||
if (!_listenFn) {
|
||||
const mod = await import('@tauri-apps/api/event')
|
||||
_listenFn = mod.listen
|
||||
}
|
||||
return _listenFn(event, cb)
|
||||
}
|
||||
|
||||
export function render() {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'page'
|
||||
|
||||
let info = null
|
||||
let health = null
|
||||
let hermesConfig = null // { model, base_url, provider, api_key }
|
||||
let models = [] // fetched model list
|
||||
let loading = true
|
||||
let actionBusy = false
|
||||
let modelBusy = false
|
||||
let fetchBusy = false
|
||||
let cfgMsg = '' // 配置区消息 HTML
|
||||
let showDropdown = false // 模型下拉是否展开
|
||||
let envDetecting = false // 环境探测中
|
||||
let envData = null // { wsl2: {...}, docker: {...} }
|
||||
let connectMode = 'local' // local | wsl2 | docker | custom
|
||||
let customGwUrl = '' // 自定义 Gateway URL
|
||||
let connectMsg = '' // 连接区消息
|
||||
|
||||
// 表单状态(跨 draw 保持,不被覆盖)
|
||||
let formBaseUrl = ''
|
||||
let formApiKey = ''
|
||||
let formModel = ''
|
||||
let formInited = false // 首次加载后用 hermesConfig 初始化
|
||||
|
||||
function syncFormFromDom() {
|
||||
const u = el.querySelector('#hm-cfg-baseurl')
|
||||
const k = el.querySelector('#hm-cfg-apikey')
|
||||
const m = el.querySelector('#hm-cfg-model')
|
||||
if (u) formBaseUrl = u.value
|
||||
if (k) formApiKey = k.value
|
||||
if (m) formModel = m.value
|
||||
}
|
||||
|
||||
function esc(s) { return (s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<') }
|
||||
|
||||
// --- 终端命令 ---
|
||||
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
|
||||
const configPath = isWin ? '%USERPROFILE%\\.hermes' : '~/.hermes'
|
||||
|
||||
const CLI_COMMANDS = [
|
||||
{ label: t('engine.cliChat'), desc: t('engine.cliChatDesc'), cmd: 'hermes chat' },
|
||||
{ label: t('engine.cliDoctor'), desc: t('engine.cliDoctorDesc'), cmd: 'hermes doctor' },
|
||||
{ label: t('engine.cliVersion'), desc: t('engine.cliVersionDesc'), cmd: 'hermes version' },
|
||||
{ label: t('engine.cliGwStart'), desc: t('engine.cliGwStartDesc'), cmd: 'hermes gateway run' },
|
||||
{ label: t('engine.cliGwStop'), desc: t('engine.cliGwStopDesc'), cmd: 'hermes gateway stop' },
|
||||
{ label: t('engine.cliUpgrade'), desc: t('engine.cliUpgradeDesc'), cmd: 'uv tool install --reinstall "hermes-agent @ git+https://github.com/NousResearch/hermes-agent.git" --python 3.11' },
|
||||
{ label: t('engine.cliUninstall'), desc: t('engine.cliUninstallDesc'), cmd: 'uv tool uninstall hermes-agent' },
|
||||
{ label: t('engine.cliConfig'), desc: t('engine.cliConfigDesc'), cmd: isWin ? `explorer ${configPath}` : `open ${configPath}` },
|
||||
]
|
||||
|
||||
function renderCliCommands() {
|
||||
return CLI_COMMANDS.map((c, i) =>
|
||||
`<div class="hm-cli-row">
|
||||
<div class="hm-cli-info">
|
||||
<span class="hm-cli-label">${c.label}</span>
|
||||
<span class="hm-cli-desc">${c.desc}</span>
|
||||
</div>
|
||||
<div class="hm-cli-cmd-wrap">
|
||||
<code class="hm-cli-cmd">${esc(c.cmd)}</code>
|
||||
<button class="hm-cli-copy" data-cmd-idx="${i}" title="${t('common.copy')}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>`
|
||||
).join('')
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const gwRunning = info?.gatewayRunning
|
||||
const port = info?.gatewayPort || 8642
|
||||
const version = info?.version || '-'
|
||||
const modelName = formModel || hermesConfig?.model || health?.model || info?.model || ''
|
||||
const displayModel = modelName || t('engine.dashNoModel')
|
||||
|
||||
// 服务商高亮匹配
|
||||
const activePreset = HERMES_PROVIDERS.find(p => formBaseUrl === p.baseUrl)
|
||||
|
||||
// 模型下拉 HTML
|
||||
const dropdownHtml = showDropdown && models.length
|
||||
? `<div id="hm-model-dropdown" style="position:absolute;top:100%;left:0;right:0;max-height:200px;overflow-y:auto;background:var(--bg-primary);border:1px solid var(--border-primary);border-radius:6px;z-index:100;box-shadow:0 4px 12px rgba(0,0,0,.15)">${models.map(m =>
|
||||
`<div class="hm-model-opt" data-model="${esc(m)}" style="padding:5px 10px;cursor:pointer;font-size:12px;border-bottom:1px solid var(--border-primary);${m === formModel ? 'font-weight:600;color:var(--accent)' : ''}">${esc(m)}</div>`
|
||||
).join('')}</div>`
|
||||
: ''
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="page-header" style="display:flex;align-items:center;gap:12px">
|
||||
<h1 style="margin:0">${t('engine.hermesDashboardTitle')}</h1>
|
||||
<button class="btn-icon hm-dash-refresh" title="Refresh" style="opacity:0.5;cursor:pointer;background:none;border:none;padding:4px">${ICONS.refresh}</button>
|
||||
</div>
|
||||
|
||||
<!-- 状态卡片行 -->
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-bottom:20px">
|
||||
<div class="card" style="border-left:4px solid ${gwRunning ? 'var(--success, #22c55e)' : 'var(--error, #ef4444)'}">
|
||||
<div class="card-body" style="padding:16px">
|
||||
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:6px">${t('engine.dashGatewayStatus')}</div>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
${gwRunning ? ICONS.running : ICONS.stopped}
|
||||
<span style="font-size:16px;font-weight:600">${gwRunning ? t('engine.dashRunning') : t('engine.dashStopped')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:16px">
|
||||
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:6px">${t('engine.dashModel')}</div>
|
||||
<div style="font-size:14px;font-weight:600;word-break:break-all">${esc(displayModel)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:16px">
|
||||
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:6px">${t('engine.dashVersion')}</div>
|
||||
<div style="font-size:14px;font-weight:600">${version}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:16px">
|
||||
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:6px">${t('engine.dashApiEndpoint')}</div>
|
||||
<div style="font-size:13px;font-weight:600;font-family:var(--font-mono, monospace)">http://127.0.0.1:${port}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型配置区 -->
|
||||
<div class="card" style="margin-bottom:20px">
|
||||
<div class="card-body" style="padding:20px">
|
||||
<h3 style="margin:0 0 12px;font-size:15px">${t('engine.dashModelConfig')}</h3>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px">
|
||||
${HERMES_PROVIDERS.map(p =>
|
||||
`<button class="btn btn-sm btn-secondary hm-preset-btn" data-key="${p.key}" data-url="${esc(p.baseUrl)}" data-api="${p.api || 'openai-completions'}" style="font-size:11px;padding:2px 8px;${activePreset?.key === p.key ? 'opacity:1;font-weight:600' : 'opacity:0.6'}">${p.label}</button>`
|
||||
).join('')}
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
|
||||
<label style="display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--text-secondary)">
|
||||
API Base URL
|
||||
<input type="text" id="hm-cfg-baseurl" class="input" value="${esc(formBaseUrl)}" placeholder="https://gpt.qt.cool/v1" style="font-size:13px">
|
||||
</label>
|
||||
<label style="display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--text-secondary)">
|
||||
API Key
|
||||
<input type="password" id="hm-cfg-apikey" class="input" value="${esc(formApiKey)}" placeholder="sk-..." style="font-size:13px">
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:flex-end;margin-bottom:12px">
|
||||
<label style="flex:1;display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--text-secondary)">
|
||||
${t('engine.configModel')}
|
||||
<div style="position:relative">
|
||||
<input type="text" id="hm-cfg-model" class="input" value="${esc(formModel)}" placeholder="QC-B01" style="font-size:13px">
|
||||
${dropdownHtml}
|
||||
</div>
|
||||
</label>
|
||||
<button class="btn btn-sm btn-secondary hm-fetch-models" style="white-space:nowrap;flex-shrink:0" ${fetchBusy ? 'disabled' : ''}>${fetchBusy ? t('engine.configFetching') : t('engine.configFetchModels')}</button>
|
||||
</div>
|
||||
<div id="hm-cfg-msg" style="font-size:12px;min-height:16px;margin-bottom:8px">${cfgMsg}</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-primary btn-sm hm-save-model" ${modelBusy ? 'disabled' : ''}>${modelBusy ? '...' : t('engine.configSaveBtn')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gateway 控制 -->
|
||||
<div class="card" style="margin-bottom:20px">
|
||||
<div class="card-body" style="padding:16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
${!gwRunning ? `<button class="btn btn-primary btn-sm hm-dash-start" ${actionBusy ? 'disabled' : ''}>${actionBusy ? t('engine.gatewayStarting') : t('engine.dashStartGw')}</button>` : ''}
|
||||
${gwRunning ? `<button class="btn btn-sm btn-secondary hm-dash-stop" ${actionBusy ? 'disabled' : ''}>${actionBusy ? t('engine.dashStopping') : t('engine.dashStopGw')}</button>` : ''}
|
||||
${gwRunning ? `<button class="btn btn-sm btn-secondary hm-dash-restart" ${actionBusy ? 'disabled' : ''}>${actionBusy ? t('engine.dashRestarting') : t('engine.dashRestartGw')}</button>` : ''}
|
||||
<div id="hm-dash-msg" style="font-size:12px;margin-left:8px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连接目标 -->
|
||||
<div class="card" style="margin-bottom:20px">
|
||||
<div class="card-body" style="padding:16px">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
|
||||
<h3 style="margin:0;font-size:15px">${t('engine.dashConnectTarget')}</h3>
|
||||
<button class="btn btn-sm btn-secondary hm-detect-env" ${envDetecting ? 'disabled' : ''} style="font-size:11px;padding:2px 10px">${envDetecting ? t('engine.dashDetecting') : t('engine.dashDetectEnv')}</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px">
|
||||
<button class="btn btn-sm hm-connect-mode ${connectMode === 'local' ? 'btn-primary' : 'btn-secondary'}" data-mode="local" style="font-size:11px;padding:2px 10px">
|
||||
🖥️ ${t('engine.dashConnLocal')}
|
||||
</button>
|
||||
${envData?.wsl2?.available ? `<button class="btn btn-sm hm-connect-mode ${connectMode === 'wsl2' ? 'btn-primary' : 'btn-secondary'}" data-mode="wsl2" style="font-size:11px;padding:2px 10px">
|
||||
🐧 WSL2 ${envData.wsl2.gatewayRunning ? '✅' : envData.wsl2.hermesInstalled ? '⚠️' : ''}
|
||||
</button>` : ''}
|
||||
${envData?.docker?.available ? `<button class="btn btn-sm hm-connect-mode ${connectMode === 'docker' ? 'btn-primary' : 'btn-secondary'}" data-mode="docker" style="font-size:11px;padding:2px 10px">
|
||||
🐋 Docker ${envData.docker.hermesContainers?.length ? '✅' : ''}
|
||||
</button>` : ''}
|
||||
<button class="btn btn-sm hm-connect-mode ${connectMode === 'custom' ? 'btn-primary' : 'btn-secondary'}" data-mode="custom" style="font-size:11px;padding:2px 10px">
|
||||
🌐 ${t('engine.dashConnCustom')}
|
||||
</button>
|
||||
</div>
|
||||
${connectMode === 'wsl2' && envData?.wsl2 ? `
|
||||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">
|
||||
<div>IP: <code>${esc(envData.wsl2.ip || '-')}</code> · Distros: ${(envData.wsl2.distros || []).join(', ')}</div>
|
||||
${envData.wsl2.hermesInstalled ? `<div style="color:var(--success)">✓ Hermes ${esc(envData.wsl2.hermesInfo || '')}</div>` : '<div style="color:var(--warning)">Hermes 未安装</div>'}
|
||||
${envData.wsl2.gatewayRunning ? `<div style="color:var(--success)">✓ Gateway: ${esc(envData.wsl2.gatewayUrl || '')}</div>` : '<div style="color:var(--text-tertiary)">Gateway 未运行</div>'}
|
||||
</div>
|
||||
` : ''}
|
||||
${connectMode === 'docker' && envData?.docker ? `
|
||||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">
|
||||
<div>Docker ${esc(envData.docker.version || '')}</div>
|
||||
${envData.docker.hermesContainers?.length ? envData.docker.hermesContainers.map(c =>
|
||||
`<div style="margin-top:4px">🔹 <code>${esc(c.name)}</code> (${esc(c.image)}) — ${esc(c.ports)}</div>`
|
||||
).join('') : '<div style="color:var(--text-tertiary)">未发现 Hermes 容器</div>'}
|
||||
</div>
|
||||
` : ''}
|
||||
${connectMode === 'custom' ? `
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
|
||||
<input type="text" id="hm-custom-gw-url" class="input" value="${esc(customGwUrl)}" placeholder="http://192.168.1.100:8642" style="flex:1;font-size:13px">
|
||||
</div>
|
||||
` : ''}
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button class="btn btn-sm btn-primary hm-apply-connect" style="font-size:11px;padding:2px 12px">${t('engine.dashConnApply')}</button>
|
||||
<span id="hm-connect-msg" style="font-size:12px">${connectMsg}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<div style="margin-bottom:12px;font-size:14px;font-weight:600">${t('engine.dashQuickActions')}</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:24px">
|
||||
<button class="card hm-dash-link" data-route="/h/chat" style="cursor:pointer;border:none;text-align:left">
|
||||
<div class="card-body" style="padding:16px;display:flex;align-items:center;gap:10px">
|
||||
${ICONS.chat}
|
||||
<span style="font-size:14px;font-weight:500">${t('engine.dashOpenChat')}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="card hm-dash-link" data-route="/h/cron" style="cursor:pointer;border:none;text-align:left">
|
||||
<div class="card-body" style="padding:16px;display:flex;align-items:center;gap:10px">
|
||||
${ICONS.cron}
|
||||
<span style="font-size:14px;font-weight:500">${t('engine.dashOpenCron')}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="card hm-dash-link" data-route="/h/setup" style="cursor:pointer;border:none;text-align:left">
|
||||
<div class="card-body" style="padding:16px;display:flex;align-items:center;gap:10px">
|
||||
${ICONS.config}
|
||||
<span style="font-size:14px;font-weight:500">${t('engine.dashOpenSetup')}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 终端命令 -->
|
||||
<div class="card" style="margin-bottom:20px">
|
||||
<div class="card-body" style="padding:20px">
|
||||
<h3 style="margin:0 0 4px;font-size:15px">${t('engine.dashCliTitle')}</h3>
|
||||
<p style="margin:0 0 14px;font-size:12px;color:var(--text-tertiary)">${t('engine.dashCliDesc')}</p>
|
||||
<div class="hm-cli-grid">
|
||||
${renderCliCommands()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
bind()
|
||||
}
|
||||
|
||||
function bind() {
|
||||
el.querySelector('.hm-dash-refresh')?.addEventListener('click', refresh)
|
||||
// Gateway actions
|
||||
el.querySelector('.hm-dash-start')?.addEventListener('click', async () => {
|
||||
actionBusy = true; draw()
|
||||
showGwMsg(t('engine.gatewayStarting'), false)
|
||||
try {
|
||||
const result = await api.hermesGatewayAction('start')
|
||||
showGwMsg(result || 'Gateway 已启动', false)
|
||||
} catch (e) {
|
||||
showGwMsg(String(e).replace(/^Error:\s*/, ''), true)
|
||||
}
|
||||
actionBusy = false; await refresh()
|
||||
})
|
||||
el.querySelector('.hm-dash-stop')?.addEventListener('click', async () => {
|
||||
actionBusy = true; draw()
|
||||
try { await api.hermesGatewayAction('stop') } catch (e) { showGwMsg(String(e).replace(/^Error:\s*/, ''), true) }
|
||||
actionBusy = false; await refresh()
|
||||
})
|
||||
el.querySelector('.hm-dash-restart')?.addEventListener('click', async () => {
|
||||
actionBusy = true; draw()
|
||||
try { await api.hermesGatewayAction('stop') } catch (_) { /* ignore stop failure on Windows */ }
|
||||
await new Promise(r => setTimeout(r, 1500))
|
||||
try {
|
||||
await api.hermesGatewayAction('start')
|
||||
} catch (e) { showGwMsg(String(e).replace(/^Error:\s*/, ''), true) }
|
||||
actionBusy = false; await refresh()
|
||||
})
|
||||
// Quick links
|
||||
el.querySelectorAll('.hm-dash-link').forEach(btn => {
|
||||
btn.addEventListener('click', () => { window.location.hash = '#' + btn.dataset.route })
|
||||
})
|
||||
// Provider presets — 点击填充 URL
|
||||
el.querySelectorAll('.hm-preset-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
formBaseUrl = btn.dataset.url
|
||||
draw()
|
||||
})
|
||||
})
|
||||
// Fetch models — 通过 Rust 后端代理获取(避免 CORS)
|
||||
el.querySelector('.hm-fetch-models')?.addEventListener('click', doFetchModels)
|
||||
// Model dropdown click
|
||||
el.querySelectorAll('.hm-model-opt').forEach(opt => {
|
||||
opt.addEventListener('click', () => {
|
||||
formModel = opt.dataset.model
|
||||
showDropdown = false
|
||||
draw()
|
||||
})
|
||||
})
|
||||
// 输入框聚焦时展开已获取的下拉
|
||||
el.querySelector('#hm-cfg-model')?.addEventListener('focus', () => {
|
||||
if (models.length) { showDropdown = true; syncFormFromDom(); draw() }
|
||||
})
|
||||
// 点击外部收起下拉
|
||||
el.addEventListener('click', (e) => {
|
||||
if (showDropdown && !e.target.closest('#hm-cfg-model') && !e.target.closest('#hm-model-dropdown') && !e.target.closest('.hm-fetch-models')) {
|
||||
showDropdown = false; syncFormFromDom(); draw()
|
||||
}
|
||||
})
|
||||
// Save model config
|
||||
el.querySelector('.hm-save-model')?.addEventListener('click', doSaveModel)
|
||||
// --- 连接目标 ---
|
||||
el.querySelector('.hm-detect-env')?.addEventListener('click', doDetectEnv)
|
||||
el.querySelectorAll('.hm-connect-mode').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
connectMode = btn.dataset.mode
|
||||
// WSL2 选中时自动填充 URL
|
||||
if (connectMode === 'wsl2' && envData?.wsl2?.gatewayUrl) {
|
||||
customGwUrl = envData.wsl2.gatewayUrl
|
||||
}
|
||||
syncFormFromDom(); draw()
|
||||
})
|
||||
})
|
||||
el.querySelector('.hm-apply-connect')?.addEventListener('click', doApplyConnect)
|
||||
// CLI copy buttons
|
||||
el.querySelectorAll('.hm-cli-copy').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const idx = parseInt(btn.dataset.cmdIdx)
|
||||
const cmd = CLI_COMMANDS[idx]?.cmd
|
||||
if (!cmd) return
|
||||
navigator.clipboard.writeText(cmd).then(() => {
|
||||
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="var(--success, #22c55e)" stroke-width="2.5" width="14" height="14"><polyline points="20 6 9 17 4 12"/></svg>'
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>'
|
||||
}, 1500)
|
||||
}).catch(() => {})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function doFetchModels() {
|
||||
syncFormFromDom()
|
||||
if (!formBaseUrl) { cfgMsg = `<span style="color:var(--warning)">${t('engine.configFetchNeedUrl')}</span>`; draw(); return }
|
||||
if (!formApiKey) { cfgMsg = `<span style="color:var(--warning)">${t('engine.configFetchNeedKey')}</span>`; draw(); return }
|
||||
|
||||
const matched = HERMES_PROVIDERS.find(p => formBaseUrl === p.baseUrl)
|
||||
const apiType = matched?.api || 'openai-completions'
|
||||
|
||||
fetchBusy = true; cfgMsg = ''; draw()
|
||||
try {
|
||||
const fetchedModels = await api.hermesFetchModels(formBaseUrl, formApiKey, apiType)
|
||||
models = fetchedModels || []
|
||||
cfgMsg = `<span style="color:var(--success)">✓ ${t('engine.configFetchSuccess', { count: models.length })}</span>`
|
||||
showDropdown = models.length > 0
|
||||
} catch (err) {
|
||||
const msg = String(err).replace(/^Error:\s*/, '')
|
||||
cfgMsg = `<span style="color:var(--error)">✗ ${msg}</span>`
|
||||
} finally {
|
||||
fetchBusy = false; draw()
|
||||
}
|
||||
}
|
||||
|
||||
async function doSaveModel() {
|
||||
syncFormFromDom()
|
||||
if (!formApiKey) { cfgMsg = `<span style="color:var(--warning)">${t('engine.configFetchNeedKey')}</span>`; draw(); return }
|
||||
if (!formModel) { cfgMsg = `<span style="color:var(--warning)">请输入模型名</span>`; draw(); return }
|
||||
|
||||
const matched = HERMES_PROVIDERS.find(p => formBaseUrl && p.baseUrl === formBaseUrl)
|
||||
const provider = matched?.key || 'custom'
|
||||
|
||||
modelBusy = true; cfgMsg = ''; draw()
|
||||
try {
|
||||
await api.configureHermes(provider, formApiKey, formModel, formBaseUrl || null)
|
||||
cfgMsg = `<span style="color:var(--success)">✓ 配置已保存</span>`
|
||||
// 刷新后端状态(不覆盖 form)
|
||||
try { hermesConfig = await api.hermesReadConfig() } catch (_) {}
|
||||
} catch (e) {
|
||||
cfgMsg = `<span style="color:var(--error)">✗ ${String(e).replace(/^Error:\s*/, '')}</span>`
|
||||
} finally {
|
||||
modelBusy = false; draw()
|
||||
}
|
||||
}
|
||||
|
||||
async function doDetectEnv() {
|
||||
envDetecting = true; draw()
|
||||
try {
|
||||
envData = await api.hermesDetectEnvironments()
|
||||
} catch (e) {
|
||||
connectMsg = `<span style="color:var(--error)">探测失败: ${String(e).replace(/^Error:\s*/, '')}</span>`
|
||||
}
|
||||
envDetecting = false; draw()
|
||||
}
|
||||
|
||||
async function doApplyConnect() {
|
||||
let targetUrl = null
|
||||
if (connectMode === 'local') {
|
||||
targetUrl = null // 清除自定义,使用本地默认
|
||||
} else if (connectMode === 'wsl2') {
|
||||
targetUrl = envData?.wsl2?.gatewayUrl || null
|
||||
if (!targetUrl) {
|
||||
connectMsg = '<span style="color:var(--warning)">WSL2 Gateway 未运行,请先在 WSL 中启动</span>'
|
||||
draw(); return
|
||||
}
|
||||
} else if (connectMode === 'docker') {
|
||||
// Docker 模式暂时需要用户提供 URL
|
||||
const urlInput = el.querySelector('#hm-custom-gw-url')
|
||||
targetUrl = urlInput?.value?.trim() || null
|
||||
if (!targetUrl && envData?.docker?.hermesContainers?.length) {
|
||||
connectMsg = '<span style="color:var(--warning)">请切换到"自定义"模式并输入容器的 Gateway URL</span>'
|
||||
draw(); return
|
||||
}
|
||||
} else if (connectMode === 'custom') {
|
||||
const urlInput = el.querySelector('#hm-custom-gw-url')
|
||||
targetUrl = urlInput?.value?.trim() || null
|
||||
if (!targetUrl) {
|
||||
connectMsg = '<span style="color:var(--warning)">请输入 Gateway URL</span>'
|
||||
draw(); return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.hermesSetGatewayUrl(targetUrl)
|
||||
connectMsg = `<span style="color:var(--success)">✓ ${result}</span>`
|
||||
// 刷新状态
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
connectMsg = `<span style="color:var(--error)">✗ ${String(e).replace(/^Error:\s*/, '')}</span>`
|
||||
draw()
|
||||
}
|
||||
}
|
||||
|
||||
function showGwMsg(msg, isErr) {
|
||||
const msgEl = el.querySelector('#hm-dash-msg')
|
||||
if (msgEl) {
|
||||
msgEl.textContent = msg
|
||||
msgEl.style.color = isErr ? 'var(--error)' : 'var(--success)'
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
info = await api.checkHermes()
|
||||
if (info?.gatewayRunning) {
|
||||
try { health = await api.hermesHealthCheck() } catch (_) {}
|
||||
} else {
|
||||
health = null
|
||||
}
|
||||
try { hermesConfig = await api.hermesReadConfig() } catch (_) {}
|
||||
} catch (_) {}
|
||||
loading = false
|
||||
// 首次加载时用 hermesConfig 初始化表单
|
||||
if (!formInited && hermesConfig) {
|
||||
formBaseUrl = hermesConfig.base_url || ''
|
||||
formApiKey = hermesConfig.api_key || ''
|
||||
formModel = hermesConfig.model || ''
|
||||
formInited = true
|
||||
}
|
||||
draw()
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
refresh()
|
||||
|
||||
// --- Guardian 事件监听:实时响应 Gateway 状态变化 ---
|
||||
let unlisteners = []
|
||||
let autoRefreshTimer = null
|
||||
|
||||
async function setupListeners() {
|
||||
try {
|
||||
// 监听 Guardian 推送的状态变化
|
||||
const unlisten1 = await tauriListen('hermes-gateway-status', (evt) => {
|
||||
const data = evt.payload
|
||||
if (info) {
|
||||
const wasRunning = info.gatewayRunning
|
||||
info.gatewayRunning = !!data.running
|
||||
if (data.port) info.gatewayPort = data.port
|
||||
// 状态变化时刷新(不覆盖 form 表单)
|
||||
if (wasRunning !== info.gatewayRunning) {
|
||||
draw()
|
||||
}
|
||||
}
|
||||
})
|
||||
unlisteners.push(unlisten1)
|
||||
|
||||
// 监听 Guardian 日志(显示在消息区)
|
||||
const unlisten2 = await tauriListen('hermes-guardian-log', (evt) => {
|
||||
showGwMsg(evt.payload || '', false)
|
||||
})
|
||||
unlisteners.push(unlisten2)
|
||||
} catch (_) {
|
||||
// Web 模式下无 Tauri 事件,静默忽略
|
||||
}
|
||||
|
||||
// 定期自动刷新(15s),作为事件监听的补充
|
||||
autoRefreshTimer = setInterval(async () => {
|
||||
if (actionBusy || modelBusy) return
|
||||
try {
|
||||
const newInfo = await api.checkHermes()
|
||||
if (newInfo && info) {
|
||||
const changed = newInfo.gatewayRunning !== info.gatewayRunning
|
||||
info = newInfo
|
||||
if (changed) draw()
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 15000)
|
||||
}
|
||||
setupListeners()
|
||||
|
||||
// 页面卸载时清理
|
||||
const cleanup = () => {
|
||||
unlisteners.forEach(fn => fn())
|
||||
unlisteners = []
|
||||
if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null }
|
||||
}
|
||||
// MutationObserver 检测元素从 DOM 移除
|
||||
const detachObserver = new MutationObserver(() => {
|
||||
if (!el.isConnected) { cleanup(); detachObserver.disconnect() }
|
||||
})
|
||||
requestAnimationFrame(() => {
|
||||
if (el.parentNode) detachObserver.observe(el.parentNode, { childList: true })
|
||||
})
|
||||
|
||||
return el
|
||||
}
|
||||
16
src/engines/hermes/pages/services.js
Normal file
16
src/engines/hermes/pages/services.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Hermes Agent 服务管理
|
||||
*/
|
||||
import { t } from '../../../lib/i18n.js'
|
||||
|
||||
export function render() {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'page'
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h1>${t('engine.hermesServicesTitle')}</h1></div>
|
||||
<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">
|
||||
${t('engine.comingSoonPhase2')}
|
||||
</div></div>
|
||||
`
|
||||
return el
|
||||
}
|
||||
563
src/engines/hermes/pages/setup.js
Normal file
563
src/engines/hermes/pages/setup.js
Normal file
@@ -0,0 +1,563 @@
|
||||
/**
|
||||
* Hermes Agent 一键安装/配置向导
|
||||
*
|
||||
* 状态机: detect → install → configure → gateway → complete
|
||||
*/
|
||||
import { t } from '../../../lib/i18n.js'
|
||||
import { api } from '../../../lib/tauri-api.js'
|
||||
import { PROVIDER_PRESETS } from '../../../lib/model-presets.js'
|
||||
import { getActiveEngine } from '../../../lib/engine-manager.js'
|
||||
|
||||
// SVG 图标
|
||||
const ICONS = {
|
||||
check: `<svg viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2.5" width="16" height="16"><polyline points="20 6 9 17 4 12"/></svg>`,
|
||||
warn: `<svg viewBox="0 0 24 24" fill="none" stroke="var(--warning, #f59e0b)" stroke-width="2" width="16" height="16"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
|
||||
error: `<svg viewBox="0 0 24 24" fill="none" stroke="var(--error, #ef4444)" stroke-width="2" width="16" height="16"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`,
|
||||
spinner: `<svg class="hermes-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M12 2a10 10 0 0110 10"/></svg>`,
|
||||
rocket: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 00-2.91-.09z"/><path d="M12 15l-3-3a22 22 0 012-3.95A12.88 12.88 0 0122 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 01-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg>`,
|
||||
done: `<svg viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" width="24" height="24"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>`,
|
||||
}
|
||||
|
||||
// 可选 extras
|
||||
const EXTRAS_LIST = [
|
||||
{ key: 'cron', i18n: 'extraCron', recommended: true },
|
||||
{ key: 'cli', i18n: 'extraCli', recommended: true },
|
||||
{ key: 'pty', i18n: 'extraPty', recommended: true },
|
||||
{ key: 'mcp', i18n: 'extraMcp', recommended: true },
|
||||
{ key: 'messaging', i18n: 'extraMessaging' },
|
||||
{ key: 'feishu', i18n: 'extraFeishu' },
|
||||
{ key: 'dingtalk', i18n: 'extraDingtalk' },
|
||||
{ key: 'slack', i18n: 'extraSlack' },
|
||||
{ key: 'voice', i18n: 'extraVoice' },
|
||||
]
|
||||
|
||||
// Hermes 使用 OpenAI 兼容接口,过滤出兼容的服务商
|
||||
const HERMES_PROVIDERS = PROVIDER_PRESETS.filter(p => !p.hidden)
|
||||
|
||||
export function render() {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'page'
|
||||
|
||||
// 状态
|
||||
let phase = 'detect' // detect | install | configure | gateway | complete
|
||||
let pyInfo = null
|
||||
let hermesInfo = null
|
||||
let logs = []
|
||||
let installing = false
|
||||
let progress = 0
|
||||
let showLogs = false
|
||||
let selectedExtras = ['cron', 'cli', 'pty', 'mcp']
|
||||
let unlisten = null
|
||||
|
||||
function draw() {
|
||||
el.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>Hermes Agent</h1>
|
||||
<p style="color:var(--text-secondary);margin-top:4px">${t('engine.hermesSetupDesc')}</p>
|
||||
</div>
|
||||
<div style="max-width:720px">
|
||||
${renderPhaseIndicator()}
|
||||
${phase === 'detect' ? renderDetect() : ''}
|
||||
${phase === 'install' ? renderInstall() : ''}
|
||||
${phase === 'configure' ? renderConfigure() : ''}
|
||||
${phase === 'gateway' ? renderGateway() : ''}
|
||||
${phase === 'complete' ? renderComplete() : ''}
|
||||
${renderLogPanel()}
|
||||
<div style="margin-top:16px;text-align:right">
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/getting-started/installation/" target="_blank" rel="noopener"
|
||||
style="font-size:13px;color:var(--accent);text-decoration:none">
|
||||
${t('engine.hermesSetupDocLink')} →
|
||||
</a>
|
||||
</div>
|
||||
</div>`
|
||||
bind()
|
||||
}
|
||||
|
||||
// --- 阶段指示器 ---
|
||||
function renderPhaseIndicator() {
|
||||
const phases = [
|
||||
{ id: 'detect', label: '检测' },
|
||||
{ id: 'install', label: '安装' },
|
||||
{ id: 'configure', label: '配置' },
|
||||
{ id: 'gateway', label: '启动' },
|
||||
{ id: 'complete', label: '完成' },
|
||||
]
|
||||
const idx = phases.findIndex(p => p.id === phase)
|
||||
return `<div class="hermes-phases">${phases.map((p, i) => {
|
||||
const cls = i < idx ? 'done' : i === idx ? 'active' : ''
|
||||
return `<div class="hermes-phase ${cls}">
|
||||
<span class="hermes-phase-dot">${i < idx ? ICONS.check : i + 1}</span>
|
||||
<span class="hermes-phase-label">${p.label}</span>
|
||||
</div>`
|
||||
}).join('<div class="hermes-phase-line"></div>')}</div>`
|
||||
}
|
||||
|
||||
// --- 检测阶段 ---
|
||||
function renderDetect() {
|
||||
const rows = []
|
||||
if (!pyInfo && !hermesInfo) {
|
||||
rows.push(`<div class="hermes-detect-row">${ICONS.spinner} <span>${t('engine.detecting')}</span></div>`)
|
||||
} else {
|
||||
// Python
|
||||
if (pyInfo) {
|
||||
if (pyInfo.installed && pyInfo.versionOk) {
|
||||
rows.push(`<div class="hermes-detect-row ok">${ICONS.check} <span>${t('engine.pythonFound', { version: pyInfo.version })}</span></div>`)
|
||||
} else if (pyInfo.installed && !pyInfo.versionOk) {
|
||||
rows.push(`<div class="hermes-detect-row warn">${ICONS.warn} <span>${t('engine.pythonTooOld', { version: pyInfo.version })}</span></div>`)
|
||||
} else {
|
||||
rows.push(`<div class="hermes-detect-row warn">${ICONS.warn} <span>${t('engine.pythonNotFound')}</span></div>`)
|
||||
}
|
||||
// uv
|
||||
if (pyInfo.hasUv) {
|
||||
rows.push(`<div class="hermes-detect-row ok">${ICONS.check} <span>${t('engine.uvFound')}</span></div>`)
|
||||
} else {
|
||||
rows.push(`<div class="hermes-detect-row warn">${ICONS.warn} <span>${t('engine.uvNotFound')}</span></div>`)
|
||||
}
|
||||
// git(从 GitHub 安装需要)
|
||||
if (pyInfo.hasGit) {
|
||||
rows.push(`<div class="hermes-detect-row ok">${ICONS.check} <span>${t('engine.gitFound')}</span></div>`)
|
||||
} else {
|
||||
rows.push(`<div class="hermes-detect-row warn">${ICONS.error} <span>${t('engine.gitNotFound')}</span></div>`)
|
||||
}
|
||||
}
|
||||
// Hermes
|
||||
if (hermesInfo) {
|
||||
if (hermesInfo.installed) {
|
||||
rows.push(`<div class="hermes-detect-row ok">${ICONS.check} <span>${t('engine.hermesFound', { version: hermesInfo.version })}</span></div>`)
|
||||
if (hermesInfo.gatewayRunning) {
|
||||
rows.push(`<div class="hermes-detect-row ok">${ICONS.check} <span>${t('engine.hermesReady')}</span></div>`)
|
||||
}
|
||||
} else {
|
||||
rows.push(`<div class="hermes-detect-row">${ICONS.warn} <span>${t('engine.hermesNotFound')}</span></div>`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return `<div class="card" style="margin-bottom:16px">
|
||||
<div class="card-body" style="padding:24px">
|
||||
<p style="color:var(--text-secondary);line-height:1.7;margin:0 0 16px">${t('engine.hermesSetupIntro')}</p>
|
||||
<div class="hermes-detect-list">${rows.join('')}</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
// --- 安装阶段 ---
|
||||
function renderInstall() {
|
||||
const extrasHtml = EXTRAS_LIST.map(ex => {
|
||||
const checked = selectedExtras.includes(ex.key) ? 'checked' : ''
|
||||
return `<label class="hermes-extra-item">
|
||||
<input type="checkbox" value="${ex.key}" ${checked} class="hermes-extra-cb">
|
||||
<span>${t('engine.' + ex.i18n)}${ex.recommended ? ' ⭐' : ''}</span>
|
||||
</label>`
|
||||
}).join('')
|
||||
|
||||
const btnText = installing ? `${ICONS.spinner} ${t('engine.installingBtn')}` : `${ICONS.rocket} ${t('engine.installBtn')}`
|
||||
const btnDisabled = installing ? 'disabled' : ''
|
||||
|
||||
return `<div class="card" style="margin-bottom:16px">
|
||||
<div class="card-body" style="padding:24px">
|
||||
<h3 style="margin:0 0 4px;font-size:16px">${t('engine.installTitle')}</h3>
|
||||
<p style="color:var(--text-secondary);margin:0 0 20px;font-size:13px">${t('engine.installDesc')}</p>
|
||||
|
||||
<div style="margin-bottom:20px">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:8px">${t('engine.extrasTitle')}</div>
|
||||
<p style="font-size:12px;color:var(--text-tertiary);margin:0 0 10px">${t('engine.extrasDesc')}</p>
|
||||
<div class="hermes-extras-grid">${extrasHtml}</div>
|
||||
<button class="btn-text hermes-select-all" style="margin-top:6px;font-size:12px">${t('engine.extraAll')}</button>
|
||||
</div>
|
||||
|
||||
${progress > 0 ? `<div class="hermes-progress"><div class="hermes-progress-bar" style="width:${progress}%"></div></div>` : ''}
|
||||
|
||||
<div style="display:flex;gap:10px;align-items:center">
|
||||
<button class="btn btn-primary hermes-install-btn" ${btnDisabled}>${btnText}</button>
|
||||
${!installing ? `<button class="btn-text hermes-toggle-logs" style="font-size:12px">${showLogs ? t('engine.hideLogs') : t('engine.viewLogs')}</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
// --- 配置阶段 ---
|
||||
function renderConfigure() {
|
||||
const presetBtns = HERMES_PROVIDERS.map(p =>
|
||||
`<button class="btn btn-sm btn-secondary hermes-preset-btn" data-key="${p.key}" data-url="${p.baseUrl}" data-api="${p.api}" style="font-size:12px;padding:3px 10px;margin:0 6px 6px 0">${p.label}${p.badge ? ` <span style="font-size:9px;background:var(--accent);color:#fff;padding:1px 4px;border-radius:6px;margin-left:3px">${p.badge}</span>` : ''}</button>`
|
||||
).join('')
|
||||
|
||||
return `<div class="card" style="margin-bottom:16px">
|
||||
<div class="card-body" style="padding:24px">
|
||||
<h3 style="margin:0 0 4px;font-size:16px">${t('engine.configTitle')}</h3>
|
||||
<p style="color:var(--text-secondary);margin:0 0 20px;font-size:13px">${t('engine.configDesc')}</p>
|
||||
|
||||
<div class="hermes-form">
|
||||
<div class="hermes-field">
|
||||
<span>${t('engine.configProvider')}</span>
|
||||
<div style="display:flex;flex-wrap:wrap">${presetBtns}</div>
|
||||
<div id="hm-preset-detail" style="display:none;margin-top:6px;padding:8px 12px;background:var(--bg-tertiary);border-radius:var(--radius-md,8px);font-size:12px"></div>
|
||||
</div>
|
||||
<label class="hermes-field">
|
||||
<span>API Base URL</span>
|
||||
<input type="text" id="hm-baseurl" class="input" placeholder="https://openrouter.ai/api/v1">
|
||||
</label>
|
||||
<div class="hermes-field">
|
||||
<span>${t('engine.configApiKey')}</span>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input type="password" id="hm-apikey" class="input" placeholder="sk-..." autocomplete="off" style="flex:1">
|
||||
<button class="btn btn-sm btn-secondary hermes-fetch-models" style="white-space:nowrap;flex-shrink:0">${t('engine.configFetchModels')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hm-fetch-result" style="font-size:12px;min-height:16px;margin:-6px 0 2px"></div>
|
||||
<div class="hermes-field">
|
||||
<span>${t('engine.configModel')}</span>
|
||||
<div style="position:relative">
|
||||
<input type="text" id="hm-model" class="input" placeholder="anthropic/claude-sonnet-4-20250514" autocomplete="off">
|
||||
<div id="hm-model-dropdown" class="hermes-model-dropdown" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-top:20px">
|
||||
<button class="btn btn-primary hermes-config-save">${t('engine.configSaveBtn')}</button>
|
||||
<button class="btn-text hermes-config-skip">${t('engine.configSkipBtn')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
// --- Gateway 阶段 ---
|
||||
function renderGateway() {
|
||||
const running = hermesInfo?.gatewayRunning
|
||||
return `<div class="card" style="margin-bottom:16px">
|
||||
<div class="card-body" style="padding:24px">
|
||||
<h3 style="margin:0 0 4px;font-size:16px">${t('engine.gatewayTitle')}</h3>
|
||||
<p style="color:var(--text-secondary);margin:0 0 20px;font-size:13px">${t('engine.gatewayDesc')}</p>
|
||||
<div class="hermes-detect-row ${running ? 'ok' : ''}">
|
||||
${running ? ICONS.check : ICONS.warn}
|
||||
<span>${running ? t('engine.gatewayRunning', { port: hermesInfo?.gatewayPort || 8642 }) : t('engine.gatewayStopped')}</span>
|
||||
</div>
|
||||
<div id="hm-gw-error" style="display:none;margin-top:12px;padding:10px 14px;background:var(--error-bg, #fef2f2);border:1px solid var(--error, #ef4444);border-radius:var(--radius-sm,6px);color:var(--error, #ef4444);font-size:13px;line-height:1.5;word-break:break-all"></div>
|
||||
<div style="display:flex;gap:10px;margin-top:16px">
|
||||
${!running ? `<button class="btn btn-primary hermes-gw-start">${t('engine.gatewayStartBtn')}</button>` : ''}
|
||||
<button class="btn btn-primary hermes-gw-next">${running ? t('engine.goToDashboard') : t('engine.configSkipBtn')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
// --- 完成 ---
|
||||
function renderComplete() {
|
||||
return `<div class="card" style="margin-bottom:16px">
|
||||
<div class="card-body" style="padding:32px;text-align:center">
|
||||
<div style="margin-bottom:12px">${ICONS.done}</div>
|
||||
<h3 style="margin:0 0 6px;font-size:18px">${t('engine.setupComplete')}</h3>
|
||||
<p style="color:var(--text-secondary);margin:0 0 20px">${t('engine.setupCompleteDesc')}</p>
|
||||
<button class="btn btn-primary hermes-go-dashboard">${t('engine.goToDashboard')}</button>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
// --- 日志面板 ---
|
||||
function renderLogPanel() {
|
||||
if (!showLogs || logs.length === 0) return ''
|
||||
return `<div class="hermes-log-panel">
|
||||
<div class="hermes-log-content">${logs.map(l => `<div>${esc(l)}</div>`).join('')}</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
// --- 事件绑定 ---
|
||||
function bind() {
|
||||
// 安装按钮
|
||||
el.querySelector('.hermes-install-btn')?.addEventListener('click', doInstall)
|
||||
// 全选 extras
|
||||
el.querySelector('.hermes-select-all')?.addEventListener('click', () => {
|
||||
selectedExtras = EXTRAS_LIST.map(e => e.key)
|
||||
draw()
|
||||
})
|
||||
// extras checkbox
|
||||
el.querySelectorAll('.hermes-extra-cb').forEach(cb => {
|
||||
cb.addEventListener('change', () => {
|
||||
if (cb.checked && !selectedExtras.includes(cb.value)) selectedExtras.push(cb.value)
|
||||
else selectedExtras = selectedExtras.filter(k => k !== cb.value)
|
||||
})
|
||||
})
|
||||
// 日志切换
|
||||
el.querySelector('.hermes-toggle-logs')?.addEventListener('click', () => {
|
||||
showLogs = !showLogs; draw()
|
||||
})
|
||||
// 服务商预设按钮
|
||||
el.querySelectorAll('.hermes-preset-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const baseUrlInput = el.querySelector('#hm-baseurl')
|
||||
if (baseUrlInput) baseUrlInput.value = btn.dataset.url
|
||||
// 高亮选中
|
||||
el.querySelectorAll('.hermes-preset-btn').forEach(b => b.style.opacity = '0.5')
|
||||
btn.style.opacity = '1'
|
||||
// 显示服务商详情
|
||||
const preset = HERMES_PROVIDERS.find(p => p.key === btn.dataset.key)
|
||||
const detailEl = el.querySelector('#hm-preset-detail')
|
||||
if (detailEl && preset) {
|
||||
let html = preset.desc ? `<div style="color:var(--text-secondary);line-height:1.5">${preset.desc}</div>` : ''
|
||||
if (preset.site) html += `<a href="${preset.site}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-size:11px;margin-top:3px;display:inline-block">→ ${preset.label} 官网</a>`
|
||||
detailEl.innerHTML = html
|
||||
detailEl.style.display = html ? 'block' : 'none'
|
||||
}
|
||||
})
|
||||
})
|
||||
// 获取模型列表
|
||||
el.querySelector('.hermes-fetch-models')?.addEventListener('click', doFetchModels)
|
||||
// 模型下拉选择:点击选项填入 input
|
||||
el.querySelector('#hm-model-dropdown')?.addEventListener('click', (e) => {
|
||||
const opt = e.target.closest('.hermes-model-option')
|
||||
if (!opt) return
|
||||
const modelInput = el.querySelector('#hm-model')
|
||||
if (modelInput) modelInput.value = opt.dataset.model
|
||||
el.querySelector('#hm-model-dropdown').style.display = 'none'
|
||||
})
|
||||
// 点击 input 时如果有下拉就展开
|
||||
el.querySelector('#hm-model')?.addEventListener('focus', () => {
|
||||
const dd = el.querySelector('#hm-model-dropdown')
|
||||
if (dd && dd.children.length > 0) dd.style.display = 'block'
|
||||
})
|
||||
// 点击其他地方关闭下拉
|
||||
document.addEventListener('click', (e) => {
|
||||
const dd = el.querySelector('#hm-model-dropdown')
|
||||
if (dd && !e.target.closest('.hermes-field')) dd.style.display = 'none'
|
||||
})
|
||||
// 配置保存
|
||||
el.querySelector('.hermes-config-save')?.addEventListener('click', doSaveConfig)
|
||||
el.querySelector('.hermes-config-skip')?.addEventListener('click', () => { phase = 'gateway'; refreshHermes() })
|
||||
// Gateway
|
||||
el.querySelector('.hermes-gw-start')?.addEventListener('click', doStartGateway)
|
||||
el.querySelector('.hermes-gw-next')?.addEventListener('click', () => {
|
||||
if (hermesInfo?.gatewayRunning) { phase = 'complete'; draw() }
|
||||
else { phase = 'complete'; draw() }
|
||||
})
|
||||
// 仪表盘
|
||||
el.querySelector('.hermes-go-dashboard')?.addEventListener('click', async () => {
|
||||
const engine = getActiveEngine()
|
||||
if (engine?.detect) await engine.detect()
|
||||
window.location.hash = '#/h/dashboard'
|
||||
})
|
||||
// 自动滚日志到底
|
||||
const logEl = el.querySelector('.hermes-log-content')
|
||||
if (logEl) logEl.scrollTop = logEl.scrollHeight
|
||||
}
|
||||
|
||||
// --- 检测流程 ---
|
||||
async function detect() {
|
||||
phase = 'detect'
|
||||
draw()
|
||||
try {
|
||||
const [py, hm] = await Promise.all([api.checkPython(), api.checkHermes()])
|
||||
pyInfo = py
|
||||
hermesInfo = hm
|
||||
|
||||
draw()
|
||||
|
||||
// 自动跳转
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
if (hm.installed && hm.gatewayRunning) {
|
||||
phase = 'complete'
|
||||
} else if (hm.installed && hm.configExists) {
|
||||
phase = 'gateway'
|
||||
} else if (hm.installed) {
|
||||
phase = 'configure'
|
||||
} else {
|
||||
phase = 'install'
|
||||
}
|
||||
draw()
|
||||
} catch (e) {
|
||||
logs.push(`检测错误: ${e}`)
|
||||
phase = 'install'
|
||||
draw()
|
||||
}
|
||||
}
|
||||
|
||||
// --- 安装流程 ---
|
||||
async function doInstall() {
|
||||
installing = true
|
||||
progress = 0
|
||||
showLogs = true
|
||||
logs = []
|
||||
draw()
|
||||
|
||||
// 监听事件
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
const u1 = await listen('hermes-install-log', (e) => {
|
||||
logs.push(String(e.payload))
|
||||
const logEl = el.querySelector('.hermes-log-content')
|
||||
if (logEl) {
|
||||
logEl.innerHTML += `<div>${esc(String(e.payload))}</div>`
|
||||
logEl.scrollTop = logEl.scrollHeight
|
||||
}
|
||||
})
|
||||
const u2 = await listen('hermes-install-progress', (e) => {
|
||||
progress = Number(e.payload) || 0
|
||||
const bar = el.querySelector('.hermes-progress-bar')
|
||||
if (bar) bar.style.width = progress + '%'
|
||||
})
|
||||
unlisten = () => { u1(); u2() }
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
await api.installHermes('uv-tool', selectedExtras)
|
||||
installing = false
|
||||
progress = 100
|
||||
logs.push(t('engine.installSuccess'))
|
||||
phase = 'configure'
|
||||
draw()
|
||||
} catch (e) {
|
||||
installing = false
|
||||
logs.push(`${t('engine.installFailed')}: ${e}`)
|
||||
draw()
|
||||
} finally {
|
||||
if (unlisten) { unlisten(); unlisten = null }
|
||||
}
|
||||
}
|
||||
|
||||
// --- 获取模型列表 ---
|
||||
async function doFetchModels() {
|
||||
const btn = el.querySelector('.hermes-fetch-models')
|
||||
const resultEl = el.querySelector('#hm-fetch-result')
|
||||
const dropdown = el.querySelector('#hm-model-dropdown')
|
||||
const baseUrl = el.querySelector('#hm-baseurl')?.value?.trim()
|
||||
const apiKey = el.querySelector('#hm-apikey')?.value?.trim()
|
||||
|
||||
if (!baseUrl) {
|
||||
if (resultEl) resultEl.innerHTML = `<span style="color:var(--warning)">${t('engine.configFetchNeedUrl')}</span>`
|
||||
return
|
||||
}
|
||||
if (!apiKey) {
|
||||
if (resultEl) resultEl.innerHTML = `<span style="color:var(--warning)">${t('engine.configFetchNeedKey')}</span>`
|
||||
return
|
||||
}
|
||||
|
||||
if (btn) { btn.disabled = true; btn.textContent = t('engine.configFetching') }
|
||||
if (resultEl) resultEl.innerHTML = `<span style="color:var(--text-tertiary)">${t('engine.configFetching')}</span>`
|
||||
|
||||
try {
|
||||
// 清理 URL:去掉尾部多余路径,确保 /models 能正确拼接
|
||||
let base = baseUrl.replace(/\/+$/, '')
|
||||
// 移除常见尾部路径
|
||||
base = base.replace(/\/(chat\/completions|completions|responses|messages|models)\/?$/, '')
|
||||
|
||||
// 判断 API 类型(大部分是 OpenAI 兼容)
|
||||
const matched = HERMES_PROVIDERS.find(p => baseUrl === p.baseUrl)
|
||||
const apiType = matched?.api || 'openai-completions'
|
||||
|
||||
let models = []
|
||||
|
||||
if (apiType === 'anthropic-messages') {
|
||||
// Anthropic 格式
|
||||
if (!base.endsWith('/v1')) base += '/v1'
|
||||
const resp = await fetch(base + '/models', {
|
||||
headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', 'x-api-key': apiKey },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status)
|
||||
const data = await resp.json()
|
||||
models = (data.data || []).map(m => m.id).filter(Boolean).sort()
|
||||
} else if (apiType === 'google-generative-ai') {
|
||||
// Google Gemini
|
||||
const resp = await fetch(base + '/models?key=' + apiKey, { signal: AbortSignal.timeout(15000) })
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status)
|
||||
const data = await resp.json()
|
||||
models = (data.models || []).map(m => (m.name || '').replace('models/', '')).filter(Boolean).sort()
|
||||
} else {
|
||||
// OpenAI 兼容(大多数服务商)
|
||||
const resp = await fetch(base + '/models', {
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status)
|
||||
const data = await resp.json()
|
||||
models = (data.data || []).map(m => m.id).filter(Boolean).sort()
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
if (resultEl) resultEl.innerHTML = `<span style="color:var(--warning)">${t('engine.configFetchNotSupported')}</span>`
|
||||
return
|
||||
}
|
||||
|
||||
if (resultEl) resultEl.innerHTML = `<span style="color:var(--success)">✓ ${t('engine.configFetchSuccess', { count: models.length })}</span>`
|
||||
if (dropdown) {
|
||||
dropdown.innerHTML = models.map(m =>
|
||||
`<div class="hermes-model-option" data-model="${m}" style="padding:6px 12px;cursor:pointer;font-size:13px;border-bottom:1px solid var(--border-primary)">${m}</div>`
|
||||
).join('')
|
||||
dropdown.style.display = 'block'
|
||||
}
|
||||
} catch (err) {
|
||||
// 网络错误或不支持
|
||||
const msg = err.message || String(err)
|
||||
if (resultEl) {
|
||||
if (msg.includes('403') || msg.includes('404') || msg.includes('405') || msg.includes('timeout') || msg.includes('Failed to fetch')) {
|
||||
resultEl.innerHTML = `<span style="color:var(--warning)">${t('engine.configFetchNotSupported')}</span>`
|
||||
} else {
|
||||
resultEl.innerHTML = `<span style="color:var(--error)">✗ ${t('engine.configFetchFailed', { error: msg })}</span>`
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = t('engine.configFetchModels') }
|
||||
}
|
||||
}
|
||||
|
||||
// --- 配置保存 ---
|
||||
async function doSaveConfig() {
|
||||
const baseUrl = el.querySelector('#hm-baseurl')?.value?.trim()
|
||||
const apiKey = el.querySelector('#hm-apikey')?.value?.trim()
|
||||
const model = el.querySelector('#hm-model')?.value?.trim()
|
||||
// 从 baseUrl 推断 provider key
|
||||
const matched = HERMES_PROVIDERS.find(p => baseUrl && p.baseUrl === baseUrl)
|
||||
const provider = matched?.key || 'openai'
|
||||
|
||||
if (!apiKey) {
|
||||
alert('请输入 API Key')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.configureHermes(provider, apiKey, model, baseUrl)
|
||||
phase = 'gateway'
|
||||
await refreshHermes()
|
||||
} catch (e) {
|
||||
alert(`配置保存失败: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gateway 启动 ---
|
||||
let gwStarting = false
|
||||
async function doStartGateway() {
|
||||
const btn = el.querySelector('.hermes-gw-start')
|
||||
if (btn) { btn.disabled = true; btn.textContent = t('engine.gatewayStarting') }
|
||||
gwStarting = true
|
||||
try {
|
||||
await api.hermesGatewayAction('start')
|
||||
await refreshHermes()
|
||||
} catch (e) {
|
||||
const msg = String(e).replace(/^Error:\s*/, '')
|
||||
// 在 Gateway 阶段显示错误信息
|
||||
const errEl = el.querySelector('#hm-gw-error')
|
||||
if (errEl) {
|
||||
errEl.textContent = msg || t('engine.gatewayStartFailed')
|
||||
errEl.style.display = 'block'
|
||||
} else {
|
||||
alert(msg || t('engine.gatewayStartFailed'))
|
||||
}
|
||||
} finally {
|
||||
gwStarting = false
|
||||
if (btn) { btn.disabled = false; btn.textContent = t('engine.gatewayStartBtn') }
|
||||
}
|
||||
}
|
||||
|
||||
// --- 刷新 hermes 状态 ---
|
||||
async function refreshHermes() {
|
||||
try { hermesInfo = await api.checkHermes() } catch (_) {}
|
||||
draw()
|
||||
}
|
||||
|
||||
// 启动检测
|
||||
detect()
|
||||
|
||||
return el
|
||||
}
|
||||
16
src/engines/hermes/pages/skills.js
Normal file
16
src/engines/hermes/pages/skills.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Hermes Agent Skills 管理
|
||||
*/
|
||||
import { t } from '../../../lib/i18n.js'
|
||||
|
||||
export function render() {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'page'
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h1>${t('engine.hermesSkillsTitle')}</h1></div>
|
||||
<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">
|
||||
${t('engine.comingSoonPhase2')}
|
||||
</div></div>
|
||||
`
|
||||
return el
|
||||
}
|
||||
138
src/engines/openclaw/index.js
Normal file
138
src/engines/openclaw/index.js
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* OpenClaw 引擎
|
||||
* 包装现有 OpenClaw 逻辑为统一的 Engine 接口,不改动原有代码
|
||||
*/
|
||||
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, isGatewayForeign,
|
||||
onGatewayChange, startGatewayPoll, stopGatewayPoll, onReadyChange } from '../../lib/app-state.js'
|
||||
import { initFeatureGates, isFeatureAvailable } from '../../lib/feature-gates.js'
|
||||
import { t } from '../../lib/i18n.js'
|
||||
|
||||
export default {
|
||||
id: 'openclaw',
|
||||
name: 'OpenClaw',
|
||||
description: 'OpenClaw AI Agent Framework',
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>',
|
||||
|
||||
/** 检测 OpenClaw 是否已安装 */
|
||||
async detect() {
|
||||
const ready = await detectOpenclawStatus()
|
||||
return { installed: ready, ready }
|
||||
},
|
||||
|
||||
/** 启动 OpenClaw 引擎相关逻辑 */
|
||||
async boot() {
|
||||
await detectOpenclawStatus()
|
||||
await initFeatureGates().catch(() => {})
|
||||
startGatewayPoll()
|
||||
},
|
||||
|
||||
/** 清理(停止轮询等) */
|
||||
cleanup() {
|
||||
stopGatewayPoll()
|
||||
},
|
||||
|
||||
/** 侧边栏菜单项 */
|
||||
getNavItems() {
|
||||
if (!isOpenclawReady()) {
|
||||
return [{
|
||||
section: '',
|
||||
items: [
|
||||
{ route: '/setup', label: t('sidebar.setup'), icon: 'setup' },
|
||||
{ route: '/assistant', label: t('sidebar.assistant'), icon: 'assistant' },
|
||||
]
|
||||
}, {
|
||||
section: '',
|
||||
items: [
|
||||
{ route: '/settings', label: t('sidebar.settings'), icon: 'settings' },
|
||||
{ route: '/chat-debug', label: t('sidebar.chatDebug'), icon: 'debug' },
|
||||
{ route: '/about', label: t('sidebar.about'), icon: 'about' },
|
||||
]
|
||||
}]
|
||||
}
|
||||
return [{
|
||||
section: t('sidebar.sectionMonitor'),
|
||||
items: [
|
||||
{ route: '/dashboard', label: t('sidebar.dashboard'), icon: 'dashboard' },
|
||||
{ route: '/assistant', label: t('sidebar.assistant'), icon: 'assistant' },
|
||||
{ route: '/chat', label: t('sidebar.chat'), icon: 'chat' },
|
||||
{ route: '/route-map', label: t('sidebar.routeMap'), icon: 'route-map' },
|
||||
{ route: '/services', label: t('sidebar.services'), icon: 'services' },
|
||||
{ route: '/logs', label: t('sidebar.logs'), icon: 'logs' },
|
||||
]
|
||||
}, {
|
||||
section: t('sidebar.sectionConfig'),
|
||||
items: [
|
||||
{ route: '/models', label: t('sidebar.models'), icon: 'models' },
|
||||
{ route: '/agents', label: t('sidebar.agents'), icon: 'agents' },
|
||||
{ route: '/gateway', label: t('sidebar.gateway'), icon: 'gateway' },
|
||||
{ route: '/channels', label: t('sidebar.channels'), icon: 'channels' },
|
||||
{ route: '/communication', label: t('sidebar.communication'), icon: 'settings' },
|
||||
{ route: '/security', label: t('sidebar.security'), icon: 'security' },
|
||||
]
|
||||
}, {
|
||||
section: t('sidebar.sectionData'),
|
||||
items: [
|
||||
{ route: '/memory', label: t('sidebar.memory'), icon: 'memory', gate: 'memory' },
|
||||
{ route: '/dreaming', label: t('sidebar.dreaming'), icon: 'dreaming', gate: 'dreaming' },
|
||||
{ route: '/cron', label: t('sidebar.cron'), icon: 'clock', gate: 'cron' },
|
||||
{ route: '/usage', label: t('sidebar.usage'), icon: 'bar-chart' },
|
||||
]
|
||||
}, {
|
||||
section: t('sidebar.sectionExtension'),
|
||||
items: [
|
||||
{ route: '/skills', label: t('sidebar.skills'), icon: 'skills', gate: 'skills' },
|
||||
{ route: '/plugin-hub', label: t('sidebar.pluginHub'), icon: 'extensions' },
|
||||
]
|
||||
}, {
|
||||
section: '',
|
||||
items: [
|
||||
{ route: '/settings', label: t('sidebar.settings'), icon: 'settings' },
|
||||
{ route: '/chat-debug', label: t('sidebar.checkRepair'), icon: 'diagnose' },
|
||||
{ route: '/about', label: t('sidebar.about'), icon: 'about' },
|
||||
]
|
||||
}]
|
||||
},
|
||||
|
||||
/** 路由注册表 */
|
||||
getRoutes() {
|
||||
return [
|
||||
{ path: '/dashboard', loader: () => import('../../pages/dashboard.js') },
|
||||
{ path: '/chat', loader: () => import('../../pages/chat.js') },
|
||||
{ path: '/chat-debug', loader: () => import('../../pages/chat-debug.js') },
|
||||
{ path: '/services', loader: () => import('../../pages/services.js') },
|
||||
{ path: '/logs', loader: () => import('../../pages/logs.js') },
|
||||
{ path: '/models', loader: () => import('../../pages/models.js') },
|
||||
{ path: '/agents', loader: () => import('../../pages/agents.js') },
|
||||
{ path: '/agent-detail', loader: () => import('../../pages/agent-detail.js') },
|
||||
{ path: '/gateway', loader: () => import('../../pages/gateway.js') },
|
||||
{ path: '/memory', loader: () => import('../../pages/memory.js') },
|
||||
{ path: '/dreaming', loader: () => import('../../pages/dreaming.js') },
|
||||
{ path: '/skills', loader: () => import('../../pages/skills.js') },
|
||||
{ path: '/security', loader: () => import('../../pages/security.js') },
|
||||
{ path: '/about', loader: () => import('../../pages/about.js') },
|
||||
{ path: '/assistant', loader: () => import('../../pages/assistant.js') },
|
||||
{ path: '/setup', loader: () => import('../../pages/setup.js') },
|
||||
{ path: '/channels', loader: () => import('../../pages/channels.js') },
|
||||
{ path: '/cron', loader: () => import('../../pages/cron.js') },
|
||||
{ path: '/usage', loader: () => import('../../pages/usage.js') },
|
||||
{ path: '/communication', loader: () => import('../../pages/communication.js') },
|
||||
{ path: '/settings', loader: () => import('../../pages/settings.js') },
|
||||
{ path: '/route-map', loader: () => import('../../pages/route-map.js') },
|
||||
{ path: '/plugin-hub', loader: () => import('../../pages/plugin-hub.js') },
|
||||
{ path: '/diagnose', loader: () => import('../../pages/chat-debug.js') },
|
||||
]
|
||||
},
|
||||
|
||||
getSetupRoute() { return '/setup' },
|
||||
getDefaultRoute() { return '/dashboard' },
|
||||
|
||||
isReady() { return isOpenclawReady() },
|
||||
isGatewayRunning() { return isGatewayRunning() },
|
||||
isGatewayForeign() { return isGatewayForeign() },
|
||||
|
||||
onStateChange(fn) { return onGatewayChange(fn) },
|
||||
onReadyChange(fn) { return onReadyChange(fn) },
|
||||
|
||||
/** 功能门控:基于 OpenClaw 版本号 */
|
||||
isFeatureAvailable(featureId) { return isFeatureAvailable(featureId) },
|
||||
}
|
||||
Reference in New Issue
Block a user