mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 20:30:00 +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:
@@ -7159,6 +7159,41 @@ const handlers = {
|
||||
return { model: displayModel, model_raw: modelName, base_url: baseUrl, provider, api_key: apiKey, config_exists: fs.existsSync(configPath) }
|
||||
},
|
||||
|
||||
// P1-3 lazy_deps: Web 模式下不能调 venv python,但仍提供 feature 列表 + 提示用户走桌面端装
|
||||
hermes_lazy_deps_features() {
|
||||
const features = [
|
||||
{ feature: 'platform.telegram', specs: ['python-telegram-bot[webhooks]==22.6'] },
|
||||
{ feature: 'platform.discord', specs: ['discord.py[voice]==2.7.1'] },
|
||||
{ feature: 'platform.slack', specs: ['slack-bolt==1.27.0', 'slack-sdk==3.40.1', 'aiohttp==3.13.3'] },
|
||||
{ feature: 'platform.matrix', specs: ['matrix-nio[e2e]'] },
|
||||
{ feature: 'platform.dingtalk', specs: ['dingtalk-stream'] },
|
||||
{ feature: 'platform.feishu', specs: ['lark-oapi'] },
|
||||
{ feature: 'tts.edge', specs: ['edge-tts==7.2.7'] },
|
||||
{ feature: 'tts.elevenlabs', specs: ['elevenlabs==1.59.0'] },
|
||||
{ feature: 'stt.faster_whisper', specs: ['faster-whisper==1.2.1', 'sounddevice==0.5.5', 'numpy==2.4.3'] },
|
||||
{ feature: 'search.exa', specs: ['exa-py==2.10.2'] },
|
||||
{ feature: 'search.firecrawl', specs: ['firecrawl-py==4.17.0'] },
|
||||
{ feature: 'search.parallel', specs: ['parallel-web==0.4.2'] },
|
||||
{ feature: 'provider.anthropic', specs: ['anthropic==0.86.0'] },
|
||||
{ feature: 'provider.bedrock', specs: ['boto3==1.42.89'] },
|
||||
{ feature: 'memory.honcho', specs: ['honcho-ai==2.0.1'] },
|
||||
{ feature: 'memory.hindsight', specs: ['hindsight-client==0.6.1'] },
|
||||
{ feature: 'image.fal', specs: ['fal-client==0.13.1'] },
|
||||
]
|
||||
return { ok: true, features }
|
||||
},
|
||||
|
||||
hermes_lazy_deps_status({ features }) {
|
||||
// Web 模式无法实际查询 venv,全部标 unknown
|
||||
const status = {}
|
||||
for (const f of features || []) status[f] = { known: true, satisfied: false, missing: [] }
|
||||
return { ok: true, status }
|
||||
},
|
||||
|
||||
hermes_lazy_deps_ensure({ feature }) {
|
||||
return { ok: false, error: `Web 模式下无法预装依赖。请在桌面端 ClawPanel 完成 ${feature} 安装。` }
|
||||
},
|
||||
|
||||
// P1-4:完整解析 config.yaml,让前端能读 14+ 高价值字段
|
||||
// Web 模式不引入 yaml 依赖,简单返回 raw + null highlights(前端按需渲染)
|
||||
hermes_read_config_full() {
|
||||
|
||||
@@ -2150,6 +2150,141 @@ pub async fn hermes_read_config_full() -> Result<Value, String> {
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1-3: lazy_deps 预处理命令 — 让用户启用渠道时不再「首启 Gateway 卡 30 秒后崩」
|
||||
//
|
||||
// Hermes 内核 tools/lazy_deps.py 维护了一个 allowlist:每个 feature(如
|
||||
// `platform.telegram` / `tts.elevenlabs`)对应一组 PyPI 包。原本只有 Gateway
|
||||
// 启动 platform 模块时才会调 ensure() 装包,导致首次启动卡住甚至超时崩。
|
||||
//
|
||||
// 这里把 lazy_deps 暴露给 ClawPanel UI:
|
||||
// - hermes_lazy_deps_features() — 列所有可装的 feature(小白选)
|
||||
// - hermes_lazy_deps_status(features) — 批量查每个 feature 是否已安装
|
||||
// - hermes_lazy_deps_ensure(feature) — 主动预装
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 找到 Hermes venv 的 Python 解释器路径
|
||||
fn hermes_venv_python() -> Option<PathBuf> {
|
||||
let venv_dir = dirs::home_dir()?.join(".hermes-venv");
|
||||
#[cfg(target_os = "windows")]
|
||||
let py = venv_dir.join("Scripts").join("python.exe");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let py = venv_dir.join("bin").join("python");
|
||||
if py.exists() {
|
||||
Some(py)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一跑 venv python -c "<script>" 拿 JSON 结果。失败给可读错误。
|
||||
async fn run_venv_python_json(script: &str) -> Result<Value, String> {
|
||||
let py = hermes_venv_python().ok_or_else(|| {
|
||||
"Hermes venv 未找到(~/.hermes-venv 不存在)。请先安装 Hermes。".to_string()
|
||||
})?;
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&py);
|
||||
cmd.arg("-c").arg(script);
|
||||
cmd.env("PYTHONIOENCODING", "utf-8");
|
||||
cmd.env("PATH", super::enhanced_path());
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("启动 Python 子进程失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let stderr_trimmed = stderr.trim();
|
||||
return Err(if stderr_trimmed.is_empty() {
|
||||
format!("Python 进程退出码 {},无 stderr 输出", output.status)
|
||||
} else {
|
||||
stderr_trimmed.to_string()
|
||||
});
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
// 取最后一行 JSON(避免被 Python 模块的 print 干扰)
|
||||
let last_line = stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|l| !l.trim().is_empty())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
serde_json::from_str(last_line)
|
||||
.map_err(|e| format!("Python 输出解析失败: {e}\n原文: {stdout}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_lazy_deps_features() -> Result<Value, String> {
|
||||
let script = r#"
|
||||
import json
|
||||
try:
|
||||
from tools.lazy_deps import LAZY_DEPS
|
||||
out = []
|
||||
for feat, specs in LAZY_DEPS.items():
|
||||
out.append({"feature": feat, "specs": list(specs)})
|
||||
print(json.dumps({"ok": True, "features": out}))
|
||||
except Exception as e:
|
||||
print(json.dumps({"ok": False, "error": str(e)}))
|
||||
"#;
|
||||
run_venv_python_json(script).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_lazy_deps_status(features: Vec<String>) -> Result<Value, String> {
|
||||
// 把 features 列表序列化成 Python 合法的列表字面量
|
||||
// serde_json 的输出(如 ["platform.telegram","platform.discord"])正好是 Python 合法字面量
|
||||
let features_literal = serde_json::to_string(&features)
|
||||
.map_err(|e| format!("features 序列化失败: {e}"))?;
|
||||
let script = format!(
|
||||
r#"
|
||||
import json
|
||||
try:
|
||||
from tools.lazy_deps import feature_missing, LAZY_DEPS
|
||||
targets = {features_literal}
|
||||
result = {{}}
|
||||
for f in targets:
|
||||
if f not in LAZY_DEPS:
|
||||
result[f] = {{"known": False, "satisfied": False, "missing": []}}
|
||||
continue
|
||||
miss = list(feature_missing(f))
|
||||
result[f] = {{"known": True, "satisfied": len(miss) == 0, "missing": miss}}
|
||||
print(json.dumps({{"ok": True, "status": result}}))
|
||||
except Exception as e:
|
||||
print(json.dumps({{"ok": False, "error": str(e)}}))
|
||||
"#
|
||||
);
|
||||
run_venv_python_json(&script).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_lazy_deps_ensure(feature: String) -> Result<Value, String> {
|
||||
// serde_json::to_string 把字符串包成 Python 合法的字符串字面量(已含引号 + escape)
|
||||
let feature_literal = serde_json::to_string(&feature)
|
||||
.map_err(|e| format!("feature 名序列化失败: {e}"))?;
|
||||
let script = format!(
|
||||
r#"
|
||||
import json, sys
|
||||
try:
|
||||
from tools.lazy_deps import ensure, feature_missing, FeatureUnavailable
|
||||
feat = {feature_literal}
|
||||
before_missing = list(feature_missing(feat))
|
||||
if not before_missing:
|
||||
print(json.dumps({{"ok": True, "alreadySatisfied": True, "installed": []}}))
|
||||
sys.exit(0)
|
||||
try:
|
||||
ensure(feat, prompt=False)
|
||||
print(json.dumps({{"ok": True, "alreadySatisfied": False, "installed": before_missing}}))
|
||||
except FeatureUnavailable as fe:
|
||||
print(json.dumps({{"ok": False, "error": str(fe), "missing": list(getattr(fe, "missing", []))}}))
|
||||
except Exception as e:
|
||||
print(json.dumps({{"ok": False, "error": str(e)}}))
|
||||
"#
|
||||
);
|
||||
run_venv_python_json(&script).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hermes_fetch_models — 从 API 获取模型列表(后端代理,避免 CORS)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -241,6 +241,9 @@ pub fn run() {
|
||||
hermes::hermes_agent_run,
|
||||
hermes::hermes_read_config,
|
||||
hermes::hermes_read_config_full,
|
||||
hermes::hermes_lazy_deps_features,
|
||||
hermes::hermes_lazy_deps_status,
|
||||
hermes::hermes_lazy_deps_ensure,
|
||||
hermes::hermes_fetch_models,
|
||||
hermes::hermes_update_model,
|
||||
hermes::hermes_detect_environments,
|
||||
|
||||
@@ -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) }
|
||||
@@ -477,6 +477,9 @@ export const api = {
|
||||
hermesAgentRunStream: (input, sessionId, conversationHistory, instructions, onEvent, options) => webStreamInvoke('hermes_agent_run_stream', { input, sessionId: sessionId || null, conversationHistory: conversationHistory || null, instructions: instructions || null }, onEvent, options),
|
||||
hermesReadConfig: () => invoke('hermes_read_config'),
|
||||
hermesReadConfigFull: () => invoke('hermes_read_config_full'),
|
||||
hermesLazyDepsFeatures: () => cachedInvoke('hermes_lazy_deps_features', {}, 600000),
|
||||
hermesLazyDepsStatus: (features) => invoke('hermes_lazy_deps_status', { features }),
|
||||
hermesLazyDepsEnsure: (feature) => invoke('hermes_lazy_deps_ensure', { feature }),
|
||||
hermesFetchModels: (baseUrl, apiKey, apiType, provider) => invoke('hermes_fetch_models', { baseUrl, apiKey, apiType: apiType || null, provider: provider || null }),
|
||||
hermesUpdateModel: (model, provider) => invoke('hermes_update_model', { model, provider: provider || null }),
|
||||
hermesListProviders: () => cachedInvoke('hermes_list_providers', {}, 600000),
|
||||
|
||||
@@ -38,13 +38,14 @@ import engine from './modules/engine.js'
|
||||
import ciaoBug from './modules/ciaoBug.js'
|
||||
import cliConflict from './modules/cliConflict.js'
|
||||
import glossary from './modules/glossary.js'
|
||||
import hermesLazyDeps from './modules/hermesLazyDeps.js'
|
||||
|
||||
const MODULES = {
|
||||
common, sidebar, instance, dashboard, services, settings,
|
||||
models, agents, agentDetail, gateway, security, communication, channels,
|
||||
memory, dreaming, cron, usage, skills, chat, chatDebug, setup, about,
|
||||
ext, logs, assistant, toast, modal, engagement, diagnose, routeMap, extensions,
|
||||
engine, ciaoBug, cliConflict, glossary,
|
||||
engine, ciaoBug, cliConflict, glossary, hermesLazyDeps,
|
||||
}
|
||||
|
||||
/** 构建所有语言字典 { 'zh-CN': { common: {...}, sidebar: {...}, ... }, ... } */
|
||||
|
||||
99
src/locales/modules/hermesLazyDeps.js
Normal file
99
src/locales/modules/hermesLazyDeps.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Hermes lazy_deps 依赖管理 i18n(P1-3)
|
||||
*
|
||||
* 11 语言全覆盖(其它语言 fallback 到 en)。
|
||||
*/
|
||||
import { _ } from '../helper.js'
|
||||
|
||||
export default {
|
||||
title: _(
|
||||
'可选依赖管理',
|
||||
'Optional Dependencies',
|
||||
'可選相依套件',
|
||||
'オプション依存関係',
|
||||
'선택적 의존성',
|
||||
'Phụ thuộc tùy chọn',
|
||||
'Dependencias opcionales',
|
||||
'Dependências opcionais',
|
||||
'Дополнительные зависимости',
|
||||
'Dépendances optionnelles',
|
||||
'Optionale Abhängigkeiten'
|
||||
),
|
||||
desc: _(
|
||||
'渠道(Telegram / Discord 等)、TTS / STT、搜索引擎等需要单独的 PyPI 包。点「装」一次性预装好,避免启动 Gateway 时卡住。',
|
||||
'Channels (Telegram / Discord, etc.), TTS / STT, search backends require additional PyPI packages. Click "Install" to pre-install them once and avoid getting stuck when starting the Gateway.',
|
||||
'頻道(Telegram / Discord 等)、TTS / STT、搜尋引擎等需要單獨的 PyPI 套件。點「安裝」一次性預裝好,避免啟動 Gateway 時卡住。',
|
||||
'チャンネル(Telegram / Discord 等)、TTS / STT、検索バックエンドには個別の PyPI パッケージが必要です。「インストール」をクリックして事前にインストールし、Gateway 起動時の停滞を回避しましょう。',
|
||||
'채널(Telegram / Discord 등), TTS / STT, 검색 백엔드는 별도의 PyPI 패키지가 필요합니다. "설치"를 눌러 미리 설치하여 Gateway 시작 시 멈추는 것을 방지하세요.',
|
||||
'Các kênh (Telegram / Discord, v.v.), TTS / STT, công cụ tìm kiếm cần các gói PyPI riêng. Nhấn "Cài đặt" để cài trước, tránh kẹt khi khởi động Gateway.',
|
||||
'Los canales (Telegram / Discord, etc.), TTS / STT y motores de búsqueda requieren paquetes PyPI adicionales. Haz clic en "Instalar" para preinstalarlos y evitar que el Gateway se atasque al iniciar.',
|
||||
'Os canais (Telegram / Discord, etc.), TTS / STT e backends de busca exigem pacotes PyPI extras. Clique em "Instalar" para pré-instalar e evitar travamentos ao iniciar o Gateway.',
|
||||
'Каналы (Telegram / Discord и др.), TTS / STT, поисковые бэкенды требуют отдельных PyPI-пакетов. Нажмите «Установить», чтобы установить заранее и избежать зависания при запуске Gateway.',
|
||||
'Les canaux (Telegram / Discord, etc.), TTS / STT et moteurs de recherche nécessitent des paquets PyPI supplémentaires. Cliquez sur « Installer » pour les pré-installer et éviter le blocage au démarrage du Gateway.',
|
||||
'Kanäle (Telegram / Discord usw.), TTS / STT und Suchbackends benötigen zusätzliche PyPI-Pakete. Klicken Sie auf „Installieren", um sie vorab zu installieren und Hänger beim Start des Gateways zu vermeiden.'
|
||||
),
|
||||
refresh: _('刷新状态', 'Refresh', '重新整理', '更新', '새로고침', 'Làm mới', 'Actualizar', 'Atualizar', 'Обновить', 'Actualiser', 'Aktualisieren'),
|
||||
loadFailed: _('加载失败', 'Failed to load', '載入失敗', '読み込み失敗', '불러오기 실패', 'Tải thất bại', 'Error al cargar', 'Falha ao carregar', 'Не удалось загрузить', 'Échec du chargement', 'Laden fehlgeschlagen'),
|
||||
emptyTitle: _('暂无可装的依赖', 'No optional dependencies', '暫無可裝的相依套件', 'オプション依存関係なし', '선택적 의존성이 없습니다', 'Không có phụ thuộc tùy chọn', 'No hay dependencias opcionales', 'Nenhuma dependência opcional', 'Нет дополнительных зависимостей', 'Aucune dépendance optionnelle', 'Keine optionalen Abhängigkeiten'),
|
||||
|
||||
installed: _('已装', 'Installed', '已安裝', 'インストール済み', '설치됨', 'Đã cài', 'Instalado', 'Instalado', 'Установлено', 'Installé', 'Installiert'),
|
||||
notInstalled: _('未装', 'Not installed', '未安裝', '未インストール', '미설치', 'Chưa cài', 'No instalado', 'Não instalado', 'Не установлено', 'Non installé', 'Nicht installiert'),
|
||||
install: _('一键安装', 'Install', '一鍵安裝', 'インストール', '설치', 'Cài đặt', 'Instalar', 'Instalar', 'Установить', 'Installer', 'Installieren'),
|
||||
reinstall: _('重新安装', 'Reinstall', '重新安裝', '再インストール', '재설치', 'Cài lại', 'Reinstalar', 'Reinstalar', 'Переустановить', 'Réinstaller', 'Neu installieren'),
|
||||
installing: _('安装中', 'Installing', '安裝中', 'インストール中', '설치 중', 'Đang cài', 'Instalando', 'Instalando', 'Установка', 'Installation', 'Installation'),
|
||||
installSuccess: _(
|
||||
'已成功安装 {feature}', 'Successfully installed {feature}', '已成功安裝 {feature}', '{feature} のインストールに成功しました',
|
||||
'{feature} 설치 성공', 'Đã cài đặt {feature} thành công', 'Instalado correctamente: {feature}', '{feature} instalado com sucesso',
|
||||
'{feature} успешно установлено', '{feature} installé avec succès', '{feature} erfolgreich installiert'
|
||||
),
|
||||
installFailed: _(
|
||||
'{feature} 安装失败', 'Failed to install {feature}', '{feature} 安裝失敗', '{feature} のインストールに失敗',
|
||||
'{feature} 설치 실패', 'Cài đặt {feature} thất bại', 'Error al instalar {feature}', 'Falha ao instalar {feature}',
|
||||
'Не удалось установить {feature}', 'Échec de l\'installation de {feature}', '{feature} Installation fehlgeschlagen'
|
||||
),
|
||||
alreadyInstalled: _(
|
||||
'{feature} 已经装好了', '{feature} is already installed', '{feature} 已經裝好了', '{feature} は既にインストール済みです',
|
||||
'{feature}는 이미 설치되어 있습니다', '{feature} đã được cài', '{feature} ya está instalado', '{feature} já está instalado',
|
||||
'{feature} уже установлено', '{feature} est déjà installé', '{feature} ist bereits installiert'
|
||||
),
|
||||
installedSpecs: _(
|
||||
'已装包:{specs}', 'Installed: {specs}', '已裝套件:{specs}', 'インストール済み: {specs}',
|
||||
'설치됨: {specs}', 'Đã cài: {specs}', 'Instalados: {specs}', 'Instalados: {specs}',
|
||||
'Установлено: {specs}', 'Installé : {specs}', 'Installiert: {specs}'
|
||||
),
|
||||
missingCount: _(
|
||||
'缺 {n} 个包', 'Missing {n} package(s)', '缺 {n} 個套件', '{n} 個のパッケージが不足',
|
||||
'{n}개 패키지 누락', 'Thiếu {n} gói', 'Faltan {n} paquete(s)', 'Faltam {n} pacote(s)',
|
||||
'Не хватает {n} пакетов', 'Manque {n} paquet(s)', 'Fehlen {n} Pakete'
|
||||
),
|
||||
|
||||
catPlatform: _('消息渠道', 'Messaging Channels', '訊息頻道', 'メッセージチャネル', '메시지 채널', 'Kênh nhắn tin', 'Canales de mensajería', 'Canais de mensagens', 'Каналы обмена сообщениями', 'Canaux de messagerie', 'Nachrichtenkanäle'),
|
||||
catTts: _('语音合成 (TTS)', 'Text-to-Speech (TTS)', '語音合成 (TTS)', '音声合成 (TTS)', '음성 합성 (TTS)', 'Tổng hợp giọng nói (TTS)', 'Síntesis de voz (TTS)', 'Síntese de voz (TTS)', 'Синтез речи (TTS)', 'Synthèse vocale (TTS)', 'Sprachsynthese (TTS)'),
|
||||
catStt: _('语音识别 (STT)', 'Speech-to-Text (STT)', '語音辨識 (STT)', '音声認識 (STT)', '음성 인식 (STT)', 'Nhận dạng giọng nói (STT)', 'Reconocimiento de voz (STT)', 'Reconhecimento de voz (STT)', 'Распознавание речи (STT)', 'Reconnaissance vocale (STT)', 'Spracherkennung (STT)'),
|
||||
catSearch: _('搜索引擎', 'Search Engines', '搜尋引擎', '検索エンジン', '검색 엔진', 'Công cụ tìm kiếm', 'Motores de búsqueda', 'Motores de busca', 'Поисковые движки', 'Moteurs de recherche', 'Suchmaschinen'),
|
||||
catProvider: _('模型提供商', 'Model Providers', '模型提供商', 'モデルプロバイダー', '모델 제공자', 'Nhà cung cấp mô hình', 'Proveedores de modelos', 'Provedores de modelos', 'Поставщики моделей', 'Fournisseurs de modèles', 'Modellanbieter'),
|
||||
catMemory: _('长期记忆', 'Long-term Memory', '長期記憶', '長期記憶', '장기 기억', 'Bộ nhớ dài hạn', 'Memoria a largo plazo', 'Memória de longo prazo', 'Долгосрочная память', 'Mémoire à long terme', 'Langzeitgedächtnis'),
|
||||
catImage: _('图像生成', 'Image Generation', '影像生成', '画像生成', '이미지 생성', 'Tạo hình ảnh', 'Generación de imágenes', 'Geração de imagens', 'Генерация изображений', 'Génération d\'images', 'Bildgenerierung'),
|
||||
catOther: _('其他', 'Other', '其他', 'その他', '기타', 'Khác', 'Otro', 'Outro', 'Прочее', 'Autre', 'Andere'),
|
||||
|
||||
// 友好显示名(小白看到的不是 platform.telegram 而是「Telegram」)
|
||||
featureName: {
|
||||
'platform.telegram': _('Telegram', 'Telegram'),
|
||||
'platform.discord': _('Discord', 'Discord'),
|
||||
'platform.slack': _('Slack', 'Slack'),
|
||||
'platform.matrix': _('Matrix', 'Matrix'),
|
||||
'platform.dingtalk': _('钉钉', 'DingTalk', '釘釘'),
|
||||
'platform.feishu': _('飞书', 'Feishu (Lark)', '飛書'),
|
||||
'tts.edge': _('Edge TTS(微软)', 'Edge TTS (Microsoft)', 'Edge TTS(微軟)'),
|
||||
'tts.elevenlabs': _('ElevenLabs', 'ElevenLabs'),
|
||||
'stt.faster_whisper': _('Faster Whisper(本地)', 'Faster Whisper (local)', 'Faster Whisper(本地)'),
|
||||
'search.exa': _('Exa Search', 'Exa Search'),
|
||||
'search.firecrawl': _('Firecrawl', 'Firecrawl'),
|
||||
'search.parallel': _('Parallel.ai Web', 'Parallel.ai Web'),
|
||||
'provider.anthropic': _('Anthropic(原生 SDK)', 'Anthropic (native SDK)', 'Anthropic(原生 SDK)'),
|
||||
'provider.bedrock': _('AWS Bedrock', 'AWS Bedrock'),
|
||||
'memory.honcho': _('Honcho 记忆', 'Honcho Memory', 'Honcho 記憶'),
|
||||
'memory.hindsight': _('Hindsight 记忆', 'Hindsight Memory', 'Hindsight 記憶'),
|
||||
'image.fal': _('FAL 图像', 'FAL Image', 'FAL 影像'),
|
||||
},
|
||||
}
|
||||
@@ -473,6 +473,91 @@ mark {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
/* P1-3: lazy_deps 依赖管理页 */
|
||||
.lazy-deps-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.lazy-deps-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.lazy-deps-card:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.lazy-deps-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lazy-deps-card-title {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lazy-deps-card-meta {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono, monospace);
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
max-height: 3em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.lazy-deps-missing {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--warning, #f59e0b);
|
||||
}
|
||||
|
||||
.lazy-deps-card-actions {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.lazy-deps-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lazy-deps-badge.ok {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--success, #22c55e);
|
||||
}
|
||||
|
||||
.lazy-deps-badge.warn {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: var(--warning, #f59e0b);
|
||||
}
|
||||
|
||||
.lazy-deps-badge.unknown {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ── 新手引导卡片 ── */
|
||||
.onboarding-card {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(168, 85, 247, 0.05));
|
||||
|
||||
Reference in New Issue
Block a user