chore: release v0.14.0

集中发版:

新功能(10)
- 心甜Claw 引擎入口(第 3 个引擎模式)
- Hermes 22 个 Provider 注册表 + 安装/仪表盘动态加载
- Hermes .env 高级编辑(拒绝触碰托管 Provider 密钥)
- Hermes 会话与用量分析增强
- Hermes Dashboard 自动拉起 + Windows POSIX-only 兼容模态
- Hermes Skills 工具集面板
- 官网 Hermes Agent 黑金特色区 + 图文指南
- Boot Manifest 启动页(双语 + 错峰动画)
- 官网 Markdown 阅读器图片 lightbox
- Hermes Memory 概览卡

改进(9)
- Hermes 仪表盘/扩展页全面本地化
- 记忆编辑大尺寸模态
- 日志下载 Web/桌面分流
- 侧边栏导航补全
- 模型备选管理 UI(PR #232)
- 模型加载错误 UX 重做(错误卡 + 详情 + 重试)
- .page 布局 clamp + .page-narrow
- Memory 单列断点提早到 1100px
- Web 模式跳过前端热更新检查

修复(12)
- Gateway 启动 platforms.api_server.enabled 自修复(含 7 unit test)
- Memory 页 overview 卡穿模(旧 flex 列约束 → 自然块流)
- Skills 页 hero/toolsets 被压缩(flex-shrink:0)
- Web 模式 Skills ReferenceError(补 _readHermesDisabledSkills)
- 日志/记忆下载行为分流
- src/pages/models.js 5 处 typo
- 删除 56 行 .hm-memory-* 死代码 + line-clamp 标准属性
- Dependabot rustls-webpki / postcss / rand
This commit is contained in:
晴天
2026-04-25 23:47:22 +08:00
parent 8a314ff64e
commit 9ee99ead24
35 changed files with 2348 additions and 230 deletions

View File

@@ -34,19 +34,21 @@ 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.
* plugin (which respects `xdg-open` / `start` / `open`); Web mode uses
* `window.open` with `noopener` to avoid tab-jacking. Errors propagate so
* the caller can decide how to surface them — silent fallback hid real
* scope/CSP errors and made "9119 打不开" hard to diagnose.
*/
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')
if (window.__TAURI_INTERNALS__) {
const { open } = await import('@tauri-apps/plugin-shell')
await open(url)
return
}
// Web 模式:打开用户浏览器中的新标签
const win = window.open(url, '_blank', 'noopener,noreferrer')
if (!win) throw new Error('popup blocked')
}
export function render() {
@@ -463,14 +465,235 @@ 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
// 流程Probe → 没起就 auto-start → start 失败再看是否依赖缺失走安装流程
el.querySelector('.hm-dash-open-native')?.addEventListener('click', async (e) => {
const href = e.currentTarget.dataset.href
const btn = e.currentTarget
const href = btn.dataset.href
if (!href) return
const origText = btn.textContent
btn.disabled = true
btn.textContent = t('engine.dashNativePanelChecking')
const tryOpen = async (port) => {
const url = href.replace(/:9119(\/?$)/, ':' + port + '$1')
await openExternalUrl(url)
}
// 共用:调用 hermesDashboardStart带"首次启动"提示,端口起来后开浏览器
// 返回 { ok, kind?, port, log_tail? } —— ok=true 时已经打开浏览器
const startAndOpen = async () => {
btn.textContent = t('engine.dashNativePanelStarting')
// 首次启动可能慢Hermes 会跑 npm build 构建前端),给用户一个 toast 安抚
let firstHintTimer = null
const showFirstHint = async () => {
const { toast } = await import('../../../components/toast.js')
toast(t('engine.dashNativePanelStartFirstHint'), 'info', { duration: 8000 })
}
firstHintTimer = setTimeout(showFirstHint, 5000)
try {
const result = await api.hermesDashboardStart().catch((err) => ({
started: false, kind: 'spawn_failed', port: 9119,
log_tail: String(err?.message || err),
}))
if (result?.started) {
try {
await tryOpen(result.port || 9119)
return { ok: true, ...result }
} catch (err) {
const { toast } = await import('../../../components/toast.js')
toast(t('engine.dashNativePanelOpenFail') + ': ' + (err?.message || err), 'error')
return { ok: false, kind: 'open_failed', ...result }
}
}
return { ok: false, ...(result || {}) }
} finally {
if (firstHintTimer) clearTimeout(firstHintTimer)
}
}
try {
await openExternalUrl(href)
const probe = await api.hermesDashboardProbe().catch(() => ({ running: false, port: 9119 }))
if (probe?.running) {
await tryOpen(probe.port || 9119)
return
}
// 自动启动 Dashboard
const startResult = await startAndOpen()
if (startResult.ok) return
// 启动失败,按 kind 分发
const port = startResult.port || probe?.port || 9119
const { toast } = await import('../../../components/toast.js')
if (startResult.kind === 'timeout') {
toast(t('engine.dashNativePanelStartTimeout', { port }), 'warning', { duration: 6000 })
return
}
if (startResult.kind === 'port_in_use') {
toast(t('engine.dashNativePanelStartPortBusy', { port }), 'warning', { duration: 6000 })
return
}
if (startResult.kind === 'posix_only_module') {
// Hermes Agent 上游 bugpty_bridge.py / memory_tool.py 在 Windows 上 import fcntl 等 POSIX-only 模块
// 见 https://github.com/NousResearch/hermes-agent/issues/5246
// 没办法在前端绕过——只能告诉用户原因和替代方案
const { showContentModal } = await import('../../../components/modal.js')
const m = showContentModal({
title: t('engine.dashNativePanelWindowsTitle'),
width: 580,
content: `
<p style="margin:0 0 14px;line-height:1.6;color:var(--text-secondary)">
${t('engine.dashNativePanelWindowsDesc')}
</p>
<ul style="margin:0 0 12px 20px;padding:0;line-height:1.7;color:var(--text-primary)">
<li>${t('engine.dashNativePanelWindowsAlt1')}</li>
<li>${t('engine.dashNativePanelWindowsAlt2')}</li>
</ul>
<pre style="margin:0;padding:10px 12px;background:var(--surface-2,#f5f5f4);border:1px solid var(--border,#e5e5e5);border-radius:6px;font-family:var(--hm-font-mono,monospace);font-size:11px;color:var(--text-tertiary,#888);max-height:120px;overflow:auto;white-space:pre-wrap;word-break:break-all">${(startResult.log_tail || '').split('\n').slice(-6).join('\n').replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'})[c])}</pre>
`,
buttons: [
{ label: t('engine.dashNativePanelWindowsReportLink'), className: 'btn btn-secondary btn-sm', id: 'hm-dash-issue-link' },
],
})
m.querySelector('#hm-dash-issue-link')?.addEventListener('click', async () => {
try { await openExternalUrl('https://github.com/NousResearch/hermes-agent/issues/5246') }
catch {}
})
return
}
if (startResult.kind !== 'deps_missing') {
// spawn_failed / 其他未知 → 显示日志尾部摘要
const detail = (startResult.log_tail || '').split('\n').slice(-3).join('\n').trim()
toast(t('engine.dashNativePanelStartGeneric') + (detail ? ': ' + detail : ''), 'error', { duration: 8000 })
return
}
// —— 依赖缺失fastapi/uvicorn弹安装引导 modal ——
const { showContentModal, showUpgradeModal } = await import('../../../components/modal.js')
const overlay = showContentModal({
title: t('engine.dashNativePanelDownTitle'),
width: 560,
content: `
<p style="margin:0 0 10px;line-height:1.6;color:var(--text-secondary)">
${t('engine.dashNativePanelDepHint')}
</p>
<pre style="margin:0 0 12px;padding:12px 14px;background:var(--surface-2,#f5f5f4);border:1px solid var(--border,#e5e5e5);border-radius:6px;font-family:var(--hm-font-mono,monospace);font-size:13px;color:var(--text-primary);user-select:all;white-space:pre-wrap;word-break:break-all"><code>uv tool install --force 'hermes-agent[web] @ git+https://github.com/NousResearch/hermes-agent.git'</code></pre>
<p style="margin:0;font-size:12px;color:var(--text-tertiary,#999);line-height:1.6">
${t('engine.dashNativePanelDown', { port })}
</p>
`,
buttons: [
{ label: t('common.copy') || 'Copy', className: 'btn btn-secondary btn-sm', id: 'hm-dash-copy-cmd' },
{ label: t('common.retry') || 'Retry', className: 'btn btn-secondary btn-sm', id: 'hm-dash-retry' },
{ label: t('engine.dashNativePanelInstallWeb'), className: 'btn btn-primary btn-sm', id: 'hm-dash-install-web' },
],
})
overlay.querySelector('#hm-dash-copy-cmd')?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(`uv tool install --force 'hermes-agent[web] @ git+https://github.com/NousResearch/hermes-agent.git'`)
toast(t('common.copied') || 'Copied', 'success')
} catch {}
})
overlay.querySelector('#hm-dash-retry')?.addEventListener('click', async () => {
// 重试:先 probe再 auto-start
overlay.close()
const retryProbe = await api.hermesDashboardProbe().catch(() => ({ running: false, port }))
if (retryProbe?.running) {
try { await tryOpen(retryProbe.port || port) }
catch (err) { toast(t('engine.dashNativePanelOpenFail') + ': ' + (err?.message || err), 'error') }
return
}
const r = await startAndOpen()
if (!r.ok) {
toast(t('engine.dashNativePanelDown', { port: r.port || port }), 'warning')
}
})
overlay.querySelector('#hm-dash-install-web')?.addEventListener('click', async () => {
overlay.close()
// 进度 modal 复用现有 showUpgradeModal已有日志窗 + 进度条 + 任务栏最小化)
const um = showUpgradeModal(t('engine.dashNativePanelInstallWebTitle'))
um.setProgressLabels({
preparing: t('engine.dashNativePanelInstallWebTitle'),
downloading: t('engine.dashNativePanelInstallWebTitle'),
installing: t('engine.dashNativePanelInstallWebTitle'),
done: t('engine.dashNativePanelInstallWebDone'),
})
let unlisten = null
// Gateway 是否运行 → 装前停、装后重启。Windows 下 uv 无法覆盖被占用的
// ~/.local/bin/hermes.exeos error 32所以必须先释放文件锁。
let gatewayWasRunning = false
try {
await api.hermesHealthCheck()
gatewayWasRunning = true
} catch { /* gateway not running, no pre-stop needed */ }
let installOk = false
try {
if (window.__TAURI_INTERNALS__) {
const { listen } = await import('@tauri-apps/api/event')
const u1 = await listen('hermes-install-log', (ev) => um.appendLog(String(ev.payload)))
const u2 = await listen('hermes-install-progress', (ev) => um.setProgress(Number(ev.payload) || 0))
unlisten = () => { u1(); u2() }
}
if (gatewayWasRunning) {
um.appendLog(t('engine.dashNativePanelInstallStoppingGw'))
try {
await api.hermesGatewayAction('stop')
await new Promise(r => setTimeout(r, 800))
um.appendLog(t('engine.dashNativePanelInstallGwStopped'))
} catch (err) {
um.appendLog(t('engine.dashNativePanelInstallGwWarn') + ': ' + (err?.message || err))
}
}
await api.installHermes('uv-tool', ['web'])
um.setDone(t('engine.dashNativePanelInstallWebDone'))
installOk = true
} catch (err) {
const msg = String(err?.message || err).replace(/^Error:\s*/, '')
um.setError(t('engine.dashNativePanelInstallWebFailed') + ': ' + msg)
} finally {
if (unlisten) { unlisten() }
if (gatewayWasRunning) {
um.appendLog(t('engine.dashNativePanelInstallRestartingGw'))
try {
await api.hermesGatewayAction('start')
um.appendLog(t('engine.dashNativePanelInstallGwRestarted'))
} catch (err) {
um.appendLog(t('engine.dashNativePanelInstallGwWarn') + ': ' + (err?.message || err))
}
}
}
// 安装成功 → 自动启动 dashboard 并打开浏览器,省去用户跑命令的步骤
if (installOk) {
um.appendLog('')
um.appendLog('▶ ' + t('engine.dashNativePanelStarting'))
const startRes = await api.hermesDashboardStart().catch((err) => ({
started: false, kind: 'spawn_failed',
log_tail: String(err?.message || err),
}))
if (startRes?.started) {
um.appendLog('✓ Dashboard @ 127.0.0.1:' + (startRes.port || port))
try {
await tryOpen(startRes.port || port)
} catch (err) {
um.appendLog('⚠ ' + t('engine.dashNativePanelOpenFail') + ': ' + (err?.message || err))
}
} else {
const detail = (startRes?.log_tail || '').split('\n').slice(-3).join('\n').trim()
um.appendLog('⚠ ' + t('engine.dashNativePanelStartGeneric') + (detail ? ': ' + detail : ''))
}
}
})
} catch (err) {
const { toast } = await import('../../../components/toast.js')
toast(t('engine.dashNativePanelOpenFail') + ': ' + (err?.message || err), 'error')
} finally {
btn.disabled = false
btn.textContent = origText
}
})
// Provider presets — 点击填充 URL

View File

@@ -123,6 +123,42 @@ export function render() {
el.querySelector('#hm-ext-refresh')?.addEventListener('click', load)
el.querySelector('#hm-ext-rescan')?.addEventListener('click', rescan)
// 拦截 Dashboard 本地链接probe → auto-start → 打开。避免直接打开浏览器看到 ERR_CONNECTION_REFUSED
el.querySelectorAll('a[href^="http://127.0.0.1:9119"]').forEach(a => {
a.addEventListener('click', async (ev) => {
ev.preventDefault()
const openWith = async (port) => {
const url = a.href.replace(/:9119(\/?)/, ':' + port + '$1')
if (window.__TAURI_INTERNALS__) {
const { open } = await import('@tauri-apps/plugin-shell')
await open(url)
} else {
window.open(url, '_blank', 'noopener,noreferrer')
}
}
// 1. probe
const probe = await api.hermesDashboardProbe().catch(() => ({ running: false, port: 9119 }))
if (probe?.running) {
try { await openWith(probe.port || 9119) }
catch (err) { toast(t('engine.dashNativePanelOpenFail') + ': ' + (err?.message || err), 'error') }
return
}
// 2. auto-start
const r = await api.hermesDashboardStart().catch(() => ({ started: false, kind: 'spawn_failed', port: probe?.port || 9119 }))
if (r?.started) {
try { await openWith(r.port || 9119) }
catch (err) { toast(t('engine.dashNativePanelOpenFail') + ': ' + (err?.message || err), 'error') }
return
}
// 3. 失败 → toastdashboard 页面有完整安装流程,这里只引导)
const port = r?.port || probe?.port || 9119
if (r?.kind === 'deps_missing') {
toast(t('engine.dashNativePanelDepHint'), 'warning', { duration: 6000 })
} else {
toast(t('engine.dashNativePanelDown', { port }), 'warning')
}
})
})
el.querySelectorAll('.hm-theme-choice').forEach(btn => {
btn.addEventListener('click', async () => {
const name = btn.dataset.theme

View File

@@ -219,6 +219,44 @@ export function render() {
draw()
}
function renderOverview() {
const all = SECTIONS.map(section => ({
section,
stats: contentStats(data[section.key] || ''),
filled: Boolean((data[section.key] || '').trim()),
}))
const totalWords = all.reduce((sum, item) => sum + item.stats.words, 0)
const filledCount = all.filter(item => item.filled).length
const latest = Math.max(0, ...SECTIONS.map(section => mtimes[section.key] || 0))
return `
<div class="hm-mem-overview">
<div class="hm-mem-overview-copy">
<div class="hm-mem-kicker">${t('engine.memoryOverviewKicker')}</div>
<div class="hm-mem-overview-title">${t('engine.memoryOverviewTitle')}</div>
<div class="hm-mem-overview-desc">${t('engine.memoryOverviewDesc')}</div>
</div>
<div class="hm-mem-overview-stats">
<div class="hm-mem-stat">
<span class="hm-mem-stat-label">${t('engine.memoryFiles')}</span>
<strong>3</strong>
</div>
<div class="hm-mem-stat">
<span class="hm-mem-stat-label">${t('engine.memoryFilled')}</span>
<strong>${filledCount}/3</strong>
</div>
<div class="hm-mem-stat">
<span class="hm-mem-stat-label">${t('engine.memoryTotalWords')}</span>
<strong>${totalWords}</strong>
</div>
<div class="hm-mem-stat">
<span class="hm-mem-stat-label">${t('engine.memoryLatest')}</span>
<strong>${latest ? escHtml(fmtMtime(latest)) : '—'}</strong>
</div>
</div>
</div>
`
}
function renderSection(section) {
const content = data[section.key] || ''
const { chars, words } = contentStats(content)
@@ -231,7 +269,7 @@ export function render() {
</span>`
return `
<div class="hm-panel hm-mem-panel" data-key="${section.key}">
<div class="hm-panel hm-mem-panel hm-mem-panel--${section.key}" data-key="${section.key}">
<div class="hm-panel-header">
<div class="hm-panel-title">
<span class="hm-panel-title-icon">${section.icon}</span>
@@ -243,12 +281,17 @@ export function render() {
</div>
</div>
<div class="hm-panel-body">
<div class="hm-mem-card-topline">
<div class="hm-mem-card-index">${section.key.toUpperCase()}</div>
<div class="hm-mem-card-meter"><span style="width:${Math.min(100, Math.max(8, words / 8))}%"></span></div>
</div>
<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>
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-mem-edit hm-mem-empty-cta" data-key="${section.key}">${ICONS.edit} ${t('engine.memoryEdit')}</button>
</div>`}
</div>
</div>
@@ -273,6 +316,8 @@ export function render() {
</div>
</div>
${!loading && !loadError ? renderOverview() : ''}
${loadError ? `
<div class="hm-panel" style="margin-bottom:18px">
<div class="hm-panel-body hm-panel-body--tight">

View File

@@ -108,6 +108,12 @@ export function render() {
let fileContent = ''
let loadingFile = false
// Toolsets state — backend returns { raw: <stdout> }; we parse rows on the fly.
// toolsets is null when never loaded, [] when loaded but empty/parse-failed.
let toolsets = null // [{ name, enabled, description }]
let toolsetsRaw = '' // raw stdout, kept for fallback display when parsing fails
let toolsetsLoading = true
// ============================================================ loaders
async function loadSkills() {
@@ -124,6 +130,61 @@ export function render() {
draw()
}
/**
* Strip ANSI escape sequences (color/style/cursor) from a string.
* Hermes' `tools list` may include them when stdout is detected as a TTY,
* even though we capture via pipe — be defensive.
*/
function stripAnsi(s) {
if (!s) return ''
// Standard CSI sequences: ESC [ ... letter
return String(s).replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '')
}
/**
* Parse `hermes tools list --platform <p>` stdout. Format observed
* (Hermes 0.6+):
*
* Built-in toolsets (cli):
* ✓ enabled web 🔍 Web Search & Scraping
* ✗ disabled image_gen 🎨 Image Generation
* ...
*
* Returns an array; empty array means parse failed or no rows.
*/
function parseToolsets(raw) {
const clean = stripAnsi(raw || '')
const out = []
for (const line of clean.split(/\r?\n/)) {
// Use [^\s] explicitly because emoji/multi-codepoint description part needs greedy tail.
const m = line.match(/^\s*([✓✗])\s+(enabled|disabled)\s+(\S+)\s+(.+?)\s*$/u)
if (!m) continue
out.push({
name: m[3],
enabled: m[1] === '✓' || m[2] === 'enabled',
description: m[4],
})
}
return out
}
async function loadToolsets() {
toolsetsLoading = true
draw()
try {
const r = await api.hermesToolsetsList()
toolsetsRaw = r?.raw || ''
toolsets = parseToolsets(toolsetsRaw)
} catch (e) {
console.error('Failed to load toolsets:', e)
toolsetsRaw = ''
toolsets = []
} finally {
toolsetsLoading = false
draw()
}
}
async function loadDetail(skill) {
activeSkill = skill
loadingDetail = true
@@ -301,6 +362,103 @@ export function render() {
`
}
function renderToolsets() {
// 加载中骨架屏
if (toolsetsLoading) {
return `
<section class="hm-toolsets">
<div class="hm-toolsets-head">
<div class="hm-toolsets-title-block">
<div class="hm-toolsets-title">${t('engine.toolsetsTitle')}</div>
<div class="hm-toolsets-sub">${t('engine.toolsetsSubtitle')}</div>
</div>
</div>
<div class="hm-toolsets-grid">
${Array.from({ length: 8 }).map(() =>
`<div class="hm-toolset-card hm-toolset-card--skel"><div class="hm-skel" style="width:55%;height:14px;margin-bottom:8px"></div><div class="hm-skel" style="width:80%;height:11px"></div></div>`
).join('')}
</div>
</section>
`
}
const items = toolsets || []
const activeCount = items.filter(x => x.enabled).length
const total = items.length
// 解析失败但有 raw 输出 → 显示原始内容
if (total === 0 && toolsetsRaw && toolsetsRaw.trim()) {
return `
<section class="hm-toolsets">
<div class="hm-toolsets-head">
<div class="hm-toolsets-title-block">
<div class="hm-toolsets-title">${t('engine.toolsetsTitle')}</div>
<div class="hm-toolsets-sub">${t('engine.toolsetsSubtitle')}</div>
</div>
<button class="hm-btn hm-btn--ghost hm-btn--sm" id="hm-toolsets-refresh">
${ICONS.refresh} ${t('engine.skillsRefresh')}
</button>
</div>
<div class="hm-toolsets-fallback">
<div class="hm-toolsets-fallback-hint">${t('engine.toolsetsParseFailed')}</div>
<pre class="hm-toolsets-fallback-pre">${escHtml(stripAnsi(toolsetsRaw))}</pre>
</div>
</section>
`
}
// 完全空hermes 没装/版本太老)
if (total === 0) {
return `
<section class="hm-toolsets">
<div class="hm-toolsets-head">
<div class="hm-toolsets-title-block">
<div class="hm-toolsets-title">${t('engine.toolsetsTitle')}</div>
<div class="hm-toolsets-sub">${t('engine.toolsetsSubtitle')}</div>
</div>
<button class="hm-btn hm-btn--ghost hm-btn--sm" id="hm-toolsets-refresh">
${ICONS.refresh} ${t('engine.skillsRefresh')}
</button>
</div>
<div class="hm-toolsets-empty">${t('engine.toolsetsEmpty')}</div>
</section>
`
}
// 正常态
const countLabel = t('engine.toolsetsActiveCount')
.replace('{n}', String(activeCount))
.replace('{total}', String(total))
return `
<section class="hm-toolsets">
<div class="hm-toolsets-head">
<div class="hm-toolsets-title-block">
<div class="hm-toolsets-title">
${t('engine.toolsetsTitle')}
<span class="hm-toolsets-count">${countLabel}</span>
</div>
<div class="hm-toolsets-sub">${t('engine.toolsetsSubtitle')}</div>
</div>
<button class="hm-btn hm-btn--ghost hm-btn--sm" id="hm-toolsets-refresh">
${ICONS.refresh} ${t('engine.skillsRefresh')}
</button>
</div>
<div class="hm-toolsets-grid">
${items.map(it => `
<div class="hm-toolset-card ${it.enabled ? 'is-on' : 'is-off'}" title="${escHtml(it.description)}">
<div class="hm-toolset-card-row">
<span class="hm-toolset-status ${it.enabled ? 'is-on' : 'is-off'}">${it.enabled ? '✓' : '✗'}</span>
<span class="hm-toolset-name">${escHtml(it.name)}</span>
</div>
<div class="hm-toolset-desc">${escHtml(it.description)}</div>
</div>
`).join('')}
</div>
<div class="hm-toolsets-hint">${t('engine.toolsetsHint')}</div>
</section>
`
}
function renderDetail() {
if (!activeSkill) return renderEmpty()
if (loadingDetail) {
@@ -394,6 +552,8 @@ export function render() {
</div>
</div>
${renderToolsets()}
<div class="hm-skills-layout">
${renderSidebar()}
<section class="hm-skills-main">${renderDetail()}</section>
@@ -411,6 +571,7 @@ export function render() {
})
el.querySelector('#hm-skills-refresh')?.addEventListener('click', () => loadSkills())
el.querySelector('#hm-toolsets-refresh')?.addEventListener('click', () => loadToolsets())
el.querySelectorAll('.hm-skill-cat-header').forEach(btn => {
btn.addEventListener('click', () => {
@@ -454,5 +615,6 @@ export function render() {
}
loadSkills()
loadToolsets()
return el
}

View File

@@ -135,9 +135,9 @@
* ==========================================================================
*/
[data-engine="hermes"].page {
padding: 40px 48px 64px;
max-width: 1280px;
margin: 0 auto;
/* 移除 1280px 硬限,宽屏填满;水平 padding 自适应避免内容贴边 */
padding: 40px clamp(20px, 3.5vw, 64px) 64px;
width: 100%;
font-family: var(--hm-font-sans);
color: var(--hm-text-primary);
background: var(--hm-surface-0);
@@ -978,11 +978,9 @@
`data-engine`. The descendant form silently misses every Hermes page
that uses the shared shell. */
[data-engine="hermes"].page {
/* Generous 40/48 gutter matches the custom editorial shells so switching
between dashboard / cron / services / memory doesn't feel like hopping
between two different products. */
padding: 40px 48px 56px;
max-width: 1280px;
/* Generous gutter via clamp() so dashboard / cron / services / memory feel
like one product on any width. No max-width — fill the viewport. */
padding: 40px clamp(20px, 3.5vw, 56px) 56px;
}
@media (max-width: 960px) {
[data-engine="hermes"].page {
@@ -1842,12 +1840,120 @@ body[data-active-engine="hermes"][data-theme="dark"] {
parent/child pair. The descendant-combinator form silently failed and
left every page shell padding-less, hugging the viewport edges. */
[data-engine="hermes"].hermes-memory-page {
padding: 40px 48px 64px;
max-width: 1280px;
margin: 0 auto;
padding: 40px clamp(20px, 3.5vw, 64px) 64px;
width: 100%;
font-family: var(--hm-font-sans);
color: var(--hm-text-primary);
background: var(--hm-surface-0);
position: relative;
}
[data-engine="hermes"].hermes-memory-page::before {
content: '';
position: fixed;
top: 64px;
right: 0;
width: min(42vw, 520px);
height: min(42vw, 520px);
background: radial-gradient(circle, var(--hm-accent-soft), transparent 68%);
pointer-events: none;
opacity: 0.8;
z-index: -1;
}
[data-engine="hermes"] .hm-mem-overview {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
gap: 24px;
align-items: stretch;
margin: -10px 0 28px;
padding: 28px;
border: 1px solid var(--hm-border);
border-radius: var(--hm-radius-lg);
background:
linear-gradient(135deg, rgba(202, 138, 4, 0.10), transparent 36%),
var(--hm-surface-1);
box-shadow: var(--hm-shadow-md);
position: relative;
overflow: hidden;
}
[data-engine="hermes"] .hm-mem-overview::after {
content: '';
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--hm-border-subtle) 1px, transparent 1px),
linear-gradient(90deg, var(--hm-border-subtle) 1px, transparent 1px);
background-size: 28px 28px;
mask-image: linear-gradient(90deg, transparent, #000 18%, transparent 78%);
pointer-events: none;
opacity: 0.45;
}
[data-engine="hermes"] .hm-mem-overview-copy,
[data-engine="hermes"] .hm-mem-overview-stats {
position: relative;
z-index: 1;
}
[data-engine="hermes"] .hm-mem-kicker {
font-family: var(--hm-font-mono);
font-size: 10.5px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--hm-accent);
margin-bottom: 12px;
}
[data-engine="hermes"] .hm-mem-overview-title {
font-family: var(--hm-font-serif);
font-size: clamp(24px, 3vw, 36px);
font-weight: 500;
line-height: 1.15;
letter-spacing: -0.025em;
max-width: 620px;
margin-bottom: 12px;
}
[data-engine="hermes"] .hm-mem-overview-desc {
color: var(--hm-text-secondary);
line-height: 1.85;
max-width: 660px;
}
[data-engine="hermes"] .hm-mem-overview-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
border: 1px solid var(--hm-border);
border-radius: var(--hm-radius-md);
overflow: hidden;
background: var(--hm-border);
}
[data-engine="hermes"] .hm-mem-stat {
padding: 18px;
background: color-mix(in srgb, var(--hm-surface-1) 88%, transparent);
backdrop-filter: blur(8px);
}
[data-engine="hermes"] .hm-mem-stat-label {
display: block;
font-family: var(--hm-font-mono);
font-size: 10.5px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--hm-text-muted);
margin-bottom: 8px;
}
[data-engine="hermes"] .hm-mem-stat strong {
font-family: var(--hm-font-serif);
font-size: 26px;
font-weight: 500;
line-height: 1;
color: var(--hm-text-primary);
}
[data-engine="hermes"] .hm-mem-stats {
@@ -1871,6 +1977,59 @@ body[data-active-engine="hermes"][data-theme="dark"] {
margin-bottom: 14px;
}
[data-engine="hermes"] .hm-mem-panel {
position: relative;
background:
linear-gradient(180deg, color-mix(in srgb, var(--hm-surface-1) 96%, var(--hm-accent-soft)), var(--hm-surface-1));
}
[data-engine="hermes"] .hm-mem-panel::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(180deg, transparent, var(--hm-accent), transparent);
opacity: 0.55;
}
[data-engine="hermes"] .hm-mem-panel:hover {
border-color: color-mix(in srgb, var(--hm-accent) 38%, var(--hm-border));
box-shadow: var(--hm-shadow-lg);
}
[data-engine="hermes"] .hm-mem-panel .hm-panel-header {
background: linear-gradient(90deg, var(--hm-surface-1), transparent);
}
[data-engine="hermes"] .hm-mem-card-topline {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 16px;
}
[data-engine="hermes"] .hm-mem-card-index {
font-family: var(--hm-font-mono);
font-size: 10px;
letter-spacing: 0.2em;
color: var(--hm-accent);
}
[data-engine="hermes"] .hm-mem-card-meter {
flex: 1;
height: 1px;
background: var(--hm-border);
overflow: hidden;
}
[data-engine="hermes"] .hm-mem-card-meter span {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--hm-accent), transparent);
}
/* Memory edit button — promote the ghost CTA to a visible outline chip
so users recognise it as an action, not inline meta-text. */
[data-engine="hermes"] .hm-mem-edit {
@@ -1946,10 +2105,13 @@ body[data-active-engine="hermes"][data-theme="dark"] {
}
[data-engine="hermes"] .hm-mem-empty {
padding: 18px 0 4px;
padding: 22px 24px;
display: flex;
flex-direction: column;
gap: 4px;
gap: 6px;
border: 1px dashed var(--hm-border-strong);
border-radius: var(--hm-radius-md);
background: color-mix(in srgb, var(--hm-surface-2) 72%, transparent);
}
[data-engine="hermes"] .hm-mem-empty-title {
font-family: var(--hm-font-serif);
@@ -1958,11 +2120,22 @@ body[data-active-engine="hermes"][data-theme="dark"] {
font-size: 14px;
}
[data-engine="hermes"] .hm-mem-empty-cta {
width: fit-content;
margin-top: 8px;
}
[data-engine="hermes"] .hm-mem-rendered {
font-family: var(--hm-font-sans);
font-size: 14px;
line-height: 1.75;
color: var(--hm-text-primary);
padding: 16px 18px;
border: 1px solid var(--hm-border-subtle);
border-radius: var(--hm-radius-md);
background: color-mix(in srgb, var(--hm-surface-0) 74%, transparent);
max-height: 280px;
overflow: auto;
}
[data-engine="hermes"] .hm-mem-rendered h2,
[data-engine="hermes"] .hm-mem-rendered h3,
@@ -2007,12 +2180,52 @@ body[data-active-engine="hermes"][data-theme="dark"] {
text-underline-offset: 2px;
}
/* 注意viewport 宽度并不等于实际内容区宽度——ClawPanel 有 ~280px 侧栏。
在 1100px viewport 下内容区只有 ~820px2 列布局copy + 320~420px stats
会把 copy 压到 230px标题被迫 4 行。所以 1100px 就该切到单列。 */
@media (max-width: 1100px) {
[data-engine="hermes"] .hm-mem-overview {
grid-template-columns: 1fr;
}
}
@media (max-width: 920px) {
[data-engine="hermes"].hermes-memory-page {
padding: 28px 24px 44px;
}
}
@media (max-width: 620px) {
[data-engine="hermes"].hermes-memory-page {
padding: 22px 16px 36px;
}
[data-engine="hermes"] .hm-mem-overview {
padding: 20px;
border-radius: var(--hm-radius-md);
}
[data-engine="hermes"] .hm-mem-overview-stats {
grid-template-columns: 1fr;
}
[data-engine="hermes"] .hm-mem-panel .hm-panel-header {
flex-direction: column;
align-items: flex-start;
}
[data-engine="hermes"] .hm-mem-panel .hm-panel-actions {
width: 100%;
justify-content: space-between;
}
}
/* ==========================================================================
* 16 · Logs page (editorial layout with sidebar + live entries)
* ==========================================================================
*/
[data-engine="hermes"].hermes-logs-page {
padding: 40px 48px 48px;
padding: 40px clamp(20px, 3.5vw, 64px) 48px;
max-width: none;
height: 100vh;
display: flex;
@@ -2479,8 +2692,8 @@ body[data-active-engine="hermes"][data-theme="dark"] {
/* ---- Page shell ---- */
[data-engine="hermes"].hermes-skills-page {
/* Bottom padding restored so the two-column card doesn't kiss the viewport
edge — matches the logs page gutter (40/48/48). */
padding: 40px 48px 48px;
edge — matches the logs page gutter. */
padding: 40px clamp(20px, 3.5vw, 64px) 48px;
max-width: none;
height: 100vh;
display: flex;
@@ -3028,6 +3241,172 @@ body[data-active-engine="hermes"][data-theme="dark"] {
}
}
/* ---- Skills page · prevent hero + toolsets from being squashed ---------
.hermes-skills-page is `display:flex;flex-direction:column;height:100%`
(defined in pages.css) so .hm-skills-layout (flex:1) can fill the viewport
and let the two-column sidebar scroll independently. Without these
shrink-locks, the flex distribution squeezes hero (240→165px) and toolsets
when they share viewport with the layout. */
[data-engine="hermes"].hermes-skills-page > .hm-hero,
[data-engine="hermes"].hermes-skills-page > .hm-toolsets {
flex-shrink: 0;
}
/* ---- Skills · Toolsets section ---------------------------------------- */
[data-engine="hermes"] .hm-toolsets {
margin: 24px 0 0;
padding: 18px 22px 14px;
background: var(--hm-surface-1);
border: 1px solid var(--hm-border);
border-radius: var(--hm-radius-md);
box-shadow: 0 1px 2px rgba(28, 25, 23, 0.03), 0 2px 6px rgba(28, 25, 23, 0.035);
}
[data-theme="dark"] [data-engine="hermes"] .hm-toolsets { box-shadow: none; }
[data-engine="hermes"] .hm-toolsets-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
[data-engine="hermes"] .hm-toolsets-title-block {
min-width: 0;
}
[data-engine="hermes"] .hm-toolsets-title {
font-family: var(--hm-font-serif);
font-size: 17px;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--hm-text-primary);
display: flex;
align-items: center;
gap: 10px;
}
[data-engine="hermes"] .hm-toolsets-count {
font-family: var(--hm-font-mono);
font-size: 11px;
font-weight: 400;
color: var(--hm-text-tertiary);
background: var(--hm-surface-2);
padding: 2px 8px;
border-radius: 999px;
}
[data-engine="hermes"] .hm-toolsets-sub {
font-size: 11.5px;
color: var(--hm-text-tertiary);
margin-top: 4px;
font-family: var(--hm-font-mono);
}
[data-engine="hermes"] .hm-toolsets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px;
}
[data-engine="hermes"] .hm-toolset-card {
padding: 12px 14px;
background: var(--hm-surface-0);
border: 1px solid var(--hm-border-subtle);
border-radius: var(--hm-radius-sm);
transition: border-color 0.18s ease, background 0.18s ease;
}
[data-engine="hermes"] .hm-toolset-card.is-on {
border-color: var(--hm-border);
}
[data-engine="hermes"] .hm-toolset-card.is-off {
background: transparent;
opacity: 0.55;
}
[data-engine="hermes"] .hm-toolset-card:hover {
border-color: var(--hm-accent, var(--hm-text-primary));
background: var(--hm-surface-1);
}
[data-engine="hermes"] .hm-toolset-card--skel {
pointer-events: none;
border-color: var(--hm-border-subtle);
}
[data-engine="hermes"] .hm-toolset-card-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
[data-engine="hermes"] .hm-toolset-status {
font-size: 13px;
font-weight: 700;
width: 14px;
display: inline-block;
text-align: center;
}
[data-engine="hermes"] .hm-toolset-status.is-on { color: var(--hm-success, #2d7d4f); }
[data-engine="hermes"] .hm-toolset-status.is-off { color: var(--hm-text-tertiary); }
[data-engine="hermes"] .hm-toolset-name {
font-family: var(--hm-font-mono);
font-size: 12.5px;
font-weight: 500;
color: var(--hm-text-primary);
letter-spacing: -0.01em;
}
[data-engine="hermes"] .hm-toolset-desc {
font-size: 11.5px;
color: var(--hm-text-secondary);
line-height: 1.5;
/* 限两行 + 截断emoji + 文字混排时避免一个 toolset 的描述把卡片撑得很高 */
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
[data-engine="hermes"] .hm-toolsets-empty,
[data-engine="hermes"] .hm-toolsets-fallback-hint {
padding: 12px 0 4px;
font-style: italic;
font-family: var(--hm-font-serif);
color: var(--hm-text-tertiary);
font-size: 13px;
}
[data-engine="hermes"] .hm-toolsets-fallback-pre {
margin: 8px 0 0;
padding: 12px 14px;
background: var(--hm-surface-0);
border: 1px solid var(--hm-border-subtle);
border-radius: var(--hm-radius-sm);
font-family: var(--hm-font-mono);
font-size: 11.5px;
line-height: 1.6;
color: var(--hm-text-secondary);
white-space: pre-wrap;
word-break: break-all;
max-height: 320px;
overflow: auto;
}
[data-engine="hermes"] .hm-toolsets-hint {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--hm-border-subtle);
font-size: 11px;
color: var(--hm-text-tertiary);
line-height: 1.6;
font-family: var(--hm-font-mono);
}
@media (max-width: 720px) {
[data-engine="hermes"] .hm-toolsets {
padding: 14px 16px 12px;
}
[data-engine="hermes"] .hm-toolsets-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
}
/* ==========================================================================
* 19 · Chat page (editorial luxury re-write)
* ==========================================================================
@@ -5121,9 +5500,8 @@ body[data-active-engine="hermes"][data-theme="dark"] {
}
[data-engine="hermes"].hm-usage-page {
max-width: 1120px;
margin: 0 auto;
padding: 28px 30px 40px;
width: 100%;
padding: 28px clamp(16px, 3vw, 56px) 40px;
color: var(--hm-text-primary);
}
[data-engine="hermes"] .hm-usage-hero {
@@ -5383,9 +5761,8 @@ body[data-active-engine="hermes"][data-theme="dark"] {
}
[data-engine="hermes"].hm-services-page {
max-width: 1120px;
margin: 0 auto;
padding: 28px 30px 40px;
width: 100%;
padding: 28px clamp(16px, 3vw, 56px) 40px;
color: var(--hm-text-primary);
}
[data-engine="hermes"] .hm-services-desc {

View File

@@ -99,10 +99,11 @@
}
[data-engine="xintian"] .xt-stage {
/* 移除 1240px 硬限,宽屏填满;水平 padding 自适应。
内部 .xt-hero / .xt-section-head 自带 max-width 保证可读性。 */
position: relative;
max-width: 1240px;
margin: 0 auto;
padding: 56px 48px 96px;
width: 100%;
padding: 56px clamp(20px, 4vw, 80px) 96px;
}
@media (max-width: 860px) {

View File

@@ -112,4 +112,12 @@ export function initI18n() {
_lang = 'en'
}
_dict = LANGS[_lang] || LANGS[FALLBACK]
// 桥接 splash 启动屏的语言切换splash 在 dispatch 'clawpanel-lang-change' 后,应用同步切换
if (typeof window !== 'undefined') {
window.addEventListener('clawpanel-lang-change', (e) => {
const next = e?.detail
if (next && LANGS[next] && next !== _lang) setLang(next)
})
}
}

View File

@@ -290,7 +290,14 @@ export const api = {
// 面板配置 (clawpanel.json)
getOpenclawDir: () => invoke('get_openclaw_dir'),
relaunchApp: () => invoke('relaunch_app'),
// Tauri: 重启应用进程Web: 没有应用进程概念,刷新浏览器即可拿到新状态
relaunchApp: () => {
if (!isTauriRuntime()) {
try { window.location.reload() } catch {}
return Promise.resolve({ ok: true, mode: 'web-reload' })
}
return invoke('relaunch_app')
},
readPanelConfig: () => invoke('read_panel_config'),
writePanelConfig: (config) => { invalidate(); return invoke('write_panel_config', { config }).then(r => { invoke('invalidate_path_cache').catch(() => {}); return r }) },
testProxy: (url) => invoke('test_proxy', { url: url || null }),
@@ -428,6 +435,9 @@ export const api = {
hermesDashboardThemeSet: (name) => invoke('hermes_dashboard_theme_set', { name }),
hermesDashboardPlugins: () => invoke('hermes_dashboard_plugins'),
hermesDashboardPluginsRescan: () => invoke('hermes_dashboard_plugins_rescan'),
hermesDashboardProbe: () => invoke('hermes_dashboard_probe'),
hermesDashboardStart: () => invoke('hermes_dashboard_start'),
hermesDashboardStop: () => invoke('hermes_dashboard_stop'),
hermesToolsetsList: () => invoke('hermes_toolsets_list'),
hermesCronJobsList: () => invoke('hermes_cron_jobs_list'),
hermesSkillsList: () => invoke('hermes_skills_list'),

View File

@@ -118,6 +118,82 @@ export default {
dashNativePanelOffline: _('Gateway 未运行', 'Gateway offline', 'Gateway 未執行', 'Gateway 未実行', 'Gateway 미실행'),
dashNativePanelTooOld: _('需 v0.10.0+', 'Requires v0.10.0+', '需 v0.10.0+', 'v0.10.0+ が必要', 'v0.10.0+ 필요'),
dashNativePanelOpenFail: _('打开浏览器失败', 'Failed to open browser', '開啟瀏覽器失敗', 'ブラウザを開くのに失敗しました', '브라우저 열기 실패'),
dashNativePanelChecking: _('检测 Dashboard 端口…', 'Checking dashboard port…', '檢測 Dashboard 連接埠…', 'Dashboard ポートを確認中…', 'Dashboard 포트 확인 중…'),
dashNativePanelStarting: _('正在启动 Dashboard…', 'Starting Dashboard…', '正在啟動 Dashboard…', 'Dashboard を起動中…', 'Dashboard 시작 중…'),
dashNativePanelStartFirstHint: _('首次启动需构建前端,最长约 1-2 分钟…',
'First launch builds the frontend; this can take 1-2 minutes…',
'首次啟動需建構前端,最長約 1-2 分鐘…',
'初回起動はフロントエンドをビルドするため 1-2 分かかる場合があります…',
'첫 실행 시 프론트엔드 빌드로 1-2분 소요될 수 있습니다…'),
dashNativePanelStartTimeout: _('启动超时(端口 {port} 仍未响应)。请稍候再试,或在终端运行 hermes dashboard 查看实际错误。',
'Startup timed out (port {port} still unresponsive). Wait a bit and retry, or run hermes dashboard in a terminal to see the real error.',
'啟動逾時(連接埠 {port} 仍未回應)。請稍候再試,或在終端執行 hermes dashboard 查看實際錯誤。',
'起動タイムアウト(ポート {port} が応答しません)。少し待って再試行するか、ターミナルで hermes dashboard を実行して実際のエラーを確認してください。',
'시작 시간 초과 (포트 {port} 응답 없음). 잠시 후 다시 시도하거나 터미널에서 hermes dashboard를 실행하여 실제 오류를 확인하세요.'),
dashNativePanelStartPortBusy: _('端口 {port} 被占用,可能有别的 Hermes Dashboard 实例在运行。',
'Port {port} is in use; another Hermes Dashboard instance may be running.',
'連接埠 {port} 已被佔用,可能有另一個 Hermes Dashboard 實例在執行。',
'ポート {port} は使用中です。別の Hermes Dashboard インスタンスが実行されている可能性があります。',
'포트 {port}이(가) 사용 중입니다. 다른 Hermes Dashboard 인스턴스가 실행 중일 수 있습니다.'),
dashNativePanelStartGeneric: _('Dashboard 启动失败',
'Failed to start Dashboard',
'Dashboard 啟動失敗',
'Dashboard の起動に失敗しました',
'Dashboard 시작 실패'),
dashNativePanelWindowsTitle: _('原生 Windows 暂不支持 Hermes Dashboard',
'Hermes Dashboard not supported on native Windows',
'原生 Windows 暫不支援 Hermes Dashboard',
'ネイティブ Windows では Hermes Dashboard はサポートされていません',
'네이티브 Windows에서 Hermes Dashboard는 지원되지 않습니다'),
dashNativePanelWindowsDesc: _('Hermes Agent 在原生 Windows 上无条件导入 POSIX-only 标准库fcntl / termios导致 dashboard 启动崩溃。这是已知的上游 bug官方暂未修复。',
'Hermes Agent unconditionally imports POSIX-only stdlib modules (fcntl / termios) on native Windows, which crashes the dashboard at startup. This is a known upstream bug not yet fixed.',
'Hermes Agent 在原生 Windows 上無條件匯入 POSIX-only 標準函式庫fcntl / termios導致 dashboard 啟動崩潰。這是已知的上游 bug官方暫未修復。',
'Hermes Agent はネイティブ Windows で POSIX 専用の標準ライブラリfcntl / termiosを無条件にインポートするため、dashboard が起動時にクラッシュします。これは既知のアップストリームバグで未修正です。',
'Hermes Agent는 네이티브 Windows에서 POSIX 전용 표준 라이브러리(fcntl / termios)를 무조건 가져와 dashboard 시작 시 충돌합니다. 알려진 업스트림 버그이며 아직 수정되지 않았습니다.'),
dashNativePanelWindowsAlt1: _('继续使用 ClawPanel 内置的 Hermes 面板(推荐,体验更好)',
'Keep using ClawPanel\'s built-in Hermes panel (recommended, better experience)',
'繼續使用 ClawPanel 內建的 Hermes 面板(推薦,體驗更好)',
'ClawPanel 内蔵の Hermes パネルを使い続ける(推奨、より良い体験)',
'ClawPanel 내장 Hermes 패널을 계속 사용 (권장, 더 나은 경험)'),
dashNativePanelWindowsAlt2: _('在 WSL2 中安装并运行 Hermeshermes dashboard 在 Linux 上工作正常)',
'Install and run Hermes inside WSL2 (hermes dashboard works fine on Linux)',
'在 WSL2 中安裝並執行 Hermeshermes dashboard 在 Linux 上工作正常)',
'WSL2 内で Hermes をインストール・実行Linux 上では hermes dashboard は正常に動作)',
'WSL2 내에서 Hermes 설치 및 실행 (Linux에서는 hermes dashboard 정상 작동)'),
dashNativePanelWindowsReportLink: _('查看上游 Issue #5246', 'View upstream issue #5246', '檢視上游 Issue #5246', 'アップストリーム Issue #5246 を見る', '업스트림 Issue #5246 보기'),
dashNativePanelDown: _('Dashboard 服务未运行(端口 {port} 无响应。请在终端运行hermes dashboard',
'Dashboard service is not running (port {port} unreachable). Run in terminal: hermes dashboard',
'Dashboard 服務未執行(連接埠 {port} 無回應。請在終端執行hermes dashboard',
'Dashboard サービスが実行されていません(ポート {port} に接続できません)。ターミナルで実行: hermes dashboard',
'Dashboard 서비스가 실행 중이 아닙니다 (포트 {port} 연결 불가). 터미널에서 실행: hermes dashboard'),
dashNativePanelDownTitle: _('Hermes Dashboard 未启动', 'Hermes Dashboard not running', 'Hermes Dashboard 未啟動', 'Hermes Dashboard が実行されていません', 'Hermes Dashboard가 실행 중이 아닙니다'),
dashNativePanelStartHint: _('在终端运行下面命令启动 Dashboard 后再试:', 'Run the command below in your terminal to start the Dashboard, then retry:', '在終端執行以下命令啟動 Dashboard 後再試:', 'ターミナルで以下を実行して Dashboard を起動してから再試行してください:', '아래 명령을 터미널에서 실행하여 Dashboard를 시작한 후 다시 시도하세요:'),
dashNativePanelDepHint: _('首次使用需要先安装 Web 依赖fastapi + uvicorn。点击下方按钮自动安装或在终端运行',
'First-time use requires installing the Web dependencies (fastapi + uvicorn). Click the button below to auto-install, or run in your terminal:',
'首次使用需要先安裝 Web 依賴fastapi + uvicorn。點擊下方按鈕自動安裝或在終端執行',
'初回使用には Web 依存関係fastapi + uvicornのインストールが必要です。下のボタンで自動インストールするか、ターミナルで実行',
'처음 사용 시 Web 종속성(fastapi + uvicorn) 설치가 필요합니다. 아래 버튼으로 자동 설치하거나 터미널에서 실행:'),
dashNativePanelInstallWeb: _('安装 Web 依赖', 'Install Web Deps', '安裝 Web 依賴', 'Web 依存関係をインストール', 'Web 종속성 설치'),
dashNativePanelInstallWebTitle: _('安装 Hermes Web 依赖', 'Installing Hermes Web Dependencies', '安裝 Hermes Web 依賴', 'Hermes Web 依存関係をインストール中', 'Hermes Web 종속성 설치 중'),
dashNativePanelInstallWebDone: _('Web 依赖安装完成请在终端运行hermes dashboard',
'Web dependencies installed. Now run in your terminal: hermes dashboard',
'Web 依賴安裝完成請在終端執行hermes dashboard',
'Web 依存関係のインストールが完了しました。ターミナルで実行hermes dashboard',
'Web 종속성 설치 완료. 터미널에서 실행: hermes dashboard'),
dashNativePanelInstallWebFailed: _('安装失败', 'Install failed', '安裝失敗', 'インストールに失敗しました', '설치 실패'),
dashNativePanelInstallStoppingGw: _('⏸ 停止 Gateway 以释放 hermes.exe 文件锁…',
'⏸ Stopping Gateway to release hermes.exe file lock…',
'⏸ 停止 Gateway 以釋放 hermes.exe 檔案鎖…',
'⏸ hermes.exe ファイルロックを解放するため Gateway を停止中…',
'⏸ hermes.exe 파일 잠금 해제를 위해 Gateway 중지 중…'),
dashNativePanelInstallGwStopped: _('✓ Gateway 已停止', '✓ Gateway stopped', '✓ Gateway 已停止', '✓ Gateway を停止しました', '✓ Gateway 중지됨'),
dashNativePanelInstallRestartingGw: _('▶ 重启 Gateway…', '▶ Restarting Gateway…', '▶ 重新啟動 Gateway…', '▶ Gateway を再起動中…', '▶ Gateway 재시작 중…'),
dashNativePanelInstallGwRestarted: _('✓ Gateway 已重启', '✓ Gateway restarted', '✓ Gateway 已重新啟動', '✓ Gateway を再起動しました', '✓ Gateway 재시작됨'),
dashNativePanelInstallGwWarn: _('⚠ Gateway 操作异常',
'⚠ Gateway operation warning',
'⚠ Gateway 操作異常',
'⚠ Gateway 操作の警告',
'⚠ Gateway 작업 경고'),
dashOpenCron: _('定时任务', 'Cron Jobs', '定時任務'),
dashOpenSetup: _('重新配置', 'Reconfigure', '重新配置'),
dashNoModel: _('未配置', 'Not configured', '未配置'),
@@ -443,6 +519,36 @@ export default {
skillsFileLoadFailed: _('文件加载失败', 'File load failed', '檔案載入失敗'),
skillsAttachedFiles: _('附带资源', 'Attached Files', '附帶資源'),
skillsBackTo: _('返回', 'Back to', '返回'),
// Skills 页面 — Toolsets 区hermes tools list --platform cli
toolsetsTitle: _('内置工具集',
'Built-in Toolsets',
'內建工具集',
'組み込みツールセット',
'내장 툴셋'),
toolsetsSubtitle: _('platform: cli · 配置位于 ~/.hermes/config.yaml',
'platform: cli · configured in ~/.hermes/config.yaml',
'platform: cli · 配置位於 ~/.hermes/config.yaml',
'platform: cli · ~/.hermes/config.yaml で設定',
'platform: cli · ~/.hermes/config.yaml에서 설정'),
toolsetsLoading: _('正在读取工具集…', 'Loading toolsets…', '正在讀取工具集…', 'ツールセットを読み込み中…', '툴셋 로드 중…'),
toolsetsEmpty: _('暂无工具集数据。可能 Hermes 未安装或版本过旧。',
'No toolset data. Hermes may not be installed or the version is too old.',
'暫無工具集資料。可能 Hermes 未安裝或版本過舊。',
'ツールセットデータがありません。Hermes がインストールされていないか、バージョンが古い可能性があります。',
'툴셋 데이터가 없습니다. Hermes가 설치되지 않았거나 버전이 너무 오래되었을 수 있습니다.'),
toolsetsParseFailed: _('无法解析输出,下面显示原始内容:',
'Could not parse output. Raw content shown below:',
'無法解析輸出,下面顯示原始內容:',
'出力を解析できませんでした。下に生のコンテンツを表示します:',
'출력을 분석할 수 없습니다. 아래에 원시 콘텐츠를 표시합니다:'),
toolsetsHint: _('要切换某个工具集,运行 hermes tools 进入交互界面,或编辑 config.yaml 的 platforms.cli.toolsets 字段。',
'To toggle a toolset, run hermes tools for the interactive UI or edit platforms.cli.toolsets in config.yaml.',
'要切換某個工具集,執行 hermes tools 進入互動介面,或編輯 config.yaml 的 platforms.cli.toolsets 欄位。',
'ツールセットを切り替えるには、hermes tools で対話 UI を起動するか、config.yaml の platforms.cli.toolsets を編集します。',
'툴셋을 전환하려면 hermes tools 대화형 UI를 실행하거나 config.yaml의 platforms.cli.toolsets를 편집하세요.'),
toolsetsEnabled: _('启用', 'enabled', '啟用', '有効', '활성화'),
toolsetsDisabled: _('停用', 'disabled', '停用', '無効', '비활성화'),
toolsetsActiveCount: _('{n}/{total} 启用', '{n}/{total} active', '{n}/{total} 啟用', '{n}/{total} 有効', '{n}/{total} 활성화'),
// Memory 页面
hermesMemoryTitle: _('Agent 记忆', 'Agent Memory', 'Agent 記憶'),
memoryNotes: _('笔记', 'Notes', '筆記'),
@@ -459,6 +565,13 @@ export default {
memorySaved: _('已保存', 'Saved', '已儲存'),
memorySaveHint: _('Ctrl/⌘ + S 保存 · Esc 取消', 'Ctrl/⌘ + S to save · Esc to cancel', 'Ctrl/⌘ + S 儲存 · Esc 取消'),
memoryEyebrow: _('AGENT 长期记忆', 'AGENT PERSISTENT MEMORY', 'AGENT 長期記憶'),
memoryOverviewKicker: _('Hermes Memory Fabric', 'Hermes Memory Fabric', 'Hermes Memory Fabric'),
memoryOverviewTitle: _('三份 Markdown组成 Agent 的长期上下文', 'Three Markdown files form the Agents long-term context', '三份 Markdown組成 Agent 的長期上下文'),
memoryOverviewDesc: _('笔记记录事实用户画像沉淀偏好灵魂档案塑造人格。Hermes 会在会话中持续读取这些长期记忆。', 'Notes store facts, profile captures preferences, and soul shapes persona. Hermes continuously reads these persistent memories during conversations.', '筆記記錄事實用戶畫像沉澱偏好靈魂檔案塑造人格。Hermes 會在會話中持續讀取這些長期記憶。'),
memoryFiles: _('记忆文件', 'Memory Files', '記憶檔案'),
memoryFilled: _('已填写', 'Filled', '已填寫'),
memoryTotalWords: _('总词数', 'Total Words', '總詞數'),
memoryLatest: _('最近更新', 'Latest Update', '最近更新'),
memorySoul: _('灵魂档案', 'Soul', '靈魂檔案'),
memoryNotesDesc: _('Agent 的笔记与事实备忘——会话间持续累积的知识。', 'Agent\'s notes and factual memories — knowledge accumulated across sessions.', 'Agent 的筆記與事實備忘——會話間持續累積的知識。'),
memoryProfileDesc: _('用户偏好、身份、背景信息——每次对话都会参考。', 'User preferences, identity, context — referenced in every conversation.', '用戶偏好、身份、背景資訊——每次對話都會參考。'),

View File

@@ -144,6 +144,8 @@ export default {
configNotReady: _('配置未加载完成,请稍候', 'Config not loaded yet, please wait', '設定未載入完成,請稍候'),
fetchRemoteFailed: _('无法获取模型列表,请检查网络或稍后重试', 'Cannot fetch model list. Check network or try later.', '無法取得模型列表,請檢查網路或稍后重試'),
configLoadFailed: _('加载配置失败', 'Failed to load config', '載入設定失敗'),
configLoadFailedHint: _('请确认 Gateway 配置文件可读取,或刷新页面重试。如问题持续,可点击下方查看技术详情。', 'Please make sure the Gateway config file is accessible, or refresh the page to retry. Expand the details below if the problem persists.', '請確認 Gateway 設定檔可讀取,或重新整理頁面重試。如問題持續,可點擊下方查看技術詳情。'),
configLoadDetails: _('查看技术详情', 'Show technical details', '查看技術詳情'),
autoFixUrl: _('已自动修复模型接口地址(如 Ollama /v1', 'Auto-fixed model API URLs (e.g. Ollama /v1)', '已自動修复模型介面位址(如 Ollama /v1'),
saveFailed: _('保存失败', 'Save failed', '儲存失敗', '保存失敗', '저장 실패'),
autoSaveFailed: _('自动保存失败', 'Auto-save failed', '自動儲存失敗'),

View File

@@ -887,6 +887,8 @@ async function checkGlobalUpdate() {
}
function startUpdateChecker() {
// Web 模式:浏览器每次刷新都拿最新前端,前端热更新无意义;跳过避免 404 噪音
if (!isTauri) return
// 启动后 5 秒检查一次
setTimeout(checkGlobalUpdate, 5000)
// 之后每 30 分钟检查一次

View File

@@ -10,6 +10,12 @@ import { API_TYPES, PROVIDER_PRESETS, QTCOOL, MODEL_PRESETS, fetchQtcoolModels }
import { t } from '../lib/i18n.js'
import { scheduleGatewayRestart, fireRestartNow, cancelPendingRestart, onRestartState } from '../lib/gateway-restart-queue.js'
// HTML 转义,防止错误信息中的特殊字符破坏页面或被注入
function escapeHtml(str) {
if (str == null) return ''
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
export async function render() {
const page = document.createElement('div')
page.className = 'page'
@@ -88,8 +94,29 @@ async function loadConfig(page, state) {
renderDefaultBar(page, state)
renderProviders(page, state)
} catch (e) {
listEl.innerHTML = '<div style="color:var(--error);padding:20px">' + t('models.configLoadFailed') + ': ' + e + '</div>'
toast(t('models.configLoadFailed') + ': ' + e, 'error')
console.error('[models] loadConfig failed:', e)
const detail = escapeHtml(e?.stack || e?.message || String(e))
const shortMsg = escapeHtml(e?.message || String(e))
listEl.innerHTML = `
<div class="models-load-error" style="padding:36px 20px;text-align:center;max-width:560px;margin:0 auto">
<div style="display:inline-flex;align-items:center;justify-content:center;width:48px;height:48px;border-radius:50%;background:rgba(239,68,68,0.10);color:var(--error);margin-bottom:14px">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</div>
<div style="color:var(--text-primary);font-weight:600;font-size:15px;margin-bottom:6px">${t('models.configLoadFailed')}</div>
<div style="color:var(--text-secondary);font-size:13px;line-height:1.65;margin-bottom:18px">${t('models.configLoadFailedHint')}</div>
<details style="text-align:left;margin-bottom:18px">
<summary style="cursor:pointer;color:var(--text-tertiary);font-size:12px;padding:4px 0;user-select:none">${t('models.configLoadDetails')}</summary>
<pre style="margin-top:8px;padding:10px 12px;background:var(--bg-secondary);border:1px solid var(--border-primary);border-radius:6px;font-size:11px;color:var(--text-secondary);white-space:pre-wrap;word-break:break-all;max-height:220px;overflow:auto;text-align:left">${detail}</pre>
</details>
<button class="btn btn-primary btn-sm" id="models-retry-load">${t('models.retryRestart')}</button>
</div>
`
listEl.querySelector('#models-retry-load')?.addEventListener('click', () => loadConfig(page, state))
toast(`${t('models.configLoadFailed')}: ${shortMsg}`, 'error')
}
}
@@ -116,7 +143,7 @@ function collectAllModels(config) {
if (id) result.push({ provider: pk, modelId: id, full: `${pk}/${id}` })
}
}
return resul
return result
}
function getApiTypeLabel(apiType) {
@@ -1513,7 +1540,7 @@ async function handleBatchTest(section, state, providerKey) {
const start = Date.now()
try {
await api.testModel(provider.baseUrl, provider.apiKey || '', modelId, provider.api || 'openai-completions')
const elapsed = Date.now() - star
const elapsed = Date.now() - start
if (model && typeof model === 'object') {
model.latency = elapsed
model.lastTestAt = Date.now()
@@ -1522,7 +1549,7 @@ async function handleBatchTest(section, state, providerKey) {
}
ok++
} catch (e) {
const elapsed = Date.now() - star
const elapsed = Date.now() - start
if (model && typeof model === 'object') {
model.latency = null
model.lastTestAt = Date.now()
@@ -1554,7 +1581,7 @@ async function handleBatchTest(section, state, providerKey) {
newBtn.classList.add('btn-secondary')
}
const aborted = ctrl.abor
const aborted = ctrl.abort
autoSave(state)
if (aborted) {
toast(t('models.batchTestAborted', { ok, fail, skip: ids.length - ok - fail }), 'warning')
@@ -1678,13 +1705,13 @@ async function testModel(btn, state, providerKey, idx) {
const modelId = typeof model === 'string' ? model : model.id
btn.disabled = true
const origText = btn.textConten
const origText = btn.textContent
btn.textContent = t('models.testing')
const start = Date.now()
try {
const reply = await api.testModel(provider.baseUrl, provider.apiKey || '', modelId, provider.api || 'openai-completions')
const elapsed = Date.now() - star
const elapsed = Date.now() - start
// 记录到模型对象
if (typeof model === 'object') {
model.latency = elapsed
@@ -1707,7 +1734,7 @@ async function testModel(btn, state, providerKey, idx) {
toast(t('models.testOk', { model: modelId, time: (elapsed / 1000).toFixed(1), reply: reply.slice(0, 50) }), 'success')
}
} catch (e) {
const elapsed = Date.now() - star
const elapsed = Date.now() - start
if (typeof model === 'object') {
model.latency = null
model.lastTestAt = Date.now()
@@ -1717,7 +1744,7 @@ async function testModel(btn, state, providerKey, idx) {
toast(t('models.testFail', { model: modelId, time: (elapsed / 1000).toFixed(1), error: e }), 'error', { duration: 8000 })
} finally {
btn.disabled = false
btn.textContent = origTex
btn.textContent = origText
// 刷新卡片显示最新状态
const page = btn.closest('.page')
if (page) {

View File

@@ -263,14 +263,20 @@
.agent-channels-section {
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
.agent-files-section {
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
.agent-overview {
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
.agent-multiline-input {

View File

@@ -426,11 +426,18 @@
}
.page {
padding: var(--space-xl) var(--space-2xl);
max-width: 1200px;
/* 水平 padding 在宽屏自动放大,避免内容贴边;垂直保持 --space-xl */
padding: var(--space-xl) clamp(var(--space-xl), 3vw, var(--space-3xl));
width: 100%;
animation: pageIn 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* 可选:对长文本/表单为主的页面(如 关于、设置、安全),可加 .page-narrow 以保持可读性 */
.page.page-narrow {
max-width: 960px;
margin: 0 auto;
}
.page-header {
margin-bottom: var(--space-xl);
}

View File

@@ -1555,6 +1555,7 @@
min-height: 36px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@@ -2651,61 +2652,7 @@
.hm-skills-detail-content h3 { font-size: 15px; margin: 14px 0 6px; }
.hm-skills-detail-content h4 { font-size: 14px; margin: 12px 0 4px; }
/* === Hermes Memory Page === */
.hermes-memory-page { height: 100%; display: flex; flex-direction: column; }
.hm-memory-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 20px; border-bottom: 1px solid var(--border-primary);
flex-shrink: 0; gap: 12px;
}
.hm-memory-header-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.hm-memory-content { flex: 1; overflow: hidden; padding: 16px 20px; display: flex; flex-direction: column; }
.hm-memory-loading {
flex: 1; display: flex; align-items: center; justify-content: center;
color: var(--text-tertiary); font-size: 13px;
}
.hm-memory-sections { display: flex; gap: 16px; flex: 1; min-height: 0; }
.hm-memory-section {
flex: 1; min-height: 0; border: 1px solid var(--border-primary);
border-radius: var(--radius-md, 10px); overflow: hidden;
display: flex; flex-direction: column;
}
.hm-memory-section-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 16px; background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary); flex-shrink: 0;
}
.hm-memory-section-title-row { display: flex; align-items: center; gap: 8px; }
.hm-memory-section-icon { color: var(--text-secondary); display: flex; }
.hm-memory-section-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.hm-memory-section-body {
flex: 1; overflow-y: auto; padding: 16px; min-height: 0;
font-size: 14px; line-height: 1.7; color: var(--text-primary);
}
.hm-memory-section-body pre {
background: var(--bg-secondary); border: 1px solid var(--border-primary);
border-radius: 8px; padding: 12px 16px; overflow-x: auto; font-size: 13px; margin: 8px 0;
}
.hm-memory-section-body code {
font-family: var(--font-mono, 'Consolas', monospace); font-size: 0.9em;
}
.hm-memory-section-body code:not(pre code) {
background: rgba(0,0,0,0.06); padding: 1px 5px; border-radius: 4px;
}
.hm-memory-empty {
color: var(--text-tertiary); font-style: italic; font-size: 13px; padding: 12px 0;
}
.hm-memory-editor {
flex: 1; width: 100%; min-height: 0; padding: 12px;
border: 1px solid var(--border-primary); border-radius: var(--radius-sm, 6px);
background: var(--bg-primary); color: var(--text-primary);
font-family: var(--font-mono, 'Consolas', monospace); font-size: 13px; line-height: 1.6;
resize: none; outline: none; box-sizing: border-box;
}
.hm-memory-editor:focus { border-color: var(--accent); }
.hm-memory-edit-wrap {
flex: 1; display: flex; flex-direction: column; padding: 12px 16px; min-height: 0;
}
.hm-memory-edit-actions {
display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; flex-shrink: 0;
}
/* === Hermes Memory Page ===
编辑改版后 memory.js 用 .hm-mem-overview + .hm-mem-panel 自然堆叠(见
engines/hermes/style/hermes.css。原来 .hm-memory-* 那一坨规则已无任何
JS / CSS 引用,已在此处删除。 */