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

@@ -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 = `
<div class="page-header">
<h1>Hermes Agent</h1>
@@ -124,10 +135,19 @@ export function render() {
}
}
}
// 检测完成但存在环境警告 → 不自动跳转,显示结果并等用户确认继续
const continueBlock = detectDone ? `
<div style="margin-top:16px">
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:10px">${t('engine.detectHasWarnings')}</div>
<button class="btn btn-primary hermes-detect-continue">${t('engine.detectContinue')}</button>
</div>
` : ''
return `<div class="card" style="margin-bottom:16px">
<div class="card-body" style="padding:24px">
<p style="color:var(--text-secondary);line-height:1.7;margin:0 0 16px">${t('engine.hermesSetupIntro')}</p>
<div class="hermes-detect-list">${rows.join('')}</div>
${continueBlock}
</div>
</div>`
}
@@ -221,6 +241,7 @@ export function render() {
${modeSwitch}
${errorBlock}
${progressBlock}
${renderMirrorBox()}
<div style="display:flex;gap:10px;align-items:center;margin-top:16px">
<button class="btn btn-primary hermes-install-btn" ${btnDisabled}>${installError ? `${ICONS.rocket} ${t('engine.retryBtn')}` : btnText}</button>
</div>
@@ -228,6 +249,41 @@ export function render() {
</div>`
}
// 网络与镜像折叠区:国内用户下载依赖失败/慢的第一解法,选择后随安装持久化
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 `
<details id="hm-mirror-box" ${mirrorOpen ? 'open' : ''} style="margin-top:14px;border:1px solid var(--border-primary);border-radius:var(--radius-md,8px);padding:10px 14px;background:var(--bg-tertiary)">
<summary style="cursor:pointer;font-size:13px;color:var(--text-secondary);user-select:none">${t('engine.installNetworkTitle')}</summary>
<div style="display:grid;gap:10px;margin-top:12px">
<label class="hermes-field">
<span>${t('engine.installPypiMirror')}</span>
<select id="hm-pypi-mirror" class="input">
${PYPI_MIRRORS.map(m => `<option value="${m.value}" ${m.value === selectValue ? 'selected' : ''}>${t(m.labelKey)}</option>`).join('')}
</select>
</label>
<label class="hermes-field" id="hm-pypi-custom-row" style="display:${selectValue === 'custom' ? '' : 'none'}">
<span>${t('engine.installPypiCustomLabel')}</span>
<input type="text" id="hm-pypi-custom" class="input" placeholder="https://mirror.example.com/simple" value="${selectValue === 'custom' ? esc(saved) : ''}">
</label>
<label class="hermes-field">
<span>${t('engine.installGitMirror')}</span>
<input type="text" id="hm-git-mirror" class="input" placeholder="https://ghproxy.com/" value="${esc(mirrorPrefs?.gitMirror || '')}">
</label>
<div style="font-size:11px;color:var(--text-tertiary);line-height:1.6">${t('engine.installNetworkHint')}</div>
</div>
</details>`
}
// --- 配置阶段 ---
function renderConfigure() {
const presetBtns = renderGroupedProviderButtons()
@@ -253,6 +309,7 @@ export function render() {
<input type="password" id="hm-apikey" class="input" placeholder="sk-..." autocomplete="off" style="flex:1">
<button class="btn btn-sm btn-secondary hermes-fetch-models" style="white-space:nowrap;flex-shrink:0">${t('engine.configFetchModels')}</button>
</div>
<div style="font-size:11px;color:var(--text-tertiary);margin-top:4px">${t('engine.configKeyVerifyHint')}</div>
</div>
<div id="hm-fetch-result" style="font-size:12px;min-height:16px;margin:-6px 0 2px"></div>
<div class="hermes-field">
@@ -264,6 +321,7 @@ export function render() {
</div>
</div>
<div id="hm-config-error" style="display:none;margin-top:14px;padding:10px 14px;background:var(--error-bg, #fef2f2);border:1px solid var(--error, #ef4444);border-radius:var(--radius-sm,6px);color:var(--error, #ef4444);font-size:13px;line-height:1.5;word-break:break-all"></div>
<div style="display:flex;gap:10px;margin-top:20px">
<button class="btn btn-primary hermes-config-save">${t('engine.configSaveBtn')}</button>
<button class="btn-text hermes-config-skip">${t('engine.configSkipBtn')}</button>
@@ -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()
})()

View File

@@ -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 實例,適用於已在其他機器或手動安裝的場景。'),

View File

@@ -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) {
</div>
<div class="mch-url" title="${attr(channel.baseUrl)}">${esc(channel.baseUrl || '-')}</div>
<div class="mch-meta">
<span class="mch-chip">${esc(channel.apiType)}</span>
<span class="mch-chip">${esc(apiTypeLabel(channel.apiType))}</span>
<span class="mch-chip">${icon('box', 11)} ${t('modelChannels.modelCount', { count: (channel.models || []).length })}</span>
<span class="mch-chip ${channel.apiKeySaved ? '' : 'mch-chip-warn'}">${icon('key', 11)} ${esc(keyInfo)}</span>
${channel.defaultModel ? `<span class="mch-chip">${icon('check', 11)} ${esc(channel.defaultModel)}</span>` : ''}
@@ -223,7 +228,7 @@ function renderEditor(state) {
<div class="form-group">
<label class="form-label" for="mch-api-type">${t('modelChannels.apiType')}</label>
<select class="form-input" id="mch-api-type">
${API_TYPES.map(v => `<option value="${attr(v)}" ${v === draft.apiType ? 'selected' : ''}>${esc(v)}</option>`).join('')}
${API_TYPES.map(item => `<option value="${attr(item.value)}" ${item.value === draft.apiType ? 'selected' : ''}>${esc(item.label)}</option>`).join('')}
</select>
</div>
<div class="form-group">