feat(hermes): P1-3 lazy_deps 预处理 - 加 IM 渠道不再「首启 Gateway 卡 30 秒后崩」

Hermes 内核 tools/lazy_deps.py 维护了一个 allowlist:每个 feature(如 platform.telegram /
tts.elevenlabs / search.exa)对应一组 PyPI 包。原本只有 Gateway 启动 platform 模块时
才会调 ensure() 装包,导致首次启动卡 30 秒甚至超时崩溃。

本 PR 把 lazy_deps 暴露给 ClawPanel UI,让用户能主动预装。

## 后端三个新 Tauri 命令
- hermes_lazy_deps_features() — 列所有 LAZY_DEPS allowlist feature(17 个)
- hermes_lazy_deps_status(features) — 批量查每个 feature 是否已装好
- hermes_lazy_deps_ensure(feature) — 主动调内核 tools.lazy_deps.ensure 装

实现方式:
- 找到 ~/.hermes-venv 的 python 路径(unix: bin/python,windows: Scripts/python.exe)
- 用 tokio::process::Command spawn `python -c "<embedded script>"` 跑临时 Python 脚本
- 脚本走 from tools.lazy_deps import ensure / feature_missing / LAZY_DEPS
- 把结果以 JSON dump 给 stdout,Rust 端解析最后一行
- enhanced_path() 注入 PATH 兼容 macOS Tauri 启动后 PATH 不全
- serde_json::to_string 把字符串和列表序列化为 Python 合法字面量(防注入)

已注册到 lib.rs,前端 wrapper 在 tauri-api.js(features 走 10min 缓存)。

## 前端
- 新页面 src/engines/hermes/pages/lazy-deps.js
- 分类 grid(消息渠道 / TTS / STT / 搜索 / 模型商 / 记忆 / 图像 / 其他),每类有 emoji
- 卡片式:feature 名(友好显示)+ specs 元信息 + 状态徽章(已装✓ / 未装warn / 未知)+ 装/重装按钮
- 装/重装按钮 await ensure,期间「安装中…」disabled,完成后刷新整张表
- 失败走 humanizeError 统一提示
- 17 个 feature 都有友好显示名 i18n(platform.telegram → Telegram,platform.dingtalk → 钉钉 等)
- 完整 11 语言 i18n(hermesLazyDeps 模块),其它语言 fallback 到 en

## sidebar
- Hermes 引擎「管理」section 新增「可选依赖管理」入口
- 路由 /h/lazy-deps

## CSS
- 加 .lazy-deps-grid / .lazy-deps-card / .lazy-deps-badge.{ok,warn,unknown}
- 复用现有 .empty-state / .empty-compact 风格

## Web 模式
- dev-api.js 加三个同名 handler:
  - features 返回内置常见 platform 列表
  - status 全标 unknown(无法 spawn python)
  - ensure 直接 reject,提示走桌面端

## 累计变动
- 2 新文件(lazy-deps.js page + hermesLazyDeps.js i18n)
- 7 修改(dev-api / hermes.rs / lib.rs / hermes/index.js / tauri-api.js / locales/index.js / components.css)
- 11 语言 × ~17 个新 i18n 键
- cargo check ✓ + npm build ✓
This commit is contained in:
晴天
2026-05-14 04:18:33 +08:00
parent c4bf769eab
commit b852ebb6ee
9 changed files with 564 additions and 1 deletions

View File

@@ -86,6 +86,7 @@ export default {
{ route: '/h/skills', label: t('sidebar.skills'), icon: 'skills' },
{ route: '/h/memory', label: t('sidebar.memory'), icon: 'memory' },
{ route: '/h/cron', label: t('sidebar.cron'), icon: 'clock' },
{ route: '/h/lazy-deps', label: t('hermesLazyDeps.title'), icon: 'package' },
{ route: '/h/extensions', label: t('sidebar.extensions'), icon: 'package' },
]
}, {
@@ -112,6 +113,7 @@ export default {
{ path: '/h/memory', loader: () => import('./pages/memory.js') },
{ path: '/h/cron', loader: () => import('./pages/cron.js') },
{ path: '/h/extensions', loader: () => import('./pages/extensions.js') },
{ path: '/h/lazy-deps', loader: () => import('./pages/lazy-deps.js') },
{ path: '/h/services', loader: () => import('./pages/services.js') },
{ path: '/h/config', loader: () => import('./pages/config.js') },
{ path: '/h/channels', loader: () => import('./pages/channels.js') },

View File

@@ -0,0 +1,200 @@
/**
* 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'
// feature 分类配置(决定分组顺序 + 图标 + 文案)
const CATEGORIES = [
{ prefix: 'platform.', emoji: '💬', titleKey: 'hermesLazyDeps.catPlatform' },
{ prefix: 'tts.', emoji: '🔊', titleKey: 'hermesLazyDeps.catTts' },
{ prefix: 'stt.', emoji: '🎙️', titleKey: 'hermesLazyDeps.catStt' },
{ prefix: 'search.', emoji: '🔍', titleKey: 'hermesLazyDeps.catSearch' },
{ prefix: 'provider.', emoji: '🧠', titleKey: 'hermesLazyDeps.catProvider' },
{ prefix: 'memory.', emoji: '🗂️', titleKey: 'hermesLazyDeps.catMemory' },
{ prefix: 'image.', emoji: '🎨', 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: '', emoji: '🧩', 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(t('hermesLazyDeps.loadFailed'))}: ${escapeHtml(String(e))}</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">📦</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="font-size:18px;line-height:1">${group.emoji}</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">✓ ${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) }