Files
clawpanel/src/engines/hermes/pages/lazy-deps.js
晴天 bf55ca0135 refactor(hermes): emoji → SVG 图标,统一视觉语言
## 问题
新加的 hermes 页面里到处是 emoji(⚠️📁📋💬🔐🔗🖼️📝⚙️📦💬🔊🎙🔍✓ 等),
不同 OS 渲染样式差异大(macOS Apple Color Emoji vs Windows Segoe UI Emoji
vs Linux Noto Color Emoji),看着很不专业,也跟现有 SVG 图标系统割裂。

## 方案
新建 src/engines/hermes/lib/svg-icons.js — 集中所有需要的 SVG path:
  状态:alert-triangle, check, x, check-circle, x-circle, info
  文件:folder, folder-up, file, file-text, image, link-2, settings
  列表:clipboard-list, message-square, inbox
  安全:lock, shield, key
  媒体:volume, mic, search

导出 svgIcon(name, opts) — 渲染时统一 viewBox / stroke-width / currentColor,
size 可选覆盖。

## 替换覆盖(7 个页面 + 2 处 CSS)
- profiles.js:    ⚠️ → alert-triangle, 📁 → folder
- kanban.js:      ⚠️ → alert-triangle, 📋 → clipboard-list, 💬 → message-square
- oauth.js:       ⚠️ → alert-triangle, 🔐 → lock
- group-chat.js:  ⚠️ → alert-triangle
- logs.js:        ⚠️ → 纯文本 [ERROR](log raw 字段不需要图标)
- files.js:       📁🔗🖼️📝⚙️📄 全部 → folder/link-2/image/file-text/settings/file
                  ".." → folder-up
- lazy-deps.js:   7 个 category emoji → message-square/volume/mic/search/
                  shield/inbox/image
                  ✓ 装好标识 → check svg
                  📦 empty → inbox

CSS 适配:
- .page-inline-error-icon: 加 inline-flex + 自动 currentColor,svg 20×20
- .empty-state .empty-icon: 加 inline-flex + svg 跟 font-size 走(1em)

## 范围控制
本次只处理我最近 3 个 commit 里新加的页面 emoji。
历史代码(setup.js / services.js / chat.js 的 ✓ 等)暂不动 — 那是
跨工作流的复杂改动,单独评估再做,避免连锁影响 CI。

## 验证
✓ npm run build PASS(1.80s)
2026-05-14 06:50:14 +08:00

202 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Hermes lazy_deps 依赖管理P1-3
*
* 列出 Hermes 内核 LAZY_DEPS allowlist 的所有 featureplatform.* / tts.* / stt.* /
* search.* / provider.* / memory.* / image.*),显示安装状态,提供「装」按钮。
*
* 解决「用户配好渠道首次启动 Gateway 卡 30 秒后崩」的常见 bug ——
* 让用户能在「启动 Gateway 之前」主动预装。
*/
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
import { toast } from '../../../components/toast.js'
import { humanizeError } from '../../../lib/humanize-error.js'
import { svgIcon } from '../lib/svg-icons.js'
// feature 分类配置(决定分组顺序 + 图标 + 文案)
const CATEGORIES = [
{ prefix: 'platform.', icon: 'message-square', titleKey: 'hermesLazyDeps.catPlatform' },
{ prefix: 'tts.', icon: 'volume', titleKey: 'hermesLazyDeps.catTts' },
{ prefix: 'stt.', icon: 'mic', titleKey: 'hermesLazyDeps.catStt' },
{ prefix: 'search.', icon: 'search', titleKey: 'hermesLazyDeps.catSearch' },
{ prefix: 'provider.', icon: 'shield', titleKey: 'hermesLazyDeps.catProvider' },
{ prefix: 'memory.', icon: 'inbox', titleKey: 'hermesLazyDeps.catMemory' },
{ prefix: 'image.', icon: 'image', titleKey: 'hermesLazyDeps.catImage' },
]
const DESC_OVERRIDE_KEY = 'hermesLazyDeps.descOverride' // i18n.key 下的 feature → 描述
// 把 feature 按分类分组
function groupByCategory(features) {
const groups = CATEGORIES.map(c => ({ ...c, items: [] }))
const other = { prefix: '', icon: 'file', titleKey: 'hermesLazyDeps.catOther', items: [] }
for (const f of features) {
const cat = groups.find(g => f.feature.startsWith(g.prefix))
if (cat) cat.items.push(f)
else other.items.push(f)
}
return [...groups.filter(g => g.items.length > 0), ...(other.items.length ? [other] : [])]
}
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.dataset.engine = 'hermes'
page.innerHTML = `
<div class="page-header">
<div>
<h1 class="page-title">${t('hermesLazyDeps.title')}</h1>
<p class="page-desc">${t('hermesLazyDeps.desc')}</p>
</div>
<div class="config-actions">
<button class="btn btn-secondary btn-sm" id="btn-refresh">${t('hermesLazyDeps.refresh')}</button>
</div>
</div>
<div id="lazy-deps-content">
<div style="padding:32px;text-align:center;color:var(--text-tertiary)">
${t('common.loading')}
</div>
</div>
`
loadAndRender(page)
page.querySelector('#btn-refresh').onclick = () => loadAndRender(page)
return page
}
async function loadAndRender(page) {
const content = page.querySelector('#lazy-deps-content')
content.innerHTML = `<div style="padding:32px;text-align:center;color:var(--text-tertiary)">${t('common.loading')}…</div>`
let featuresResp
try {
featuresResp = await api.hermesLazyDepsFeatures()
} catch (e) {
content.innerHTML = `<div style="color:var(--error);padding:20px">${escapeHtml(humanizeError(e, t('hermesLazyDeps.loadFailed')))}</div>`
return
}
if (!featuresResp?.ok) {
content.innerHTML = `<div style="color:var(--error);padding:20px">${escapeHtml(t('hermesLazyDeps.loadFailed'))}: ${escapeHtml(featuresResp?.error || 'unknown')}</div>`
return
}
const features = featuresResp.features || []
if (!features.length) {
content.innerHTML = `<div class="empty-state empty-compact">
<div class="empty-icon">${svgIcon('inbox', { size: 32 })}</div>
<div class="empty-title">${escapeHtml(t('hermesLazyDeps.emptyTitle'))}</div>
</div>`
return
}
// 批量查状态
let status = {}
try {
const statusResp = await api.hermesLazyDepsStatus(features.map(f => f.feature))
status = statusResp?.ok ? (statusResp.status || {}) : {}
} catch (e) {
// 状态查询失败也允许渲染(按未知处理)
console.warn('lazy_deps status failed:', e)
}
const groups = groupByCategory(features)
content.innerHTML = groups.map(g => renderGroup(g, status)).join('')
// 绑定每个 feature 的「装」按钮
content.querySelectorAll('button[data-feature]').forEach(btn => {
btn.onclick = () => onEnsureClick(page, btn.dataset.feature, btn)
})
}
function renderGroup(group, status) {
const items = group.items.map(f => renderItem(f, status[f.feature])).join('')
return `
<div class="config-section">
<div class="config-section-title">
<span style="display:inline-flex;align-items:center;color:var(--accent);margin-right:8px">${svgIcon(group.icon, { size: 18 })}</span>
${escapeHtml(t(group.titleKey))}
</div>
<div class="lazy-deps-grid">
${items}
</div>
</div>
`
}
function renderItem(f, st) {
const satisfied = st && st.satisfied
const known = st ? st.known : true
const missing = st?.missing || []
const specsTitle = (f.specs || []).join('\n')
const featureLabel = featureDisplayName(f.feature)
const stateBadge = satisfied
? `<span class="lazy-deps-badge ok">${svgIcon('check', { size: 11 })} ${escapeHtml(t('hermesLazyDeps.installed'))}</span>`
: (known
? `<span class="lazy-deps-badge warn">${escapeHtml(t('hermesLazyDeps.notInstalled'))}</span>`
: `<span class="lazy-deps-badge unknown">?</span>`)
const installBtn = satisfied
? `<button class="btn btn-sm btn-secondary" data-feature="${escapeAttr(f.feature)}" data-action="reinstall">${escapeHtml(t('hermesLazyDeps.reinstall'))}</button>`
: `<button class="btn btn-sm btn-primary" data-feature="${escapeAttr(f.feature)}" data-action="install">${escapeHtml(t('hermesLazyDeps.install'))}</button>`
const missingHint = !satisfied && missing.length
? `<div class="lazy-deps-missing" title="${escapeAttr(missing.join('\n'))}">${escapeHtml(t('hermesLazyDeps.missingCount', { n: missing.length }))}</div>`
: ''
return `
<div class="lazy-deps-card">
<div class="lazy-deps-card-head">
<div class="lazy-deps-card-title" title="${escapeAttr(f.feature)}">${escapeHtml(featureLabel)}</div>
${stateBadge}
</div>
<div class="lazy-deps-card-meta" title="${escapeAttr(specsTitle)}">${escapeHtml((f.specs || []).join(', '))}</div>
${missingHint}
<div class="lazy-deps-card-actions">
${installBtn}
</div>
</div>
`
}
// 映射 feature → 友好显示名(兼容 i18n 缺词时 fallback 到原名)
function featureDisplayName(feature) {
const friendly = t('hermesLazyDeps.featureName.' + feature)
// i18n 没翻译时 t() 返回 key 本身,做 fallback
if (friendly && !friendly.endsWith('.' + feature)) return friendly
return feature
}
async function onEnsureClick(page, feature, btn) {
const origText = btn.textContent
btn.disabled = true
btn.textContent = t('hermesLazyDeps.installing') + '…'
try {
const resp = await api.hermesLazyDepsEnsure(feature)
if (resp?.ok) {
const installed = resp.installed || []
if (resp.alreadySatisfied) {
toast(t('hermesLazyDeps.alreadyInstalled', { feature }), 'success')
} else {
toast({
message: t('hermesLazyDeps.installSuccess', { feature }),
hint: installed.length
? t('hermesLazyDeps.installedSpecs', { specs: installed.join(', ') })
: '',
}, 'success')
}
} else {
toast(humanizeError(resp?.error || 'unknown', t('hermesLazyDeps.installFailed', { feature })), 'error')
}
} catch (e) {
toast(humanizeError(e, t('hermesLazyDeps.installFailed', { feature })), 'error')
} finally {
btn.disabled = false
btn.textContent = origText
// 装完刷新整张页面状态
setTimeout(() => loadAndRender(page), 600)
}
}
function escapeHtml(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function escapeAttr(s) { return escapeHtml(s) }