feat(hermes): 安装向导体验重做——实时日志、镜像配置、可操作错误与断点续行

依据上游 Hermes 源码(官方 install.sh/ps1 → uv → setup → doctor 流程)
与面板安装链路审计(18 项问题清单,见 .tmp 调研文档)实施:

- 安装输出实时流式:新增 run_install_command_streaming,uv tool/pip
  安装的 stdout/stderr 逐行实时 emit(原 wait_with_output 等进程结束
  才发日志,用户盯 3-10 分钟转圈);stderr 保留尾部 8KB 供失败诊断
- 网络与镜像 UI:安装步新增折叠区,PyPI 镜像(清华/阿里云/自定义)
  与 Git 镜像前缀直接在向导内配置并持久化(panelConfig,后端已读取)
- 失败诊断可操作:诊断关键词覆盖 PyPI 超时/SSL/代理/解析失败,
  给出镜像与代理建议;uv pip 备选安装路径同样接入诊断
- 检测门控:环境有提示项(Python 版本/Git 缺失)时停留检测页等确认,
  不再 800ms 自动跳走;全部正常仍自动进入下一步
- 断点续行:向导阶段写入 sessionStorage,重开回到上次步骤
  (以检测结果为前提校验,complete 不恢复)
- 配置步错误改内联面板,API Key 旁提示「获取模型列表」可验证 Key;
  自定义网关健康检查对 ok=false 响应视为失败

另修复模型渠道页 API 类型下拉显示 [object Object](API_TYPES 为
{value,label} 对象数组),卡片芯片同步显示可读类型名。

验证:npm build / node --test 427 通过 / cargo fmt+clippy -D warnings /
cargo test --lib 314 通过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
晴天
2026-07-05 12:07:40 -07:00
parent dba69ee0fc
commit 1dc9151b63
5 changed files with 297 additions and 71 deletions

View File

@@ -2471,7 +2471,23 @@ fn apply_git_mirror_env(cmd: &mut tokio::process::Command) {
/// 命中返回建议文案(含「可在设置页启用 Git 镜像」提示)。
fn diagnose_install_network_error(text: &str) -> Option<String> {
let lower = text.to_lowercase();
let hits = [
// PyPI / 通用网络类失败下载依赖超时、SSL、解析失败等国内环境高频
let pypi_hits = [
"pypi.org",
"files.pythonhosted.org",
"read timed out",
"operation timed out",
"request timeout",
"tls handshake",
"ssl",
"certificate",
"proxy",
"error sending request",
"failed to fetch",
"failed to download",
"name resolution",
];
let git_hits = [
"failed to connect to github.com",
"could not connect to server",
"failed to clone",
@@ -2482,15 +2498,93 @@ fn diagnose_install_network_error(text: &str) -> Option<String> {
"network is unreachable",
"could not resolve host",
];
if !hits.iter().any(|h| lower.contains(h)) {
return None;
}
Some(
"⚠ 检测到安装过程中无法访问外部 Git 服务。请任选一项重试:\
if git_hits.iter().any(|h| lower.contains(h)) {
return Some(
"⚠ 检测到安装过程中无法访问外部 Git 服务。请任选一项重试:\
\n 1) 在「设置 → 网络代理」配置可用代理后重试;\
\n 2) 在「设置 → Hermes 安装镜像」填入可用的 Git 镜像前缀。"
.to_string(),
)
.to_string(),
);
}
if pypi_hits.iter().any(|h| lower.contains(h)) {
return Some(
"⚠ 检测到下载 Python 依赖失败PyPI 网络问题)。请任选一项重试:\
\n 1) 在安装页「网络与镜像」选择国内 PyPI 镜像(清华 / 阿里云)后重试;\
\n 2) 在「设置 → 网络代理」配置可用代理后重试。"
.to_string(),
);
}
None
}
/// 以流式方式运行安装类子进程stdout / stderr 逐行实时 emit 到前端日志,
/// 返回退出状态与 stderr 尾部内容(用于失败诊断)。
/// 修复旧实现 wait_with_output 等进程结束才发日志、用户长时间只见转圈的问题
async fn run_install_command_streaming(
app: &tauri::AppHandle,
mut cmd: tokio::process::Command,
) -> Result<(std::process::ExitStatus, String), String> {
use tokio::io::{AsyncBufReadExt, BufReader};
const STDERR_KEEP_BYTES: usize = 8 * 1024;
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().map_err(|e| format!("启动安装进程失败: {e}"))?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let app_out = app.clone();
let out_task = tokio::spawn(async move {
let Some(stdout) = stdout else { return };
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
let trimmed = line.trim();
if !trimmed.is_empty() {
let _ = app_out.emit(
"hermes-install-log",
sanitize_hermes_install_output(trimmed),
);
}
}
});
let app_err = app.clone();
let err_task = tokio::spawn(async move {
let mut collected = String::new();
let Some(stderr) = stderr else {
return collected;
};
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let _ = app_err.emit(
"hermes-install-log",
sanitize_hermes_install_output(trimmed),
);
collected.push_str(trimmed);
collected.push('\n');
// 只保留尾部uv 输出可能很长,错误信息几乎都在最后
if collected.len() > STDERR_KEEP_BYTES * 2 {
let mut cut = collected.len() - STDERR_KEEP_BYTES;
while !collected.is_char_boundary(cut) {
cut += 1;
}
collected.drain(..cut);
}
}
collected
});
let status = child
.wait()
.await
.map_err(|e| format!("等待安装进程失败: {e}"))?;
let _ = out_task.await;
let stderr_text = err_task.await.unwrap_or_default();
Ok((status, stderr_text))
}
/// 通过 uv tool install 安装 Hermes Agent
@@ -2537,10 +2631,6 @@ async fn install_via_uv_tool(
#[cfg(target_os = "windows")]
cmd.creation_flags(CREATE_NO_WINDOW);
// 捕获输出
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let _ = app.emit(
"hermes-install-log",
format!(
@@ -2549,26 +2639,10 @@ async fn install_via_uv_tool(
),
);
let child = cmd.spawn().map_err(|e| format!("启动安装进程失败: {e}"))?;
let output = child
.wait_with_output()
.await
.map_err(|e| format!("等待安装进程失败: {e}"))?;
// 流式执行:安装输出逐行实时显示,不再等进程结束
let (status, stderr_text) = run_install_command_streaming(app, cmd).await?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
// 逐行输出日志
for line in stdout.lines().chain(stderr.lines()) {
if !line.trim().is_empty() {
let _ = app.emit(
"hermes-install-log",
sanitize_hermes_install_output(line.trim()),
);
}
}
if output.status.success() {
if status.success() {
let _ = app.emit("hermes-install-log", "✓ uv tool install 完成");
if crate::commands::portable::portable_context().is_some() {
let _ = app.emit(
@@ -2587,20 +2661,20 @@ async fn install_via_uv_tool(
}
Ok(())
} else {
let cleaned = sanitize_hermes_install_output(stderr.trim());
let cleaned = sanitize_hermes_install_output(stderr_text.trim());
// 命中 git/network 失败 → 在日志流尾部追加诊断 + 给最终错误消息加上提示
if let Some(hint) = diagnose_install_network_error(&cleaned) {
let _ = app.emit("hermes-install-log", &hint);
return Err(format!(
"安装失败 (exit {}): {}\n\n{}",
output.status.code().unwrap_or(-1),
status.code().unwrap_or(-1),
cleaned,
hint
));
}
Err(format!(
"安装失败 (exit {}): {}",
output.status.code().unwrap_or(-1),
status.code().unwrap_or(-1),
cleaned
))
}
@@ -2667,27 +2741,16 @@ async fn install_via_uv_pip(
#[cfg(target_os = "windows")]
pip_cmd.creation_flags(CREATE_NO_WINDOW);
let pip_out = pip_cmd
.output()
.await
.map_err(|e| format!("pip install 失败: {e}"))?;
// 流式执行pip 下载/构建输出逐行实时显示
let (pip_status, pip_stderr) = run_install_command_streaming(app, pip_cmd).await?;
let stdout = String::from_utf8_lossy(&pip_out.stdout);
let stderr = String::from_utf8_lossy(&pip_out.stderr);
for line in stdout.lines().chain(stderr.lines()) {
if !line.trim().is_empty() {
let _ = app.emit(
"hermes-install-log",
sanitize_hermes_install_output(line.trim()),
);
if !pip_status.success() {
let cleaned = sanitize_hermes_install_output(pip_stderr.trim());
if let Some(hint) = diagnose_install_network_error(&cleaned) {
let _ = app.emit("hermes-install-log", &hint);
return Err(format!("pip install 失败: {cleaned}\n\n{hint}"));
}
}
if !pip_out.status.success() {
return Err(format!(
"pip install 失败: {}",
sanitize_hermes_install_output(stderr.trim())
));
return Err(format!("pip install 失败: {cleaned}"));
}
let _ = app.emit("hermes-install-log", "✓ pip install 完成");