feat(hermes): align dashboard APIs and add xintian engine

This commit is contained in:
晴天
2026-04-25 10:31:32 +08:00
parent b25808f7f0
commit 3ed59fcb2b
40 changed files with 15246 additions and 1105 deletions

View File

@@ -76,7 +76,9 @@ export default {
items: [
{ route: '/h/dashboard', label: t('sidebar.dashboard'), icon: 'dashboard' },
{ route: '/h/chat', label: t('sidebar.chat'), icon: 'chat' },
{ route: '/h/sessions', label: t('sidebar.sessions'), icon: 'inbox' },
{ route: '/h/logs', label: t('sidebar.logs'), icon: 'logs' },
{ route: '/h/usage', label: t('sidebar.usage'), icon: 'bar-chart' },
]
}, {
section: t('sidebar.sectionManage'),
@@ -84,6 +86,7 @@ export default {
{ route: '/h/skills', label: t('sidebar.skills'), icon: 'skills' },
{ route: '/h/memory', label: t('sidebar.memory'), icon: 'memory' },
{ route: '/h/cron', label: t('sidebar.cron'), icon: 'clock' },
{ route: '/h/extensions', label: t('sidebar.extensions'), icon: 'package' },
]
}, {
section: '',
@@ -101,10 +104,13 @@ export default {
{ 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/sessions', loader: () => import('./pages/sessions.js') },
{ path: '/h/logs', loader: () => import('./pages/logs.js') },
{ path: '/h/usage', loader: () => import('./pages/usage.js') },
{ path: '/h/skills', loader: () => import('./pages/skills.js') },
{ path: '/h/memory', loader: () => import('./pages/memory.js') },
{ path: '/h/cron', loader: () => import('./pages/cron.js') },
{ path: '/h/extensions', loader: () => import('./pages/extensions.js') },
{ path: '/h/services', loader: () => import('./pages/services.js') },
{ path: '/h/config', loader: () => import('./pages/config.js') },
{ path: '/h/channels', loader: () => import('./pages/channels.js') },

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import { t } from '../../../lib/i18n.js'
export function render() {
const el = document.createElement('div')
el.className = 'page'
el.dataset.engine = 'hermes'
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)">

File diff suppressed because it is too large Load Diff

View File

@@ -2,15 +2,91 @@
* Hermes Agent 配置编辑
*/
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
import { toast } from '../../../components/toast.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>
`
el.dataset.engine = 'hermes'
let yaml = ''
let loading = true
let saving = false
let error = ''
function esc(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function draw() {
el.innerHTML = `
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">HERMES AGENT · CONFIG</div>
<h1 class="hm-hero-h1">${t('engine.hermesConfigTitle')}</h1>
<div class="hm-hero-sub">~/.hermes/config.yaml</div>
</div>
<div class="hm-hero-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm" id="hm-config-reload" ${loading || saving ? 'disabled' : ''}>重新加载</button>
<button class="hm-btn hm-btn--cta hm-btn--sm" id="hm-config-save" ${loading || saving ? 'disabled' : ''}>保存配置</button>
</div>
</div>
<div class="hm-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">config.yaml</div>
<div class="hm-panel-actions">
<span class="hm-muted">${saving ? 'saving…' : loading ? 'loading…' : 'raw yaml editor'}</span>
</div>
</div>
<div class="hm-panel-body" style="padding:0">
${error ? `<div style="margin:16px 18px;padding:10px 14px;border-radius:var(--hm-radius-sm);background:var(--hm-error-soft);color:var(--hm-error);font-family:var(--hm-font-mono);font-size:12px">${esc(error)}</div>` : ''}
<textarea id="hm-config-yaml" class="hm-input" spellcheck="false" ${loading || saving ? 'disabled' : ''} style="width:100%;min-height:560px;border:0;border-radius:0;background:var(--hm-surface-0);font-family:var(--hm-font-mono);font-size:12px;line-height:1.7;padding:18px 20px;resize:vertical">${esc(yaml)}</textarea>
</div>
</div>
`
el.querySelector('#hm-config-reload')?.addEventListener('click', load)
el.querySelector('#hm-config-save')?.addEventListener('click', save)
}
async function load() {
loading = true
error = ''
draw()
try {
const data = await api.hermesConfigRawRead()
yaml = data?.yaml || ''
} catch (err) {
error = String(err?.message || err).replace(/^Error:\s*/, '')
} finally {
loading = false
draw()
}
}
async function save() {
const textarea = el.querySelector('#hm-config-yaml')
yaml = textarea?.value || ''
saving = true
error = ''
draw()
try {
await api.hermesConfigRawWrite(yaml)
toast('配置已保存,建议重启 Hermes Gateway 生效', 'success')
} catch (err) {
error = String(err?.message || err).replace(/^Error:\s*/, '')
toast(error, 'error')
} finally {
saving = false
draw()
}
}
draw()
load()
return el
}

View File

@@ -69,6 +69,7 @@ const ICONS = {
export function render() {
const el = document.createElement('div')
el.className = 'page'
el.dataset.engine = 'hermes'
let jobs = []
let gwOnline = false
@@ -86,109 +87,265 @@ export function render() {
const info = await api.checkHermes()
gwOnline = !!info?.gatewayRunning
} catch (_) {}
if (gwOnline) await loadJobs()
await loadJobs()
loading = false
draw()
}
async function loadJobs() {
try {
const data = await gw('/api/jobs')
jobs = data.jobs || []
if (gwOnline) {
const data = await gw('/api/jobs')
jobs = data.jobs || []
} else {
const data = await api.hermesCronJobsList()
jobs = Array.isArray(data) ? data : []
}
errorMsg = ''
} catch (e) {
errorMsg = String(e.message || e)
jobs = []
try {
const data = await api.hermesCronJobsList()
jobs = Array.isArray(data) ? data : []
errorMsg = ''
} catch (_) {
errorMsg = String(e.message || e)
jobs = []
}
}
}
// ── 主渲染 ──
// ── Helpers ──
/**
* Derive a semantic job state label.
* Priority: running > paused > disabled > scheduled
* Mirrors the logic used by hermes-web-ui's JobCard.vue.
*/
function jobStateOf(j) {
if (j.state === 'running') return 'running'
if (j.state === 'paused' || j.paused) return 'paused'
if (j.enabled === false) return 'disabled'
return 'scheduled'
}
/** Format any server-side timestamp (ISO / epoch-sec / epoch-ms) → local. */
function fmtJobTime(ts) {
if (!ts && ts !== 0) return '—'
let d
if (typeof ts === 'number') {
d = new Date(ts > 1e12 ? ts : ts * 1000)
} else {
d = new Date(ts)
}
if (isNaN(d.getTime())) return String(ts)
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/** Human-friendly "in X minutes" hint for next_run_at. */
function relativeFuture(ts) {
if (!ts && ts !== 0) return ''
let d
if (typeof ts === 'number') d = new Date(ts > 1e12 ? ts : ts * 1000)
else d = new Date(ts)
const diff = Math.floor((d.getTime() - Date.now()) / 1000)
if (diff < 0) return t('engine.cronOverdue')
if (diff < 60) return t('engine.cronInSeconds').replace('{n}', diff)
if (diff < 3600) return t('engine.cronInMinutes').replace('{n}', Math.floor(diff / 60))
if (diff < 86400) return t('engine.cronInHours').replace('{n}', Math.floor(diff / 3600))
return t('engine.cronInDays').replace('{n}', Math.floor(diff / 86400))
}
// ── 主渲染 ──
function draw() {
if (editingJob) { drawForm(); return }
const total = jobs.length
const active = jobs.filter(j => !j.paused).length
const paused = total - active
const runningCount = jobs.filter(j => jobStateOf(j) === 'running').length
const paused = jobs.filter(j => jobStateOf(j) === 'paused').length
const failed = jobs.filter(j => j.last_status && j.last_status !== 'ok').length
el.innerHTML = `
<div class="page-header" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
<h1 style="margin:0">${t('engine.hermesCronTitle')}</h1>
<div style="display:flex;gap:8px">
<button class="btn btn-sm btn-secondary hm-cron-refresh" title="Refresh" style="padding:4px 10px">${ICONS.refresh}</button>
<button class="btn btn-primary btn-sm hm-cron-create" ${!gwOnline ? 'disabled' : ''}>${t('engine.cronCreate')}</button>
<!-- Editorial hero -->
<div class="hm-hero" data-state="${gwOnline ? 'running' : 'stopped'}">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<span class="hm-dot hm-dot--${gwOnline ? 'run' : 'stop'}"></span>
${t('engine.cronEyebrow')}
</div>
<h1 class="hm-hero-h1">${t('engine.hermesCronTitle')}</h1>
<div class="hm-hero-sub">${total} ${t('engine.cronJobs')} · ${runningCount} ${t('engine.cronRunning').toLowerCase()}</div>
</div>
<div class="hm-hero-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-cron-refresh" ${!gwOnline || loading ? 'disabled' : ''} title="${t('engine.logsRefresh')}">
${ICONS.refresh} ${t('engine.logsRefresh')}
</button>
<button class="hm-btn hm-btn--cta hm-cron-create" ${!gwOnline ? 'disabled' : ''}>
+ ${t('engine.cronCreate')}
</button>
</div>
</div>
${errorMsg ? `<div style="color:var(--error);font-size:13px;margin-bottom:12px;padding:8px 12px;background:var(--error-muted, #fee2e2);border-radius:6px">${esc(errorMsg)}</div>` : ''}
${errorMsg ? `
<div class="hm-panel" style="margin-bottom:16px">
<div class="hm-panel-body hm-panel-body--tight">
<div style="color:var(--hm-error);font-family:var(--hm-font-mono);font-size:12.5px">${esc(errorMsg)}</div>
</div>
</div>
` : ''}
${!gwOnline ? `
<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">
<div style="margin-bottom:8px">${ICONS.clock.replace('width="14"', 'width="32"').replace('height="14"', 'height="32"')}</div>
${t('engine.chatGatewayOffline')}
<div class="hm-panel"><div class="hm-panel-body" style="text-align:center;padding:40px 28px">
<div style="margin-bottom:10px;color:var(--hm-text-muted)">${ICONS.clock.replace('width="14"', 'width="32"').replace('height="14"', 'height="32"')}</div>
<div style="font-family:var(--hm-font-serif);font-style:italic;font-size:15px;color:var(--hm-text-tertiary)">${t('engine.chatGatewayOffline')}</div>
</div></div>
` : ''}
${gwOnline && !loading ? `
<!-- 统计卡片 -->
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
<div class="card"><div class="card-body" style="padding:12px 16px">
<div style="font-size:11px;color:var(--text-tertiary);margin-bottom:4px">${t('engine.cronTotal')}</div>
<div style="font-size:20px;font-weight:700">${total}</div>
</div></div>
<div class="card"><div class="card-body" style="padding:12px 16px">
<div style="font-size:11px;color:var(--text-tertiary);margin-bottom:4px">${t('engine.cronRunning')}</div>
<div style="font-size:20px;font-weight:700;color:var(--success,#22c55e)">${active}</div>
</div></div>
<div class="card"><div class="card-body" style="padding:12px 16px">
<div style="font-size:11px;color:var(--text-tertiary);margin-bottom:4px">${t('engine.cronPaused')}</div>
<div style="font-size:20px;font-weight:700;color:var(--text-tertiary)">${paused}</div>
</div></div>
<!-- KPI grid (4 stats) -->
<div class="hm-kpi-grid">
<div class="hm-kpi" data-tone="accent">
<div class="hm-kpi-label">${t('engine.cronTotal')}</div>
<div class="hm-kpi-value">${total}</div>
<div class="hm-kpi-foot">jobs defined</div>
</div>
<div class="hm-kpi" data-tone="success">
<div class="hm-kpi-label">${t('engine.cronRunning')}</div>
<div class="hm-kpi-value">${runningCount}</div>
<div class="hm-kpi-foot">actively executing</div>
</div>
<div class="hm-kpi" data-tone="${paused > 0 ? 'warn' : ''}">
<div class="hm-kpi-label">${t('engine.cronPaused')}</div>
<div class="hm-kpi-value">${paused}</div>
<div class="hm-kpi-foot">manually paused</div>
</div>
<div class="hm-kpi" data-tone="${failed > 0 ? 'error' : ''}">
<div class="hm-kpi-label">${t('engine.cronFailed')}</div>
<div class="hm-kpi-value">${failed}</div>
<div class="hm-kpi-foot">last run failed</div>
</div>
</div>
${total === 0 ? `
<div class="card"><div class="card-body" style="padding:40px;text-align:center">
<div style="margin-bottom:8px;color:var(--text-tertiary)">${ICONS.clock.replace('width="14"', 'width="40"').replace('height="14"', 'height="40"')}</div>
<div style="font-size:15px;color:var(--text-secondary);margin-bottom:6px">${t('engine.cronNoJobs')}</div>
<div style="font-size:12px;color:var(--text-tertiary)">${t('engine.cronNoJobsHint')}</div>
<div class="hm-panel"><div class="hm-panel-body" style="text-align:center;padding:48px 28px">
<div style="margin-bottom:12px;color:var(--hm-text-muted)">${ICONS.clock.replace('width="14"', 'width="40"').replace('height="14"', 'height="40"')}</div>
<div style="font-family:var(--hm-font-serif);font-size:16px;color:var(--hm-text-secondary);margin-bottom:6px">${t('engine.cronNoJobs')}</div>
<div class="hm-muted">${t('engine.cronNoJobsHint')}</div>
</div></div>
` : renderJobList()}
` : ''}
${loading ? `
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
${[1,2,3].map(() => '<div class="card"><div class="card-body" style="padding:12px 16px"><div class="skeleton-line" style="width:60%;height:12px;margin-bottom:8px"></div><div class="skeleton-line" style="width:40%;height:20px"></div></div></div>').join('')}
<div class="hm-kpi-grid">
${[1,2,3,4].map(() => `<div class="hm-kpi">
<div class="hm-skel" style="width:60%;height:11px;margin-bottom:10px"></div>
<div class="hm-skel" style="width:40%;height:20px;margin-bottom:8px"></div>
<div class="hm-skel" style="width:50%;height:10px"></div>
</div>`).join('')}
</div>
${[1,2].map(() => '<div class="card" style="margin-bottom:12px"><div class="card-body" style="padding:16px"><div class="skeleton-line" style="width:50%;height:14px;margin-bottom:8px"></div><div class="skeleton-line" style="width:70%;height:12px"></div></div></div>').join('')}
${[1,2].map(() => `<div class="hm-panel" style="margin-bottom:12px"><div class="hm-panel-body">
<div class="hm-skel" style="width:30%;height:14px;margin-bottom:10px"></div>
<div class="hm-skel" style="width:60%;height:12px"></div>
</div></div>`).join('')}
` : ''}
`
bindList()
}
function renderJobList() {
return `<div style="display:flex;flex-direction:column;gap:10px">${jobs.map(j => {
return `<div class="hm-cron-list">${jobs.map(j => {
const expr = extractCronExpr(j.schedule)
const desc = describeCron(j.schedule)
const id = esc(j.id || j.name)
const id = esc(j.id || j.job_id || j.name)
const state = jobStateOf(j)
const stateBadge = {
running: { cls: 'hm-badge--accent', label: t('engine.cronStateRunning') },
paused: { cls: 'hm-badge--warn', label: t('engine.cronStatePaused') },
disabled: { cls: 'hm-badge--error', label: t('engine.cronStateDisabled') },
scheduled: { cls: 'hm-badge--success', label: t('engine.cronStateScheduled') },
}[state]
const lastStatus = j.last_status
? (j.last_status === 'ok'
? `<span class="hm-cron-last-ok"> ok</span>`
: `<span class="hm-cron-last-err" title="${esc(j.last_error || '')}">✗ ${esc(j.last_status)}</span>`)
: ''
const repeatTxt = j.repeat && typeof j.repeat === 'object'
? `${j.repeat.completed ?? 0} / ${j.repeat.times ?? '∞'}`
: (typeof j.repeat === 'string' ? j.repeat : '')
const deliverLabel = j.deliver
? (j.deliver === 'origin' && j.origin
? `${esc(j.deliver)} (${esc(j.origin.platform || '')})`
: esc(j.deliver))
: '—'
const promptPreview = j.prompt_preview || j.prompt || ''
return `
<div class="card hm-cron-item" data-id="${id}" style="transition:opacity .2s;${j.paused ? 'opacity:0.65' : ''}">
<div class="card-body" style="padding:14px 18px">
<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="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<span style="font-weight:600;font-size:14px">${esc(j.name)}</span>
<span style="font-size:10px;padding:2px 8px;border-radius:10px;font-weight:500;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>
<div class="hm-panel hm-cron-item" data-id="${id}" data-state="${state}">
<div class="hm-cron-head">
<div class="hm-cron-head-left">
<div class="hm-cron-title-row">
<span class="hm-cron-name">${esc(j.name)}</span>
<span class="hm-badge ${stateBadge.cls}">${stateBadge.label}</span>
</div>
<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-tertiary);margin-bottom:2px">
${ICONS.clock}
<span>${esc(desc)}</span>
<code style="font-size:11px;padding:1px 6px;background:var(--bg-tertiary);border-radius:4px;color:var(--text-secondary)">${esc(expr)}</code>
</div>
${j.prompt ? `<div style="font-size:12px;color:var(--text-secondary);margin-top:4px;max-width:500px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.prompt)}</div>` : ''}
${promptPreview ? `<div class="hm-cron-prompt">${esc(promptPreview)}</div>` : ''}
</div>
<div style="display:flex;gap:6px;flex-shrink:0">
<button class="btn btn-sm btn-secondary hm-cron-toggle" data-id="${id}" data-paused="${j.paused ? '1' : '0'}" title="${j.paused ? 'Resume' : 'Pause'}" style="padding:5px 8px">${j.paused ? ICONS.play : ICONS.pause}</button>
<button class="btn btn-sm btn-secondary hm-cron-run" data-id="${id}" title="${t('engine.cronRunNow')}" style="padding:5px 8px">${ICONS.zap}</button>
<button class="btn btn-sm btn-secondary hm-cron-edit" data-id="${id}" title="${t('engine.cronEdit')}" style="padding:5px 8px">${ICONS.edit}</button>
<button class="btn btn-sm btn-secondary hm-cron-del" data-id="${id}" title="${t('engine.cronDelete')}" style="padding:5px 8px;color:var(--error)">${ICONS.trash}</button>
<div class="hm-cron-actions">
<button class="hm-btn hm-btn--icon hm-cron-toggle" data-id="${id}" data-paused="${state === 'paused' ? '1' : '0'}" title="${state === 'paused' ? t('engine.cronResume') : t('engine.cronPauseBtn')}">
${state === 'paused' ? ICONS.play : ICONS.pause}
</button>
<button class="hm-btn hm-btn--icon hm-cron-run" data-id="${id}" title="${t('engine.cronRunNow')}">${ICONS.zap}</button>
<button class="hm-btn hm-btn--icon hm-cron-edit" data-id="${id}" title="${t('engine.cronEdit')}">${ICONS.edit}</button>
<button class="hm-btn hm-btn--icon hm-cron-del" data-id="${id}" title="${t('engine.cronDelete')}" style="color:var(--hm-error)">${ICONS.trash}</button>
</div>
</div>
</div>
</div>`
<div class="hm-cron-meta">
<div class="hm-cron-meta-item">
<span class="hm-cron-meta-label">${t('engine.cronScheduleLabel')}</span>
<span class="hm-cron-meta-value">
<span class="hm-cron-schedule-desc">${esc(desc)}</span>
<code class="hm-code hm-cron-schedule-expr">${esc(expr)}</code>
</span>
</div>
<div class="hm-cron-meta-item">
<span class="hm-cron-meta-label">${t('engine.cronNextRun')}</span>
<span class="hm-cron-meta-value">
${esc(fmtJobTime(j.next_run_at))}
${j.next_run_at ? `<span class="hm-cron-rel">${esc(relativeFuture(j.next_run_at))}</span>` : ''}
</span>
</div>
<div class="hm-cron-meta-item">
<span class="hm-cron-meta-label">${t('engine.cronLastRun')}</span>
<span class="hm-cron-meta-value">
${esc(fmtJobTime(j.last_run_at))}
${lastStatus}
</span>
</div>
<div class="hm-cron-meta-item">
<span class="hm-cron-meta-label">${t('engine.cronDeliverLabel')}</span>
<span class="hm-cron-meta-value">${deliverLabel}</span>
</div>
${repeatTxt ? `
<div class="hm-cron-meta-item">
<span class="hm-cron-meta-label">${t('engine.cronRepeatLabel')}</span>
<span class="hm-cron-meta-value">${esc(repeatTxt)}</span>
</div>
` : ''}
${Array.isArray(j.skills) && j.skills.length ? `
<div class="hm-cron-meta-item hm-cron-meta-item--skills">
<span class="hm-cron-meta-label">${t('engine.cronSkillsLabel')}</span>
<span class="hm-cron-meta-value">${j.skills.map(s => `<span class="hm-cron-skill-tag">${esc(s)}</span>`).join('')}</span>
</div>
` : ''}
</div>
${j.last_error ? `
<div class="hm-cron-err">
<span class="hm-cron-err-label">${t('engine.cronLastError')}</span>
<code class="hm-cron-err-msg">${esc(j.last_error)}</code>
</div>
` : ''}
</div>`
}).join('')}</div>`
}
@@ -247,47 +404,95 @@ export function render() {
// ── 创建/编辑表单 ──
/** Light cron expression sanity check — 5 space-separated fields. */
function validateCron(expr) {
if (!expr) return false
const parts = expr.trim().split(/\s+/)
return parts.length === 5
}
function drawForm() {
const isEdit = !!editingJob._editing
const id = editingJob.id || editingJob.name
const id = editingJob.id || editingJob.job_id || editingJob.name
const initSchedule = editingJob.schedule || '0 9 * * *'
const initDeliver = editingJob.deliver || 'origin'
const initRepeat = editingJob.repeat_times != null
? editingJob.repeat_times
: (typeof editingJob.repeat === 'number'
? editingJob.repeat
: (typeof editingJob.repeat === 'object' ? editingJob.repeat?.times : ''))
const shortcutsHtml = CRON_SHORTCUTS().map(s => {
const selected = s.expr === initSchedule
return `<button type="button" class="btn btn-sm ${selected ? 'btn-primary' : 'btn-secondary'} hm-cron-shortcut" data-expr="${escAttr(s.expr)}" style="font-size:11px;padding:3px 10px">${s.text}</button>`
return `<button type="button" class="hm-pill hm-cron-shortcut ${selected ? 'is-active' : ''}" data-expr="${escAttr(s.expr)}">${s.text}</button>`
}).join('')
el.innerHTML = `
<div class="page-header" style="display:flex;align-items:center;gap:12px;margin-bottom:20px">
<button class="btn btn-sm btn-secondary hm-cron-back" style="padding:5px 8px">${ICONS.back}</button>
<h1 style="margin:0">${isEdit ? t('engine.cronEdit') + ' — ' + esc(editingJob.name) : t('engine.cronCreate')}</h1>
</div>
${errorMsg ? `<div style="color:var(--error);font-size:13px;margin-bottom:12px;padding:8px 12px;background:var(--error-muted, #fee2e2);border-radius:6px">${esc(errorMsg)}</div>` : ''}
<div class="card">
<div class="card-body" style="padding:24px;display:flex;flex-direction:column;gap:18px">
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:6px">${t('engine.cronName')}</label>
<input class="input" id="hm-cron-name" value="${escAttr(editingJob.name)}" placeholder="${t('engine.cronName')}" style="width:100%" ${isEdit ? 'disabled' : ''}>
<!-- Back hero -->
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<button class="hm-cron-back" style="color:inherit;background:none;border:none;cursor:pointer;display:inline-flex;align-items:center;gap:6px;font:inherit;padding:0">
${ICONS.back} ${t('engine.hermesCronTitle')}
</button>
</div>
<h1 class="hm-hero-h1">${isEdit ? t('engine.cronEdit') : t('engine.cronCreate')}</h1>
<div class="hm-hero-sub">${isEdit ? esc(editingJob.name) : t('engine.cronNoJobsHint')}</div>
</div>
</div>
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:6px">${t('engine.cronSchedule')}</label>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">${shortcutsHtml}</div>
<input class="input" id="hm-cron-schedule" value="${escAttr(initSchedule)}" placeholder="0 9 * * *" style="width:100%;font-family:var(--font-mono,monospace)">
<div id="hm-cron-preview" style="font-size:12px;color:var(--text-tertiary);margin-top:6px;display:flex;align-items:center;gap:6px">
${errorMsg ? `
<div class="hm-panel" style="margin-bottom:16px">
<div class="hm-panel-body hm-panel-body--tight">
<div style="color:var(--hm-error);font-family:var(--hm-font-mono);font-size:12.5px">${esc(errorMsg)}</div>
</div>
</div>
` : ''}
<div class="hm-panel">
<div class="hm-panel-body" style="display:flex;flex-direction:column;gap:22px">
<!-- Name -->
<label class="hm-field">
<span class="hm-field-label">${t('engine.cronName')}</span>
<input class="hm-input" id="hm-cron-name" value="${escAttr(editingJob.name)}" placeholder="daily-standup-summary" ${isEdit ? 'disabled' : ''}>
</label>
<!-- Schedule -->
<div class="hm-field">
<span class="hm-field-label">${t('engine.cronSchedule')}</span>
<div class="hm-pills" style="margin-bottom:10px">${shortcutsHtml}</div>
<input class="hm-input" id="hm-cron-schedule" value="${escAttr(initSchedule)}" placeholder="0 9 * * *">
<div id="hm-cron-preview" class="hm-muted" style="margin-top:6px;display:flex;align-items:center;gap:6px">
${ICONS.clock} <span>${describeCron(initSchedule)}</span>
</div>
</div>
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:6px">${t('engine.cronPrompt')}</label>
<textarea class="input" id="hm-cron-prompt" rows="4" style="width:100%;resize:vertical;font-size:13px;line-height:1.5" placeholder="${t('engine.cronPrompt')}">${esc(editingJob.prompt || '')}</textarea>
<!-- Prompt -->
<label class="hm-field">
<span class="hm-field-label">${t('engine.cronPrompt')}</span>
<textarea class="hm-input" id="hm-cron-prompt" rows="5" style="resize:vertical;height:auto;min-height:120px;line-height:1.6;padding:12px 14px" placeholder="e.g. Summarize today's standup and post to the team channel">${esc(editingJob.prompt || '')}</textarea>
</label>
<!-- Deliver + Repeat (side-by-side) -->
<div class="hm-field-row">
<label class="hm-field">
<span class="hm-field-label">${t('engine.cronDeliverLabel')}</span>
<select class="hm-input" id="hm-cron-deliver">
<option value="origin" ${initDeliver === 'origin' ? 'selected' : ''}>${t('engine.cronDeliverOrigin')}</option>
<option value="local" ${initDeliver === 'local' ? 'selected' : ''}>${t('engine.cronDeliverLocal')}</option>
</select>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.cronRepeatLimit')}</span>
<input class="hm-input" id="hm-cron-repeat" type="number" min="1" step="1" value="${initRepeat != null && initRepeat !== '' ? String(initRepeat) : ''}" placeholder="∞">
<span class="hm-muted" style="margin-top:4px">${t('engine.cronRepeatLimitHint')}</span>
</label>
</div>
<div style="display:flex;gap:10px;margin-top:4px">
<button class="btn btn-primary btn-sm hm-cron-save" ${busy ? 'disabled' : ''}>${busy ? t('engine.cronSaving') : t('engine.cronSave')}</button>
<button class="btn btn-secondary btn-sm hm-cron-cancel">${t('engine.cronCancel')}</button>
<div class="hm-stack" style="margin-top:8px">
<button class="hm-btn hm-btn--cta hm-cron-save" ${busy ? 'disabled' : ''}>${busy ? t('engine.cronSaving') : t('engine.cronSave')}</button>
<button class="hm-btn hm-btn--sm hm-cron-cancel">${t('engine.cronCancel')}</button>
</div>
</div>
</div>
@@ -299,42 +504,61 @@ export function render() {
el.querySelector('.hm-cron-back')?.addEventListener('click', () => { editingJob = null; errorMsg = ''; draw() })
el.querySelector('.hm-cron-cancel')?.addEventListener('click', () => { editingJob = null; errorMsg = ''; draw() })
// 快捷预设
// Cron shortcut pills
el.querySelectorAll('.hm-cron-shortcut').forEach(btn => {
btn.addEventListener('click', () => {
el.querySelectorAll('.hm-cron-shortcut').forEach(b => { b.classList.remove('btn-primary'); b.classList.add('btn-secondary') })
btn.classList.remove('btn-secondary'); btn.classList.add('btn-primary')
el.querySelectorAll('.hm-cron-shortcut').forEach(b => b.classList.remove('is-active'))
btn.classList.add('is-active')
const input = el.querySelector('#hm-cron-schedule')
input.value = btn.dataset.expr
updatePreview(btn.dataset.expr)
})
})
// 实时预览
// Live preview & sync shortcut highlight
const schedInput = el.querySelector('#hm-cron-schedule')
schedInput?.addEventListener('input', () => {
const val = schedInput.value.trim()
updatePreview(val)
el.querySelectorAll('.hm-cron-shortcut').forEach(b => {
b.classList.remove('btn-primary'); b.classList.add('btn-secondary')
if (b.dataset.expr === val) { b.classList.remove('btn-secondary'); b.classList.add('btn-primary') }
b.classList.toggle('is-active', b.dataset.expr === val)
})
})
// 保存
// Save
el.querySelector('.hm-cron-save')?.addEventListener('click', async () => {
const name = el.querySelector('#hm-cron-name')?.value?.trim()
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) { errorMsg = t('engine.cronNameRequired'); draw(); return }
if (!schedule) { errorMsg = t('engine.cronScheduleRequired'); draw(); return }
if (!prompt) { errorMsg = t('engine.cronPromptRequired'); draw(); return }
const prompt = el.querySelector('#hm-cron-prompt')?.value?.trim()
const deliver = el.querySelector('#hm-cron-deliver')?.value || 'origin'
const repeatRaw = el.querySelector('#hm-cron-repeat')?.value?.trim()
const repeat = repeatRaw ? parseInt(repeatRaw, 10) : undefined
if (!name) { errorMsg = t('engine.cronNameRequired'); draw(); return }
if (!schedule) { errorMsg = t('engine.cronScheduleRequired'); draw(); return }
if (!validateCron(schedule)){ errorMsg = t('engine.cronInvalidCron'); draw(); return }
if (!prompt) { errorMsg = t('engine.cronPromptRequired'); draw(); return }
if (repeat !== undefined && (Number.isNaN(repeat) || repeat < 1)) {
errorMsg = t('engine.cronRepeatLimit'); draw(); return
}
busy = true; errorMsg = ''; draw()
try {
const payload = {
name,
schedule: { kind: 'cron', expr: schedule },
prompt,
deliver,
}
if (repeat !== undefined) payload.repeat = repeat
if (isEdit) {
await gw(`/api/jobs/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify({ schedule: { kind: 'cron', expr: schedule }, prompt }) })
// PATCH does not accept `name`; keep it out to match hermes-web-ui contract.
const patch = { schedule: payload.schedule, prompt, deliver }
if (repeat !== undefined) patch.repeat = repeat
await gw(`/api/jobs/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(patch) })
} else {
await gw('/api/jobs', { method: 'POST', body: JSON.stringify({ name, schedule: { kind: 'cron', expr: schedule }, prompt }) })
await gw('/api/jobs', { method: 'POST', body: JSON.stringify(payload) })
}
editingJob = null
await loadJobs()

View File

@@ -30,9 +30,31 @@ async function tauriListen(event, cb) {
return _listenFn(event, cb)
}
const HERMES_DASHBOARD_URL = 'http://127.0.0.1:9119/'
/**
* Open `url` in the user's system browser. Tauri desktop uses the shell
* plugin (which respects `xdg-open` / `start` / `open`); Web mode falls back
* to `window.open` with a `noopener` to avoid tab-jacking.
*/
async function openExternalUrl(url) {
if (!url) return
try {
if (window.__TAURI_INTERNALS__) {
const { open } = await import('@tauri-apps/plugin-shell')
await open(url)
return
}
} catch (_) { /* fall through to window.open */ }
window.open(url, '_blank', 'noopener,noreferrer')
}
export function render() {
const el = document.createElement('div')
el.className = 'page'
// Scope the new Hermes-dense design system to this subtree only,
// so OpenClaw and other engines stay completely unaffected.
el.dataset.engine = 'hermes'
let info = null
let health = null
@@ -101,29 +123,37 @@ export function render() {
}
function draw() {
// 加载骨架屏
// 加载骨架屏data-dense style
if (loading) {
el.innerHTML = `
<div class="page-header" style="display:flex;align-items:center;gap:12px">
<h1 style="margin:0">${t('engine.hermesDashboardTitle')}</h1>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-bottom:20px">
${[1,2,3,4].map(() => `<div class="card"><div class="card-body" style="padding:16px">
<div class="skeleton-line" style="width:60%;height:12px;margin-bottom:10px"></div>
<div class="skeleton-line" style="width:80%;height:20px"></div>
</div></div>`).join('')}
</div>
<div class="card" style="margin-bottom:20px"><div class="card-body" style="padding:20px">
<div class="skeleton-line" style="width:40%;height:16px;margin-bottom:16px"></div>
<div style="display:flex;gap:6px;margin-bottom:14px">${[1,2,3,4].map(() => '<div class="skeleton-line" style="width:60px;height:24px;border-radius:12px"></div>').join('')}</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="skeleton-line" style="height:36px"></div>
<div class="skeleton-line" style="height:36px"></div>
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<span class="hm-dot hm-dot--idle"></span>
${t('engine.dashEyebrowLoading')}
</div>
<div class="hm-skel" style="width:240px;height:28px;margin-bottom:6px"></div>
<div class="hm-skel" style="width:180px;height:14px"></div>
</div>
</div></div>
<div class="card" style="margin-bottom:20px"><div class="card-body" style="padding:16px">
<div class="skeleton-line" style="width:120px;height:32px;border-radius:6px"></div>
</div></div>
</div>
<div class="hm-kpi-grid">
${[1,2,3,4,5].map(() => `
<div class="hm-kpi">
<div class="hm-skel" style="width:70%;height:10px;margin-bottom:10px"></div>
<div class="hm-skel" style="width:50%;height:22px;margin-bottom:8px"></div>
<div class="hm-skel" style="width:40%;height:10px"></div>
</div>
`).join('')}
</div>
<div class="hm-panel">
<div class="hm-panel-header">
<div class="hm-skel" style="width:120px;height:12px"></div>
</div>
<div class="hm-panel-body">
<div class="hm-skel" style="width:100%;height:34px;margin-bottom:12px"></div>
<div class="hm-skel" style="width:100%;height:34px"></div>
</div>
</div>
`
return
}
@@ -137,188 +167,255 @@ export function render() {
// 服务商高亮匹配
const activePreset = inferProviderByBaseUrl(hermesProviders, formBaseUrl)
// 模型下拉 HTML
// 模型下拉 HTMLdata-dense
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>`
? `<div id="hm-model-dropdown" class="hm-dropdown">${models.map(m =>
`<div class="hm-dropdown-item hm-model-opt ${m === formModel ? 'is-selected' : ''}" data-model="${esc(m)}">${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>
<!-- Hero strip: dynamic colored bar + title + CTA + icon actions -->
<div class="hm-hero" data-state="${gwRunning ? 'running' : 'stopped'}">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<span class="hm-dot hm-dot--${gwRunning ? 'run' : 'stop'}"></span>
${gwRunning ? t('engine.dashEyebrowOnline') : t('engine.dashEyebrowOffline')}
</div>
<h1 class="hm-hero-h1">${t('engine.hermesDashboardTitle')}</h1>
<div class="hm-hero-sub">127.0.0.1:${port} · ${esc(displayModel || '—')} · v${version}</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 class="card hm-dash-open-panel" style="cursor:pointer;border-left:4px solid var(--accent,#6366f1)">
<div class="card-body" style="padding:16px">
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:6px;display:flex;align-items:center;gap:6px">
${t('engine.dashOpenPanel')}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12" style="opacity:.6"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</div>
<div style="font-size:14px;font-weight:600">${t('engine.dashOpenPanelDesc')}</div>
</div>
<div class="hm-hero-actions">
${!gwRunning ? `<button class="hm-btn hm-btn--cta hm-dash-start" ${actionBusy ? 'disabled' : ''}>▶ ${actionBusy ? t('engine.gatewayStarting') : t('engine.dashStartGw')}</button>` : ''}
${gwRunning ? `<button class="hm-btn hm-btn--danger hm-dash-stop" ${actionBusy ? 'disabled' : ''}>■ ${actionBusy ? t('engine.dashStopping') : t('engine.dashStopGw')}</button>` : ''}
${gwRunning ? `<button class="hm-btn hm-dash-restart" ${actionBusy ? 'disabled' : ''}>↻ ${actionBusy ? t('engine.dashRestarting') : t('engine.dashRestartGw')}</button>` : ''}
<button class="hm-btn hm-btn--icon hm-dash-refresh" title="${t('engine.dashRefresh')}">${ICONS.refresh}</button>
</div>
</div>
<!-- 模型配置区 -->
<div class="card" style="margin-bottom:20px">
<div class="card-body" style="padding:0">
<div class="hm-cfg-toggle" style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;cursor:pointer;user-select:none">
<h3 style="margin:0;font-size:15px">${t('engine.dashModelConfig')}</h3>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="transition:transform .2s;transform:rotate(${modelConfigCollapsed ? '0' : '180'}deg);opacity:0.5"><polyline points="6 9 12 15 18 9"/></svg>
<!-- KPI grid: 5 cards with tone indicators -->
<div class="hm-kpi-grid">
<div class="hm-kpi" data-tone="${gwRunning ? 'success' : 'error'}">
<div class="hm-kpi-label">${t('engine.dashGatewayStatus')}</div>
<div class="hm-kpi-value" style="font-size:15px">
<span class="hm-dot hm-dot--${gwRunning ? 'run' : 'stop'}"></span>
${gwRunning ? t('engine.dashRunning') : t('engine.dashStopped')}
</div>
<div style="${modelConfigCollapsed ? 'display:none' : 'padding:0 20px 20px'}">
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px">
${hermesProviders.filter(p => p.id !== 'custom').map(p => {
const api = p.transport === 'anthropic_messages' ? 'anthropic-messages'
: p.transport === 'google_gemini' ? 'google-generative-ai'
: 'openai-completions'
const active = activePreset?.id === p.id
return `<button class="btn btn-sm btn-secondary hm-preset-btn" data-key="${p.id}" data-url="${esc(p.baseUrl)}" data-api="${api}" style="font-size:11px;padding:2px 8px;${active ? 'opacity:1;font-weight:600' : 'opacity:0.6'}">${p.name}</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;align-items:center">
<button class="btn btn-primary btn-sm hm-save-model" ${modelBusy ? 'disabled' : ''}>${modelBusy ? '...' : t('engine.configSaveBtn')}</button>
<a href="#/h/env" style="font-size:11px;color:var(--text-tertiary);text-decoration:none;margin-left:auto" title=".env 文件高级编辑(自定义环境变量)">.env 高级编辑 →</a>
</div>
<div class="hm-kpi-foot">${t('engine.dashPort')} <span style="color:var(--hm-text-secondary)">:${port}</span></div>
</div>
<div class="hm-kpi" data-tone="accent">
<div class="hm-kpi-label">${t('engine.dashModel')}</div>
<div class="hm-kpi-value" style="font-size:13px;word-break:break-all">${esc(displayModel)}</div>
<div class="hm-kpi-foot">${t('engine.dashProvider')} <code class="hm-code" style="padding:0 5px;font-size:10px">${esc(hermesConfig?.provider || activePreset?.id || '—')}</code></div>
</div>
<div class="hm-kpi">
<div class="hm-kpi-label">${t('engine.dashVersion')}</div>
<div class="hm-kpi-value">v${version}</div>
<div class="hm-kpi-foot"><span class="hm-badge hm-badge--accent">uv-tool</span></div>
</div>
<div class="hm-kpi">
<div class="hm-kpi-label">${t('engine.dashApiEndpoint')}</div>
<div class="hm-kpi-value" style="font-size:13px">127.0.0.1</div>
<div class="hm-kpi-foot"><code class="hm-code" style="padding:0 5px;font-size:10.5px">:${port}/v1</code></div>
</div>
<div class="hm-kpi hm-kpi--link hm-dash-open-panel" data-tone="accent">
<div class="hm-kpi-label">
${t('engine.dashOpenPanel')}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="10" height="10" style="opacity:.7"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</div>
<div class="hm-kpi-value" style="font-size:13px">${t('engine.dashOpenPanelDesc')}</div>
<div class="hm-kpi-foot">${t('engine.dashOpenChat')}</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 class="hm-native-dashboard-hint">
<span>${t('engine.dashNativePanelDesc')}</span>
<button class="hm-native-dashboard-link hm-dash-open-native" data-href="${HERMES_DASHBOARD_URL}">
${t('engine.dashNativePanelOpen')}
</button>
</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>
<!-- Model config panel (collapsible) -->
<div class="hm-panel">
<div class="hm-panel-header hm-panel-header--toggle hm-cfg-toggle ${modelConfigCollapsed ? '' : 'is-open'}">
<div class="hm-panel-title">
<svg class="hm-panel-title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v6M12 17v6M4.22 4.22l4.24 4.24M15.54 15.54l4.24 4.24M1 12h6M17 12h6M4.22 19.78l4.24-4.24M15.54 8.46l4.24-4.24"/></svg>
${t('engine.dashModelConfig')}
<span class="hm-panel-title-count">${hermesProviders.filter(p => p.id !== 'custom').length}</span>
</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 class="hm-panel-actions">
<svg class="hm-panel-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
</div>
${!modelConfigCollapsed ? `
<div class="hm-panel-body">
<div class="hm-field-label" style="margin-bottom:10px">${t('engine.dashProviderPresets')}</div>
<div class="hm-pills" style="margin-bottom:18px">
${hermesProviders.filter(p => p.id !== 'custom').map(p => {
const api = p.transport === 'anthropic_messages' ? 'anthropic-messages'
: p.transport === 'google_gemini' ? 'google-generative-ai'
: 'openai-completions'
const active = activePreset?.id === p.id
return `<button class="hm-pill hm-preset-btn ${active ? 'is-active' : ''}" data-key="${p.id}" data-url="${esc(p.baseUrl)}" data-api="${api}">${esc(p.name)}</button>`
}).join('')}
</div>
<div class="hm-field-row">
<label class="hm-field">
<span class="hm-field-label">${t('engine.dashApiBaseUrl')}</span>
<input type="text" id="hm-cfg-baseurl" class="hm-input" value="${esc(formBaseUrl)}" placeholder="https://api.deepseek.com/v1">
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.dashApiKey')}</span>
<input type="password" id="hm-cfg-apikey" class="hm-input" value="${esc(formApiKey)}" placeholder="sk-…">
</label>
</div>
<div style="display:flex;gap:10px;align-items:flex-end;margin-top:12px">
<label class="hm-field" style="flex:1">
<span class="hm-field-label">${t('engine.configModel')}</span>
<div style="position:relative">
<input type="text" id="hm-cfg-model" class="hm-input" value="${esc(formModel)}" placeholder="deepseek-chat">
${dropdownHtml}
</div>
</label>
<button class="hm-btn hm-btn--sm hm-fetch-models" ${fetchBusy ? 'disabled' : ''}>${fetchBusy ? t('engine.configFetching') : t('engine.configFetchModels')}</button>
</div>
<div id="hm-cfg-msg" class="hm-muted" style="min-height:16px;margin:12px 0 6px">${cfgMsg}</div>
<div class="hm-stack">
<button class="hm-btn hm-btn--primary hm-btn--sm hm-save-model" ${modelBusy ? 'disabled' : ''}>${modelBusy ? '...' : t('engine.configSaveBtn')}</button>
<span class="hm-spacer"></span>
<a href="#/h/env" class="hm-btn hm-btn--ghost hm-btn--sm" title="${t('engine.dashEnvAdvancedEdit')}">${t('engine.dashEnvAdvancedEdit')}</a>
</div>
</div>
` : ''}
</div>
<!-- Gateway message line (actions moved to Hero bar) -->
<div id="hm-dash-msg" class="hm-muted" style="min-height:14px;margin:-6px 4px 12px;font-size:11px"></div>
<!-- Connection target panel -->
<div class="hm-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">
<svg class="hm-panel-title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 100 20 10 10 0 000-20z"/><path d="M2 12h20M12 2a15.3 15.3 0 010 20M12 2a15.3 15.3 0 000 20"/></svg>
${t('engine.dashConnectTarget')}
</div>
<div class="hm-panel-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-detect-env" ${envDetecting ? 'disabled' : ''}>${envDetecting ? t('engine.dashDetecting') : '↻ ' + t('engine.dashDetectEnv')}</button>
</div>
</div>
<div class="hm-panel-body hm-panel-body--tight">
<div class="hm-pills" style="margin-bottom:12px">
<button class="hm-pill hm-connect-mode ${connectMode === 'local' ? 'is-active' : ''}" data-mode="local">${t('engine.dashConnLocal')} · 127.0.0.1</button>
${envData?.wsl2?.available ? `<button class="hm-pill hm-connect-mode ${connectMode === 'wsl2' ? 'is-active' : ''}" data-mode="wsl2">${t('engine.dashConnWsl2')}${envData.wsl2.gatewayRunning ? ' ✓' : envData.wsl2.hermesInstalled ? ' !' : ''}</button>` : ''}
${envData?.docker?.available ? `<button class="hm-pill hm-connect-mode ${connectMode === 'docker' ? 'is-active' : ''}" data-mode="docker">${t('engine.dashConnDocker')}${envData.docker.hermesContainers?.length ? ' ✓' : ''}</button>` : ''}
<button class="hm-pill hm-connect-mode ${connectMode === 'custom' ? 'is-active' : ''}" data-mode="custom">${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 class="hm-term" style="margin-bottom:12px">
<span class="hm-muted">$ wsl --status</span><br>
IP <span style="color:var(--hm-accent)">${esc(envData.wsl2.ip || '-')}</span> · distros [${(envData.wsl2.distros || []).join(', ')}]<br>
${envData.wsl2.hermesInstalled ? `<span style="color:var(--hm-cta)">✓ hermes ${esc(envData.wsl2.hermesInfo || '')}</span>` : `<span style="color:var(--hm-warn)">! ${t('engine.dashHermesMissing')}</span>`}<br>
${envData.wsl2.gatewayRunning ? `<span style="color:var(--hm-cta)">✓ gateway: ${esc(envData.wsl2.gatewayUrl || '')}</span>` : `<span class="hm-muted">${t('engine.dashGatewayNotRunning')}</span>`}
</div>
` : ''}
${connectMode === 'docker' && envData?.docker ? `
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">
<div>Docker ${esc(envData.docker.version || '')}</div>
<div class="hm-term" style="margin-bottom:12px">
<span class="hm-muted">$ docker ps --filter ancestor=hermes</span><br>
engine <span style="color:var(--hm-accent)">${esc(envData.docker.version || '')}</span><br>
${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>'}
`<span style="color:var(--hm-cta)">▶</span> <code>${esc(c.name)}</code> (${esc(c.image)}) ${esc(c.ports)}`
).join('<br>') : `<span class="hm-muted">${t('engine.dashNoHermesContainers')}</span>`}
</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 style="margin-bottom:12px">
<input type="text" id="hm-custom-gw-url" class="hm-input" value="${esc(customGwUrl)}" placeholder="http://192.168.1.100:8642">
</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 class="hm-stack">
<button class="hm-btn hm-btn--primary hm-btn--sm hm-apply-connect">${t('engine.dashConnApply')}</button>
<span id="hm-connect-msg" class="hm-muted">${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>
<!-- Quick actions -->
<div class="hm-field-label" style="margin:8px 2px 10px">${t('engine.dashQuickActions')}</div>
<div class="hm-kpi-grid" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr))">
<button class="hm-kpi hm-kpi--link hm-dash-link" data-route="/h/chat" data-tone="accent" style="text-align:left;font-family:inherit;color:inherit;cursor:pointer">
<div class="hm-kpi-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
${t('engine.dashOpenChat')}
</div>
<div class="hm-kpi-value" style="font-size:13px">${t('engine.dashOpenChat')}</div>
<div class="hm-kpi-foot">${t('engine.dashInteractiveSession')}</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>
<button class="hm-kpi hm-kpi--link hm-dash-link" data-route="/h/setup" data-tone="accent" style="text-align:left;font-family:inherit;color:inherit;cursor:pointer">
<div class="hm-kpi-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><circle cx="12" cy="12" r="3"/><path d="M12 1v6M12 17v6M4.22 4.22l4.24 4.24M15.54 15.54l4.24 4.24M1 12h6M17 12h6"/></svg>
${t('engine.dashOpenSetup')}
</div>
<div class="hm-kpi-value" style="font-size:13px">${t('engine.dashOpenSetup')}</div>
<div class="hm-kpi-foot">${t('engine.dashInstallerWizard')}</div>
</button>
<button class="hm-kpi hm-kpi--link hm-dash-link" data-route="/h/logs" data-tone="info" style="text-align:left;font-family:inherit;color:inherit;cursor:pointer">
<div class="hm-kpi-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
${t('engine.servicesOpenLogs')}
</div>
<div class="hm-kpi-value" style="font-size:13px">gateway.log</div>
<div class="hm-kpi-foot">${t('engine.dashLogsFoot')}</div>
</button>
<button class="hm-kpi hm-kpi--link hm-dash-link" data-route="/h/env" data-tone="warn" style="text-align:left;font-family:inherit;color:inherit;cursor:pointer">
<div class="hm-kpi-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
.ENV
</div>
<div class="hm-kpi-value" style="font-size:13px">${t('engine.dashAdvancedEdit')}</div>
<div class="hm-kpi-foot">${t('engine.dashCustomVars')}</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()}
<!-- CLI reference as data table -->
<div class="hm-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">
<svg class="hm-panel-title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
${t('engine.dashCliTitle')}
<span class="hm-panel-title-count">${CLI_COMMANDS.length}</span>
</div>
<div class="hm-panel-actions">
<span class="hm-muted">${t('engine.dashCliDesc')}</span>
</div>
</div>
<div class="hm-panel-body hm-panel-body--none">
<table class="hm-table">
<thead>
<tr>
<th style="width:38%">${t('engine.dashCliCommand')}</th>
<th>${t('engine.dashCliDescription')}</th>
<th style="width:48px;text-align:center">${t('engine.dashCliCopy')}</th>
</tr>
</thead>
<tbody>
${CLI_COMMANDS.map((c, i) => `
<tr>
<td><code class="hm-code">${esc(c.cmd)}</code></td>
<td>
<div style="color:var(--hm-text-primary);font-family:var(--hm-font-sans);font-size:12px;font-weight:500;margin-bottom:2px">${c.label}</div>
<div class="hm-muted">${c.desc}</div>
</td>
<td style="text-align:center">
<button class="hm-btn hm-btn--icon 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="13" height="13"><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>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`
@@ -339,7 +436,7 @@ export function render() {
showGwMsg(t('engine.gatewayStarting'), false)
try {
const result = await api.hermesGatewayAction('start')
showGwMsg(result || 'Gateway 已启动', false)
showGwMsg(result || t('engine.dashGatewayStarted'), false)
} catch (e) {
showGwMsg(String(e).replace(/^Error:\s*/, ''), true)
}
@@ -365,6 +462,17 @@ export function render() {
})
// Open panel card
el.querySelector('.hm-dash-open-panel')?.addEventListener('click', () => { window.location.hash = '#/h/chat' })
// Open Hermes native dashboard in system browser
el.querySelector('.hm-dash-open-native')?.addEventListener('click', async (e) => {
const href = e.currentTarget.dataset.href
if (!href) return
try {
await openExternalUrl(href)
} catch (err) {
const { toast } = await import('../../../components/toast.js')
toast(t('engine.dashNativePanelOpenFail') + ': ' + (err?.message || err), 'error')
}
})
// Provider presets — 点击填充 URL
el.querySelectorAll('.hm-preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
@@ -452,7 +560,7 @@ export function render() {
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 }
if (!formModel) { cfgMsg = `<span style="color:var(--warning)">${t('engine.configModelRequired')}</span>`; draw(); return }
const matched = inferProviderByBaseUrl(hermesProviders, formBaseUrl)
const provider = matched?.id || 'custom'
@@ -460,7 +568,7 @@ export function render() {
modelBusy = true; cfgMsg = ''; draw()
try {
await api.configureHermes(provider, formApiKey, formModel, formBaseUrl || null)
cfgMsg = `<span style="color:var(--success)">✓ 配置已保存</span>`
cfgMsg = `<span style="color:var(--success)">✓ ${t('engine.configSaved')}</span>`
// 刷新后端状态(不覆盖 form
try { hermesConfig = await api.hermesReadConfig() } catch (_) {}
} catch (e) {
@@ -475,7 +583,7 @@ export function render() {
try {
envData = await api.hermesDetectEnvironments()
} catch (e) {
connectMsg = `<span style="color:var(--error)">探测失败: ${String(e).replace(/^Error:\s*/, '')}</span>`
connectMsg = `<span style="color:var(--error)">${t('engine.envDetectFailed')}: ${String(e).replace(/^Error:\s*/, '')}</span>`
}
envDetecting = false; draw()
}
@@ -487,7 +595,7 @@ export function render() {
} else if (connectMode === 'wsl2') {
targetUrl = envData?.wsl2?.gatewayUrl || null
if (!targetUrl) {
connectMsg = '<span style="color:var(--warning)">WSL2 Gateway 未运行,请先在 WSL 中启动</span>'
connectMsg = `<span style="color:var(--warning)">${t('engine.connWslGatewayMissing')}</span>`
draw(); return
}
} else if (connectMode === 'docker') {
@@ -495,14 +603,14 @@ export function render() {
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>'
connectMsg = `<span style="color:var(--warning)">${t('engine.connDockerCustomHint')}</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>'
connectMsg = `<span style="color:var(--warning)">${t('engine.connUrlRequired')}</span>`
draw(); return
}
}
@@ -587,7 +695,7 @@ export function render() {
// 监听 config.yaml 自愈事件api_server guardian
const unlisten3 = await tauriListen('hermes-config-patched', async (evt) => {
const { toast } = await import('../../../components/toast.js')
const msg = evt?.payload?.message || 'config.yaml 已自动修复'
const msg = evt?.payload?.message || t('engine.dashConfigPatched')
toast(msg, 'info', { duration: 6000 })
})
unlisteners.push(unlisten3)

View File

@@ -18,6 +18,7 @@ const ICONS = {
back: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="15 18 9 12 15 6"/></svg>`,
trash: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>`,
edit: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
eye: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>`,
save: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>`,
cancel: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
plus: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
@@ -26,6 +27,7 @@ const ICONS = {
export function render() {
const el = document.createElement('div')
el.className = 'page'
el.dataset.engine = 'hermes'
let rows = [] // [{ key, value, editing: false, draftValue: '', isNew: false }]
let loading = true
@@ -35,22 +37,56 @@ export function render() {
function skeleton() {
return `
<div class="page-header" style="display:flex;align-items:center;gap:12px">
<a href="#/h/dashboard" class="btn-text" style="display:inline-flex;align-items:center;gap:4px;font-size:13px">
${ICONS.back} 返回仪表盘
</a>
<h1 style="margin:0;font-size:20px">.env 高级编辑</h1>
</div>
<div style="max-width:860px">
<div class="card" style="margin-bottom:16px">
<div class="card-body" style="padding:20px">
<div style="padding:10px 14px;background:var(--bg-tertiary);border-radius:var(--radius-sm,6px);font-size:12px;line-height:1.6;color:var(--text-secondary);margin-bottom:16px">
以下环境变量由 ClawPanel 在 Hermes 配置页面管理:<code>OPENAI_API_KEY</code> / <code>ANTHROPIC_API_KEY</code> / <code>DEEPSEEK_API_KEY</code> 等 provider 密钥和 base URL以及 <code>GATEWAY_ALLOW_ALL_USERS</code> / <code>API_SERVER_KEY</code>。请通过 Hermes 仪表盘的「模型配置」修改这些项——本页仅用于添加自定义环境变量(如 <code>TAVILY_API_KEY</code>、<code>HTTP_PROXY</code>、Skills 所需的自定义变量等)。
</div>
<div id="env-list"></div>
<div id="env-empty" style="display:none;padding:18px 14px;text-align:center;color:var(--text-tertiary);font-size:13px"></div>
<div id="env-error" style="display:none;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;margin-top:12px"></div>
<!-- Hero: editorial title + back link -->
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<a href="#/h/dashboard" style="color:inherit;text-decoration:none;display:inline-flex;align-items:center;gap:6px">
${ICONS.back} back to dashboard
</a>
</div>
<h1 class="hm-hero-h1">.env editor</h1>
<div class="hm-hero-sub">custom environment variables · ~/.hermes/.env</div>
</div>
</div>
<!-- Notice panel: which keys are managed elsewhere -->
<div class="hm-panel" style="margin-bottom:18px">
<div class="hm-panel-body hm-panel-body--tight">
<div style="font-family:var(--hm-font-serif);font-style:italic;font-size:13px;color:var(--hm-text-tertiary);line-height:1.75">
以下变量由 ClawPanel 在仪表盘「模型配置」中托管:
<code class="hm-code">OPENAI_API_KEY</code>
<code class="hm-code">ANTHROPIC_API_KEY</code>
<code class="hm-code">DEEPSEEK_API_KEY</code>
等 provider 密钥及 base URL以及
<code class="hm-code">GATEWAY_ALLOW_ALL_USERS</code>
<code class="hm-code">API_SERVER_KEY</code>。
请通过仪表盘修改这些项——本页仅管理你的自定义变量(如
<code class="hm-code">TAVILY_API_KEY</code>、
<code class="hm-code">HTTP_PROXY</code>、
skills 自定义变量等)。
</div>
</div>
</div>
<!-- Variables panel -->
<div class="hm-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">
<svg class="hm-panel-title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
custom.env
</div>
<div class="hm-panel-actions">
<span class="hm-muted" id="env-row-count"></span>
</div>
</div>
<div class="hm-panel-body hm-panel-body--none">
<div id="env-list"></div>
<div id="env-empty" style="display:none;padding:32px 28px;text-align:center">
<div style="font-family:var(--hm-font-serif);font-style:italic;font-size:14px;color:var(--hm-text-tertiary);margin-bottom:6px">no custom variables yet</div>
<div class="hm-muted">click "add variable" below to create one</div>
</div>
<div id="env-error" style="display:none;margin:14px 28px;padding:10px 14px;background:var(--hm-error-soft);border-radius:var(--hm-radius-sm);color:var(--hm-error);font-family:var(--hm-font-mono);font-size:12px"></div>
</div>
</div>
`
@@ -61,52 +97,62 @@ export function render() {
const emptyEl = el.querySelector('#env-empty')
if (!listEl) return
// update count badge in panel header
const countEl = el.querySelector('#env-row-count')
if (countEl) {
countEl.textContent = loading ? '' : (rows.length ? `${rows.length} variable${rows.length > 1 ? 's' : ''}` : '')
}
if (loading) {
listEl.innerHTML = `<div style="padding:18px 14px;text-align:center;color:var(--text-tertiary);font-size:13px">加载中…</div>`
listEl.innerHTML = `
<div style="padding:28px 28px;text-align:center">
<div class="hm-skel" style="width:60%;height:14px;margin:0 auto 10px"></div>
<div class="hm-skel" style="width:40%;height:12px;margin:0 auto"></div>
</div>
`
if (emptyEl) emptyEl.style.display = 'none'
return
}
if (!rows.length) {
listEl.innerHTML = ''
if (emptyEl) {
emptyEl.textContent = '暂无自定义环境变量。点击下方「添加变量」新增一条。'
emptyEl.style.display = 'block'
}
if (emptyEl) emptyEl.style.display = 'block'
renderFooter()
return
}
if (emptyEl) emptyEl.style.display = 'none'
// Table-style header
const header = `
<div style="display:grid;grid-template-columns:1fr 2fr 88px;gap:10px;padding:6px 4px;font-size:11px;color:var(--text-tertiary);font-weight:500">
<div>变量名</div>
<div></div>
<div style="text-align:right">操作</div>
<div style="display:grid;grid-template-columns:1fr 2fr 148px;gap:14px;padding:14px 28px;font-family:var(--hm-font-serif);font-style:italic;font-size:12px;color:var(--hm-text-tertiary);background:var(--hm-surface-0);border-bottom:1px solid var(--hm-border)">
<div>variable</div>
<div>value</div>
<div style="text-align:right">action</div>
</div>
`
const body = rows.map((row, idx) => {
if (row.editing) {
return `
<div class="env-row" data-idx="${idx}" style="display:grid;grid-template-columns:1fr 2fr 88px;gap:10px;align-items:center;padding:6px 4px;border-top:1px solid var(--border-primary)">
<input type="text" class="input env-key-input" ${row.isNew ? '' : 'readonly'} value="${esc(row.key)}" placeholder="EXAMPLE_KEY" style="font-family:var(--font-mono, ui-monospace);font-size:12px;padding:4px 8px">
<input type="text" class="input env-value-input" value="${esc(row.draftValue)}" placeholder="..." style="font-size:12px;padding:4px 8px">
<div class="env-row" data-idx="${idx}" style="display:grid;grid-template-columns:1fr 2fr 148px;gap:14px;align-items:center;padding:12px 28px;border-bottom:1px solid var(--hm-border-subtle);background:var(--hm-accent-soft)">
<input type="text" class="hm-input env-key-input" ${row.isNew ? '' : 'readonly'} value="${esc(row.key)}" placeholder="EXAMPLE_KEY" style="height:32px;font-size:12px">
<input type="text" class="hm-input env-value-input" value="${esc(row.draftValue)}" placeholder="value..." style="height:32px;font-size:12px">
<div style="display:flex;gap:6px;justify-content:flex-end">
<button class="btn btn-sm btn-primary env-save-btn" title="保存">${ICONS.save}</button>
<button class="btn btn-sm btn-secondary env-cancel-btn" title="取消">${ICONS.cancel}</button>
<button class="hm-btn hm-btn--cta hm-btn--sm env-save-btn" title="保存">${ICONS.save}</button>
<button class="hm-btn hm-btn--sm env-cancel-btn" title="取消">${ICONS.cancel}</button>
</div>
</div>
`
}
return `
<div class="env-row" data-idx="${idx}" style="display:grid;grid-template-columns:1fr 2fr 88px;gap:10px;align-items:center;padding:6px 4px;border-top:1px solid var(--border-primary)">
<code style="font-size:12px;color:var(--text-primary);word-break:break-all">${esc(row.key)}</code>
<code style="font-size:12px;color:var(--text-secondary);word-break:break-all;opacity:0.8">${esc(maskValue(row.value))}</code>
<div class="env-row" data-idx="${idx}" style="display:grid;grid-template-columns:1fr 2fr 148px;gap:14px;align-items:center;padding:14px 28px;border-bottom:1px solid var(--hm-border-subtle);transition:background 180ms ease">
<code class="hm-code" style="background:transparent;border:none;padding:0;font-size:12px;color:var(--hm-text-primary);word-break:break-all">${esc(row.key)}</code>
<code class="hm-code" style="background:transparent;border:none;padding:0;font-size:12px;color:var(--hm-text-tertiary);word-break:break-all">${esc(row.revealed ? row.value : maskValue(row.value))}</code>
<div style="display:flex;gap:6px;justify-content:flex-end">
<button class="btn btn-sm btn-secondary env-edit-btn" title="编辑">${ICONS.edit}</button>
<button class="btn btn-sm btn-secondary env-delete-btn" title="删除" style="color:var(--error)">${ICONS.trash}</button>
<button class="hm-btn hm-btn--icon env-reveal-btn" title="${row.revealed ? '隐藏' : '明文'}">${ICONS.eye}</button>
<button class="hm-btn hm-btn--icon env-edit-btn" title="编辑">${ICONS.edit}</button>
<button class="hm-btn hm-btn--icon env-delete-btn" title="删除" style="color:var(--hm-error)">${ICONS.trash}</button>
</div>
</div>
`
@@ -123,10 +169,12 @@ export function render() {
// Append footer after list contents
const hasAddRow = rows.some(r => r.isNew)
const footer = document.createElement('div')
footer.style.cssText = 'margin-top:14px;display:flex;gap:10px'
footer.style.cssText = 'padding:18px 28px;border-top:1px solid var(--hm-border);display:flex;gap:10px;align-items:center'
footer.innerHTML = hasAddRow
? ''
: `<button class="btn btn-primary env-add-btn" style="display:inline-flex;align-items:center;gap:6px">${ICONS.plus} 添加变量</button>`
? '<span class="hm-muted">editing new variable…</span>'
: `<button class="hm-btn hm-btn--cta env-add-btn">${ICONS.plus} 添加变量</button>
<span class="hm-spacer"></span>
<span class="hm-muted">changes take effect on next gateway restart</span>`
// Remove existing footer
const old = el.querySelector('.env-footer')
if (old) old.remove()
@@ -154,6 +202,21 @@ export function render() {
row.draftValue = row.value
renderList()
})
rowEl.querySelector('.env-reveal-btn')?.addEventListener('click', async () => {
if (row.revealed) {
row.revealed = false
renderList()
return
}
try {
const data = await api.hermesEnvReveal(row.key)
row.value = data?.value ?? row.value
row.revealed = true
renderList()
} catch (err) {
toast(String(err).replace(/^Error:\s*/, ''), 'error')
}
})
rowEl.querySelector('.env-cancel-btn')?.addEventListener('click', () => {
if (row.isNew) {
rows.splice(idx, 1)
@@ -215,6 +278,7 @@ export function render() {
value: v,
editing: false,
draftValue: '',
revealed: false,
isNew: false,
}))
} catch (err) {

View File

@@ -0,0 +1,177 @@
import { api } from '../../../lib/tauri-api.js'
import { icon } from '../../../lib/icons.js'
import { toast } from '../../../components/toast.js'
import { t } from '../../../lib/i18n.js'
function esc(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function formatTokens(value) {
const n = Number(value || 0)
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
return String(Math.round(n))
}
function formatCost(value) {
const n = Number(value || 0)
if (!n) return '$0.00'
if (n < 0.01) return '<$0.01'
return '$' + n.toFixed(2)
}
export function render() {
const el = document.createElement('div')
el.className = 'page hm-extensions-page'
el.dataset.engine = 'hermes'
let loading = true
let themes = []
let activeTheme = 'default'
let plugins = []
let analytics = null
let error = ''
const docs = [
['engine.extensionsDocGettingStarted', 'https://hermes-agent.nousresearch.com/docs/getting-started/installation/'],
['engine.extensionsDocCron', 'https://hermes-agent.nousresearch.com/docs/guides/automate-with-cron/'],
['engine.extensionsDocSkills', 'https://hermes-agent.nousresearch.com/docs/guides/skills/'],
['engine.extensionsDocDashboard', 'http://127.0.0.1:9119/'],
]
function draw() {
const totals = analytics?.totals || {}
const tokens = Number(totals.total_input || 0) + Number(totals.total_output || 0)
el.innerHTML = `
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">${esc(t('engine.extensionsEyebrow'))}</div>
<h1 class="hm-hero-h1">${esc(t('engine.extensionsTitle'))}</h1>
<div class="hm-hero-sub">${esc(t('engine.extensionsDesc'))}</div>
</div>
<div class="hm-hero-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm" id="hm-ext-refresh" ${loading ? 'disabled' : ''}>${icon('refresh-cw', 14)}${esc(t('engine.extensionsRefresh'))}</button>
<button class="hm-btn hm-btn--cta hm-btn--sm" id="hm-ext-rescan" ${loading ? 'disabled' : ''}>${icon('package', 14)}${esc(t('engine.extensionsRescan'))}</button>
</div>
</div>
${error ? `<div class="hm-panel" style="margin-bottom:16px"><div class="hm-panel-body" style="color:var(--hm-error)">${esc(error)}</div></div>` : ''}
<div class="hm-grid hm-grid--2" style="display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:18px;margin-bottom:18px">
<section class="hm-panel">
<div class="hm-panel-header"><div class="hm-panel-title">${esc(t('engine.extensionsDocs'))}</div></div>
<div class="hm-panel-body" style="display:grid;gap:10px">
${docs.map(([labelKey, href]) => `<a class="hm-native-dashboard-link" href="${esc(href)}" target="_blank" rel="noopener noreferrer">${esc(t(labelKey))} <span>↗</span></a>`).join('')}
</div>
</section>
<section class="hm-panel">
<div class="hm-panel-header"><div class="hm-panel-title">${esc(t('engine.extensionsAnalytics'))}</div></div>
<div class="hm-panel-body">
<div class="hm-kpi-grid" style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px">
<div class="hm-kpi"><div class="hm-kpi-label">${esc(t('engine.extensionsSessions'))}</div><div class="hm-kpi-value">${esc(totals.total_sessions || 0)}</div></div>
<div class="hm-kpi"><div class="hm-kpi-label">${esc(t('engine.extensionsTokens'))}</div><div class="hm-kpi-value">${esc(formatTokens(tokens))}</div></div>
<div class="hm-kpi"><div class="hm-kpi-label">${esc(t('engine.extensionsCost'))}</div><div class="hm-kpi-value">${esc(formatCost(totals.total_actual_cost || totals.total_estimated_cost))}</div></div>
</div>
</div>
</section>
</div>
<div class="hm-grid hm-grid--2" style="display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:18px">
<section class="hm-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">${esc(t('engine.extensionsThemes'))}</div>
<div class="hm-panel-actions"><span class="hm-muted">${esc(t('engine.extensionsActive'))}: ${esc(activeTheme)}</span></div>
</div>
<div class="hm-panel-body" style="display:grid;gap:10px">
${themes.length ? themes.map(theme => `
<button class="hm-btn ${theme.name === activeTheme ? 'hm-btn--cta' : 'hm-btn--ghost'} hm-theme-choice" data-theme="${esc(theme.name)}" style="justify-content:flex-start;text-align:left;height:auto;padding:12px 14px">
<span style="display:grid;gap:3px">
<strong>${esc(theme.label || theme.name)}</strong>
<span class="hm-muted">${esc(theme.description || theme.name)}</span>
</span>
</button>
`).join('') : `<div class="hm-muted">${esc(t('engine.extensionsNoThemes'))}</div>`}
</div>
</section>
<section class="hm-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">${esc(t('engine.extensionsPlugins'))}</div>
<div class="hm-panel-actions"><span class="hm-muted">${esc(t('engine.extensionsManifestCount').replace('{n}', plugins.length))}</span></div>
</div>
<div class="hm-panel-body" style="display:grid;gap:10px">
${plugins.length ? plugins.map(plugin => `
<article style="padding:12px 14px;border:1px solid var(--hm-border);border-radius:var(--hm-radius-sm);background:var(--hm-surface-0)">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:4px">
<strong>${esc(plugin.label || plugin.name)}</strong>
<span class="hm-muted">v${esc(plugin.version || '0.0.0')}</span>
</div>
<div class="hm-muted" style="line-height:1.6">${esc(plugin.description || t('engine.extensionsNoDescription'))}</div>
<div class="hm-muted" style="margin-top:6px;font-family:var(--hm-font-mono);font-size:11px">${esc(plugin.tab?.path || '')}${plugin.has_api ? ' · API' : ''}</div>
</article>
`).join('') : `<div class="hm-muted">${esc(t('engine.extensionsNoPlugins'))}</div>`}
</div>
</section>
</div>
`
el.querySelector('#hm-ext-refresh')?.addEventListener('click', load)
el.querySelector('#hm-ext-rescan')?.addEventListener('click', rescan)
el.querySelectorAll('.hm-theme-choice').forEach(btn => {
btn.addEventListener('click', async () => {
const name = btn.dataset.theme
if (!name || name === activeTheme) return
try {
await api.hermesDashboardThemeSet(name)
activeTheme = name
toast(t('engine.extensionsThemeSaved'), 'success')
draw()
} catch (err) {
toast(String(err?.message || err).replace(/^Error:\s*/, ''), 'error')
}
})
})
}
async function load() {
loading = true
error = ''
draw()
try {
const [themeData, pluginData, usageData] = await Promise.all([
api.hermesDashboardThemes(),
api.hermesDashboardPlugins(),
api.hermesUsageAnalytics(30),
])
themes = Array.isArray(themeData?.themes) ? themeData.themes : []
activeTheme = themeData?.active || 'default'
plugins = Array.isArray(pluginData) ? pluginData : []
analytics = usageData || null
} catch (err) {
error = String(err?.message || err).replace(/^Error:\s*/, '')
} finally {
loading = false
draw()
}
}
async function rescan() {
try {
await api.hermesDashboardPluginsRescan()
await load()
toast(t('engine.extensionsPluginsRescanned'), 'success')
} catch (err) {
toast(String(err?.message || err).replace(/^Error:\s*/, ''), 'error')
}
}
draw()
load()
return el
}

View File

@@ -1,19 +1,89 @@
/**
* Hermes Agent 日志查看器
* 支持按文件/级别/关键字过滤,实时查看 Agent 运行日志
* Hermes Agent — Log viewer
*
* Data contract mirrors `hermes-web-ui`'s `/api/hermes/logs` endpoints:
* { files: [{ name, size, modified }] }
* { entries: [{ timestamp, level, logger, message, raw }, ...] }
*
* Extras beyond the official UI:
* - Download entire log file to user's disk
* - Clear the currently rendered entries (local only)
* - Auto-refresh (polling tail) toggle — 2s tick
* - Access-log colouring: method / path / status are parsed and highlighted
* - Live regex search that also highlights matches inline
*/
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
import { toast } from '../../../components/toast.js'
function escHtml(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') }
function escHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
const LOG_LEVELS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
const LEVEL_CLASS = { DEBUG: 'debug', INFO: 'info', WARNING: 'warn', WARN: 'warn', ERROR: 'error', CRITICAL: 'error', FATAL: 'error' }
const LEVEL_TONE = {
DEBUG: 'debug',
INFO: 'info',
WARNING: 'warn', WARN: 'warn',
ERROR: 'error', CRITICAL: 'error', FATAL: 'error',
}
const ICONS = {
refresh: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>',
download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
clear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>',
play: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="11" height="11"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
pause: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="11" height="11"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>',
}
/** Extract HH:MM:SS from arbitrary timestamp string; fallback to the raw. */
function formatTime(ts) {
if (!ts) return ''
const match = String(ts).match(/\d{2}:\d{2}:\d{2}/)
return match ? match[0] : String(ts)
}
/** Parse an HTTP access log message. Returns null on miss. */
function parseAccessLog(msg) {
const match = String(msg || '').match(/"(\w+)\s+(\S+)\s+HTTP\/[^"]+"\s+(\d+)/)
if (!match) return null
return { method: match[1], path: match[2], status: match[3] }
}
function formatSize(bytes) {
if (typeof bytes === 'string') return bytes
if (!bytes) return '0 B'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
/** Highlight substrings matching `query` in an HTML-escaped text. */
function highlight(text, query) {
if (!query) return text
const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'ig')
return text.replace(re, '<mark class="hm-log-hl">$1</mark>')
}
/** Trigger a browser file download of `content` as `filename`. */
function triggerDownload(content, filename) {
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
export function render() {
const el = document.createElement('div')
el.className = 'hermes-logs-page'
el.dataset.engine = 'hermes'
// --- State ---
let logFiles = []
let activeFile = ''
let entries = []
@@ -22,23 +92,30 @@ export function render() {
let searchQuery = ''
let lineLimit = 200
let autoScroll = true
let tailing = false // auto-refresh tick active
let downloading = false
let tailTimer = null
// --- Data ---
async function loadFiles() {
try {
logFiles = await api.hermesLogsList()
if (logFiles.length && !activeFile) activeFile = logFiles[0].name
} catch (e) {
console.error('Failed to load log files:', e)
console.error('[logs] Failed to load file list:', e)
logFiles = []
}
}
async function loadEntries() {
if (!activeFile) { entries = []; draw(); return }
loading = true
draw()
async function loadEntries({ silent = false } = {}) {
if (!activeFile) { entries = []; if (!silent) draw(); return }
if (!silent) { loading = true; draw() }
try {
entries = await api.hermesLogsRead(activeFile, lineLimit, levelFilter !== 'ALL' ? levelFilter : null)
entries = await api.hermesLogsRead(
activeFile,
lineLimit,
levelFilter !== 'ALL' ? levelFilter : null,
)
} catch (e) {
entries = [{ raw: `⚠️ ${t('engine.logsLoadFailed')}: ${e.message || e}` }]
}
@@ -49,53 +126,180 @@ export function render() {
function filteredEntries() {
if (!searchQuery) return entries
const q = searchQuery.toLowerCase()
return entries.filter(e => (e.raw || e.message || '').toLowerCase().includes(q))
return entries.filter(e => {
const hay = [e.raw, e.message, e.logger].filter(Boolean).join(' ').toLowerCase()
return hay.includes(q)
})
}
function formatSize(bytes) {
if (typeof bytes === 'string') return bytes
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
// --- Tailing (simple poll, 2s) ---
function startTail() {
if (tailTimer) return
tailing = true
tailTimer = setInterval(() => loadEntries({ silent: true }), 2000)
draw()
}
function stopTail() {
if (tailTimer) { clearInterval(tailTimer); tailTimer = null }
tailing = false
draw()
}
function toggleTail() { tailing ? stopTail() : startTail() }
// --- Actions ---
async function doDownload() {
if (!activeFile || downloading) return
downloading = true
draw()
try {
const result = await api.hermesLogsDownload(activeFile)
if (typeof result === 'string') {
triggerDownload(result, activeFile)
toast(t('engine.logsDownloadBrowserOk'), 'success', { duration: 5000 })
} else {
const path = result?.path || ''
toast(t('engine.logsDownloadOk').replace('{path}', path), 'success', { duration: 7000 })
}
} catch (e) {
toast(t('engine.logsDownloadFailed') + ': ' + (e?.message || e), 'error')
}
downloading = false
draw()
}
function doClearView() {
// Local-only clear: drop rendered entries. The file on disk is untouched.
entries = []
draw()
}
// --- Rendering ---
function renderLevelBadge(lvl, tone) {
return `<span class="hm-log-level" data-tone="${tone || ''}">${escHtml(lvl || '-')}</span>`
}
function renderEntry(e) {
const lvl = (e.level || '').toUpperCase()
const tone = LEVEL_TONE[lvl] || ''
const logger = e.logger || ''
const time = formatTime(e.timestamp)
const rawMsg = e.message || ''
const access = parseAccessLog(rawMsg)
// Raw (unparsed) fallback — preserve full line
if (!e.timestamp && !lvl) {
const raw = escHtml(e.raw || '')
return `<div class="hm-log-entry hm-log-entry--raw">
<span class="hm-log-msg">${highlight(raw, searchQuery)}</span>
</div>`
}
let msgHtml
if (access) {
const statusClass = `hm-log-status--${access.status?.[0] || 'x'}xx`
msgHtml = `
<span class="hm-log-access">
<span class="hm-log-method">${escHtml(access.method)}</span>
<span class="hm-log-path">${escHtml(access.path)}</span>
<span class="hm-log-status ${statusClass}">${escHtml(access.status)}</span>
</span>
`
} else {
msgHtml = `<span class="hm-log-msg">${highlight(escHtml(rawMsg), searchQuery)}</span>`
}
return `<div class="hm-log-entry" data-tone="${tone}">
<span class="hm-log-time">${escHtml(time)}</span>
${renderLevelBadge(lvl, tone)}
${logger ? `<span class="hm-log-logger">${highlight(escHtml(logger), searchQuery)}</span>` : ''}
${msgHtml}
</div>`
}
function draw() {
const filtered = filteredEntries()
const totalVisible = filtered.length
const totalLoaded = entries.length
el.innerHTML = `
<div class="hm-logs-header">
<span class="hm-logs-header-title">${t('engine.hermesLogsTitle')}</span>
<div class="hm-logs-header-actions">
<select id="hm-logs-level" class="hm-logs-select">
${LOG_LEVELS.map(l => `<option value="${l}" ${l === levelFilter ? 'selected' : ''}>${l}</option>`).join('')}
</select>
<select id="hm-logs-lines" class="hm-logs-select">
${[100, 200, 500, 1000].map(n => `<option value="${n}" ${n === lineLimit ? 'selected' : ''}>${n} ${t('engine.logsLines')}</option>`).join('')}
</select>
<input type="text" id="hm-logs-search" class="hm-logs-search" placeholder="${t('engine.logsSearch')}" value="${escHtml(searchQuery)}">
<button class="btn btn-sm" id="hm-logs-refresh">${t('engine.logsRefresh')}</button>
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<span class="hm-dot hm-dot--${tailing ? 'run' : 'idle'}"></span>
${tailing ? t('engine.logsTailing') : t('engine.logsEyebrow')}
</div>
<h1 class="hm-hero-h1">${t('engine.hermesLogsTitle')}</h1>
<div class="hm-hero-sub">~/.hermes/logs/${activeFile ? ' · ' + escHtml(activeFile) : ''}</div>
</div>
<div class="hm-hero-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-logs-tail ${tailing ? 'is-active' : ''}" title="${t('engine.logsToggleTail')}">
${tailing ? ICONS.pause : ICONS.play} ${tailing ? t('engine.logsTailStop') : t('engine.logsTailStart')}
</button>
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-logs-download" ${!activeFile || downloading ? 'disabled' : ''} title="${t('engine.logsDownload')}">
${ICONS.download} ${downloading ? '…' : t('engine.logsDownload')}
</button>
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-logs-refresh" ${loading ? 'disabled' : ''} title="${t('engine.logsRefresh')}">
${ICONS.refresh} ${t('engine.logsRefresh')}
</button>
</div>
</div>
<div class="hm-logs-layout">
<div class="hm-logs-sidebar">
<div class="hm-logs-sidebar-title">${t('engine.logsFiles')}</div>
${logFiles.length === 0 ? `<div class="hm-logs-empty">${t('engine.logsNoFiles')}</div>` : ''}
${logFiles.map(f => `
<div class="hm-logs-file-item ${f.name === activeFile ? 'active' : ''}" data-file="${escHtml(f.name)}">
<span class="hm-logs-file-name">${escHtml(f.name)}</span>
<span class="hm-logs-file-size">${formatSize(f.size)}</span>
</div>
`).join('')}
</div>
<div class="hm-logs-main">
<aside class="hm-logs-sidebar">
<div class="hm-panel-title hm-logs-sidebar-title">${t('engine.logsFiles')}</div>
<div class="hm-logs-file-list">
${logFiles.length === 0
? `<div class="hm-logs-empty hm-muted">${t('engine.logsNoFiles')}</div>`
: logFiles.map(f => `
<button class="hm-logs-file-item ${f.name === activeFile ? 'is-active' : ''}" data-file="${escHtml(f.name)}">
<span class="hm-logs-file-name">${escHtml(f.name)}</span>
<span class="hm-logs-file-size">${formatSize(f.size)}</span>
</button>
`).join('')}
</div>
</aside>
<section class="hm-logs-main">
<div class="hm-logs-toolbar">
<div class="hm-logs-count">${filtered.length} ${t('engine.logsEntries')}</div>
<label class="hm-logs-toolbar-item">
<span class="hm-field-label">${t('engine.logsLevel')}</span>
<select id="hm-logs-level" class="hm-input hm-logs-select">
${LOG_LEVELS.map(l => `<option value="${l}" ${l === levelFilter ? 'selected' : ''}>${l}</option>`).join('')}
</select>
</label>
<label class="hm-logs-toolbar-item">
<span class="hm-field-label">${t('engine.logsLinesLabel')}</span>
<select id="hm-logs-lines" class="hm-input hm-logs-select">
${[100, 200, 500, 1000].map(n => `<option value="${n}" ${n === lineLimit ? 'selected' : ''}>${n} ${t('engine.logsLines')}</option>`).join('')}
</select>
</label>
<label class="hm-logs-toolbar-item hm-logs-toolbar-item--grow">
<span class="hm-field-label">${t('engine.logsSearchLabel')}</span>
<input type="text" id="hm-logs-search" class="hm-input" placeholder="${t('engine.logsSearch')}" value="${escHtml(searchQuery)}">
</label>
<div class="hm-logs-toolbar-item hm-logs-toolbar-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-logs-clear" ${!entries.length ? 'disabled' : ''} title="${t('engine.logsClear')}">
${ICONS.clear}
</button>
</div>
</div>
<div class="hm-logs-count hm-muted">
${totalVisible} / ${totalLoaded} ${t('engine.logsEntries')}
${searchQuery ? `· ${t('engine.logsFilteredBy')} "${escHtml(searchQuery)}"` : ''}
</div>
<div class="hm-logs-content" id="hm-logs-content">
${loading ? `<div class="hm-logs-loading">${t('engine.logsLoading')}</div>` : ''}
${!loading && filtered.length === 0 ? `<div class="hm-logs-empty-content">${t('engine.logsEmpty')}</div>` : ''}
${!loading ? filtered.map(e => renderEntry(e)).join('') : ''}
${loading ? `
<div class="hm-logs-loading">
<div class="hm-skel" style="width:70%;height:14px;margin-bottom:10px"></div>
<div class="hm-skel" style="width:80%;height:14px;margin-bottom:10px"></div>
<div class="hm-skel" style="width:60%;height:14px"></div>
</div>
` : ''}
${!loading && totalVisible === 0 ? `<div class="hm-logs-empty-content hm-muted">${t('engine.logsEmpty')}</div>` : ''}
${!loading ? filtered.map(renderEntry).join('') : ''}
</div>
</div>
</section>
</div>
`
bind()
@@ -105,25 +309,16 @@ export function render() {
}
}
function renderEntry(e) {
const lvl = (e.level || '').toUpperCase()
const cls = LEVEL_CLASS[lvl] || ''
if (e.timestamp) {
const time = e.timestamp.replace(/^.*?(\d{2}:\d{2}:\d{2}).*$/, '$1') || e.timestamp
return `<div class="hm-log-entry ${cls}">
<span class="hm-log-time">${escHtml(time)}</span>
<span class="hm-log-level ${cls}">${escHtml(lvl || '-')}</span>
<span class="hm-log-msg">${escHtml(e.message || '')}</span>
</div>`
}
return `<div class="hm-log-entry raw"><span class="hm-log-msg">${escHtml(e.raw || '')}</span></div>`
}
// --- Event binding ---
function bind() {
el.querySelector('#hm-logs-refresh')?.addEventListener('click', () => loadEntries())
el.querySelector('.hm-logs-refresh')?.addEventListener('click', () => loadEntries())
el.querySelector('.hm-logs-tail')?.addEventListener('click', toggleTail)
el.querySelector('.hm-logs-download')?.addEventListener('click', doDownload)
el.querySelector('.hm-logs-clear')?.addEventListener('click', doClearView)
el.querySelectorAll('.hm-logs-file-item').forEach(item => {
item.addEventListener('click', () => {
if (item.dataset.file === activeFile) return
activeFile = item.dataset.file
loadEntries()
})
@@ -145,7 +340,18 @@ export function render() {
})
}
// Init
// --- Lifecycle: stop tail when the page is detached ---
const detachObserver = new MutationObserver(() => {
if (!el.isConnected) {
stopTail()
detachObserver.disconnect()
}
})
requestAnimationFrame(() => {
if (el.parentNode) detachObserver.observe(el.parentNode, { childList: true })
})
// --- Init ---
async function init() {
await loadFiles()
await loadEntries()

View File

@@ -1,13 +1,28 @@
/**
* Hermes Agent 记忆编辑器
* 读写 ~/.hermes/memories/MEMORY.md 和 USER.md
* 支持 Markdown 预览和编辑模式切换
* Hermes Agent — Memory editor (three-section: MEMORY / USER / SOUL)
*
* Mirrors the data contract used by the official `hermes-web-ui`:
* GET /api/hermes/memory → { memory, user, soul, mtimes }
* POST /api/hermes/memory → { section, content }
*
* ClawPanel calls the equivalent Rust/Web-stub commands (`hermes_memory_read_all`
* + `hermes_memory_write`) so the page works on Tauri and Web modes.
*
* All three files live in `~/.hermes/memories/` and are plain Markdown.
*/
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
import { toast } from '../../../components/toast.js'
import { showContentModal } from '../../../components/modal.js'
function escHtml(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') }
function escHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/**
* Markdown → HTML. Intentionally minimal (no external dep). Good enough for
* short agent persona notes. Code blocks preserved. Tables NOT supported.
*/
function mdToHtml(text) {
return text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
@@ -23,122 +38,269 @@ function mdToHtml(text) {
.replace(/\n/g, '<br>')
}
const ICONS = {
memory: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>',
user: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
soul: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 2a10 10 0 100 20 10 10 0 000-20z"/><path d="M12 6v6l4 2"/></svg>',
edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
save: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>',
refresh: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>',
}
/** Format epoch-seconds → relative/short local time (serif-friendly). */
function fmtMtime(epoch) {
if (!epoch) return ''
const now = Date.now() / 1000
const diff = now - epoch
if (diff < 60) return t('engine.memoryJustNow')
if (diff < 3600) return t('engine.memoryMinAgo').replace('{n}', Math.floor(diff / 60))
if (diff < 86400) return t('engine.memoryHrAgo').replace('{n}', Math.floor(diff / 3600))
const d = new Date(epoch * 1000)
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/** Rough word + char count. CJK counted per character. */
function contentStats(text) {
const t = text || ''
const chars = t.length
// Split on whitespace OR CJK character boundary
const words = (t.match(/[\u4e00-\u9fff]|[A-Za-z0-9_]+/g) || []).length
return { chars, words }
}
export function render() {
const el = document.createElement('div')
el.className = 'hermes-memory-page'
el.dataset.engine = 'hermes'
let memoryContent = ''
let userContent = ''
let editingSection = null // null | 'memory' | 'user'
let editBuffer = ''
// --- State ---
const SECTIONS = [
{ key: 'memory', titleKey: 'engine.memoryNotes', icon: ICONS.memory, descKey: 'engine.memoryNotesDesc' },
{ key: 'user', titleKey: 'engine.memoryProfile', icon: ICONS.user, descKey: 'engine.memoryProfileDesc' },
{ key: 'soul', titleKey: 'engine.memorySoul', icon: ICONS.soul, descKey: 'engine.memorySoulDesc' },
]
const data = { memory: '', user: '', soul: '' }
const mtimes = { memory: null, user: null, soul: null }
let editing = null // { key, buffer }
let loading = true
let saving = false
let loadError = null
async function loadAll() {
loading = true
loadError = null
draw()
try {
const [mem, usr] = await Promise.all([
api.hermesMemoryRead('memory'),
api.hermesMemoryRead('user'),
])
memoryContent = mem || ''
userContent = usr || ''
const res = await api.hermesMemoryReadAll()
data.memory = res?.memory || ''
data.user = res?.user || ''
data.soul = res?.soul || ''
mtimes.memory = res?.memory_mtime ?? null
mtimes.user = res?.user_mtime ?? null
mtimes.soul = res?.soul_mtime ?? null
} catch (e) {
console.error('Failed to load memory:', e)
loadError = String(e?.message || e).replace(/^Error:\s*/, '')
}
loading = false
draw()
}
function startEdit(section) {
editingSection = section
editBuffer = section === 'memory' ? memoryContent : userContent
draw()
el.querySelector('#hm-memory-textarea')?.focus()
function startEdit(key) {
const section = SECTIONS.find(s => s.key === key)
editing = { key, buffer: data[key] || '' }
const { chars, words } = contentStats(editing.buffer)
const overlay = showContentModal({
title: `${t(section?.titleKey || 'engine.hermesMemoryTitle')} · ${t('engine.memoryEdit')}`,
width: 920,
content: `
<div class="hm-mem-modal-wrap">
<div class="hm-mem-desc">${t(section?.descKey || 'engine.memoryNotesDesc')}</div>
<textarea id="hm-mem-modal-textarea" class="hm-input hm-mem-editor hm-mem-modal-editor" spellcheck="false" placeholder="${t('engine.memoryPlaceholder')}">${escHtml(editing.buffer)}</textarea>
<div class="hm-mem-modal-foot">
<span class="hm-mem-stats" id="hm-mem-modal-stats">
<span>${words} ${t('engine.memoryWords')}</span>
<span class="hm-mem-sep">·</span>
<span>${chars} ${t('engine.memoryChars')}</span>
</span>
<span class="hm-spacer"></span>
<span class="hm-muted">${t('engine.memorySaveHint')}</span>
</div>
</div>
`,
buttons: [{ id: 'hm-mem-modal-save', className: 'btn btn-primary btn-sm', label: t('engine.memorySave') }],
})
overlay.classList.add('hm-mem-modal-overlay')
overlay.dataset.engine = 'hermes'
const ta = overlay.querySelector('#hm-mem-modal-textarea')
const cancelBtn = overlay.querySelector('[data-action="cancel"]')
const saveBtn = overlay.querySelector('#hm-mem-modal-save')
const closeWithConfirm = () => {
if (!editing) {
overlay.remove()
return
}
const dirty = editing.buffer !== (data[editing.key] || '')
if (dirty && !confirm(t('engine.memoryUnsaved'))) return
editing = null
overlay.remove()
}
cancelBtn.textContent = t('engine.memoryCancel')
cancelBtn.onclick = closeWithConfirm
saveBtn.onclick = save
overlay.addEventListener('click', (e) => {
if (e.target !== overlay) return
e.stopImmediatePropagation()
closeWithConfirm()
}, true)
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopImmediatePropagation()
closeWithConfirm()
}
}, true)
ta.focus()
ta.setSelectionRange(ta.value.length, ta.value.length)
ta.addEventListener('input', (e) => {
if (!editing) return
editing.buffer = e.target.value
const statsEl = overlay.querySelector('#hm-mem-modal-stats')
const stats = contentStats(editing.buffer)
if (statsEl) {
statsEl.innerHTML = `
<span>${stats.words} ${t('engine.memoryWords')}</span>
<span class="hm-mem-sep">·</span>
<span>${stats.chars} ${t('engine.memoryChars')}</span>
`
}
})
ta.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
save()
}
})
}
function cancelEdit() {
const original = editingSection === 'memory' ? memoryContent : userContent
if (editBuffer !== original && !confirm(t('engine.memoryUnsaved'))) return
editingSection = null
editBuffer = ''
if (!editing) return
const dirty = editing.buffer !== (data[editing.key] || '')
if (dirty && !confirm(t('engine.memoryUnsaved'))) return
editing = null
document.querySelector('.hm-mem-modal-overlay')?.remove()
draw()
}
async function save() {
if (!editingSection) return
if (!editing || saving) return
saving = true
draw()
const saveBtn = document.querySelector('#hm-mem-modal-save')
if (saveBtn) {
saveBtn.disabled = true
saveBtn.textContent = t('engine.memorySaving')
}
const { key, buffer } = editing
try {
await api.hermesMemoryWrite(editingSection, editBuffer)
if (editingSection === 'memory') memoryContent = editBuffer
else userContent = editBuffer
editingSection = null
editBuffer = ''
await api.hermesMemoryWrite(key, buffer)
data[key] = buffer
mtimes[key] = Math.floor(Date.now() / 1000)
editing = null
document.querySelector('.hm-mem-modal-overlay')?.remove()
toast(t('engine.memorySaved'), 'success')
} catch (e) {
alert(`${t('engine.memorySaveFailed')}: ${e.message || e}`)
if (saveBtn) {
saveBtn.disabled = false
saveBtn.textContent = t('engine.memorySave')
}
toast(t('engine.memorySaveFailed') + ': ' + (e?.message || e), 'error')
}
saving = false
draw()
}
function renderSection(type, title, iconSvg, content) {
const isEditing = editingSection === type
return `<div class="hm-memory-section">
<div class="hm-memory-section-header">
<div class="hm-memory-section-title-row">
<span class="hm-memory-section-icon">${iconSvg}</span>
<span class="hm-memory-section-title">${title}</span>
</div>
${!isEditing ? `<button class="btn btn-sm btn-secondary hm-memory-edit-btn" data-section="${type}">${t('engine.memoryEdit')}</button>` : ''}
</div>
${isEditing ? `
<div class="hm-memory-edit-wrap">
<textarea class="hm-memory-editor" id="hm-memory-textarea" placeholder="${t('engine.memoryPlaceholder')}">${escHtml(editBuffer)}</textarea>
<div class="hm-memory-edit-actions">
<button class="btn btn-sm" id="hm-memory-cancel">${t('engine.memoryCancel')}</button>
<button class="btn btn-sm btn-primary" id="hm-memory-save" ${saving ? 'disabled' : ''}>${saving ? t('engine.memorySaving') : t('engine.memorySave')}</button>
function renderSection(section) {
const content = data[section.key] || ''
const { chars, words } = contentStats(content)
const mtime = mtimes[section.key]
const statsMarkup = `<span class="hm-mem-stats">
<span>${words} ${t('engine.memoryWords')}</span>
<span class="hm-mem-sep">·</span>
<span>${chars} ${t('engine.memoryChars')}</span>
${mtime ? `<span class="hm-mem-sep">·</span><span>${escHtml(fmtMtime(mtime))}</span>` : ''}
</span>`
return `
<div class="hm-panel hm-mem-panel" data-key="${section.key}">
<div class="hm-panel-header">
<div class="hm-panel-title">
<span class="hm-panel-title-icon">${section.icon}</span>
${t(section.titleKey)}
</div>
<div class="hm-panel-actions">
${statsMarkup}
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-mem-edit" data-key="${section.key}">${ICONS.edit} ${t('engine.memoryEdit')}</button>
</div>
</div>
` : `
<div class="hm-memory-section-body markdown-body">
${content.trim() ? mdToHtml(content) : `<div class="hm-memory-empty">${t('engine.memoryEmpty')}</div>`}
<div class="hm-panel-body">
<div class="hm-mem-desc">${t(section.descKey)}</div>
${content.trim()
? `<div class="hm-mem-rendered markdown-body">${mdToHtml(content)}</div>`
: `<div class="hm-mem-empty">
<span class="hm-mem-empty-title">${t('engine.memoryEmpty')}</span>
<span class="hm-muted">${t(section.descKey)}</span>
</div>`}
</div>
`}
</div>`
</div>
`
}
function draw() {
const notesIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'
const userIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'
el.innerHTML = `
<div class="hm-memory-header">
<span class="hm-memory-header-title">${t('engine.hermesMemoryTitle')}</span>
<button class="btn btn-sm" id="hm-memory-refresh">${t('engine.logsRefresh')}</button>
</div>
<div class="hm-memory-content">
${loading ? `<div class="hm-memory-loading">${t('engine.memoryLoading')}</div>` : `
<div class="hm-memory-sections">
${renderSection('memory', t('engine.memoryNotes'), notesIcon, memoryContent)}
${renderSection('user', t('engine.memoryProfile'), userIcon, userContent)}
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<span class="hm-dot hm-dot--run"></span>
${t('engine.memoryEyebrow')}
</div>
`}
<h1 class="hm-hero-h1">${t('engine.hermesMemoryTitle')}</h1>
<div class="hm-hero-sub">~/.hermes/memories/ · 3 files</div>
</div>
<div class="hm-hero-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-mem-refresh" ${loading ? 'disabled' : ''} title="${t('engine.logsRefresh')}">
${ICONS.refresh} ${t('engine.logsRefresh')}
</button>
</div>
</div>
${loadError ? `
<div class="hm-panel" style="margin-bottom:18px">
<div class="hm-panel-body hm-panel-body--tight">
<div style="color:var(--hm-error);font-family:var(--hm-font-mono);font-size:12.5px">
${escHtml(loadError)}
</div>
</div>
</div>
` : ''}
${loading ? `
<div class="hm-panel"><div class="hm-panel-body">
<div class="hm-skel" style="width:40%;height:14px;margin-bottom:12px"></div>
<div class="hm-skel" style="width:100%;height:80px"></div>
</div></div>
<div class="hm-panel"><div class="hm-panel-body">
<div class="hm-skel" style="width:30%;height:14px;margin-bottom:12px"></div>
<div class="hm-skel" style="width:100%;height:60px"></div>
</div></div>
` : SECTIONS.map(renderSection).join('')}
`
bind()
}
function bind() {
el.querySelector('#hm-memory-refresh')?.addEventListener('click', () => loadAll())
el.querySelectorAll('.hm-memory-edit-btn').forEach(btn => {
btn.addEventListener('click', () => startEdit(btn.dataset.section))
})
el.querySelector('#hm-memory-cancel')?.addEventListener('click', () => cancelEdit())
el.querySelector('#hm-memory-save')?.addEventListener('click', () => save())
el.querySelector('#hm-memory-textarea')?.addEventListener('input', (e) => {
editBuffer = e.target.value
el.querySelector('.hm-mem-refresh')?.addEventListener('click', () => loadAll())
el.querySelectorAll('.hm-mem-edit').forEach(btn => {
btn.addEventListener('click', () => startEdit(btn.dataset.key))
})
}

View File

@@ -1,16 +1,563 @@
/**
* Hermes Agent 服务管理
*/
import { api, invalidate } from '../../../lib/tauri-api.js'
import { t } from '../../../lib/i18n.js'
const ICONS = {
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>',
start: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
stop: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>',
restart: '<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"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0114.13-3.36L23 10"/><path d="M20.49 15A9 9 0 016.36 18.36L1 14"/></svg>',
package: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="15" height="15"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><polyline points="7.5 4.21 12 6.81 16.5 4.21"/><polyline points="7.5 19.79 7.5 14.6 3 12"/><polyline points="21 12 16.5 14.6 16.5 19.79"/><polyline points="12 22.08 12 16.8 21 12"/><polyline points="12 16.8 3 12"/></svg>',
config: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="15" height="15"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h10"/><circle cx="17" cy="17" r="2"/><circle cx="8" cy="7" r="2"/><circle cx="14" cy="12" r="2"/></svg>',
health: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" width="15" height="15"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>',
link: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>',
upload: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>',
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>',
}
function esc(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function stripError(error) {
return String(error?.message || error || '').replace(/^Error:\s*/, '')
}
function maskSecret(value) {
const raw = String(value || '').trim()
if (!raw) return t('engine.servicesNotSet')
if (raw.length <= 8) return '••••••••'
return `${raw.slice(0, 4)}••••${raw.slice(-4)}`
}
function isLocalGatewayUrl(url, port) {
if (!url) return true
try {
const parsed = new URL(url)
if (!['127.0.0.1', 'localhost'].includes(parsed.hostname)) return false
if (!parsed.port) return true
return Number(parsed.port) === Number(port || 8642)
} catch (_) {
return false
}
}
function summarizeHealth(value, limit = 8) {
const rows = []
function visit(prefix, current, depth) {
if (rows.length >= limit || depth > 1 || current == null) return
if (typeof current === 'string' || typeof current === 'number' || typeof current === 'boolean') {
rows.push({
key: prefix || 'status',
value: typeof current === 'boolean' ? (current ? 'true' : 'false') : String(current),
})
return
}
if (Array.isArray(current)) {
if (current.every(item => ['string', 'number', 'boolean'].includes(typeof item))) {
rows.push({ key: prefix || 'items', value: current.join(', ') })
}
return
}
if (typeof current === 'object') {
for (const [key, item] of Object.entries(current)) {
visit(prefix ? `${prefix}.${key}` : key, item, depth + 1)
if (rows.length >= limit) break
}
}
}
visit('', value, 0)
return rows
}
function renderKpi(label, value, foot, tone = '') {
return `
<div class="hm-kpi" data-tone="${tone}">
<div class="hm-kpi-label">${esc(label)}</div>
<div class="hm-kpi-value">${esc(value)}</div>
<div class="hm-kpi-foot">${esc(foot)}</div>
</div>
`
}
function renderInfoRow(label, value, mono = false) {
return `
<div class="hm-services-row">
<div class="hm-services-row-label">${esc(label)}</div>
<div class="hm-services-row-value ${mono ? 'is-mono' : ''}">${esc(value)}</div>
</div>
`
}
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>
`
el.className = 'page hm-services-page'
el.dataset.engine = 'hermes'
let info = null
let config = null
let health = null
let envData = null
let loading = true
let refreshBusy = false
let actionBusy = false
let targetBusy = false
let envBusy = false
let maintenanceBusy = false
let pageMsg = ''
let pageMsgTone = 'muted'
let connectMsg = ''
let connectMsgTone = 'muted'
let targetMode = 'local'
let customUrl = ''
function syncCustomInput() {
const input = el.querySelector('#hm-services-custom-url')
if (input) customUrl = input.value
}
function syncTargetFromInfo() {
const port = info?.gatewayPort || 8642
const currentUrl = info?.gatewayUrl || `http://127.0.0.1:${port}`
if (isLocalGatewayUrl(currentUrl, port)) {
targetMode = 'local'
customUrl = ''
return
}
if (envData?.wsl2?.gatewayUrl && currentUrl === envData.wsl2.gatewayUrl) {
targetMode = 'wsl2'
customUrl = currentUrl
return
}
targetMode = 'custom'
customUrl = currentUrl
}
function setPageMessage(message, tone = 'muted') {
pageMsg = message
pageMsgTone = tone
}
function setConnectMessage(message, tone = 'muted') {
connectMsg = message
connectMsgTone = tone
}
function draw() {
if (loading) {
el.innerHTML = `
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow"><span class="hm-dot hm-dot--idle"></span>${esc(t('engine.servicesEyebrow'))}</div>
<div class="hm-skel" style="width:260px;height:28px;margin-bottom:8px"></div>
<div class="hm-skel" style="width:220px;height:14px"></div>
</div>
</div>
<div class="hm-kpi-grid">
${[1, 2, 3, 4].map(() => `
<div class="hm-kpi">
<div class="hm-skel" style="width:60%;height:10px;margin-bottom:10px"></div>
<div class="hm-skel" style="width:45%;height:22px;margin-bottom:8px"></div>
<div class="hm-skel" style="width:55%;height:10px"></div>
</div>
`).join('')}
</div>
`
return
}
const gwRunning = !!info?.gatewayRunning
const port = info?.gatewayPort || 8642
const version = info?.version || '—'
const gatewayUrl = info?.gatewayUrl || `http://127.0.0.1:${port}`
const model = config?.model || info?.model || health?.model || t('engine.dashNoModel')
const provider = config?.provider || t('engine.servicesUnknown')
const installType = info?.managed || (info?.installed ? 'uv-tool' : t('engine.servicesUnknown'))
const installState = info?.installed ? t('engine.servicesInstalled') : t('engine.servicesMissing')
const llmBaseUrl = config?.base_url || t('engine.servicesNotSet')
const configModel = config?.model_raw || config?.model || info?.model || t('engine.dashNoModel')
const targetLabel = targetMode === 'local'
? t('engine.installModeLocal')
: targetMode === 'custom'
? t('engine.installModeCustom')
: targetMode === 'wsl2'
? 'WSL2'
: 'Docker'
const healthRows = summarizeHealth(health)
const configExists = !!(config?.config_exists || info?.configExists)
const envExists = !!info?.envExists
const customInputVisible = targetMode === 'custom' || targetMode === 'docker'
const targetNote = targetMode === 'local'
? `${t('engine.installModeLocal')} · http://127.0.0.1:${port}`
: targetMode === 'wsl2'
? (envData?.wsl2?.gatewayUrl || t('engine.servicesWslHint'))
: targetMode === 'docker'
? t('engine.servicesDockerHint')
: t('engine.installCustomDesc')
el.innerHTML = `
<div class="hm-hero" data-state="${gwRunning ? 'running' : 'stopped'}">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<span class="hm-dot hm-dot--${gwRunning ? 'run' : 'stop'}"></span>
${esc(t('engine.servicesEyebrow'))}
</div>
<h1 class="hm-hero-h1">${esc(t('engine.hermesServicesTitle'))}</h1>
<div class="hm-hero-sub">${esc(gatewayUrl)} · ${esc(model)} · v${esc(version)}</div>
</div>
<div class="hm-hero-actions">
${info?.installed && !gwRunning ? `<button class="hm-btn hm-btn--cta hm-btn--sm hm-services-start" ${actionBusy ? 'disabled' : ''}>${ICONS.start}<span>${esc(actionBusy ? t('engine.gatewayStarting') : t('engine.gatewayStartBtn'))}</span></button>` : ''}
${info?.installed && gwRunning ? `<button class="hm-btn hm-btn--danger hm-btn--sm hm-services-stop" ${actionBusy ? 'disabled' : ''}>${ICONS.stop}<span>${esc(actionBusy ? t('engine.dashStopping') : t('engine.dashStopGw'))}</span></button>` : ''}
${info?.installed && gwRunning ? `<button class="hm-btn hm-btn--sm hm-services-restart" ${actionBusy ? 'disabled' : ''}>${ICONS.restart}<span>${esc(actionBusy ? t('engine.dashRestarting') : t('engine.dashRestartGw'))}</span></button>` : ''}
<button class="hm-btn hm-btn--ghost hm-btn--icon hm-services-refresh" title="${esc(t('engine.logsRefresh'))}" ${refreshBusy ? 'disabled' : ''}>${ICONS.refresh}</button>
</div>
</div>
<div class="hm-services-desc">${esc(t('engine.servicesDesc'))}</div>
<div class="hm-kpi-grid">
${renderKpi(t('engine.servicesInstallState'), installState, `${t('engine.servicesInstallType')} · ${installType}`, info?.installed ? 'success' : 'error')}
${renderKpi(t('engine.dashGatewayStatus'), gwRunning ? t('engine.dashRunning') : t('engine.dashStopped'), `:${port}`, gwRunning ? 'success' : 'error')}
${renderKpi(t('engine.dashModel'), model, provider, 'accent')}
${renderKpi(t('engine.dashConnectTarget'), targetLabel, gatewayUrl, 'info')}
</div>
${pageMsg ? `<div class="hm-services-msg" data-tone="${esc(pageMsgTone)}">${esc(pageMsg)}</div>` : ''}
<div class="hm-services-grid">
<section class="hm-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">
<span class="hm-panel-title-icon">${ICONS.package}</span>
${esc(t('engine.servicesInstallState'))}
</div>
</div>
<div class="hm-panel-body hm-panel-body--tight">
<div class="hm-services-rows">
${renderInfoRow(t('engine.dashVersion'), `v${version}`)}
${renderInfoRow(t('engine.servicesInstallType'), installType)}
${renderInfoRow(t('engine.servicesPath'), info?.path || t('engine.servicesNotSet'), true)}
${renderInfoRow(t('engine.servicesHome'), info?.hermesHome || t('engine.servicesNotSet'), true)}
</div>
<div class="hm-field-label" style="margin:16px 0 10px">${esc(t('engine.servicesConfigFiles'))}</div>
<div class="hm-pills">
<span class="hm-pill ${configExists ? 'hm-pill--ok' : 'hm-pill--muted'}">config.yaml</span>
<span class="hm-pill ${envExists ? 'hm-pill--ok' : 'hm-pill--muted'}">.env</span>
</div>
</div>
</section>
<section class="hm-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">
<span class="hm-panel-title-icon">${ICONS.config}</span>
${esc(t('engine.hermesConfigTitle'))}
</div>
</div>
<div class="hm-panel-body hm-panel-body--tight">
<div class="hm-services-rows">
${renderInfoRow(t('engine.configProvider'), provider)}
${renderInfoRow(t('engine.configModel'), configModel)}
${renderInfoRow(t('engine.configBaseUrl'), llmBaseUrl, true)}
${renderInfoRow(t('engine.configApiKey'), maskSecret(config?.api_key), true)}
</div>
<div class="hm-stack" style="margin-top:14px">
<a class="hm-btn hm-btn--ghost hm-btn--sm" href="#/h/config">${esc(t('engine.servicesOpenConfig'))}</a>
<a class="hm-btn hm-btn--ghost hm-btn--sm" href="#/h/env">${esc(t('engine.servicesOpenEnv'))}</a>
</div>
</div>
</section>
</div>
<section class="hm-panel hm-services-panel" style="margin-top:16px">
<div class="hm-panel-header">
<div class="hm-panel-title">
<span class="hm-panel-title-icon">${ICONS.link}</span>
${esc(t('engine.dashConnectTarget'))}
</div>
<div class="hm-panel-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-services-detect-env" ${envBusy ? 'disabled' : ''}>${esc(envBusy ? t('engine.dashDetecting') : t('engine.dashDetectEnv'))}</button>
</div>
</div>
<div class="hm-panel-body hm-panel-body--tight">
<div class="hm-pills" style="margin-bottom:12px">
<button class="hm-pill hm-services-mode ${targetMode === 'local' ? 'is-active' : ''}" data-mode="local">${esc(t('engine.installModeLocal'))}</button>
${envData?.wsl2?.available ? `<button class="hm-pill hm-services-mode ${targetMode === 'wsl2' ? 'is-active' : ''}" data-mode="wsl2">WSL2${envData.wsl2.gatewayRunning ? ` · ${esc(t('engine.servicesReadyTag'))}` : ''}</button>` : ''}
${envData?.docker?.available ? `<button class="hm-pill hm-services-mode ${targetMode === 'docker' ? 'is-active' : ''}" data-mode="docker">Docker</button>` : ''}
<button class="hm-pill hm-services-mode ${targetMode === 'custom' ? 'is-active' : ''}" data-mode="custom">${esc(t('engine.installModeCustom'))}</button>
</div>
${customInputVisible ? `
<label class="hm-field" style="margin-bottom:12px">
<span class="hm-field-label">${esc(t('engine.servicesCustomUrl'))}</span>
<input id="hm-services-custom-url" class="hm-input" type="text" value="${esc(customUrl)}" placeholder="http://192.168.1.100:8642">
</label>
` : ''}
<div class="hm-services-note">${esc(targetNote)}</div>
${envData ? `
<div class="hm-services-env-grid">
${envData?.wsl2?.available ? `
<div class="hm-services-env-card">
<div class="hm-services-env-title">WSL2</div>
<div class="hm-services-env-meta">${esc((envData.wsl2.distros || []).join(', ') || t('engine.servicesDefaultDistro'))}</div>
<div class="hm-services-env-meta">${esc(envData.wsl2.ip || '—')}</div>
<div class="hm-services-env-meta">${esc(envData.wsl2.gatewayRunning ? (envData.wsl2.gatewayUrl || '') : t('engine.servicesWslHint'))}</div>
</div>
` : ''}
${envData?.docker?.available ? `
<div class="hm-services-env-card">
<div class="hm-services-env-title">Docker</div>
<div class="hm-services-env-meta">${esc(envData.docker.version || '—')}</div>
<div class="hm-services-env-meta">${esc(t('engine.servicesContainerCount', { n: String(envData.docker.hermesContainers?.length || 0) }))}</div>
<div class="hm-services-env-meta">${esc(t('engine.servicesDockerHint'))}</div>
</div>
` : ''}
</div>
` : ''}
<div class="hm-stack" style="margin-top:14px">
<button class="hm-btn hm-btn--primary hm-btn--sm hm-services-apply-target" ${targetBusy ? 'disabled' : ''}>${esc(t('engine.dashConnApply'))}</button>
${connectMsg ? `<span class="hm-services-inline-msg" data-tone="${esc(connectMsgTone)}">${esc(connectMsg)}</span>` : ''}
</div>
</div>
</section>
<section class="hm-panel hm-services-panel" style="margin-top:16px">
<div class="hm-panel-header">
<div class="hm-panel-title">
<span class="hm-panel-title-icon">${ICONS.health}</span>
${esc(t('engine.servicesHealthTitle'))}
</div>
<div class="hm-panel-actions">
<span class="hm-pill ${gwRunning ? 'hm-pill--ok' : 'hm-pill--muted'}">${esc(gwRunning ? t('engine.dashRunning') : t('engine.dashStopped'))}</span>
</div>
</div>
<div class="hm-panel-body">
${healthRows.length ? `
<div class="hm-services-health-grid">
${healthRows.map(row => `
<div class="hm-services-health-card">
<div class="hm-services-health-key">${esc(row.key)}</div>
<div class="hm-services-health-value">${esc(row.value)}</div>
</div>
`).join('')}
</div>
<details class="hm-services-json-wrap">
<summary>${esc(t('engine.servicesRawJson'))}</summary>
<pre class="hm-term hm-services-json">${esc(JSON.stringify(health, null, 2))}</pre>
</details>
` : `
<div class="hm-services-empty">${esc(t('engine.servicesNoHealth'))}</div>
`}
</div>
</section>
<section class="hm-panel hm-services-panel" style="margin-top:16px">
<div class="hm-panel-header">
<div class="hm-panel-title">
<span class="hm-panel-title-icon">${ICONS.upload}</span>
${esc(t('engine.servicesMaintenance'))}
</div>
</div>
<div class="hm-panel-body">
<div class="hm-services-action-grid">
<button class="hm-btn hm-btn--primary hm-btn--sm hm-services-upgrade" ${maintenanceBusy || !info?.installed ? 'disabled' : ''}>${ICONS.upload}<span>${esc(t('engine.servicesUpgrade'))}</span></button>
<button class="hm-btn hm-btn--sm hm-services-uninstall" ${maintenanceBusy || !info?.installed ? 'disabled' : ''}>${ICONS.trash}<span>${esc(t('engine.servicesUninstall'))}</span></button>
<button class="hm-btn hm-btn--danger hm-btn--sm hm-services-uninstall-clean" ${maintenanceBusy || !info?.installed ? 'disabled' : ''}>${ICONS.trash}<span>${esc(t('engine.servicesUninstallClean'))}</span></button>
</div>
<div class="hm-stack" style="margin-top:14px">
<a class="hm-btn hm-btn--ghost hm-btn--sm" href="#/h/logs">${esc(t('engine.servicesOpenLogs'))}</a>
<a class="hm-btn hm-btn--ghost hm-btn--sm" href="#/h/config">${esc(t('engine.servicesOpenConfig'))}</a>
<a class="hm-btn hm-btn--ghost hm-btn--sm" href="#/h/setup">${esc(t('engine.servicesOpenSetup'))}</a>
</div>
</div>
</section>
`
bind()
}
async function refresh(withSpinner = true) {
if (withSpinner) {
refreshBusy = true
if (!loading) draw()
}
invalidate('check_hermes')
try {
info = await api.checkHermes()
if (info?.gatewayRunning) {
try {
health = await api.hermesHealthCheck()
} catch (error) {
health = null
setPageMessage(stripError(error), 'warn')
}
} else {
health = null
}
try {
config = await api.hermesReadConfig()
} catch (_) {
config = null
}
syncTargetFromInfo()
} catch (error) {
setPageMessage(stripError(error), 'error')
} finally {
loading = false
refreshBusy = false
draw()
}
}
async function runGatewayAction(action) {
if (actionBusy) return
actionBusy = true
setPageMessage(
action === 'start'
? t('engine.gatewayStarting')
: action === 'restart'
? t('engine.dashRestarting')
: t('engine.dashStopping'),
'muted'
)
draw()
try {
if (action === 'restart') {
try { await api.hermesGatewayAction('stop') } catch (_) {}
await new Promise(resolve => setTimeout(resolve, 1200))
const result = await api.hermesGatewayAction('start')
setPageMessage(result || t('engine.dashRestartGw'), 'success')
} else {
const result = await api.hermesGatewayAction(action)
setPageMessage(result || action, 'success')
}
} catch (error) {
setPageMessage(stripError(error), 'error')
}
actionBusy = false
await refresh(false)
}
async function detectEnvironments() {
if (envBusy) return
envBusy = true
draw()
try {
envData = await api.hermesDetectEnvironments()
if (info) syncTargetFromInfo()
setConnectMessage('', 'muted')
} catch (error) {
setConnectMessage(stripError(error), 'error')
}
envBusy = false
draw()
}
async function applyTarget() {
if (targetBusy) return
syncCustomInput()
let targetUrl = null
if (targetMode === 'wsl2') {
targetUrl = envData?.wsl2?.gatewayUrl || null
if (!targetUrl) {
setConnectMessage(t('engine.servicesDetectFirst'), 'warn')
draw()
return
}
} else if (targetMode === 'docker') {
targetUrl = customUrl.trim() || null
if (!targetUrl) {
setConnectMessage(t('engine.servicesDockerHint'), 'warn')
draw()
return
}
} else if (targetMode === 'custom') {
targetUrl = customUrl.trim() || null
if (!targetUrl) {
setConnectMessage(t('engine.installCustomEmpty'), 'warn')
draw()
return
}
}
targetBusy = true
draw()
try {
const result = await api.hermesSetGatewayUrl(targetUrl)
setConnectMessage(result, 'success')
setPageMessage(result, 'success')
} catch (error) {
setConnectMessage(stripError(error), 'error')
}
targetBusy = false
await refresh(false)
}
async function runMaintenance(kind) {
if (maintenanceBusy) return
const confirmText = kind === 'upgrade'
? t('engine.servicesConfirmUpgrade')
: kind === 'uninstall-clean'
? t('engine.servicesConfirmUninstallClean')
: t('engine.servicesConfirmUninstall')
if (!confirm(confirmText)) return
maintenanceBusy = true
setPageMessage(kind === 'upgrade' ? t('engine.servicesUpgrade') : t('engine.servicesUninstall'), 'muted')
draw()
try {
const result = kind === 'upgrade'
? await api.updateHermes()
: await api.uninstallHermes(kind === 'uninstall-clean')
setPageMessage(result, 'success')
invalidate('check_hermes')
await refresh(false)
if (kind !== 'upgrade' && !info?.installed) {
window.location.hash = '#/h/setup'
}
} catch (error) {
setPageMessage(stripError(error), 'error')
}
maintenanceBusy = false
draw()
}
function bind() {
el.querySelector('.hm-services-refresh')?.addEventListener('click', () => refresh())
el.querySelector('.hm-services-start')?.addEventListener('click', () => runGatewayAction('start'))
el.querySelector('.hm-services-stop')?.addEventListener('click', () => runGatewayAction('stop'))
el.querySelector('.hm-services-restart')?.addEventListener('click', () => runGatewayAction('restart'))
el.querySelector('.hm-services-detect-env')?.addEventListener('click', detectEnvironments)
el.querySelector('.hm-services-apply-target')?.addEventListener('click', applyTarget)
el.querySelectorAll('.hm-services-mode').forEach(button => {
button.addEventListener('click', () => {
syncCustomInput()
targetMode = button.dataset.mode
if (targetMode === 'wsl2' && envData?.wsl2?.gatewayUrl) customUrl = envData.wsl2.gatewayUrl
if (targetMode === 'local') customUrl = ''
draw()
})
})
el.querySelector('#hm-services-custom-url')?.addEventListener('input', (event) => {
customUrl = event.target.value
})
el.querySelector('.hm-services-upgrade')?.addEventListener('click', () => runMaintenance('upgrade'))
el.querySelector('.hm-services-uninstall')?.addEventListener('click', () => runMaintenance('uninstall'))
el.querySelector('.hm-services-uninstall-clean')?.addEventListener('click', () => runMaintenance('uninstall-clean'))
}
draw()
refresh()
return el
}

View File

@@ -0,0 +1,474 @@
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
import { toast } from '../../../components/toast.js'
import { showConfirm } from '../../../components/modal.js'
import { icon } from '../../../lib/icons.js'
import { getChatStore, getSourceLabel } from '../lib/chat-store.js'
function escHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function escAttr(value) {
return escHtml(value).replace(/'/g, '&#39;')
}
function parseEpochMs(value) {
if (!value) return 0
if (typeof value === 'number' && Number.isFinite(value)) {
return value < 1e12 ? Math.round(value * 1000) : Math.round(value)
}
const ts = Date.parse(String(value))
return Number.isFinite(ts) ? ts : 0
}
function formatTime(value) {
if (!value) return '—'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return '—'
const diff = Date.now() - d.getTime()
if (diff < 60_000) return t('engine.sessionsJustNow')
if (diff < 3_600_000) return t('engine.sessionsMinutesAgo').replace('{n}', String(Math.max(1, Math.floor(diff / 60_000))))
if (diff < 86_400_000) return t('engine.sessionsHoursAgo').replace('{n}', String(Math.max(1, Math.floor(diff / 3_600_000))))
return d.toLocaleString()
}
function sessionKey(session) {
return `${session.profile || 'default'}::${session.id}`
}
function sessionTitle(session) {
return session?.title || session?.messages?.find(m => m.role === 'user')?.content?.slice(0, 64) || t('engine.sessionsUntitled')
}
function messagePreview(session) {
const first = session?.messages?.find(m => m.role === 'user') || session?.messages?.[0]
return first?.content ? String(first.content).replace(/\s+/g, ' ').slice(0, 180) : (session?.preview || t('engine.sessionsNoPreview'))
}
function tokenCount(session) {
return Number(session?.inputTokens || 0) + Number(session?.outputTokens || 0)
}
function formatTokens(value) {
const n = Number(value || 0)
if (!n) return '0'
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
return String(Math.round(n))
}
function normalizeMessage(m) {
const raw = m?.content ?? m?.toolResult ?? m?.toolArgs ?? ''
return {
role: m?.role || 'message',
content: typeof raw === 'string' ? raw : JSON.stringify(raw),
timestamp: m?.timestamp || m?.created_at || '',
}
}
function mapSessionSummary(s, profile) {
return {
id: s?.id || s?.session_id || '',
profile: profile || 'default',
title: s?.title || '',
source: s?.source || '',
model: s?.model || '',
preview: s?.preview || '',
lastActiveLabel: s?.last_active_label || '',
messageCount: Number(s?.message_count || s?.messageCount || 0),
createdAt: parseEpochMs(s?.created_at || s?.started_at || s?.createdAt),
updatedAt: parseEpochMs(s?.updated_at || s?.last_active || s?.ended_at || s?.created_at || s?.started_at || s?.updatedAt),
inputTokens: Number(s?.input_tokens || s?.inputTokens || 0),
outputTokens: Number(s?.output_tokens || s?.outputTokens || 0),
messages: Array.isArray(s?.messages) ? s.messages.map(normalizeMessage) : [],
messagesLoaded: Array.isArray(s?.messages),
}
}
function getFilteredSessions(rows, query, source) {
const q = (query || '').trim().toLowerCase()
let sessions = rows.slice()
if (source !== '__all__') sessions = sessions.filter(s => (s.source || '') === source)
if (q) {
sessions = sessions.filter(s => {
const hay = [s.id, s.profile, s.title, s.model, s.source, ...(s.messages || []).slice(0, 3).map(m => m.content)].join('\n').toLowerCase()
return hay.includes(q)
})
}
return sessions.sort((a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0))
}
function uniqueSources(sessions) {
return Array.from(new Set(sessions.map(s => s.source || ''))).sort((a, b) => getSourceLabel(a).localeCompare(getSourceLabel(b)))
}
export function render() {
const el = document.createElement('div')
el.className = 'page hm-sessions-page'
el.dataset.engine = 'hermes'
const store = getChatStore()
let query = ''
let source = '__all__'
let profileScope = store.state.activeProfile || 'default'
let rows = []
let selectedKey = null
let selected = new Set()
let loading = false
let busy = false
let detailLoadingKey = null
const unsubscribe = store.subscribe(() => draw())
function availableProfiles() {
const profiles = store.state.profiles || []
if (profiles.length) return profiles.map(p => p.name).filter(Boolean)
return [store.state.activeProfile || 'default']
}
function targetProfiles() {
return profileScope === '__all__' ? availableProfiles() : [profileScope]
}
function currentSessions() {
return getFilteredSessions(rows, query, source)
}
function findByKey(key) {
return rows.find(s => sessionKey(s) === key) || null
}
function currentSession() {
return findByKey(selectedKey) || currentSessions()[0] || null
}
async function loadRows() {
loading = true
draw()
try {
const profiles = targetProfiles()
const settled = await Promise.allSettled(profiles.map(async (profile) => {
const list = await api.hermesSessionsSummaryList(null, 80, profile)
return (Array.isArray(list) ? list : []).map(s => mapSessionSummary(s, profile)).filter(s => s.id)
}))
rows = settled.flatMap(r => r.status === 'fulfilled' ? r.value : [])
const failed = settled.filter(r => r.status === 'rejected').length
if (failed) toast(t('engine.sessionsProfileLoadPartial').replace('{n}', String(failed)), 'warning')
const visible = currentSessions()
selected = new Set([...selected].filter(key => rows.some(s => sessionKey(s) === key)))
selectedKey = selectedKey && rows.some(s => sessionKey(s) === selectedKey) ? selectedKey : (visible[0] ? sessionKey(visible[0]) : null)
} catch (err) {
toast(String(err?.message || err), 'error')
} finally {
loading = false
draw()
}
}
async function loadDetail(key, redraw = true) {
const session = findByKey(key)
if (!session || session.messagesLoaded || detailLoadingKey === key) return
detailLoadingKey = key
if (redraw) draw()
try {
const detail = await api.hermesSessionDetail(session.id, session.profile)
session.messages = Array.isArray(detail?.messages) ? detail.messages.map(normalizeMessage) : []
session.messagesLoaded = true
session.title = session.title || detail?.title || ''
session.model = session.model || detail?.model || ''
session.source = session.source || detail?.source || ''
session.messageCount = session.messageCount || session.messages.length
} catch (err) {
toast(t('engine.sessionsDetailLoadFailed') + ': ' + (err?.message || err), 'error')
} finally {
detailLoadingKey = null
if (redraw) draw()
}
}
function renderProfileBar() {
const profiles = availableProfiles()
return `
<select class="hm-sessions-profile-select" id="hm-sessions-profile">
<option value="__all__" ${profileScope === '__all__' ? 'selected' : ''}>${escHtml(t('engine.sessionsAllProfiles'))}</option>
${profiles.map(name => `<option value="${escAttr(name)}" ${profileScope === name ? 'selected' : ''}>${escHtml(name)}${name === store.state.activeProfile ? ' · active' : ''}</option>`).join('')}
</select>
`
}
function renderSessionRow(s) {
const key = sessionKey(s)
const checked = selected.has(key)
const active = currentSession() && sessionKey(currentSession()) === key
const pinned = s.profile === store.state.activeProfile && store.state.pinned.has(s.id)
const tokens = tokenCount(s)
return `
<button class="hm-session-row ${active ? 'is-active' : ''} ${checked ? 'is-selected' : ''}" data-session-key="${escAttr(key)}">
<span class="hm-session-row-check" data-check-id="${escAttr(key)}">${icon(checked ? 'check-circle' : 'circle', 16)}</span>
<span class="hm-session-row-main">
<span class="hm-session-row-title">${pinned ? icon('crown', 12) : ''}${escHtml(sessionTitle(s))}</span>
<span class="hm-session-row-preview">${escHtml(messagePreview(s))}</span>
<span class="hm-session-row-meta">
<span>${escHtml(s.profile || 'default')}</span>
<span>${escHtml(getSourceLabel(s.source || ''))}</span>
${s.model ? `<span>${escHtml(s.model)}</span>` : ''}
<span>${formatTokens(tokens)} tok</span>
</span>
</span>
<span class="hm-session-row-time">${escHtml(s.lastActiveLabel || formatTime(s.updatedAt || s.createdAt))}</span>
</button>
`
}
function renderDetail(session) {
if (!session) {
return `
<section class="hm-session-detail is-empty">
${icon('message-square', 34)}
<h3>${escHtml(t('engine.sessionsNoSelection'))}</h3>
<p>${escHtml(t('engine.sessionsNoSelectionDesc'))}</p>
</section>
`
}
const key = sessionKey(session)
const messages = (session.messages || []).slice(-30)
const canPin = session.profile === store.state.activeProfile
return `
<section class="hm-session-detail">
<div class="hm-session-detail-head">
<div>
<div class="hm-session-detail-kicker">${escHtml(session.profile || 'default')} · ${escHtml(getSourceLabel(session.source || ''))}</div>
<h2>${escHtml(sessionTitle(session))}</h2>
<div class="hm-session-detail-id">${escHtml(session.id)}</div>
</div>
<div class="hm-session-detail-actions">
<button class="hm-sessions-btn" id="hm-session-open-chat">${icon('message-circle', 14)}${escHtml(t('engine.sessionsOpenChat'))}</button>
${canPin ? `<button class="hm-sessions-btn" id="hm-session-pin">${icon(store.state.pinned.has(session.id) ? 'crown' : 'target', 14)}${escHtml(store.state.pinned.has(session.id) ? t('engine.sessionsUnpin') : t('engine.sessionsPin'))}</button>` : ''}
<button class="hm-sessions-btn is-danger" id="hm-session-delete" data-session-key="${escAttr(key)}">${icon('trash', 14)}${escHtml(t('engine.chatDeleteSession'))}</button>
</div>
</div>
<div class="hm-session-stat-grid">
<div><span>${escHtml(t('engine.sessionsMessages'))}</span><strong>${Number(session.messageCount || session.messages?.length || 0)}</strong></div>
<div><span>${escHtml(t('engine.sessionsTokens'))}</span><strong>${formatTokens(tokenCount(session))}</strong></div>
<div><span>${escHtml(t('engine.sessionsModel'))}</span><strong>${escHtml(session.model || '—')}</strong></div>
<div><span>${escHtml(t('engine.sessionsUpdated'))}</span><strong>${escHtml(session.lastActiveLabel || formatTime(session.updatedAt || session.createdAt))}</strong></div>
</div>
<div class="hm-session-message-list">
${detailLoadingKey === key ? `<div class="hm-session-empty-messages">${escHtml(t('engine.chatLoadingMessages'))}</div>` : ''}
${detailLoadingKey !== key && messages.length ? messages.map(m => `
<article class="hm-session-msg hm-session-msg--${escAttr(m.role || 'unknown')}">
<div class="hm-session-msg-role">${escHtml(m.role || 'message')}</div>
<div class="hm-session-msg-body">${escHtml(m.content || '')}</div>
</article>
`).join('') : ''}
${detailLoadingKey !== key && !messages.length ? `<div class="hm-session-empty-messages">${escHtml(t('engine.sessionsMessagesNotLoaded'))}</div>` : ''}
</div>
</section>
`
}
function draw() {
const sessions = currentSessions()
const detail = currentSession()
const sources = uniqueSources(rows)
const allVisibleSelected = sessions.length > 0 && sessions.every(s => selected.has(sessionKey(s)))
el.innerHTML = `
<div class="hm-sessions-hero">
<div>
<div class="hm-sessions-eyebrow">HERMES · SESSIONS</div>
<h1>${escHtml(t('engine.sessionsPageTitle'))}</h1>
<p>${escHtml(t('engine.sessionsPageDesc'))}</p>
</div>
<div class="hm-sessions-hero-actions">
${renderProfileBar()}
<button class="hm-sessions-btn" id="hm-sessions-refresh" ${busy || loading ? 'disabled' : ''}>${icon('refresh-cw', 14)}${escHtml(t('skills.refresh'))}</button>
<button class="hm-sessions-btn is-ghost" id="hm-sessions-open-chat">${icon('message-circle', 14)}${escHtml(t('engine.chatSessions'))}</button>
</div>
</div>
<div class="hm-sessions-stats">
<div><span>${escHtml(t('engine.sessionsTotal'))}</span><strong>${rows.length}</strong></div>
<div><span>${escHtml(t('engine.sessionsShown'))}</span><strong>${sessions.length}</strong></div>
<div><span>${escHtml(t('engine.sessionsProfiles'))}</span><strong>${targetProfiles().length}</strong></div>
<div><span>${escHtml(t('engine.sessionsSelected'))}</span><strong>${selected.size}</strong></div>
</div>
<div class="hm-sessions-shell">
<aside class="hm-sessions-list-panel">
<div class="hm-sessions-toolbar">
<label class="hm-sessions-search">
${icon('search', 14)}
<input id="hm-sessions-query" value="${escAttr(query)}" placeholder="${escAttr(t('engine.sessionsSearchPlaceholder'))}">
</label>
<select id="hm-sessions-source">
<option value="__all__" ${source === '__all__' ? 'selected' : ''}>${escHtml(t('engine.sessionsAllSources'))}</option>
${sources.map(src => `<option value="${escAttr(src)}" ${source === src ? 'selected' : ''}>${escHtml(getSourceLabel(src))}</option>`).join('')}
</select>
</div>
<div class="hm-sessions-bulkbar">
<button id="hm-sessions-select-all">${icon(allVisibleSelected ? 'x' : 'check', 13)}${escHtml(allVisibleSelected ? t('engine.chatSelectNone') : t('engine.chatSelectAll'))}</button>
<button id="hm-sessions-bulk-delete" class="is-danger" ${selected.size ? '' : 'disabled'}>${icon('trash', 13)}${escHtml(t('engine.chatBulkDelete'))}</button>
</div>
<div class="hm-sessions-list">
${loading ? `<div class="hm-sessions-loading">${escHtml(t('engine.chatLoading'))}</div>` : ''}
${!loading && !sessions.length ? `<div class="hm-sessions-empty">${escHtml(t('engine.sessionsEmpty'))}</div>` : ''}
${sessions.map(renderSessionRow).join('')}
</div>
</aside>
${renderDetail(detail)}
</div>
`
bind()
}
async function openCurrentInChat() {
const session = currentSession()
if (!session) return
try {
busy = true
draw()
if (session.profile !== store.state.activeProfile) {
await store.switchProfile(session.profile)
}
if (!store.state.sessions.some(s => s.id === session.id)) {
await store.loadSessions()
}
await store.switchSession(session.id)
window.location.hash = '#/h/chat'
} catch (err) {
toast(String(err?.message || err), 'error')
} finally {
busy = false
draw()
}
}
async function deleteOne(session) {
if (!session) return
const ok = await showConfirm(t('engine.chatConfirmDelete'))
if (!ok) return
try {
if (session.profile === store.state.activeProfile && store.state.streaming && session.id === store.state.runningSessionId) {
throw new Error('RUNNING_SESSION')
}
await api.hermesSessionDelete(session.id, session.profile)
rows = rows.filter(s => sessionKey(s) !== sessionKey(session))
selected.delete(sessionKey(session))
selectedKey = null
if (session.profile === store.state.activeProfile) await store.loadSessions()
toast(t('engine.chatSessionDeleted'), 'success')
} catch (err) {
toast(t('engine.chatDeleteFailed') + ': ' + (err?.message || err), 'error')
}
draw()
}
function bind() {
el.querySelector('#hm-sessions-refresh')?.addEventListener('click', async () => {
busy = true
draw()
try { await loadRows() }
finally { busy = false; draw() }
})
el.querySelector('#hm-sessions-open-chat')?.addEventListener('click', () => { window.location.hash = '#/h/chat' })
el.querySelector('#hm-session-open-chat')?.addEventListener('click', openCurrentInChat)
el.querySelector('#hm-sessions-query')?.addEventListener('input', (e) => {
query = e.target.value
selectedKey = currentSessions()[0] ? sessionKey(currentSessions()[0]) : null
draw()
})
el.querySelector('#hm-sessions-source')?.addEventListener('change', (e) => {
source = e.target.value
selectedKey = currentSessions()[0] ? sessionKey(currentSessions()[0]) : null
draw()
})
el.querySelector('#hm-sessions-profile')?.addEventListener('change', async (e) => {
profileScope = e.target.value
selected.clear()
selectedKey = null
await loadRows()
})
el.querySelectorAll('[data-session-key]').forEach(row => {
row.addEventListener('click', async (e) => {
const key = row.dataset.sessionKey
if (!key) return
if (e.target.closest('[data-check-id]')) {
if (selected.has(key)) selected.delete(key)
else selected.add(key)
draw()
return
}
selectedKey = key
draw()
await loadDetail(key)
})
})
el.querySelector('#hm-sessions-select-all')?.addEventListener('click', () => {
const sessions = currentSessions()
const allVisibleSelected = sessions.length > 0 && sessions.every(s => selected.has(sessionKey(s)))
if (allVisibleSelected) sessions.forEach(s => selected.delete(sessionKey(s)))
else sessions.forEach(s => selected.add(sessionKey(s)))
draw()
})
el.querySelector('#hm-sessions-bulk-delete')?.addEventListener('click', async () => {
if (!selected.size) return
const targets = [...selected].map(findByKey).filter(Boolean)
const ok = await showConfirm(t('engine.chatConfirmBulkDelete').replace('{n}', String(targets.length)))
if (!ok) return
const deleted = []
const failed = []
for (const session of targets) {
try {
if (session.profile === store.state.activeProfile && store.state.streaming && session.id === store.state.runningSessionId) {
throw new Error('RUNNING_SESSION')
}
await api.hermesSessionDelete(session.id, session.profile)
deleted.push(sessionKey(session))
} catch (err) {
failed.push({ session, err })
}
}
rows = rows.filter(s => !deleted.includes(sessionKey(s)))
selected.clear()
if (deleted.length && targets.some(s => s.profile === store.state.activeProfile)) await store.loadSessions()
if (deleted.length && !failed.length) {
toast(t('engine.chatBulkDeleted').replace('{n}', String(deleted.length)), 'success')
} else if (deleted.length) {
toast(t('engine.chatBulkPartial').replace('{n}', String(deleted.length)).replace('{f}', String(failed.length)), 'warning')
} else {
toast(t('engine.chatBulkFailed'), 'error')
}
draw()
})
el.querySelector('#hm-session-pin')?.addEventListener('click', () => {
const session = currentSession()
if (!session || session.profile !== store.state.activeProfile) return
store.togglePinned(session.id)
draw()
})
el.querySelector('#hm-session-delete')?.addEventListener('click', async () => {
await deleteOne(currentSession())
})
}
async function init() {
await store.loadProfiles().catch(() => {})
profileScope = store.state.activeProfile || 'default'
await loadRows()
}
requestAnimationFrame(() => { draw(); init() })
const observer = new MutationObserver(() => {
if (!el.isConnected) {
unsubscribe()
observer.disconnect()
}
})
requestAnimationFrame(() => { if (el.parentNode) observer.observe(el.parentNode, { childList: true }) })
return el
}

View File

@@ -33,6 +33,7 @@ let hermesGroups = { apiKeyIntl: [], apiKeyCn: [], aggregators: [], oauth: [], e
export function render() {
const el = document.createElement('div')
el.className = 'page'
el.dataset.engine = 'hermes'
// 状态
let phase = 'detect' // detect | install | configure | gateway | complete

View File

@@ -1,37 +1,115 @@
/**
* Hermes Agent Skills 浏览器
* 从 ~/.hermes/skills/ 读取技能文件,按分类展示,支持搜索和详情查看
* Hermes Agent Skills browser (editorial luxury re-write)
*
* Mirrors the official `hermes-web-ui` Skills view:
* GET /api/hermes/skills → { categories: [...] }
* PUT /api/hermes/skills/toggle → enable/disable
* GET /api/hermes/skills/:cat/:skill/files → attached files
* GET /api/hermes/skills/<path> → file content
*
* Layout:
* ┌ hero ───────────────────────────────────────────────────┐
* │ eyebrow + big-serif title + search + skill count │
* ├─ sidebar (categories + skills) ┬─ detail (markdown + files)
* │ collapsible, toggle switches │ breadcrumb when viewing
* │ │ an attached file
* └────────────────────────────────┴──────────────────────────┘
*
* Extras beyond the official UI:
* - Collapsible categories (persist in memory only)
* - File browser with breadcrumb + back button (Vue parity)
* - Inline toggle switches use stable loading state per skill
*/
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
import { toast } from '../../../components/toast.js'
function escHtml(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') }
function escHtml(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/**
* Minimal, dependency-free Markdown renderer. Matches the feature-set used
* across Hermes pages (memory/skills) so the look is consistent. Supports:
* - fenced code blocks (```lang\ncode```)
* - inline `code`, **bold**, *italic*
* - `# / ## / ### / ####` headings
* - unordered list (`- item`) → `<li>`
* - `[text](url)` → `<a>`
* Anything else is escaped and rendered as plain text with `<br>` for newlines.
*/
function mdToHtml(text) {
return text
if (!text) return ''
// First pass: extract code blocks so inner contents aren't mangled by other
// replacers. We keep a placeholder token and restore at the end.
const blocks = []
let out = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
const idx = blocks.push({ lang, code }) - 1
return `\u0000CODEBLOCK_${idx}\u0000`
})
out = out
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.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(/^#### (.+)$/gm, '<h5>$1</h5>')
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
.replace(/^(?:\s*[-*]\s+(.+))(?:\n\s*[-*]\s+(.+))*/gm, (m) =>
'<ul>' + m.trim().split(/\n\s*[-*]\s+/).map(li => `<li>${li.replace(/^[-*]\s+/, '')}</li>`).join('') + '</ul>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/\n{2,}/g, '</p><p>')
.replace(/\n/g, '<br>')
// Restore code blocks.
out = out.replace(/\u0000CODEBLOCK_(\d+)\u0000/g, (_, i) => {
const { lang, code } = blocks[Number(i)]
return `<pre><code class="lang-${escHtml(lang)}">${escHtml(code)}</code></pre>`
})
return `<p>${out}</p>`
}
const ICONS = {
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
chevron: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="11" height="11"><polyline points="6 9 12 15 18 9"/></svg>',
back: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><polyline points="15 18 9 12 15 6"/></svg>',
file: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" width="13" height="13"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" width="13" height="13"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>',
refresh: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>',
empty: '<svg width="42" height="42" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.9" opacity="0.35"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>',
}
/** Cross-platform basename (handles `/` and `\\`). */
function basename(p) {
if (!p) return ''
const s = String(p).replace(/\\/g, '/')
const idx = s.lastIndexOf('/')
return idx >= 0 ? s.slice(idx + 1) : s
}
export function render() {
const el = document.createElement('div')
el.className = 'hermes-skills-page'
el.dataset.engine = 'hermes'
let categories = []
// --- State ---
let categories = [] // [{ category, description, skills: [...] }]
let loading = true
let searchQuery = ''
let activeSkill = null // { category, file, name, path }
let collapsed = new Set() // collapsed category names
let toggling = new Set() // slugs currently being toggled
let activeSkill = null // the selected `{ category, file, name, slug, description, path, isDir, enabled }`
let skillContent = ''
let loadingDetail = false
let files = [] // attached files (excluding SKILL.md)
let viewingFile = null // relative path when browsing an attached file
let fileContent = ''
let loadingFile = false
// ============================================================ loaders
async function loadSkills() {
loading = true
draw()
@@ -40,6 +118,7 @@ export function render() {
} catch (e) {
console.error('Failed to load skills:', e)
categories = []
toast(t('engine.skillsLoadFailed') + ': ' + (e?.message || e), 'error')
}
loading = false
draw()
@@ -48,96 +127,330 @@ export function render() {
async function loadDetail(skill) {
activeSkill = skill
loadingDetail = true
viewingFile = null
fileContent = ''
files = []
skillContent = ''
draw()
try {
skillContent = await api.hermesSkillDetail(skill.path)
} catch (e) {
skillContent = `⚠️ ${e.message || e}`
}
// Kick off attached-file listing in parallel when the skill lives in a
// directory (`isDir = true`). Legacy flat skills have no attached files.
const contentPromise = api.hermesSkillDetail(skill.path)
.then(c => { skillContent = c })
.catch(e => { skillContent = `⚠️ ${t('engine.skillsLoadFailed')}: ${e?.message || e}` })
const filesPromise = skill.isDir && skill.category && skill.category !== '_root'
? api.hermesSkillFiles(skill.category, skill.slug || skill.file)
.then(list => { files = (list || []).filter(f => !f.isDir) })
.catch(() => { files = [] })
: Promise.resolve()
await Promise.all([contentPromise, filesPromise])
loadingDetail = false
draw()
}
async function openFile(relPath) {
if (!activeSkill?.isDir || !activeSkill.category) return
viewingFile = relPath
loadingFile = true
fileContent = ''
draw()
try {
const dir = activeSkill.skill_dir ||
(activeSkill.path ? activeSkill.path.replace(/[\\/]SKILL\.md$/i, '') : '')
const sep = /\\/.test(dir) && !/\//.test(dir) ? '\\' : '/'
const full = dir ? `${dir}${sep}${relPath.replace(/\//g, sep)}` : relPath
fileContent = await api.hermesSkillDetail(full)
} catch (e) {
fileContent = `⚠️ ${t('engine.skillsFileLoadFailed')}: ${e?.message || e}`
}
loadingFile = false
draw()
}
function backToSkill() {
viewingFile = null
fileContent = ''
draw()
}
async function handleToggle(skill, nextEnabled) {
if (toggling.has(skill.slug)) return
toggling.add(skill.slug)
draw()
try {
await api.hermesSkillToggle(skill.slug, nextEnabled)
skill.enabled = nextEnabled
toast(
nextEnabled ? t('engine.skillsEnabled') : t('engine.skillsDisabled'),
'success',
)
} catch (e) {
toast(t('engine.skillsToggleFailed') + ': ' + (e?.message || e), 'error')
} finally {
toggling.delete(skill.slug)
draw()
}
}
// ============================================================ derived
function filteredCategories() {
if (!searchQuery) return categories
const q = searchQuery.toLowerCase()
return categories.map(cat => ({
...cat,
skills: cat.skills.filter(s =>
s.name.toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q)
)
})).filter(cat => cat.skills.length > 0)
(s.name || '').toLowerCase().includes(q) ||
(s.slug || '').toLowerCase().includes(q) ||
(s.description || '').toLowerCase().includes(q),
),
})).filter(cat => cat.skills.length > 0 || (cat.category || '').toLowerCase().includes(q))
}
function totalSkillCount() {
return categories.reduce((sum, cat) => sum + cat.skills.length, 0)
}
function draw() {
function enabledSkillCount() {
return categories.reduce(
(sum, cat) => sum + cat.skills.filter(s => s.enabled !== false).length,
0,
)
}
// ============================================================ render
function renderSkillItem(cat, s) {
const isActive = activeSkill?.path === s.path
const isToggling = toggling.has(s.slug)
const isEnabled = s.enabled !== false
return `
<button class="hm-skill-item ${isActive ? 'is-active' : ''} ${!isEnabled ? 'is-disabled' : ''}"
data-path="${escHtml(s.path)}">
<div class="hm-skill-info">
<div class="hm-skill-name">${escHtml(s.name)}</div>
${s.description ? `<div class="hm-skill-desc">${escHtml(s.description)}</div>` : ''}
</div>
<label class="hm-switch ${isEnabled ? 'is-on' : ''} ${isToggling ? 'is-busy' : ''}"
data-slug="${escHtml(s.slug)}" data-category="${escHtml(cat.category)}"
title="${isEnabled ? t('engine.skillsDisable') : t('engine.skillsEnable')}">
<span class="hm-switch-track"></span>
<span class="hm-switch-thumb"></span>
</label>
</button>
`
}
function renderCategory(cat) {
const name = cat.category === '_root' ? t('engine.skillsUncategorized') : cat.category
const isCollapsed = collapsed.has(cat.category)
return `
<div class="hm-skill-category">
<button class="hm-skill-cat-header ${isCollapsed ? 'is-collapsed' : ''}" data-cat="${escHtml(cat.category)}">
<span class="hm-skill-cat-arrow">${ICONS.chevron}</span>
<span class="hm-skill-cat-name">${escHtml(name)}</span>
<span class="hm-skill-cat-count">${cat.skills.length}</span>
</button>
${!isCollapsed ? `
${cat.description ? `<div class="hm-skill-cat-desc">${escHtml(cat.description)}</div>` : ''}
<div class="hm-skill-cat-items">
${cat.skills.map(s => renderSkillItem(cat, s)).join('')}
</div>
` : ''}
</div>
`
}
function renderSidebar() {
const filtered = filteredCategories()
el.innerHTML = `
<div class="hm-skills-header">
<span class="hm-skills-header-title">${t('engine.hermesSkillsTitle')}</span>
<div class="hm-skills-header-right">
<input type="text" id="hm-skills-search" class="hm-skills-header-search" placeholder="${t('engine.skillsSearch')}" value="${escHtml(searchQuery)}">
<span class="hm-skills-count">${totalSkillCount()} ${t('engine.skillsTotal')}</span>
return `
<aside class="hm-skills-sidebar">
<div class="hm-skills-sidebar-search">
<span class="hm-skills-search-icon">${ICONS.search}</span>
<input type="text" id="hm-skills-search" class="hm-skills-search-input"
placeholder="${t('engine.skillsSearch')}" value="${escHtml(searchQuery)}">
</div>
<div class="hm-skills-sidebar-scroll">
${loading ? `
<div class="hm-skills-loading">
<div class="hm-skel" style="height:18px;width:60%;margin-bottom:10px"></div>
<div class="hm-skel" style="height:14px;width:85%;margin-bottom:6px"></div>
<div class="hm-skel" style="height:14px;width:70%;margin-bottom:6px"></div>
<div class="hm-skel" style="height:14px;width:90%"></div>
</div>
` : ''}
${!loading && filtered.length === 0 ? `
<div class="hm-skills-empty">
${searchQuery ? t('engine.skillsNoMatch') : t('engine.skillsEmpty')}
</div>
` : ''}
${!loading ? filtered.map(renderCategory).join('') : ''}
</div>
</aside>
`
}
function renderEmpty() {
return `
<div class="hm-skills-detail-empty">
${ICONS.empty}
<div class="hm-skills-detail-empty-title">${t('engine.skillsSelectHint')}</div>
<div class="hm-skills-detail-empty-sub">${t('engine.skillsSelectSub')}</div>
</div>
`
}
function renderDetail() {
if (!activeSkill) return renderEmpty()
if (loadingDetail) {
return `
<div class="hm-skills-detail-body">
<div class="hm-skel" style="height:24px;width:40%;margin-bottom:18px"></div>
<div class="hm-skel" style="height:14px;width:100%;margin-bottom:8px"></div>
<div class="hm-skel" style="height:14px;width:95%;margin-bottom:8px"></div>
<div class="hm-skel" style="height:14px;width:70%"></div>
</div>
`
}
// --- File view (attached file of a skill) ---
if (viewingFile) {
return `
<div class="hm-skills-detail-breadcrumb">
<button class="hm-skills-back-btn" id="hm-skills-back">
${ICONS.back}<span>${t('engine.skillsBackTo')} ${escHtml(activeSkill.name)}</span>
</button>
<span class="hm-skills-breadcrumb-sep">/</span>
<span class="hm-skills-breadcrumb-path">${escHtml(viewingFile)}</span>
</div>
<div class="hm-skills-detail-body">
${loadingFile
? `<div class="hm-skills-loading">${t('engine.skillsLoading')}</div>`
: `<div class="hm-skills-markdown">${mdToHtml(fileContent)}</div>`}
</div>
`
}
// --- Skill content view ---
return `
<div class="hm-skills-detail-head">
<div class="hm-skills-detail-title">
${activeSkill.category && activeSkill.category !== '_root' ? `
<span class="hm-skills-title-cat">${escHtml(activeSkill.category)}</span>
<span class="hm-skills-title-sep">/</span>
` : ''}
<span class="hm-skills-title-name">${escHtml(activeSkill.name)}</span>
${activeSkill.enabled === false
? `<span class="hm-pill hm-pill--muted hm-skills-status">${t('engine.skillsDisabledTag')}</span>`
: `<span class="hm-pill hm-pill--ok hm-skills-status">${t('engine.skillsEnabledTag')}</span>`}
</div>
<div class="hm-skills-detail-sub">
${activeSkill.isDir ? ICONS.folder : ICONS.file}
<span>${escHtml(activeSkill.file)}</span>
</div>
</div>
<div class="hm-skills-layout">
<div class="hm-skills-list-panel">
<div class="hm-skills-list-scroll">
${loading ? `<div class="hm-skills-loading">${t('engine.skillsLoading')}</div>` : ''}
${!loading && filtered.length === 0 ? `<div class="hm-skills-empty">${t('engine.skillsEmpty')}</div>` : ''}
${!loading ? filtered.map(cat => `
<div class="hm-skills-category">
<div class="hm-skills-cat-header">
<span class="hm-skills-cat-name">${escHtml(cat.category === '_root' ? t('engine.skillsUncategorized') : cat.category)}</span>
<span class="hm-skills-cat-count">${cat.skills.length}</span>
</div>
${cat.skills.map(s => `
<div class="hm-skills-item ${activeSkill?.path === s.path ? 'active' : ''}" data-path="${escHtml(s.path)}">
<div class="hm-skills-item-name">${escHtml(s.name)}</div>
${s.description ? `<div class="hm-skills-item-desc">${escHtml(s.description)}</div>` : ''}
</div>
`).join('')}
</div>
`).join('') : ''}
<div class="hm-skills-detail-body">
<div class="hm-skills-markdown">${mdToHtml(skillContent)}</div>
</div>
${files.length > 0 ? `
<div class="hm-skills-files">
<div class="hm-skills-files-header">
<span class="hm-skills-files-label">${t('engine.skillsAttachedFiles')}</span>
<span class="hm-skills-files-count">${files.length}</span>
</div>
<div class="hm-skills-files-list">
${files.map(f => `
<button class="hm-skills-file-chip" data-file="${escHtml(f.path)}" title="${escHtml(f.path)}">
${f.isDir ? ICONS.folder : ICONS.file}
<span>${escHtml(basename(f.path))}</span>
</button>
`).join('')}
</div>
</div>
<div class="hm-skills-detail-panel">
${!activeSkill ? `<div class="hm-skills-detail-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
<span>${t('engine.skillsSelectHint')}</span>
</div>` : ''}
${activeSkill && loadingDetail ? `<div class="hm-skills-detail-loading">${t('engine.skillsLoading')}</div>` : ''}
${activeSkill && !loadingDetail ? `
<div class="hm-skills-detail-header">
<h2>${escHtml(activeSkill.name)}</h2>
<span class="hm-skills-detail-file">${escHtml(activeSkill.file)}</span>
</div>
<div class="hm-skills-detail-content markdown-body">${mdToHtml(skillContent)}</div>
` : ''}
` : ''}
`
}
function draw() {
const enabled = enabledSkillCount()
const total = totalSkillCount()
el.innerHTML = `
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<span class="hm-dot hm-dot--idle"></span>
${t('engine.skillsEyebrow')}
</div>
<h1 class="hm-hero-h1">${t('engine.hermesSkillsTitle')}</h1>
<div class="hm-hero-sub">~/.hermes/skills/
${!loading ? `<span class="hm-skills-count-inline"> · ${enabled}/${total} ${t('engine.skillsActive')}</span>` : ''}
</div>
</div>
<div class="hm-hero-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm" id="hm-skills-refresh" ${loading ? 'disabled' : ''}>
${ICONS.refresh} ${t('engine.skillsRefresh')}
</button>
</div>
</div>
<div class="hm-skills-layout">
${renderSidebar()}
<section class="hm-skills-main">${renderDetail()}</section>
</div>
`
bind()
}
// ============================================================ bindings
function bind() {
el.querySelector('#hm-skills-search')?.addEventListener('input', (e) => {
searchQuery = e.target.value
draw()
})
el.querySelectorAll('.hm-skills-item').forEach(item => {
item.addEventListener('click', () => {
el.querySelector('#hm-skills-refresh')?.addEventListener('click', () => loadSkills())
el.querySelectorAll('.hm-skill-cat-header').forEach(btn => {
btn.addEventListener('click', () => {
const cat = btn.dataset.cat
if (collapsed.has(cat)) collapsed.delete(cat)
else collapsed.add(cat)
draw()
})
})
el.querySelectorAll('.hm-skill-item').forEach(item => {
item.addEventListener('click', (evt) => {
// Toggle switch clicks should NOT open the skill detail.
if (evt.target.closest('.hm-switch')) return
const skillPath = item.dataset.path
// Find the skill object
for (const cat of categories) {
const s = cat.skills.find(s => s.path === skillPath)
if (s) { loadDetail(s); return }
const s = cat.skills.find(x => x.path === skillPath)
if (s) { loadDetail({ ...s, category: cat.category }); return }
}
})
})
el.querySelectorAll('.hm-switch').forEach(sw => {
sw.addEventListener('click', (evt) => {
evt.stopPropagation()
if (sw.classList.contains('is-busy')) return
const slug = sw.dataset.slug
const catName = sw.dataset.category
const cat = categories.find(c => c.category === catName)
const skill = cat?.skills.find(s => s.slug === slug)
if (!skill) return
handleToggle(skill, skill.enabled === false)
})
})
el.querySelector('#hm-skills-back')?.addEventListener('click', backToSkill)
el.querySelectorAll('.hm-skills-file-chip').forEach(chip => {
chip.addEventListener('click', () => openFile(chip.dataset.file))
})
}
loadSkills()

View File

@@ -0,0 +1,401 @@
import { api } from '../../../lib/tauri-api.js'
import { t } from '../../../lib/i18n.js'
import { icon } from '../../../lib/icons.js'
const DAY_MS = 24 * 60 * 60 * 1000
function escHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function toNumber(value) {
const n = Number(value || 0)
return Number.isFinite(n) ? n : 0
}
function formatTokens(value) {
const n = toNumber(value)
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
return String(Math.round(n))
}
function formatCost(value) {
const n = toNumber(value)
if (!n) return '$0.00'
if (n < 0.01) return '<$0.01'
return '$' + n.toFixed(2)
}
function toTimestamp(session) {
const direct = toNumber(session?.started_at)
if (direct > 0) return direct
const raw = session?.created_at || session?.updated_at || ''
const ms = Date.parse(raw)
return Number.isNaN(ms) ? 0 : Math.floor(ms / 1000)
}
function toDateKey(timestampSeconds) {
return new Date(timestampSeconds * 1000).toISOString().slice(0, 10)
}
function aggregateSessions(sessions) {
const rows = Array.isArray(sessions) ? sessions.slice() : []
const totalInputTokens = rows.reduce((sum, s) => sum + toNumber(s.input_tokens), 0)
const totalOutputTokens = rows.reduce((sum, s) => sum + toNumber(s.output_tokens), 0)
const totalTokens = totalInputTokens + totalOutputTokens
const totalCacheTokens = rows.reduce((sum, s) => sum + toNumber(s.cache_read_tokens), 0)
const estimatedCost = rows.reduce((sum, s) => {
const cost = s.actual_cost_usd ?? s.estimated_cost_usd ?? 0
return sum + toNumber(cost)
}, 0)
const modelMap = new Map()
let oldestTs = 0
for (const s of rows) {
const model = s.model || t('usage.unknownModel')
if (!modelMap.has(model)) {
modelMap.set(model, {
model,
inputTokens: 0,
outputTokens: 0,
cacheTokens: 0,
totalTokens: 0,
sessions: 0,
})
}
const entry = modelMap.get(model)
entry.inputTokens += toNumber(s.input_tokens)
entry.outputTokens += toNumber(s.output_tokens)
entry.cacheTokens += toNumber(s.cache_read_tokens)
entry.totalTokens += toNumber(s.input_tokens) + toNumber(s.output_tokens)
entry.sessions += 1
const ts = toTimestamp(s)
if (ts > 0 && (!oldestTs || ts < oldestTs)) oldestTs = ts
}
const now = new Date()
const dailyMap = new Map()
for (let i = 29; i >= 0; i--) {
const d = new Date(now)
d.setDate(d.getDate() - i)
const key = d.toISOString().slice(0, 10)
dailyMap.set(key, { date: key, tokens: 0, cache: 0, sessions: 0, cost: 0 })
}
for (const s of rows) {
const ts = toTimestamp(s)
if (!ts) continue
const key = toDateKey(ts)
const entry = dailyMap.get(key)
if (!entry) continue
entry.tokens += toNumber(s.input_tokens) + toNumber(s.output_tokens)
entry.cache += toNumber(s.cache_read_tokens)
entry.sessions += 1
entry.cost += toNumber(s.actual_cost_usd ?? s.estimated_cost_usd ?? 0)
}
const dailyUsage = [...dailyMap.values()]
const modelUsage = [...modelMap.values()].sort((a, b) => b.totalTokens - a.totalTokens)
const days = oldestTs ? Math.max(1, Math.ceil((Date.now() - oldestTs * 1000) / DAY_MS)) : 1
return {
sessions: rows,
totalInputTokens,
totalOutputTokens,
totalTokens,
totalSessions: rows.length,
totalCacheTokens,
cacheHitRate: totalInputTokens > 0 ? (totalCacheTokens / totalInputTokens) * 100 : null,
estimatedCost,
modelUsage,
dailyUsage,
avgSessionsPerDay: rows.length / days,
}
}
function analyticsToUsage(data) {
const totals = data?.totals || {}
const totalInputTokens = toNumber(totals.total_input)
const totalOutputTokens = toNumber(totals.total_output)
const totalTokens = totalInputTokens + totalOutputTokens
const totalCacheTokens = toNumber(totals.total_cache_read) + toNumber(totals.total_cache_write)
const totalSessions = toNumber(totals.total_sessions)
const estimatedCost = toNumber(totals.total_actual_cost || totals.total_estimated_cost)
const periodDays = Math.max(1, toNumber(data?.period_days) || 30)
const modelUsage = (Array.isArray(data?.by_model) ? data.by_model : []).map(model => {
const inputTokens = toNumber(model.input_tokens)
const outputTokens = toNumber(model.output_tokens)
return {
model: model.model || t('usage.unknownModel'),
inputTokens,
outputTokens,
cacheTokens: toNumber(model.cache_read_tokens),
totalTokens: inputTokens + outputTokens,
sessions: toNumber(model.sessions),
}
}).sort((a, b) => b.totalTokens - a.totalTokens)
const dailyUsage = (Array.isArray(data?.daily) ? data.daily : []).map(day => ({
date: day.day || day.date || '',
tokens: toNumber(day.input_tokens) + toNumber(day.output_tokens),
cache: toNumber(day.cache_read_tokens),
sessions: toNumber(day.sessions),
cost: toNumber(day.actual_cost || day.estimated_cost),
})).filter(day => day.date)
return {
sessions: [],
totalInputTokens,
totalOutputTokens,
totalTokens,
totalSessions,
totalCacheTokens,
cacheHitRate: totalInputTokens > 0 ? (totalCacheTokens / totalInputTokens) * 100 : null,
estimatedCost,
modelUsage,
dailyUsage,
avgSessionsPerDay: totalSessions / periodDays,
}
}
function renderTrendSvg(dailyUsage) {
if (!dailyUsage.length) return ''
const width = 780
const height = 220
const padLeft = 12
const padRight = 12
const padTop = 12
const padBottom = 28
const usableWidth = width - padLeft - padRight
const usableHeight = height - padTop - padBottom
const maxTokens = Math.max(...dailyUsage.map(d => d.tokens), 1)
const stepX = dailyUsage.length > 1 ? usableWidth / (dailyUsage.length - 1) : usableWidth
const baseline = height - padBottom
const barWidth = Math.max(8, Math.min(18, usableWidth / Math.max(dailyUsage.length, 1) - 5))
const points = dailyUsage.map((d, index) => {
const x = padLeft + stepX * index
const y = baseline - (d.tokens / maxTokens) * usableHeight
return { x, y, d }
})
const grid = [0.25, 0.5, 0.75, 1].map(scale => {
const y = baseline - usableHeight * scale
return `<line x1="${padLeft}" y1="${y.toFixed(2)}" x2="${width - padRight}" y2="${y.toFixed(2)}" class="hm-usage-trend-grid" />`
}).join('')
const areaPath = points.length
? `M ${points[0].x.toFixed(2)} ${baseline.toFixed(2)} L ${points.map(p => `${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(' L ')} L ${points[points.length - 1].x.toFixed(2)} ${baseline.toFixed(2)} Z`
: ''
const linePoints = points.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
const bars = points.map(point => {
const h = Math.max(2, baseline - point.y)
return `<rect class="hm-usage-trend-bar" x="${(point.x - barWidth / 2).toFixed(2)}" y="${point.y.toFixed(2)}" width="${barWidth.toFixed(2)}" height="${h.toFixed(2)}" rx="3">
<title>${escHtml(point.d.date)} · ${formatTokens(point.d.tokens)} ${escHtml(t('usage.tokens'))} · ${point.d.sessions} ${escHtml(t('usage.sessions'))}</title>
</rect>`
}).join('')
const dots = points.map(point => `<circle class="hm-usage-trend-dot" cx="${point.x.toFixed(2)}" cy="${point.y.toFixed(2)}" r="2.6" />`).join('')
return `
<svg class="hm-usage-trend-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="hm-usage-trend-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(202, 138, 4, 0.34)" />
<stop offset="100%" stop-color="rgba(202, 138, 4, 0.02)" />
</linearGradient>
</defs>
${grid}
<path d="${areaPath}" class="hm-usage-trend-area" />
${bars}
<polyline class="hm-usage-trend-line" points="${linePoints}" />
${dots}
</svg>
`
}
function renderStatCard(label, value, sub, tone = '') {
return `
<article class="hm-usage-stat-card ${tone}">
<div class="hm-usage-stat-label">${escHtml(label)}</div>
<div class="hm-usage-stat-value">${escHtml(value)}</div>
<div class="hm-usage-stat-sub">${escHtml(sub || '')}</div>
</article>
`
}
function renderContent(usage) {
const strongestModel = usage.modelUsage[0]?.totalTokens || 1
const modelRows = usage.modelUsage.length
? usage.modelUsage.slice(0, 10).map(model => `
<div class="hm-usage-model-row">
<div class="hm-usage-model-name" title="${escHtml(model.model)}">${escHtml(model.model)}</div>
<div class="hm-usage-model-track">
<div class="hm-usage-model-bar" style="width:${Math.max(2, (model.totalTokens / strongestModel) * 100).toFixed(2)}%"></div>
</div>
<div class="hm-usage-model-meta">${escHtml(formatTokens(model.totalTokens))}</div>
</div>
`).join('')
: `<div class="hm-usage-empty-inline">${escHtml(t('usage.noData'))}</div>`
const trendRows = [...usage.dailyUsage].reverse().slice(0, 30).map(day => `
<tr>
<td>${escHtml(day.date)}</td>
<td>${escHtml(formatTokens(day.tokens))}</td>
<td>${escHtml(formatTokens(day.cache))}</td>
<td>${escHtml(String(day.sessions))}</td>
<td>${escHtml(formatCost(day.cost))}</td>
</tr>
`).join('')
const rangeStart = usage.dailyUsage[0]?.date.slice(5) || '—'
const rangeEnd = usage.dailyUsage[usage.dailyUsage.length - 1]?.date.slice(5) || '—'
return `
<div class="hm-usage-stat-grid">
${renderStatCard(
t('usage.totalTokens'),
formatTokens(usage.totalTokens),
`${formatTokens(usage.totalInputTokens)} ${t('usage.inputTokens')} / ${formatTokens(usage.totalOutputTokens)} ${t('usage.outputTokens')}`,
'is-accent'
)}
${renderStatCard(
t('usage.totalSessions'),
String(usage.totalSessions),
t('usage.avgPerDay').replace('{n}', usage.avgSessionsPerDay.toFixed(1))
)}
${renderStatCard(
t('usage.estimatedCost'),
formatCost(usage.estimatedCost),
usage.modelUsage[0]?.model || t('usage.unknownModel')
)}
${renderStatCard(
t('usage.cacheHitRate'),
usage.cacheHitRate == null ? '--' : usage.cacheHitRate.toFixed(1) + '%',
`${formatTokens(usage.totalCacheTokens)} ${t('usage.tokens')}`,
'is-muted'
)}
</div>
<section class="hm-usage-card">
<div class="hm-usage-card-head">
<h2 class="hm-usage-card-title">${escHtml(t('usage.modelBreakdown'))}</h2>
</div>
<div class="hm-usage-model-list">${modelRows}</div>
</section>
<section class="hm-usage-card hm-usage-card--trend">
<div class="hm-usage-card-head">
<h2 class="hm-usage-card-title">${escHtml(t('usage.dailyTrend'))}</h2>
</div>
<div class="hm-usage-trend-wrap">${renderTrendSvg(usage.dailyUsage)}</div>
<div class="hm-usage-trend-range">
<span>${escHtml(rangeStart)}</span>
<span>${escHtml(rangeEnd)}</span>
</div>
<div class="hm-usage-table-wrap">
<table class="hm-usage-table">
<thead>
<tr>
<th>${escHtml(t('usage.date'))}</th>
<th>${escHtml(t('usage.tokens'))}</th>
<th>${escHtml(t('usage.cache'))}</th>
<th>${escHtml(t('usage.sessions'))}</th>
<th>${escHtml(t('usage.cost'))}</th>
</tr>
</thead>
<tbody>${trendRows}</tbody>
</table>
</div>
</section>
`
}
export function render() {
const el = document.createElement('div')
el.className = 'page hm-usage-page'
el.dataset.engine = 'hermes'
let loading = true
let sessions = []
let analytics = null
let error = ''
let alive = true
function draw() {
const usage = analytics ? analyticsToUsage(analytics) : aggregateSessions(sessions)
el.innerHTML = `
<section class="hm-usage-hero">
<div class="hm-usage-hero-copy">
<div class="hm-usage-eyebrow">HERMES AGENT · ANALYTICS</div>
<h1 class="hm-usage-title">${escHtml(t('usage.title'))}</h1>
<p class="hm-usage-desc">${escHtml(t('usage.desc'))}</p>
</div>
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-usage-refresh" id="hm-usage-refresh" ${loading ? 'disabled' : ''}>
${icon('refresh-cw', 14)}
<span>${escHtml(t('usage.refresh'))}</span>
</button>
</section>
<div class="hm-usage-body">
${loading && !usage.totalSessions ? `
<div class="hm-usage-loading">${escHtml(t('common.loading'))}</div>
` : error ? `
<div class="hm-usage-error-card">
<div class="hm-usage-error-title">${escHtml(t('usage.loadFailed'))}</div>
<div class="hm-usage-error-text">${escHtml(error)}</div>
<button class="hm-btn hm-btn--primary hm-btn--sm" id="hm-usage-retry">${escHtml(t('usage.retry'))}</button>
</div>
` : !usage.totalSessions ? `
<div class="hm-usage-empty">${escHtml(t('usage.noData'))}</div>
` : renderContent(usage)}
</div>
`
el.querySelector('#hm-usage-refresh')?.addEventListener('click', load)
el.querySelector('#hm-usage-retry')?.addEventListener('click', load)
}
async function load() {
loading = true
error = ''
draw()
try {
analytics = await api.hermesUsageAnalytics(30)
if (!alive) return
sessions = []
} catch (err) {
if (!alive) return
try {
const rows = await api.hermesSessionsList(null, null)
if (!alive) return
sessions = Array.isArray(rows) ? rows : []
analytics = null
} catch (_) {
error = err?.message || String(err)
}
} finally {
if (!alive) return
loading = false
draw()
}
}
const mo = new MutationObserver(() => {
if (!el.isConnected) {
alive = false
mo.disconnect()
}
})
requestAnimationFrame(() => {
if (el.parentNode) mo.observe(el.parentNode, { childList: true })
})
draw()
load()
return el
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
/**
* 心甜Claw 引擎(产品宣传入口)
* ------------------------------------------------------------------
* 这不是一个本地引擎而是「心甜Claw」产品的一个产品落地页入口
* - 桌面客户端 + SaaS 后端Windows 安装即用
* - ClawPanel 里只承载宣传 + 跳转下载
*
* 因此它的 detect/boot/cleanup 都是 no-op永远 ready
* 也不与任何 Gateway / 本地进程打交道。
*/
import { t } from '../../lib/i18n.js'
// 心甜 LOGO · 采用 xintian-claw 桌面端同款六边形品牌图标
// 直接用 <img> 引用 public/ 下的 PNG避免 SVG 的 gradient id 冲突问题
const XINTIAN_ICON = `<img src="/images/xintian/logo-icon-64.png" srcset="/images/xintian/logo-icon-64.png 1x, /images/xintian/logo-icon-128.png 2x" alt="Xintian" width="16" height="16" style="display:block;object-fit:contain;">`
let _listeners = []
export default {
id: 'xintian',
name: '心甜Claw',
description: 'Xintian Claw · Worry-free AI Companion for Windows',
icon: XINTIAN_ICON,
async detect() {
// 不依赖任何本地进程永远「ready」
return { installed: true, ready: true }
},
async boot() {
// 无副作用启动
},
cleanup() {
// 无副作用清理
},
getNavItems() {
return [{
section: '',
items: [
{ route: '/x/landing', label: t('engine.xintianNavHome'), icon: 'assistant' },
],
}, {
section: '',
items: [
{ route: '/about', label: t('sidebar.about'), icon: 'about' },
],
}]
},
getRoutes() {
return [
{ path: '/x/landing', loader: () => import('./pages/landing.js') },
// 只暴露 /about/settings 对心甜Claw 用户无意义,故不注册。
// 切回 OpenClaw / Hermes 后会重新获得面板设置入口。
{ path: '/about', loader: () => import('../../pages/about.js') },
]
},
getSetupRoute() { return '/x/landing' },
getDefaultRoute() { return '/x/landing' },
isReady() { return true },
isGatewayRunning() { return false },
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 },
}

View File

@@ -0,0 +1,292 @@
/**
* 心甜Claw · 产品落地页
* ------------------------------------------------------------------
* 面向 Windows 桌面客户端的产品宣传 + 下载引导页。
* 所有可见文本走 i18nengine.xt*),对外链接统一经过 openExternal()
* 在 Tauri 桌面端走 @tauri-apps/plugin-shellWeb 端回退到 window.open。
*/
import { t } from '../../../lib/i18n.js'
const WEBSITE_URL = 'https://xtclaw.xtnet.cc/'
const DOWNLOAD_URL = 'https://xtclaw.xtnet.cc/download'
const HELP_URL = 'https://xtclaw.xtnet.cc/articles'
// 新版六边形品牌图标(和 xintian-claw 桌面端同源)
const LOGO_SRC = '/images/xintian/logo-icon-128.png'
const LOGO_SRC_2X = '/images/xintian/logo-icon-256.png'
const LOGO_SRC_SM = '/images/xintian/logo-icon-64.png'
// -------- 图标库(统一 stroke 风格,对齐编辑风品牌) --------
const ICON = {
heart: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`,
sparkles: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 15.9 9 18.75 8.2 15.9a4.5 4.5 0 0 0-3.1-3.1L2.25 12l2.85-.8a4.5 4.5 0 0 0 3.1-3.1L9 5.25l.8 2.85a4.5 4.5 0 0 0 3.1 3.1L15.75 12l-2.85.8a4.5 4.5 0 0 0-3.1 3.1z"/><path d="M18.26 8.72 18 9.75l-.26-1.03a3.38 3.38 0 0 0-2.46-2.46L14.25 6l1.03-.26a3.38 3.38 0 0 0 2.46-2.46L18 2.25l.26 1.03a3.38 3.38 0 0 0 2.46 2.46L21.75 6l-1.03.26a3.38 3.38 0 0 0-2.46 2.46z"/></svg>`,
brain: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3 3 3 0 0 0-3 3v1a3 3 0 0 0-2 5.5A3 3 0 0 0 7 19a3 3 0 0 0 5 1.5 3 3 0 0 0 5-1.5 3 3 0 0 0 3-4.5A3 3 0 0 0 18 9V8a3 3 0 0 0-3-3 3 3 0 0 0-3-3z"/><path d="M12 5v15"/></svg>`,
agent: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="7" r="4"/><path d="M1 21v-2a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v2"/><path d="M20 4l1 2 2 1-2 1-1 2-1-2-2-1 2-1z"/></svg>`,
book: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><path d="M9 7h7M9 11h7"/></svg>`,
clock: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 7 12 12 15 14"/></svg>`,
skills: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`,
channels: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>`,
shield: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
windows: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 5.47 10.5 4.45v7.02H3V5.47zM10.5 12.53v7.02L3 18.53v-6zm1.12-8.24L22 3v8.47H11.62V4.29zM22 12.53V21l-10.38-1.3v-7.17H22z"/></svg>`,
download: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
external: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`,
check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
arrowRight: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>`,
}
function esc(s) { return String(s ?? '').replace(/</g, '&lt;').replace(/>/g, '&gt;') }
async function openExternal(url) {
if (!url) return
try {
if (window.__TAURI_INTERNALS__) {
const { open } = await import('@tauri-apps/plugin-shell')
await open(url)
return
}
} catch (_) { /* fallback */ }
try { window.open(url, '_blank', 'noopener,noreferrer') } catch (_) {}
}
/** 核心能力8 张卡片) */
function getFeatures() {
return [
{ icon: ICON.sparkles, title: t('engine.xtFeatChatTitle'), desc: t('engine.xtFeatChatDesc') },
{ icon: ICON.agent, title: t('engine.xtFeatAgentTitle'), desc: t('engine.xtFeatAgentDesc') },
{ icon: ICON.brain, title: t('engine.xtFeatMemoryTitle'), desc: t('engine.xtFeatMemoryDesc') },
{ icon: ICON.book, title: t('engine.xtFeatRagTitle'), desc: t('engine.xtFeatRagDesc') },
{ icon: ICON.clock, title: t('engine.xtFeatCronTitle'), desc: t('engine.xtFeatCronDesc') },
{ icon: ICON.skills, title: t('engine.xtFeatSkillsTitle'), desc: t('engine.xtFeatSkillsDesc') },
{ icon: ICON.channels, title: t('engine.xtFeatChannelTitle'), desc: t('engine.xtFeatChannelDesc') },
{ icon: ICON.shield, title: t('engine.xtFeatOfflineTitle'), desc: t('engine.xtFeatOfflineDesc') },
]
}
/** 定位对比3 张卡) */
function getCompareCards() {
return [
{
id: 'openclaw',
eyebrow: t('engine.xtComparePosA'),
title: 'OpenClaw',
desc: t('engine.xtCompareADesc'),
tag: t('engine.xtCompareAForWho'),
},
{
id: 'hermes',
eyebrow: t('engine.xtComparePosB'),
title: 'Hermes Agent',
desc: t('engine.xtCompareBDesc'),
tag: t('engine.xtCompareBForWho'),
},
{
id: 'xintian',
eyebrow: t('engine.xtComparePosC'),
title: t('engine.xtCompareCTitle'),
desc: t('engine.xtCompareCDesc'),
tag: t('engine.xtCompareCForWho'),
highlight: true,
},
]
}
/** 亮点清单CTA 区下方) */
function getChecklist() {
return [
t('engine.xtBulletInstall'),
t('engine.xtBulletLogin'),
t('engine.xtBulletSync'),
t('engine.xtBulletSafe'),
]
}
// -------- 渲染 --------
export async function render() {
const root = document.createElement('div')
root.className = 'page'
// Scope xintian editorial styling to this subtree only.
root.dataset.engine = 'xintian'
const features = getFeatures()
.map((f, i) => `
<article class="xt-feat" style="--xt-i:${i}">
<div class="xt-feat-ico">${f.icon}</div>
<div class="xt-feat-body">
<h3 class="xt-feat-title">${esc(f.title)}</h3>
<p class="xt-feat-desc">${esc(f.desc)}</p>
</div>
</article>
`).join('')
const compareCards = getCompareCards()
.map(c => `
<div class="xt-cmp-card${c.highlight ? ' xt-cmp-card--highlight' : ''}" data-card="${c.id}">
<div class="xt-cmp-eyebrow">${esc(c.eyebrow)}</div>
<div class="xt-cmp-title">${esc(c.title)}</div>
<p class="xt-cmp-desc">${esc(c.desc)}</p>
<div class="xt-cmp-tag">
<span class="xt-cmp-tag-dot"></span>
<span>${esc(c.tag)}</span>
</div>
${c.highlight ? `<div class="xt-cmp-ribbon">${esc(t('engine.xtCompareRecommend'))}</div>` : ''}
</div>
`).join('')
const bullets = getChecklist()
.map(b => `<li class="xt-bullet">${ICON.check}<span>${esc(b)}</span></li>`).join('')
root.innerHTML = `
<div class="xt-stage">
<!-- Decorative aurora background -->
<div class="xt-bg" aria-hidden="true">
<div class="xt-bg-blob xt-bg-blob--1"></div>
<div class="xt-bg-blob xt-bg-blob--2"></div>
<div class="xt-bg-blob xt-bg-blob--3"></div>
<div class="xt-bg-grid"></div>
</div>
<!-- 1 · Hero -->
<section class="xt-hero">
<div class="xt-hero-badge">
<span class="xt-hero-badge-dot"></span>
<span>${esc(t('engine.xtHeroEyebrow'))}</span>
</div>
<h1 class="xt-hero-title">
<span class="xt-hero-title-lead">${esc(t('engine.xtHeroTitleLead'))}</span>
<span class="xt-hero-title-main">${esc(t('engine.xtHeroTitleA'))}<em>${esc(t('engine.xtHeroTitleB'))}</em>${esc(t('engine.xtHeroTitleC'))}</span>
</h1>
<p class="xt-hero-sub">${esc(t('engine.xtHeroSub'))}</p>
<div class="xt-hero-actions">
<button class="xt-btn xt-btn--primary" data-xt-action="download">
${ICON.windows}
<span>${esc(t('engine.xtCtaDownloadWin'))}</span>
</button>
<button class="xt-btn xt-btn--ghost" data-xt-action="website">
<span>${esc(t('engine.xtCtaVisitSite'))}</span>
${ICON.external}
</button>
</div>
<div class="xt-hero-meta">
<span class="xt-hero-meta-item">${esc(t('engine.xtHeroPlatformWin'))}</span>
<span class="xt-hero-meta-sep">·</span>
<span class="xt-hero-meta-item">${esc(t('engine.xtHeroPlatformRest'))}</span>
<span class="xt-hero-meta-sep">·</span>
<span class="xt-hero-meta-item">${esc(t('engine.xtHeroFreeTrial'))}</span>
</div>
</section>
<!-- 2 · Features -->
<section class="xt-section">
<div class="xt-section-head">
<span class="xt-eyebrow">${esc(t('engine.xtFeaturesEyebrow'))}</span>
<h2 class="xt-section-title">${esc(t('engine.xtFeaturesTitle'))}</h2>
<p class="xt-section-sub">${esc(t('engine.xtFeaturesSub'))}</p>
</div>
<div class="xt-feat-grid">${features}</div>
</section>
<!-- 3 · Compare -->
<section class="xt-section xt-section--compare">
<div class="xt-section-head">
<span class="xt-eyebrow">${esc(t('engine.xtCompareEyebrow'))}</span>
<h2 class="xt-section-title">${esc(t('engine.xtCompareTitle'))}</h2>
<p class="xt-section-sub">${esc(t('engine.xtCompareSub'))}</p>
</div>
<div class="xt-cmp-grid">${compareCards}</div>
</section>
<!-- 4 · CTA block -->
<section class="xt-cta">
<div class="xt-cta-inner">
<div class="xt-cta-left">
<span class="xt-eyebrow xt-eyebrow--on-dark">${esc(t('engine.xtCtaEyebrow'))}</span>
<h2 class="xt-cta-title">${esc(t('engine.xtCtaTitle'))}</h2>
<p class="xt-cta-sub">${esc(t('engine.xtCtaSub'))}</p>
<ul class="xt-cta-bullets">${bullets}</ul>
<div class="xt-cta-actions">
<button class="xt-btn xt-btn--primary xt-btn--lg" data-xt-action="download">
${ICON.download}
<span>${esc(t('engine.xtCtaPrimary'))}</span>
</button>
<button class="xt-btn xt-btn--ghost xt-btn--ghost-dark xt-btn--lg" data-xt-action="website">
<span>${esc(t('engine.xtCtaSecondary'))}</span>
${ICON.arrowRight}
</button>
</div>
<div class="xt-cta-link" data-xt-action="website">
<span class="xt-cta-link-label">${esc(t('engine.xtCtaLinkLabel'))}</span>
<span class="xt-cta-link-url">xtclaw.xtnet.cc</span>
${ICON.external}
</div>
</div>
<!-- Decorative product preview card -->
<div class="xt-cta-right" aria-hidden="true">
<div class="xt-preview">
<div class="xt-preview-chrome">
<span class="xt-preview-dot"></span>
<span class="xt-preview-dot"></span>
<span class="xt-preview-dot"></span>
<span class="xt-preview-title">心甜Claw</span>
</div>
<div class="xt-preview-body">
<div class="xt-preview-msg xt-preview-msg--bot">
<div class="xt-preview-avatar"><img src="${LOGO_SRC_SM}" srcset="${LOGO_SRC_SM} 1x, ${LOGO_SRC} 2x" alt="Xintian" width="28" height="28"></div>
<div class="xt-preview-bubble">${esc(t('engine.xtPreviewGreet'))}</div>
</div>
<div class="xt-preview-msg xt-preview-msg--user">
<div class="xt-preview-bubble xt-preview-bubble--user">${esc(t('engine.xtPreviewUserAsk'))}</div>
</div>
<div class="xt-preview-msg xt-preview-msg--bot">
<div class="xt-preview-avatar"><img src="${LOGO_SRC_SM}" srcset="${LOGO_SRC_SM} 1x, ${LOGO_SRC} 2x" alt="Xintian" width="28" height="28"></div>
<div class="xt-preview-bubble">
<div class="xt-preview-bubble-line">${esc(t('engine.xtPreviewAnswer1'))}</div>
<div class="xt-preview-bubble-line xt-preview-bubble-line--muted">${esc(t('engine.xtPreviewAnswer2'))}</div>
<div class="xt-preview-typing"><span></span><span></span><span></span></div>
</div>
</div>
</div>
<div class="xt-preview-foot">
<span>${ICON.sparkles}</span>
<span>${esc(t('engine.xtPreviewFoot'))}</span>
</div>
</div>
</div>
</div>
</section>
<!-- 5 · Footer -->
<footer class="xt-foot">
<div class="xt-foot-brand">
<img class="xt-foot-logo" src="${LOGO_SRC_SM}" srcset="${LOGO_SRC_SM} 1x, ${LOGO_SRC} 2x" alt="Xintian Claw" width="18" height="18">
<span>${esc(t('engine.xtFootBrand'))}</span>
</div>
<div class="xt-foot-links">
<a class="xt-foot-link" data-xt-action="website">${esc(t('engine.xtFootHome'))}</a>
<span class="xt-foot-sep">·</span>
<a class="xt-foot-link" data-xt-action="download">${esc(t('engine.xtFootDownload'))}</a>
<span class="xt-foot-sep">·</span>
<a class="xt-foot-link" data-xt-action="help">${esc(t('engine.xtFootSupport'))}</a>
</div>
</footer>
</div>
`
// 事件委托:所有 [data-xt-action] 元素
root.addEventListener('click', (e) => {
const trigger = e.target.closest('[data-xt-action]')
if (!trigger) return
const action = trigger.dataset.xtAction
if (action === 'download') {
openExternal(DOWNLOAD_URL)
} else if (action === 'help') {
openExternal(HELP_URL)
} else if (action === 'website') {
openExternal(WEBSITE_URL)
}
})
return root
}
export default { render }

File diff suppressed because it is too large Load Diff