mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-08 00:59:57 +08:00
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:
@@ -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') },
|
||||
|
||||
200
src/engines/hermes/pages/lazy-deps.js
Normal file
200
src/engines/hermes/pages/lazy-deps.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Hermes lazy_deps 依赖管理(P1-3)
|
||||
*
|
||||
* 列出 Hermes 内核 LAZY_DEPS allowlist 的所有 feature(platform.* / 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
function escapeAttr(s) { return escapeHtml(s) }
|
||||
Reference in New Issue
Block a user