From 1dc9151b635daf7daf3cc3178ca4217a12b8366f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sun, 5 Jul 2026 12:07:40 -0700 Subject: [PATCH] =?UTF-8?q?feat(hermes):=20=E5=AE=89=E8=A3=85=E5=90=91?= =?UTF-8?q?=E5=AF=BC=E4=BD=93=E9=AA=8C=E9=87=8D=E5=81=9A=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E6=97=A5=E5=BF=97=E3=80=81=E9=95=9C=E5=83=8F?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E3=80=81=E5=8F=AF=E6=93=8D=E4=BD=9C=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E4=B8=8E=E6=96=AD=E7=82=B9=E7=BB=AD=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据上游 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 --- CHANGELOG.md | 13 +++ src-tauri/src/commands/hermes.rs | 169 ++++++++++++++++++++---------- src/engines/hermes/pages/setup.js | 163 +++++++++++++++++++++++++--- src/locales/modules/engine.js | 14 +++ src/pages/model-channels.js | 9 +- 5 files changed, 297 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b0691f..de06030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ ## [未发布 (Unreleased)] +### 改进 (Improvements) + +- **Hermes 安装过程实时可见** — 安装/升级输出改为逐行实时显示(原先要等安装进程结束才出现日志),长时间安装不再只有转圈 +- **Hermes 安装镜像一键配置** — 安装步骤新增「网络与镜像」设置:PyPI 镜像(清华 / 阿里云 / 自定义)与 Git 镜像前缀可直接在向导内选择并保存,国内网络安装成功率显著提升 +- **安装失败提示可操作** — 依赖下载失败(PyPI 超时 / SSL / 代理问题)会给出明确的镜像与代理建议,不再只显示一句"安装失败" +- **环境检测不再一闪而过** — 检测到 Python 版本、Git 缺失等提示时停留在检测页等待确认,不再 0.8 秒后自动跳走;一切正常时仍自动进入下一步 +- **安装向导断点续行** — 中途关闭面板后重新进入,会回到上次进行到的步骤(以实际检测结果为准),不必从头再走 +- **配置步骤错误内联展示** — API Key / 配置保存错误改为页面内醒目提示(原为一闪而过的 toast),并提示可用「获取模型列表」验证 Key + +### 修复 (Fixes) + +- **模型渠道 API 类型下拉显示异常** — 修复选项显示为 [object Object] 的问题,改为可读的类型名称 + ### 新功能 (Features) - **统一模型渠道** — 新增「模型渠道」页面(通用分组):一处维护模型接入配置(服务商预设 / 接口地址 / API Key / 模型列表),一键同步到 OpenClaw、Hermes 与晴辰助手,不再需要在三处重复配置 diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index ff14b36..1c4ba94 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -2471,7 +2471,23 @@ fn apply_git_mirror_env(cmd: &mut tokio::process::Command) { /// 命中返回建议文案(含「可在设置页启用 Git 镜像」提示)。 fn diagnose_install_network_error(text: &str) -> Option { 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 { "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 完成"); diff --git a/src/engines/hermes/pages/setup.js b/src/engines/hermes/pages/setup.js index cf10a9a..9310404 100644 --- a/src/engines/hermes/pages/setup.js +++ b/src/engines/hermes/pages/setup.js @@ -46,8 +46,19 @@ export function render() { let customGatewayUrl = 'http://127.0.0.1:8642' let progress = 0 let unlisten = null + // 检测完成后待跳转的目标阶段;有环境警告时不自动跳,等用户确认 + let detectDone = false + let detectTarget = 'install' + // 网络与镜像偏好(panelConfig.pypiMirror / gitMirror),安装时持久化 + let mirrorPrefs = null + let mirrorOpen = false + + const PHASE_ORDER = ['detect', 'install', 'configure', 'gateway', 'complete'] + const PHASE_STORE_KEY = 'hermes-setup-phase' function draw() { + // 记录进行到的阶段:中途关闭后重开可从这里继续(检测结果允许时) + try { sessionStorage.setItem(PHASE_STORE_KEY, phase) } catch (_) {} el.innerHTML = ` ` } + // 网络与镜像折叠区:国内用户下载依赖失败/慢的第一解法,选择后随安装持久化 + const PYPI_MIRRORS = [ + { value: '', labelKey: 'engine.installPypiOfficial' }, + { value: 'https://pypi.tuna.tsinghua.edu.cn/simple', labelKey: 'engine.installPypiTuna' }, + { value: 'https://mirrors.aliyun.com/pypi/simple/', labelKey: 'engine.installPypiAliyun' }, + { value: 'custom', labelKey: 'engine.installPypiCustomOpt' }, + ] + + function renderMirrorBox() { + const saved = mirrorPrefs?.pypiMirror || '' + const isPreset = PYPI_MIRRORS.some(m => m.value === saved && m.value !== 'custom') + const selectValue = saved === '' ? '' : (isPreset ? saved : 'custom') + return ` +
+ ${t('engine.installNetworkTitle')} +
+ + + +
${t('engine.installNetworkHint')}
+
+
` + } + // --- 配置阶段 --- function renderConfigure() { const presetBtns = renderGroupedProviderButtons() @@ -253,6 +309,7 @@ export function render() { +
${t('engine.configKeyVerifyHint')}
@@ -264,6 +321,7 @@ export function render() {
+
@@ -328,6 +386,20 @@ export function render() { } }) }) + // 检测页「继续」按钮(有环境警告时不自动跳转) + el.querySelector('.hermes-detect-continue')?.addEventListener('click', () => { + phase = detectTarget + draw() + }) + // 网络与镜像折叠区 + const mirrorBox = el.querySelector('#hm-mirror-box') + if (mirrorBox) { + mirrorBox.addEventListener('toggle', () => { mirrorOpen = mirrorBox.open }) + el.querySelector('#hm-pypi-mirror')?.addEventListener('change', (e) => { + const customRow = el.querySelector('#hm-pypi-custom-row') + if (customRow) customRow.style.display = e.target.value === 'custom' ? '' : 'none' + }) + } // 安装按钮(本地模式) el.querySelector('.hermes-install-btn')?.addEventListener('click', doInstall) // 自定义连接按钮 @@ -398,6 +470,7 @@ export function render() { // --- 检测流程 --- async function detect() { phase = 'detect' + detectDone = false draw() try { invalidate('check_hermes', 'check_python') @@ -405,19 +478,38 @@ export function render() { pyInfo = py hermesInfo = hm - draw() + // 计算目标阶段 + let target + if (hm.installed && hm.gatewayRunning) target = 'complete' + else if (hm.installed && hm.configExists) target = 'gateway' + else if (hm.installed) target = 'configure' + else target = 'install' - // 自动跳转到最合适的阶段(不自动离开向导,让用户可以查看和回退每一步) - await new Promise(r => setTimeout(r, 800)) - if (hm.installed && hm.gatewayRunning) { - phase = 'complete' - } else if (hm.installed && hm.configExists) { - phase = 'gateway' - } else if (hm.installed) { - phase = 'configure' - } else { - phase = 'install' + // 恢复上次进行到的阶段:中途关闭后重开不必从头再走 + // (仅在前提一致时生效:configure/gateway 需要已安装) + try { + const stored = sessionStorage.getItem(PHASE_STORE_KEY) + if (stored && stored !== 'complete' + && PHASE_ORDER.indexOf(stored) > PHASE_ORDER.indexOf(target) + && (stored === 'install' || hm.installed)) { + target = stored + } + } catch (_) {} + + // 环境有警告(Python 版本、Git 缺失等)且尚未安装 → 停在检测页 + // 展示结果,等用户确认后再继续,避免 800ms 后自动跳走看不清问题 + const envWarnings = !hm.installed && py + && (!py.installed || py.versionOk === false || !py.hasGit || !py.hasUv) + detectTarget = target + if (envWarnings) { + detectDone = true + draw() + return } + + draw() + await new Promise(r => setTimeout(r, 500)) + phase = target draw() } catch (e) { logs.push(`[detect error] ${e?.message || e}`) @@ -443,9 +535,9 @@ export function render() { // 保存 Gateway URL await api.hermesSetGatewayUrl(url) - // 测试连接 + // 测试连接(health 可能返回对象,ok === false 也视为失败) const health = await api.hermesHealthCheck() - if (!health) throw new Error(t('engine.installCustomNoResponse')) + if (!health || health.ok === false) throw new Error(t('engine.installCustomNoResponse')) installing = false customGatewayUrl = url @@ -461,6 +553,26 @@ export function render() { // --- 安装流程 --- async function doInstall() { + // 持久化镜像偏好(后端安装命令读取 panelConfig.pypiMirror / gitMirror) + try { + const pypiSel = el.querySelector('#hm-pypi-mirror') + if (pypiSel) { + const pypiMirror = pypiSel.value === 'custom' + ? (el.querySelector('#hm-pypi-custom')?.value?.trim() || '') + : pypiSel.value + const gitMirror = el.querySelector('#hm-git-mirror')?.value?.trim() || '' + if (pypiMirror !== (mirrorPrefs?.pypiMirror || '') || gitMirror !== (mirrorPrefs?.gitMirror || '')) { + const cfg = (await api.readPanelConfig()) || {} + cfg.pypiMirror = pypiMirror + cfg.gitMirror = gitMirror + await api.writePanelConfig(cfg) + mirrorPrefs = { pypiMirror, gitMirror } + } + } + } catch (e) { + console.warn('[hermes/setup] 保存镜像偏好失败(不阻塞安装):', e) + } + installing = true installError = null progress = 0 @@ -611,8 +723,18 @@ export function render() { const matched = inferProviderByBaseUrl(hermesProviders, baseUrl) const provider = matched?.id || 'custom' + // 错误统一用内联面板展示(与安装步一致),toast 一闪而过容易错过 + const showConfigError = (msg) => { + const errEl = el.querySelector('#hm-config-error') + if (errEl) { + errEl.textContent = msg + errEl.style.display = 'block' + } else { + toast(msg, 'error') + } + } if (!apiKey) { - toast(t('engine.installCustomEmpty') || '请输入 API Key', 'warning') + showConfigError(t('engine.configApiKeyRequired')) return } try { @@ -621,7 +743,7 @@ export function render() { await refreshHermes() } catch (e) { const msg = String(e?.message || e).replace(/^Error:\s*/, '') - toast(`${t('engine.configSaveFailed') || '配置保存失败'}: ${msg}`, 'error') + showConfigError(`${t('engine.configSaveFailed')}: ${msg}`) } } @@ -665,7 +787,7 @@ export function render() { draw() } - // 启动检测前先加载 provider registry,然后启动检测 + // 启动检测前先加载 provider registry 与镜像偏好,然后启动检测 ;(async () => { try { hermesProviders = await loadHermesProviders() @@ -673,6 +795,15 @@ export function render() { } catch (err) { console.warn('[hermes/setup] failed to load providers:', err) } + try { + const cfg = await api.readPanelConfig() + mirrorPrefs = { + pypiMirror: String(cfg?.pypiMirror || '').trim(), + gitMirror: String(cfg?.gitMirror || '').trim(), + } + // 已配置过镜像 → 默认展开折叠区,让用户看到当前生效的镜像 + if (mirrorPrefs.pypiMirror || mirrorPrefs.gitMirror) mirrorOpen = true + } catch (_) {} detect() })() diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index cf34ac5..84bc24e 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -99,6 +99,20 @@ export default { installInfoUv: _('自动下载 uv 包管理器(如未安装)', 'Auto-download uv package manager (if not installed)', '自動下載 uv 包管理器(如未安裝)'), installInfoCore: _('安装 hermes-agent 核心包', 'Install hermes-agent core package', '安裝 hermes-agent 核心包'), installInfoExtrasLater: _('扩展组件(定时任务、MCP、消息渠道等)可在安装后按需添加', 'Extensions (cron, MCP, messaging, etc.) can be added later as needed', '擴展組件(定時任務、MCP、訊息頻道等)可在安裝後按需添加'), + detectContinue: _('继续下一步', 'Continue', '繼續下一步'), + detectHasWarnings: _('检测到环境提示项,请确认后继续。缺失的 uv / Python 运行时会在安装时自动补齐,Git 缺失可能导致安装失败。', 'Environment notices detected — review them, then continue. Missing uv / Python are fetched automatically during install; a missing Git may cause install failures.', '檢測到環境提示項,請確認後繼續。缺失的 uv / Python 執行環境會在安裝時自動補齊,Git 缺失可能導致安裝失敗。'), + installNetworkTitle: _('网络与镜像(下载慢或失败时展开设置)', 'Network & mirrors (expand if downloads are slow or failing)', '網路與鏡像(下載慢或失敗時展開設定)'), + installPypiMirror: _('PyPI 镜像', 'PyPI mirror', 'PyPI 鏡像'), + installPypiOfficial: _('官方源(默认)', 'Official (default)', '官方源(預設)'), + installPypiTuna: _('清华大学镜像(国内推荐)', 'Tsinghua mirror (recommended in CN)', '清華大學鏡像(中國大陸推薦)'), + installPypiAliyun: _('阿里云镜像', 'Aliyun mirror', '阿里雲鏡像'), + installPypiCustomOpt: _('自定义…', 'Custom…', '自訂…'), + installPypiCustomLabel: _('自定义镜像地址', 'Custom mirror URL', '自訂鏡像位址'), + installGitMirror: _('Git 镜像前缀(可选)', 'Git mirror prefix (optional)', 'Git 鏡像前綴(可選)'), + installNetworkHint: _('依赖下载失败或速度慢时,选择国内镜像后重试。设置会保存,下次安装/升级自动生效。', 'If dependency downloads fail or crawl, pick a mirror and retry. The setting is saved and reused for future installs/upgrades.', '依賴下載失敗或速度慢時,選擇鏡像後重試。設定會儲存,下次安裝/升級自動生效。'), + configKeyVerifyHint: _('提示:点「获取模型列表」可顺便验证 Key 是否有效', 'Tip: "Fetch models" also verifies that your key works', '提示:點「獲取模型清單」可順便驗證 Key 是否有效'), + configApiKeyRequired: _('请输入 API Key', 'Please enter an API key', '請輸入 API Key'), + configSaveFailed: _('配置保存失败', 'Failed to save configuration', '設定儲存失敗'), installModeLocal: _('本地', 'Local', '本地'), installModeCustom: _('自定义', 'Custom', '自訂'), installCustomDesc: _('连接到已有的 Hermes Agent Gateway 实例,适用于已在其他机器或手动安装的场景。', 'Connect to an existing Hermes Agent Gateway instance, for setups on other machines or manual installations.', '連接到已有的 Hermes Agent Gateway 實例,適用於已在其他機器或手動安裝的場景。'), diff --git a/src/pages/model-channels.js b/src/pages/model-channels.js index 7bdf452..d6dbb37 100644 --- a/src/pages/model-channels.js +++ b/src/pages/model-channels.js @@ -32,6 +32,11 @@ function newChannelId() { return `ch-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}` } +// apiType 值 → 用户可读标签(卡片与芯片展示用) +function apiTypeLabel(value) { + return API_TYPES.find(item => item.value === value)?.label || value +} + export async function render() { const page = document.createElement('div') page.className = 'page channels-hub-page' @@ -176,7 +181,7 @@ function renderChannelCard(state, channel) {
${esc(channel.baseUrl || '-')}
- ${esc(channel.apiType)} + ${esc(apiTypeLabel(channel.apiType))} ${icon('box', 11)} ${t('modelChannels.modelCount', { count: (channel.models || []).length })} ${icon('key', 11)} ${esc(keyInfo)} ${channel.defaultModel ? `${icon('check', 11)} ${esc(channel.defaultModel)}` : ''} @@ -223,7 +228,7 @@ function renderEditor(state) {