mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 04:10:00 +08:00
feat(hermes-install): diagnose network failures and add optional Git mirror (#273)
- Detect git/network failure patterns (failed to connect, could not resolve host, unable to access, etc.) in install/update output and append a clear hint pointing users to the proxy or mirror settings instead of leaving them with raw multi-line git stderr. - Add optional 'Hermes Install Mirror' setting (panelConfig.gitMirror): when set, install/upgrade injects GIT_CONFIG_COUNT/KEY_0/VALUE_0 to rewrite https://github.com/ via the mirror prefix at process scope only — the user's global ~/.gitconfig is never touched. - Surface the new mirror field in Settings (works for both engines), with zh-CN/en/zh-TW copy and a hint explaining how it interacts with the install flow.
This commit is contained in:
@@ -168,6 +168,54 @@ function sanitizeHermesInstallOutput(text = '') {
|
||||
.replaceAll('NousResearch/hermes-agent', 'hermes-agent')
|
||||
}
|
||||
|
||||
// 读取 panel config (~/.openclaw/clawpanel.json) 中的 gitMirror 前缀。
|
||||
// 为空/未设置 → 返回 '' 不启用镜像。
|
||||
function gitMirrorPrefix() {
|
||||
try {
|
||||
const cfgPath = path.join(DEFAULT_OPENCLAW_DIR, 'clawpanel.json')
|
||||
if (!fs.existsSync(cfgPath)) return ''
|
||||
const raw = fs.readFileSync(cfgPath, 'utf8')
|
||||
const cfg = JSON.parse(raw)
|
||||
const v = String(cfg?.gitMirror || '').trim()
|
||||
return v
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 返回一个 env 添加包,含 GIT_CONFIG_COUNT/KEY/VALUE 临时重写。
|
||||
// 未配置镜像 → 返回空对象。
|
||||
function gitMirrorEnv() {
|
||||
let mirror = gitMirrorPrefix()
|
||||
if (!mirror) return {}
|
||||
if (!mirror.endsWith('/')) mirror += '/'
|
||||
return {
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: `url.${mirror}https://github.com/.insteadOf`,
|
||||
GIT_CONFIG_VALUE_0: 'https://github.com/',
|
||||
}
|
||||
}
|
||||
|
||||
// 判断输出是否命中 「网络无法访问」 类失败,命中返回建议文案。
|
||||
function diagnoseHermesInstallError(text = '') {
|
||||
const lower = String(text || '').toLowerCase()
|
||||
const hits = [
|
||||
'failed to connect to github.com',
|
||||
'could not connect to server',
|
||||
'failed to clone',
|
||||
'unable to access',
|
||||
'git operation failed',
|
||||
'connection timed out',
|
||||
'connection refused',
|
||||
'network is unreachable',
|
||||
'could not resolve host',
|
||||
]
|
||||
if (!hits.some(h => lower.includes(h))) return null
|
||||
return '⚠ 检测到安装过程中无法访问外部 Git 服务。请任选一项重试:'
|
||||
+ '\n 1) 在「设置 → 网络代理」配置可用代理后重试;'
|
||||
+ '\n 2) 在「设置 → Hermes 安装镜像」填入可用的 Git 镜像前缀。'
|
||||
}
|
||||
|
||||
let _hermesGwProcess = null
|
||||
|
||||
const __dev_dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
@@ -6884,13 +6932,18 @@ const handlers = {
|
||||
? ['pip', 'install', pkg]
|
||||
: ['tool', 'install', '--force', pkg, '--python', '3.11', '--with', 'croniter']
|
||||
const result = spawnSync(uv, installArgs, {
|
||||
env: { ...process.env, PATH: hermesEnhancedPath(), GIT_TERMINAL_PROMPT: '0' },
|
||||
env: { ...process.env, PATH: hermesEnhancedPath(), GIT_TERMINAL_PROMPT: '0', ...gitMirrorEnv() },
|
||||
timeout: 600000,
|
||||
windowsHide: true,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
if (result.status !== 0) throw new Error(`安装失败: ${sanitizeHermesInstallOutput((result.stderr || '').trim())}`)
|
||||
if (result.status !== 0) {
|
||||
const cleaned = sanitizeHermesInstallOutput((result.stderr || '').trim())
|
||||
const hint = diagnoseHermesInstallError(cleaned)
|
||||
if (hint) throw new Error(`安装失败: ${cleaned}\n\n${hint}`)
|
||||
throw new Error(`安装失败: ${cleaned}`)
|
||||
}
|
||||
// 3. 验证
|
||||
const ver = runHermesSilent('hermes', ['version'])
|
||||
if (ver.ok) return ver.stdout
|
||||
@@ -8243,10 +8296,15 @@ const handlers = {
|
||||
const uv = fs.existsSync(uvPath) ? uvPath : 'uv'
|
||||
const pkg = 'hermes-agent[web] @ git+https://github.com/NousResearch/hermes-agent.git'
|
||||
const result = spawnSync(uv, ['tool', 'install', '--reinstall', pkg, '--python', '3.11', '--with', 'croniter'], {
|
||||
env: { ...process.env, PATH: hermesEnhancedPath(), GIT_TERMINAL_PROMPT: '0' },
|
||||
env: { ...process.env, PATH: hermesEnhancedPath(), GIT_TERMINAL_PROMPT: '0', ...gitMirrorEnv() },
|
||||
timeout: 600000, windowsHide: true, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
if (result.status !== 0) throw new Error(`升级失败: ${sanitizeHermesInstallOutput((result.stderr || '').trim())}`)
|
||||
if (result.status !== 0) {
|
||||
const cleaned = sanitizeHermesInstallOutput((result.stderr || '').trim())
|
||||
const hint = diagnoseHermesInstallError(cleaned)
|
||||
if (hint) throw new Error(`升级失败: ${cleaned}\n\n${hint}`)
|
||||
throw new Error(`升级失败: ${cleaned}`)
|
||||
}
|
||||
return '升级完成'
|
||||
},
|
||||
|
||||
|
||||
@@ -1393,6 +1393,61 @@ fn sanitize_hermes_install_output(text: &str) -> String {
|
||||
out.replace("NousResearch/hermes-agent", "hermes-agent")
|
||||
}
|
||||
|
||||
/// 从 panelConfig.gitMirror 读取镜像前缀(如 "https://ghproxy.com/")。
|
||||
/// 为空/未设置 → 不启用镜像。
|
||||
fn git_mirror_prefix() -> Option<String> {
|
||||
super::read_panel_config_value()
|
||||
.and_then(|v| v.get("gitMirror")?.as_str().map(String::from))
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// 给 tokio::process::Command 注入 git insteadOf 重写 env,
|
||||
/// 进程级别(不污染用户全局 ~/.gitconfig)。仅当配置了镜像时会动作。
|
||||
fn apply_git_mirror_env(cmd: &mut tokio::process::Command) {
|
||||
let Some(mirror) = git_mirror_prefix() else {
|
||||
return;
|
||||
};
|
||||
let mirror = if mirror.ends_with('/') {
|
||||
mirror
|
||||
} else {
|
||||
format!("{mirror}/")
|
||||
};
|
||||
// git 读取 GIT_CONFIG_COUNT 个临时配置项,仅影响当前进程
|
||||
cmd.env("GIT_CONFIG_COUNT", "1");
|
||||
cmd.env(
|
||||
"GIT_CONFIG_KEY_0",
|
||||
format!("url.{mirror}https://github.com/.insteadOf"),
|
||||
);
|
||||
cmd.env("GIT_CONFIG_VALUE_0", "https://github.com/");
|
||||
}
|
||||
|
||||
/// 诊断 Hermes 安装/升级输出是否命中「网络无法访问」类失败,
|
||||
/// 命中返回建议文案(含「可在设置页启用 Git 镜像」提示)。
|
||||
fn diagnose_install_network_error(text: &str) -> Option<String> {
|
||||
let lower = text.to_lowercase();
|
||||
let hits = [
|
||||
"failed to connect to github.com",
|
||||
"could not connect to server",
|
||||
"failed to clone",
|
||||
"unable to access",
|
||||
"git operation failed",
|
||||
"connection timed out",
|
||||
"connection refused",
|
||||
"network is unreachable",
|
||||
"could not resolve host",
|
||||
];
|
||||
if !hits.iter().any(|h| lower.contains(h)) {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
"⚠ 检测到安装过程中无法访问外部 Git 服务。请任选一项重试:\
|
||||
\n 1) 在「设置 → 网络代理」配置可用代理后重试;\
|
||||
\n 2) 在「设置 → Hermes 安装镜像」填入可用的 Git 镜像前缀。"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 通过 uv tool install 安装 Hermes Agent
|
||||
async fn install_via_uv_tool(
|
||||
app: &tauri::AppHandle,
|
||||
@@ -1427,6 +1482,8 @@ async fn install_via_uv_tool(
|
||||
cmd.env("PATH", hermes_enhanced_path());
|
||||
// uv 需要 git 来克隆仓库
|
||||
cmd.env("GIT_TERMINAL_PROMPT", "0");
|
||||
// 用户配置了 Git 镜像(如 ghproxy)→ 进程级注入 insteadOf 重写
|
||||
apply_git_mirror_env(&mut cmd);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
@@ -1469,10 +1526,21 @@ async fn install_via_uv_tool(
|
||||
let _ = update_cmd.output().await;
|
||||
Ok(())
|
||||
} else {
|
||||
let cleaned = sanitize_hermes_install_output(stderr.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),
|
||||
cleaned,
|
||||
hint
|
||||
));
|
||||
}
|
||||
Err(format!(
|
||||
"安装失败 (exit {}): {}",
|
||||
output.status.code().unwrap_or(-1),
|
||||
sanitize_hermes_install_output(stderr.trim())
|
||||
cleaned
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1529,6 +1597,7 @@ async fn install_via_uv_pip(
|
||||
if let Some(mirror) = pypi_mirror_url() {
|
||||
pip_cmd.args(["--index-url", &mirror]);
|
||||
}
|
||||
apply_git_mirror_env(&mut pip_cmd);
|
||||
super::apply_proxy_env_tokio(&mut pip_cmd);
|
||||
#[cfg(target_os = "windows")]
|
||||
pip_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
@@ -2805,6 +2874,7 @@ pub async fn update_hermes(app: tauri::AppHandle) -> Result<String, String> {
|
||||
if let Some(mirror) = pypi_mirror_url() {
|
||||
cmd.args(["--index-url", &mirror]);
|
||||
}
|
||||
apply_git_mirror_env(&mut cmd);
|
||||
super::apply_proxy_env_tokio(&mut cmd);
|
||||
cmd.env("PATH", hermes_enhanced_path());
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -2828,10 +2898,12 @@ pub async fn update_hermes(app: tauri::AppHandle) -> Result<String, String> {
|
||||
let _ = app.emit("hermes-install-progress", 100u32);
|
||||
Ok("升级完成".into())
|
||||
} else {
|
||||
Err(format!(
|
||||
"升级失败: {}",
|
||||
sanitize_hermes_install_output(stderr.trim())
|
||||
))
|
||||
let cleaned = sanitize_hermes_install_output(stderr.trim());
|
||||
if let Some(hint) = diagnose_install_network_error(&cleaned) {
|
||||
let _ = app.emit("hermes-install-log", &hint);
|
||||
return Err(format!("升级失败: {}\n\n{}", cleaned, hint));
|
||||
}
|
||||
Err(format!("升级失败: {}", cleaned))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,25 @@ export default {
|
||||
proxyCleared: _('网络代理已关闭', 'Network proxy disabled', '網路代理已關閉', 'ネットワークプロキシ無効化', '네트워크 프록시 비활성화됨'),
|
||||
modelProxyOn: _('模型请求将走代理', 'Model requests will use proxy', '模型請求將走代理', 'モデルリクエストはプロキシを使用します', '모델 요청이 프록시를 사용합니다'),
|
||||
modelProxyOff: _('模型请求已关闭代理', 'Model requests proxy disabled', '模型請求已關閉代理', 'モデルリクエストプロキシ無効化', '모델 요청 프록시 비활성화됨'),
|
||||
hermesMirror: _(
|
||||
'Hermes 安装镜像',
|
||||
'Hermes Install Mirror',
|
||||
'Hermes 安裝鏡像',
|
||||
'Hermes インストールミラー',
|
||||
'Hermes 설치 미러'
|
||||
),
|
||||
hermesMirrorHint: _(
|
||||
'当 Hermes Agent 安装/升级因 GitHub 网络不通失败时,在这里填入可用的 Git 镜像前缀(如 https://ghproxy.com/)。仅作为环境变量临时生效,不会修改全局 git 配置。留空 = 直接连 GitHub。',
|
||||
'When installing/upgrading Hermes Agent fails because GitHub is unreachable, set a Git mirror prefix here (e.g. https://ghproxy.com/). Applied only as a per-process environment override; your global git config is untouched. Leave empty to connect GitHub directly.',
|
||||
'當 Hermes Agent 安裝/升級因 GitHub 網路不通失敗時,在這裡填入可用的 Git 鏡像前綴(如 https://ghproxy.com/)。僅作為環境變數臨時生效,不會修改全局 git 配置。留空 = 直接連 GitHub。'
|
||||
),
|
||||
hermesMirrorPlaceholder: _(
|
||||
'https://ghproxy.com/ (留空 = 不使用镜像)',
|
||||
'https://ghproxy.com/ (leave empty to disable)',
|
||||
'https://ghproxy.com/ (留空 = 不使用鏡像)'
|
||||
),
|
||||
hermesMirrorSaved: _('Hermes 安装镜像已保存', 'Hermes install mirror saved', 'Hermes 安裝鏡像已儲存'),
|
||||
hermesMirrorCleared: _('已关闭 Hermes 安装镜像', 'Hermes install mirror disabled', '已關閉 Hermes 安裝鏡像'),
|
||||
gitPath: _('Git 可执行文件路径', 'Git Executable Path', 'Git 可執行檔路徑', 'Git 実行ファイルパス', 'Git 실행 파일 경로'),
|
||||
gitPathHint: _('自定义 Git 可执行文件路径。留空则自动从系统 PATH 中查找。当系统找不到 Git 时,可在此手动指定完整路径。', 'Custom Git executable path. Leave empty to auto-detect from system PATH. Specify the full path here if the system cannot find Git.', '自定義 Git 可執行檔路徑。留空則自動從系統 PATH 中尋找。當系統找不到 Git 時,可在此手動指定完整路徑。', 'カスタム Git 実行ファイルパス。空欄にするとシステム PATH から自動検出します。システムが Git を見つけられない場合はここにフルパスを指定してください。'),
|
||||
gitPathPlaceholder: _('留空自动检测,例如 C:\\Program Files\\Git\\cmd\\git.exe', 'Leave empty for auto-detect, e.g. C:\\Program Files\\Git\\cmd\\git.exe', '留空自動檢測,例如 /usr/local/bin/git', '空欄で自動検出、例: C:\\Program Files\\Git\\cmd\\git.exe'),
|
||||
|
||||
@@ -113,6 +113,11 @@ export async function render() {
|
||||
<div id="cli-binding-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
|
||||
</div>`}
|
||||
|
||||
<div class="config-section" id="hermes-mirror-section">
|
||||
<div class="config-section-title">${t('settings.hermesMirror')}</div>
|
||||
<div id="hermes-mirror-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="config-section" id="language-section">
|
||||
<div class="config-section-title">${t('settings.language')}</div>
|
||||
<div id="language-bar"></div>
|
||||
@@ -132,7 +137,7 @@ export async function render() {
|
||||
|
||||
async function loadAll(page) {
|
||||
const isHermes = getActiveEngineId() === 'hermes'
|
||||
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page)]
|
||||
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page), loadHermesMirror(page)]
|
||||
if (!isHermes) {
|
||||
tasks.push(loadOpenclawDir(page), loadOpenclawSearchPaths(page), loadDockerDefaults(page), loadGitPath(page), loadCliBinding(page), loadRegistry(page))
|
||||
}
|
||||
@@ -477,6 +482,12 @@ function bindEvents(page) {
|
||||
case 'reset-git-path':
|
||||
await handleResetGitPath(page)
|
||||
break
|
||||
case 'save-hermes-mirror':
|
||||
await handleSaveHermesMirror(page)
|
||||
break
|
||||
case 'reset-hermes-mirror':
|
||||
await handleResetHermesMirror(page)
|
||||
break
|
||||
case 'scan-git-paths':
|
||||
await handleScanGitPaths(page)
|
||||
break
|
||||
@@ -659,6 +670,50 @@ async function handleResetGitPath(page) {
|
||||
await loadGitPath(page)
|
||||
}
|
||||
|
||||
// ===== Hermes 安装镜像 =====
|
||||
|
||||
async function loadHermesMirror(page) {
|
||||
const bar = page.querySelector('#hermes-mirror-bar')
|
||||
if (!bar) return
|
||||
try {
|
||||
const cfg = await api.readPanelConfig()
|
||||
const value = cfg?.gitMirror || ''
|
||||
bar.innerHTML = `
|
||||
<div class="stat-card" style="padding:16px">
|
||||
<p style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:12px;line-height:1.5">${t('settings.hermesMirrorHint')}</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<input class="input" data-name="hermes-mirror" value="${escapeHtml(value)}" placeholder="${t('settings.hermesMirrorPlaceholder')}" style="flex:1;min-width:240px">
|
||||
<button class="btn btn-primary btn-sm" data-action="save-hermes-mirror">${t('common.save')}</button>
|
||||
${value ? `<button class="btn btn-secondary btn-sm" data-action="reset-hermes-mirror">${t('settings.resetDefault')}</button>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
} catch (e) {
|
||||
bar.innerHTML = `<div class="stat-card" style="padding:16px;color:var(--error)">${e}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveHermesMirror(page) {
|
||||
const input = page.querySelector('[data-name="hermes-mirror"]')
|
||||
const value = (input?.value || '').trim()
|
||||
const cfg = await api.readPanelConfig()
|
||||
if (value) {
|
||||
cfg.gitMirror = value
|
||||
} else {
|
||||
delete cfg.gitMirror
|
||||
}
|
||||
await api.writePanelConfig(cfg)
|
||||
toast(value ? t('settings.hermesMirrorSaved') : t('settings.hermesMirrorCleared'), 'success')
|
||||
await loadHermesMirror(page)
|
||||
}
|
||||
|
||||
async function handleResetHermesMirror(page) {
|
||||
const cfg = await api.readPanelConfig()
|
||||
delete cfg.gitMirror
|
||||
await api.writePanelConfig(cfg)
|
||||
toast(t('settings.hermesMirrorCleared'), 'success')
|
||||
await loadHermesMirror(page)
|
||||
}
|
||||
|
||||
// ===== CLI 绑定 =====
|
||||
|
||||
async function loadCliBinding(page) {
|
||||
|
||||
Reference in New Issue
Block a user