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) {