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

@@ -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
// ---------------------------------------------------------------------------

View File

@@ -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,