From 3fafa0d22843031da50ec203e322a8ade22be8d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Wed, 15 Jul 2026 01:29:19 -0700 Subject: [PATCH] chore(release): prepare v0.19.0 --- CHANGELOG.md | 11 +- package-lock.json | 4 +- package.json | 2 +- scripts/dev-api.js | 141 +++++++++- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/hermes.rs | 284 ++++++++++++++++++++- src-tauri/src/commands/hermes_providers.rs | 7 +- src-tauri/tauri.conf.json | 2 +- src/engines/hermes/pages/dashboard.js | 7 - src/engines/hermes/style/hermes.css | 34 --- src/locales/modules/assistant.js | 1 - src/locales/modules/common.js | 2 +- src/locales/modules/engine.js | 1 - src/pages/chat.js | 4 +- src/pages/services.js | 2 +- tests/hermes-dashboard-ui.test.js | 9 +- tests/hermes-setup-safety.test.js | 90 +++++++ 18 files changed, 528 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e5f6ca..aac76f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ ## [未发布 (Unreleased)] -## [0.18.6] - 2026-07-14 +## [0.19.0] - 2026-07-15 ### 新功能 (Features) @@ -53,6 +53,9 @@ - **模型配置破坏未知字段** — 保存和删除 provider 改为最小补丁及显式墓碑,保留其它 provider、认证资料和上游新增字段;旧模型结构会在写入前安全迁移 - **模型渠道下拉显示 `[object Object]`** — API 类型选项改为可读名称,未知类型在编辑时仍保留原值供用户确认 - **Hermes 安装 wheel 元数据无效** — 修复旧 `uv` 缓存冲突导致 `annotated-types` 报 `Metadata field Name not found`;自动升级受管运行时并在缓存异常时透明重试 +- **Hermes 0.18.2 Windows 启动后端口未就绪** — 所有 Hermes 子进程显式继承 ClawPanel 管理的 `HERMES_HOME`,避免新版平台默认目录变化导致读取空配置;启动前自动补齐 API Server 开关和随机强密钥,并保留已有合格密钥与其它环境配置 +- **Hermes 0.18.2 自定义模型无回复** — 旧版裸 `custom` 配置自动迁移为新版命名 Provider,继续使用用户原有 Base URL 和密钥环境变量;迁移前备份 `config.yaml`,保留其它模型及高级配置,避免请求误回退到 OpenRouter 后返回 401 +- **Hermes 仪表盘误导性 9119 入口** — 隐藏默认不运行且当前安装包不提供完整页面的原生 Dashboard 外链;继续保留 ClawPanel 内置仪表盘、实时聊天及内部按需 Dashboard API 能力 - **创作中心提交后按钮一直禁用** — 后台刷新后重新定位当前表单按钮,生成完成或失败都会正确解锁;图片和视频提交不再阻塞页面等待云端任务结束 - **视频任务瞬时错误失联** — 429 / 5xx 时保留任务并继续轮询;已有服务商任务 ID 的失败任务可恢复,无法识别任务 ID 时也保留原始响应供排查 - **媒体历史膨胀与大文件卡顿** — 持久化前裁剪大体积原始响应,预览限制 64MB 并限制缓存数量;跨端视频状态解析保持一致 @@ -63,12 +66,12 @@ ### 测试与验证 (Testing) -- Node.js `22.22.3` 下 `node --test tests/*.test.js`:505 passed -- Node.js `22.22.3` 下 `npm run build`:通过 +- Node.js `24.15.0` 下 `node --test tests/*.test.js`:511 passed +- Node.js `24.15.0` 下 `npm run build`:通过 - `cargo fmt --all -- --check`:通过 - `cargo check --locked`:通过 - `cargo clippy --locked --all-targets -- -D warnings`:通过 -- `cargo test --locked`:348 passed +- `cargo test --locked`:351 passed - Hermes Windows 隔离安装 smoke:使用 `uv 0.11.14 --no-cache` 完整安装 72 个依赖,`hermes version` 返回 `v0.18.2 (2026.7.7.2)` ## [0.18.5] - 2026-06-15 diff --git a/package-lock.json b/package-lock.json index 4c95d95..6740fcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clawpanel", - "version": "0.18.6", + "version": "0.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clawpanel", - "version": "0.18.6", + "version": "0.19.0", "license": "AGPL-3.0", "dependencies": { "@tauri-apps/api": "^2.5.0", diff --git a/package.json b/package.json index 3b636c1..be5bdfc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawpanel", - "version": "0.18.6", + "version": "0.19.0", "private": true, "description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用", "type": "module", diff --git a/scripts/dev-api.js b/scripts/dev-api.js index f509839..e3dfa7c 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -171,6 +171,14 @@ function hermesEnhancedPath() { return [...extra, current].filter(Boolean).join(sep) } +export function buildHermesRuntimeEnv(baseEnv = process.env, home = hermesHome(), enhancedPath = hermesEnhancedPath()) { + return { + ...baseEnv, + PATH: enhancedPath, + HERMES_HOME: home, + } +} + function hermesGatewayPort() { const configPath = path.join(hermesHome(), 'config.yaml') try { @@ -213,7 +221,7 @@ function isLoopbackGatewayUrl(url) { function runHermesSilent(program, args) { try { const result = spawnSync(program, args, { - env: { ...process.env, PATH: hermesEnhancedPath() }, + env: buildHermesRuntimeEnv(), timeout: 15000, windowsHide: true, encoding: 'utf8', @@ -268,8 +276,7 @@ function gitMirrorEnv() { export function buildHermesInstallEnv(config = {}, baseEnv = process.env) { const env = { - ...baseEnv, - PATH: hermesEnhancedPath(), + ...buildHermesRuntimeEnv(baseEnv), GIT_TERMINAL_PROMPT: '0', } const pypiMirror = String(config?.pypiMirror || '').trim() @@ -14799,7 +14806,18 @@ const handlers = { const envKey = pcfg?.apiKeyEnvVars?.[0] || '' const urlEnv = pcfg?.baseUrlEnvVar || '' const managedKeys = handlers._hermesManagedEnvKeys() - const newPairs = [['GATEWAY_ALLOW_ALL_USERS', 'true'], ['API_SERVER_KEY', 'clawpanel-local']] + const existingEnv = fs.existsSync(path.join(home, '.env')) + ? fs.readFileSync(path.join(home, '.env'), 'utf8') + : '' + const currentApiServerKey = _envValue(existingEnv, 'API_SERVER_KEY') + const apiServerKey = _hermesApiServerKeyIsUsable(currentApiServerKey) + ? currentApiServerKey + : _generateHermesApiServerKey() + const newPairs = [ + ['GATEWAY_ALLOW_ALL_USERS', 'true'], + ['API_SERVER_ENABLED', 'true'], + ['API_SERVER_KEY', apiServerKey], + ] if (envKey && apiKey && apiKey.trim()) { newPairs.push([envKey, apiKey.trim()]) if (providerId === 'custom' && envKey !== 'CUSTOM_API_KEY') newPairs.push(['CUSTOM_API_KEY', apiKey.trim()]) @@ -14814,6 +14832,7 @@ const handlers = { envContent = newPairs.map(([k, v]) => `${k}=${v}`).join('\n') + '\n' } fs.writeFileSync(envPath, envContent) + if (providerId === 'custom') repairHermesCustomProviderRoutingAt(home) return '配置已保存' }, @@ -14829,6 +14848,7 @@ const handlers = { try { _sanitizeHermesOpenrouterCustomMismatch() } catch (e) { console.warn('[hermes guardian] provider/base_url sanitize failed:', e.message || e) } + ensureHermesApiServerRuntimeEnvAt(hermesHome()) // 检测是否已运行 const alive = await _tcpProbe('127.0.0.1', port, 300) if (alive) return 'Gateway 已在运行' @@ -14838,7 +14858,7 @@ const handlers = { if (aliveAfterWait) return 'Gateway 已在运行' // 启动 const home = hermesHome() - const envVars = { ...process.env, PATH: enhanced } + const envVars = buildHermesRuntimeEnv(process.env, home, enhanced) const envPath = path.join(home, '.env') if (fs.existsSync(envPath)) { for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { @@ -16285,6 +16305,7 @@ const handlers = { add(provider.baseUrlEnvVar) } add('GATEWAY_ALLOW_ALL_USERS') + add('API_SERVER_ENABLED') add('API_SERVER_KEY') return out }, @@ -17518,12 +17539,113 @@ function _mergeEnvFile(existing, managedKeys, newPairs) { if (eq > 0 && managedKeys.includes(t.slice(0, eq).trim())) continue result.push(line) } + while (result.length && result[result.length - 1] === '') result.pop() + if (result.length && newPairs.length) result.push('') for (const [k, v] of newPairs) result.push(`${k}=${v}`) let content = result.join('\n') if (!content.endsWith('\n')) content += '\n' return content } +function _hermesApiServerKeyIsUsable(value) { + const cleaned = String(value || '').trim() + if (cleaned.length < 16) return false + return !new Set([ + '*', '**', '***', 'changeme', 'your_api_key', 'your_api_key_here', + 'your-api-key', 'placeholder', 'example', 'dummy', 'null', 'none', + ]).has(cleaned.toLowerCase()) +} + +function _generateHermesApiServerKey() { + return crypto.randomBytes(32).toString('hex') +} + +export function ensureHermesApiServerRuntimeEnvAt(home, keyFactory = _generateHermesApiServerKey) { + fs.mkdirSync(home, { recursive: true }) + const envPath = path.join(home, '.env') + const existing = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '' + const currentKey = _envValue(existing, 'API_SERVER_KEY') + const apiServerKey = _hermesApiServerKeyIsUsable(currentKey) ? currentKey : keyFactory() + if (!_hermesApiServerKeyIsUsable(apiServerKey)) { + throw new Error('无法生成有效的 Hermes API_SERVER_KEY') + } + const merged = _mergeEnvFile( + existing, + ['API_SERVER_ENABLED', 'API_SERVER_KEY'], + [['API_SERVER_ENABLED', 'true'], ['API_SERVER_KEY', apiServerKey]], + ) + const changed = merged !== existing + if (changed) fs.writeFileSync(envPath, merged) + return { changed, apiServerKey } +} + +function _hermesCustomProviderRouting(raw, envRaw) { + const doc = YAML.parseDocument(raw) + if (doc.errors.length) throw new Error(`config.yaml YAML 格式错误: ${doc.errors[0].message}`) + const config = doc.toJS() || {} + const model = config.model && typeof config.model === 'object' ? config.model : null + if (!model) throw new Error('Hermes config.yaml 缺少 model 配置') + const provider = String(model.provider || '').trim().toLowerCase() + if (!['custom', 'openrouter', 'custom:clawpanel'].includes(provider)) { + return { changed: false, content: raw } + } + const configuredBaseUrl = String(model.base_url || '').trim() + if (provider === 'openrouter' && !configuredBaseUrl) { + return { changed: false, content: raw } + } + const baseUrl = String( + configuredBaseUrl || _envValue(envRaw, 'CUSTOM_BASE_URL') || _envValue(envRaw, 'OPENAI_BASE_URL') || '', + ).trim().replace(/\/+$/, '') + const normalized = _normalizeProviderUrl(baseUrl) + if (!baseUrl + || normalized === _normalizeProviderUrl('https://openrouter.ai/api/v1') + || normalized === _normalizeProviderUrl('https://chatgpt.com/backend-api/codex')) { + return { changed: false, content: raw } + } + const keyEnv = _envHasValue(envRaw, 'CUSTOM_API_KEY') + ? 'CUSTOM_API_KEY' + : _envHasValue(envRaw, 'OPENAI_API_KEY') ? 'OPENAI_API_KEY' : 'CUSTOM_API_KEY' + const desired = { + provider: 'custom:clawpanel', + baseUrl, + name: 'ClawPanel Custom', + keyEnv, + defaultModel: String(model.default || model.model || '').trim(), + apiMode: 'chat_completions', + } + const current = config.providers?.clawpanel || {} + const alreadyCompatible = model.provider === desired.provider + && model.base_url === desired.baseUrl + && current.name === desired.name + && current.base_url === desired.baseUrl + && current.key_env === desired.keyEnv + && current.default_model === desired.defaultModel + && current.api_mode === desired.apiMode + if (alreadyCompatible) return { changed: false, content: raw } + + doc.setIn(['model', 'provider'], desired.provider) + doc.setIn(['model', 'base_url'], desired.baseUrl) + doc.setIn(['providers', 'clawpanel', 'name'], desired.name) + doc.setIn(['providers', 'clawpanel', 'base_url'], desired.baseUrl) + doc.setIn(['providers', 'clawpanel', 'key_env'], desired.keyEnv) + if (desired.defaultModel) doc.setIn(['providers', 'clawpanel', 'default_model'], desired.defaultModel) + doc.setIn(['providers', 'clawpanel', 'api_mode'], desired.apiMode) + return { changed: true, content: String(doc) } +} + +export function repairHermesCustomProviderRoutingAt(home) { + const configPath = path.join(home, 'config.yaml') + if (!fs.existsSync(configPath)) return { changed: false } + const envPath = path.join(home, '.env') + const raw = fs.readFileSync(configPath, 'utf8') + const envRaw = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '' + const result = _hermesCustomProviderRouting(raw, envRaw) + if (!result.changed) return { changed: false } + fs.copyFileSync(configPath, `${configPath}.bak`) + fs.writeFileSync(configPath, result.content) + return { changed: true } +} + function _normalizeProviderUrl(raw) { let out = String(raw || '').trim().replace(/\/+$/, '').toLowerCase() for (const suffix of ['/chat/completions', '/completions', '/responses', '/messages', '/models']) { @@ -17601,8 +17723,12 @@ function _sanitizeHermesOpenrouterCustomMismatch() { const expected = _normalizeProviderUrl('https://openrouter.ai/api/v1') const usesCustomEndpoint = base && base !== expected const aliasChanged = (!provider || provider === 'custom' || usesCustomEndpoint) ? _ensureCustomOpenAIKeyAlias() : false - if (!usesCustomEndpoint) return aliasChanged - if (provider === 'custom') return aliasChanged + if (!usesCustomEndpoint) { + return aliasChanged || repairHermesCustomProviderRoutingAt(home).changed + } + if (provider === 'custom') { + return aliasChanged || repairHermesCustomProviderRoutingAt(home).changed + } const out = [] inModel = false let providerWritten = false @@ -17635,6 +17761,7 @@ function _sanitizeHermesOpenrouterCustomMismatch() { let fixed = out.join('\n') if (!fixed.endsWith('\n')) fixed += '\n' fs.writeFileSync(configPath, fixed) + repairHermesCustomProviderRoutingAt(home) return true } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index eafc9c1..81e5a28 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -366,7 +366,7 @@ dependencies = [ [[package]] name = "clawpanel" -version = "0.18.6" +version = "0.19.0" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 895c16b..d0efe18 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clawpanel" -version = "0.18.6" +version = "0.19.0" edition = "2021" description = "ClawPanel - OpenClaw 可视化管理面板" authors = ["qingchencloud"] diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index c708323..161494f 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -540,6 +540,190 @@ fn ensure_custom_openai_key_alias() -> Result { Ok(true) } +fn hermes_api_server_key_is_usable(value: &str) -> bool { + let cleaned = value.trim(); + if cleaned.len() < 16 { + return false; + } + !matches!( + cleaned.to_ascii_lowercase().as_str(), + "*" | "**" + | "***" + | "changeme" + | "your_api_key" + | "your_api_key_here" + | "your-api-key" + | "placeholder" + | "example" + | "dummy" + | "null" + | "none" + ) +} + +fn generate_hermes_api_server_key() -> String { + use rand::RngCore; + + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn ensure_hermes_api_server_runtime_env() -> Result { + let home = hermes_home(); + std::fs::create_dir_all(&home).map_err(|e| format!("创建 Hermes 配置目录失败: {e}"))?; + let env_path = home.join(".env"); + let existing = std::fs::read_to_string(&env_path).unwrap_or_default(); + let current_key = env_file_value(&existing, "API_SERVER_KEY").unwrap_or_default(); + let api_server_key = if hermes_api_server_key_is_usable(¤t_key) { + current_key + } else { + generate_hermes_api_server_key() + }; + let merged = merge_env_file( + &existing, + &["API_SERVER_ENABLED", "API_SERVER_KEY"], + &[ + ("API_SERVER_ENABLED".into(), "true".into()), + ("API_SERVER_KEY".into(), api_server_key), + ], + ); + if merged == existing { + return Ok(false); + } + std::fs::write(&env_path, merged).map_err(|e| format!("写入 Hermes .env 失败: {e}"))?; + Ok(true) +} + +fn apply_hermes_custom_provider_routing( + config: &mut serde_yaml::Value, + env_raw: &str, +) -> Result { + let before = config.clone(); + let root = config + .as_mapping() + .ok_or("Hermes config.yaml 顶层必须是对象")?; + let model = root + .get(yaml_key("model")) + .and_then(|value| value.as_mapping()) + .ok_or("Hermes config.yaml 缺少 model 配置")?; + let provider = yaml_string_field(model, "provider") + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + if !matches!( + provider.as_str(), + "custom" | "openrouter" | "custom:clawpanel" + ) { + return Ok(false); + } + + let configured_base_url = yaml_string_field(model, "base_url").unwrap_or_default(); + if provider == "openrouter" && configured_base_url.trim().is_empty() { + return Ok(false); + } + let base_url = if configured_base_url.trim().is_empty() { + env_file_value(env_raw, "CUSTOM_BASE_URL") + .or_else(|| env_file_value(env_raw, "OPENAI_BASE_URL")) + .unwrap_or_default() + } else { + configured_base_url + }; + let base_url = base_url.trim().trim_end_matches('/').to_string(); + if base_url.is_empty() + || is_openai_codex_endpoint(&base_url) + || normalize_provider_url(&base_url) + == normalize_provider_url("https://openrouter.ai/api/v1") + { + return Ok(false); + } + + let model_default = yaml_string_field(model, "default") + .or_else(|| yaml_string_field(model, "model")) + .unwrap_or_default(); + let key_env = if env_file_has_value(env_raw, "CUSTOM_API_KEY") { + "CUSTOM_API_KEY" + } else if env_file_has_value(env_raw, "OPENAI_API_KEY") { + "OPENAI_API_KEY" + } else { + "CUSTOM_API_KEY" + }; + + let root = ensure_yaml_object(config)?; + let mut model = root + .get(yaml_key("model")) + .and_then(|value| value.as_mapping()) + .cloned() + .unwrap_or_default(); + model.insert( + yaml_key("provider"), + serde_yaml::Value::String("custom:clawpanel".into()), + ); + model.insert( + yaml_key("base_url"), + serde_yaml::Value::String(base_url.clone()), + ); + root.insert(yaml_key("model"), serde_yaml::Value::Mapping(model)); + + let mut providers = root + .get(yaml_key("providers")) + .and_then(|value| value.as_mapping()) + .cloned() + .unwrap_or_default(); + let mut entry = providers + .get(yaml_key("clawpanel")) + .and_then(|value| value.as_mapping()) + .cloned() + .unwrap_or_default(); + entry.insert( + yaml_key("name"), + serde_yaml::Value::String("ClawPanel Custom".into()), + ); + entry.insert(yaml_key("base_url"), serde_yaml::Value::String(base_url)); + entry.insert( + yaml_key("key_env"), + serde_yaml::Value::String(key_env.into()), + ); + if !model_default.trim().is_empty() { + entry.insert( + yaml_key("default_model"), + serde_yaml::Value::String(model_default.trim().into()), + ); + } + entry.insert( + yaml_key("api_mode"), + serde_yaml::Value::String("chat_completions".into()), + ); + providers.insert(yaml_key("clawpanel"), serde_yaml::Value::Mapping(entry)); + root.insert(yaml_key("providers"), serde_yaml::Value::Mapping(providers)); + + Ok(*config != before) +} + +fn repair_hermes_custom_provider_routing_at( + config_path: &Path, + env_path: &Path, +) -> Result { + if !config_path.exists() { + return Ok(false); + } + let raw = std::fs::read_to_string(config_path) + .map_err(|e| format!("读取 Hermes config.yaml 失败: {e}"))?; + let env_raw = std::fs::read_to_string(env_path).unwrap_or_default(); + let mut config: serde_yaml::Value = + serde_yaml::from_str(&raw).map_err(|e| format!("config.yaml YAML 格式错误: {e}"))?; + if !apply_hermes_custom_provider_routing(&mut config, &env_raw)? { + return Ok(false); + } + let fixed = serde_yaml::to_string(&config) + .map_err(|e| format!("序列化 Hermes config.yaml 失败: {e}"))?; + let backup = hermes_stable_backup_path(config_path); + std::fs::copy(config_path, &backup) + .map_err(|e| format!("备份 Hermes config.yaml 失败: {e}"))?; + std::fs::write(config_path, fixed).map_err(|e| format!("写入 Hermes config.yaml 失败: {e}"))?; + Ok(true) +} + fn sanitize_hermes_openrouter_custom_mismatch() -> Result { let home = hermes_home(); let config_path = home.join("config.yaml"); @@ -547,6 +731,7 @@ fn sanitize_hermes_openrouter_custom_mismatch() -> Result { return Ok(false); } let changed = sanitize_hermes_openrouter_custom_mismatch_at(&config_path)?; + let route_changed = repair_hermes_custom_provider_routing_at(&config_path, &home.join(".env"))?; let raw = std::fs::read_to_string(config_path).map_err(|e| format!("读取 config.yaml 失败: {e}"))?; let fields = read_top_level_hermes_model_fields(&raw)?; @@ -555,7 +740,7 @@ fn sanitize_hermes_openrouter_custom_mismatch() -> Result { } else { false }; - Ok(changed || alias_changed) + Ok(changed || route_changed || alias_changed) } fn sanitize_hermes_openrouter_custom_mismatch_at( @@ -592,6 +777,7 @@ fn sanitize_hermes_openrouter_custom_mismatch_at( /// 重启 Gateway(kill 旧进程 → 启动新进程) async fn do_restart_gateway() -> Result<(), String> { + ensure_hermes_api_server_runtime_env()?; // 1. 杀掉旧进程 kill_gateway_pid(); tokio::time::sleep(std::time::Duration::from_millis(500)).await; @@ -985,6 +1171,7 @@ fn append_existing_path(extra: &mut Vec, path: impl AsRef) { fn apply_hermes_runtime_env(cmd: &mut Command, path: &str) { cmd.env("PATH", path); + cmd.env("HERMES_HOME", hermes_home()); if let Some(ctx) = crate::commands::portable::portable_context() { let tool_bin_dir = hermes_tool_bin_dir().unwrap_or_else(|| ctx.engines_hermes_dir.join("bin")); @@ -992,7 +1179,6 @@ fn apply_hermes_runtime_env(cmd: &mut Command, path: &str) { .unwrap_or_else(|| ctx.root.join("runtimes").join("uv").join("cache")); let python_dir = hermes_uv_python_dir() .unwrap_or_else(|| ctx.root.join("runtimes").join("uv").join("python")); - cmd.env("HERMES_HOME", &ctx.hermes_home); cmd.env("UV_TOOL_DIR", &ctx.engines_hermes_dir); cmd.env("UV_TOOL_BIN_DIR", tool_bin_dir); cmd.env("UV_CACHE_DIR", cache_dir); @@ -1003,6 +1189,7 @@ fn apply_hermes_runtime_env(cmd: &mut Command, path: &str) { fn apply_hermes_runtime_env_tokio(cmd: &mut tokio::process::Command, path: &str) { cmd.env("PATH", path); + cmd.env("HERMES_HOME", hermes_home()); if let Some(ctx) = crate::commands::portable::portable_context() { let tool_bin_dir = hermes_tool_bin_dir().unwrap_or_else(|| ctx.engines_hermes_dir.join("bin")); @@ -1010,7 +1197,6 @@ fn apply_hermes_runtime_env_tokio(cmd: &mut tokio::process::Command, path: &str) .unwrap_or_else(|| ctx.root.join("runtimes").join("uv").join("cache")); let python_dir = hermes_uv_python_dir() .unwrap_or_else(|| ctx.root.join("runtimes").join("uv").join("python")); - cmd.env("HERMES_HOME", &ctx.hermes_home); cmd.env("UV_TOOL_DIR", &ctx.engines_hermes_dir); cmd.env("UV_TOOL_BIN_DIR", tool_bin_dir); cmd.env("UV_CACHE_DIR", cache_dir); @@ -3044,9 +3230,19 @@ platforms: let managed_keys_owned = hermes_providers::all_managed_env_keys(); let managed_keys: Vec<&str> = managed_keys_owned.to_vec(); + let env_path = home.join(".env"); + let existing_env = std::fs::read_to_string(&env_path).unwrap_or_default(); + let current_api_server_key = + env_file_value(&existing_env, "API_SERVER_KEY").unwrap_or_default(); + let api_server_key = if hermes_api_server_key_is_usable(¤t_api_server_key) { + current_api_server_key + } else { + generate_hermes_api_server_key() + }; let mut new_pairs: Vec<(String, String)> = vec![ ("GATEWAY_ALLOW_ALL_USERS".into(), "true".into()), - ("API_SERVER_KEY".into(), "clawpanel-local".into()), + ("API_SERVER_ENABLED".into(), "true".into()), + ("API_SERVER_KEY".into(), api_server_key), ]; if let Some(env) = key_env { @@ -3068,7 +3264,6 @@ platforms: } } - let env_path = home.join(".env"); let env_content = if env_path.exists() { let existing = std::fs::read_to_string(&env_path).unwrap_or_default(); merge_env_file(&existing, &managed_keys, &new_pairs) @@ -3082,6 +3277,10 @@ platforms: }; std::fs::write(&env_path, &env_content).map_err(|e| format!("写入 .env 失败: {e}"))?; + if provider == "custom" { + repair_hermes_custom_provider_routing_at(&config_path, &env_path)?; + } + // Unix: 设置 .env 文件权限为 600 #[cfg(not(target_os = "windows"))] { @@ -14153,6 +14352,7 @@ pub async fn hermes_gateway_action( // before every start. Auto-heal if missing (with a .bak backup). // See `ensure_api_server_enabled` for rationale. ensure_api_server_enabled(&app)?; + ensure_hermes_api_server_runtime_env()?; let _ = sanitize_hermes_openrouter_custom_mismatch()?; if gateway_quick_health_check().await { start_guardian(&app); @@ -18611,8 +18811,8 @@ model: #[cfg(test)] mod hermes_sanitizer_tests { use super::{ - normalize_hermes_provider_for_base_url, sanitize_hermes_openrouter_custom_mismatch_at, - yaml_key, + apply_hermes_custom_provider_routing, normalize_hermes_provider_for_base_url, + sanitize_hermes_openrouter_custom_mismatch_at, yaml_key, }; use std::fs; use std::path::PathBuf; @@ -18770,6 +18970,59 @@ auxiliary: assert!(err.contains("config.yaml YAML 格式错误")); assert_eq!(fs::read_to_string(&path).unwrap(), raw); } + + #[test] + fn legacy_custom_env_route_becomes_named_provider_without_losing_config() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + default: gpt-5.5 + provider: custom + context_length: 131072 +hooks: + enabled: true +"#, + ) + .unwrap(); + let env = "OPENAI_BASE_URL=https://example.test/v1\nCUSTOM_API_KEY=provider-secret\n"; + + assert!(apply_hermes_custom_provider_routing(&mut config, env).unwrap()); + assert_eq!(config["model"]["provider"], "custom:clawpanel"); + assert_eq!(config["model"]["base_url"], "https://example.test/v1"); + assert_eq!(config["model"]["context_length"], 131072); + assert_eq!( + config["providers"]["clawpanel"]["base_url"], + "https://example.test/v1" + ); + assert_eq!( + config["providers"]["clawpanel"]["key_env"], + "CUSTOM_API_KEY" + ); + assert_eq!(config["providers"]["clawpanel"]["default_model"], "gpt-5.5"); + assert_eq!( + config["providers"]["clawpanel"]["api_mode"], + "chat_completions" + ); + assert_eq!(config["hooks"]["enabled"], true); + assert!(!apply_hermes_custom_provider_routing(&mut config, env).unwrap()); + } + + #[test] + fn normal_openrouter_route_ignores_legacy_custom_env() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + default: openai/gpt-5.5 + provider: openrouter +"#, + ) + .unwrap(); + let before = config.clone(); + let env = "OPENAI_BASE_URL=https://legacy.example.test/v1\nOPENROUTER_API_KEY=openrouter-secret\n"; + + assert!(!apply_hermes_custom_provider_routing(&mut config, env).unwrap()); + assert_eq!(config, before); + } } #[cfg(test)] @@ -21935,6 +22188,23 @@ streaming: } } +#[cfg(test)] +mod hermes_gateway_runtime_compat_tests { + use super::{generate_hermes_api_server_key, hermes_api_server_key_is_usable}; + + #[test] + fn api_server_key_policy_matches_hermes_startup_guard() { + assert!(!hermes_api_server_key_is_usable("clawpanel-local")); + assert!(!hermes_api_server_key_is_usable("placeholder")); + assert!(hermes_api_server_key_is_usable( + "existing-strong-key-1234567890" + )); + let generated = generate_hermes_api_server_key(); + assert_eq!(generated.len(), 64); + assert!(generated.chars().all(|ch| ch.is_ascii_hexdigit())); + } +} + #[cfg(test)] mod hermes_terminal_config_tests { use super::{build_hermes_terminal_config_values, merge_hermes_terminal_config}; diff --git a/src-tauri/src/commands/hermes_providers.rs b/src-tauri/src/commands/hermes_providers.rs index b4c5e7b..3bbbfc3 100644 --- a/src-tauri/src/commands/hermes_providers.rs +++ b/src-tauri/src/commands/hermes_providers.rs @@ -814,7 +814,11 @@ pub fn all_managed_env_keys() -> Vec<&'static str> { } } // ClawPanel-specific keys - for extra in &["GATEWAY_ALLOW_ALL_USERS", "API_SERVER_KEY"] { + for extra in &[ + "GATEWAY_ALLOW_ALL_USERS", + "API_SERVER_ENABLED", + "API_SERVER_KEY", + ] { if !out.contains(extra) { out.push(extra); } @@ -917,6 +921,7 @@ mod tests { assert!(keys.contains(&"NOVITA_BASE_URL")); assert!(keys.contains(&"STEPFUN_API_KEY")); assert!(keys.contains(&"GATEWAY_ALLOW_ALL_USERS")); + assert!(keys.contains(&"API_SERVER_ENABLED")); assert!(keys.contains(&"API_SERVER_KEY")); // No duplicates for i in 0..keys.len() { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 02b83fd..106f106 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", "productName": "ClawPanel", - "version": "0.18.6", + "version": "0.19.0", "identifier": "ai.openclaw.clawpanel", "build": { "frontendDist": "../dist", diff --git a/src/engines/hermes/pages/dashboard.js b/src/engines/hermes/pages/dashboard.js index c1f006f..7e06734 100644 --- a/src/engines/hermes/pages/dashboard.js +++ b/src/engines/hermes/pages/dashboard.js @@ -220,13 +220,6 @@ export function render() { -
- ${t('engine.dashNativePanelDesc')} - -
-
diff --git a/src/engines/hermes/style/hermes.css b/src/engines/hermes/style/hermes.css index 4738c3c..efd23dc 100644 --- a/src/engines/hermes/style/hermes.css +++ b/src/engines/hermes/style/hermes.css @@ -412,40 +412,6 @@ color: var(--hm-error); } -[data-engine="hermes"] .hm-native-dashboard-hint { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - margin: -8px 2px 20px; - color: var(--hm-text-muted); - font-family: var(--hm-font-mono); - font-size: 11px; -} -[data-engine="hermes"] .hm-native-dashboard-link { - border: 0; - background: transparent; - color: var(--hm-text-secondary); - font: inherit; - cursor: pointer; - padding: 0; - text-decoration: underline; - text-decoration-color: color-mix(in srgb, currentColor 30%, transparent); - text-underline-offset: 3px; -} -[data-engine="hermes"] button.hm-native-dashboard-link { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 44px; - min-height: 44px; - padding: 0 10px; - touch-action: manipulation; -} -[data-engine="hermes"] .hm-native-dashboard-link:hover { - color: var(--hm-accent); -} - /* ========================================================================== * 4 · Panel — clean section container * ========================================================================== diff --git a/src/locales/modules/assistant.js b/src/locales/modules/assistant.js index 66a1e34..52ea0c0 100644 --- a/src/locales/modules/assistant.js +++ b/src/locales/modules/assistant.js @@ -299,7 +299,6 @@ export default { qtcoolInstructions: _('没有 Key?前往 签到页 免费领取', 'No key? Go to check-in page to get one for free', '沒有 Key?前往 簽到頁 免費領取'), qtcoolEnterKey: _('请输入 API Key', 'Please enter API Key', '請輸入 API Key'), qtcoolFillFirst: _('请先填写 API Key', 'Please fill in API Key first', '請先填寫 API Key'), - qtcoolFillFirst: _('请先填写 API Key', 'Please fill in API Key first', '請先填写 API Key'), qtcoolConnecting: _('连接中...', 'Connecting...', '連線中...'), qtcoolLoadingModels: _('正在获取模型列表...', 'Fetching model list...', '正在取得模型列表...'), qtcoolConnectFail: _('连接失败', 'Connection failed', '連線失敗'), diff --git a/src/locales/modules/common.js b/src/locales/modules/common.js index 7a6ec1c..d5dee66 100644 --- a/src/locales/modules/common.js +++ b/src/locales/modules/common.js @@ -27,7 +27,7 @@ export default { offline: _('离线', 'Offline', '離線', 'オフライン', '오프라인', 'Ngoại tuyến', 'Fuera de línea', '', 'Не в сети', 'Hors ligne'), running: _('运行中', 'Running', '執行中', '実行中', '실행 중', 'Đang chạy', 'Ejecutando', 'Em execução', 'Работает', 'En cours', 'Läuft'), stopped: _('已停止', 'Stopped', '', '停止', '중지됨', 'Đã dừng', 'Detenido', 'Parado', 'Остановлен', 'Arrêté', 'Gestoppt'), - error: _('错误', 'Error', '錯誤', 'エラー', '오류', 'Lỗi', '', 'Erro', 'Ошибка', 'Erreur', 'Fehler'), + errorLabel: _('错误', 'Error', '錯誤', 'エラー', '오류', 'Lỗi', '', 'Erro', 'Ошибка', 'Erreur', 'Fehler'), success: _('成功', 'Success', '', '成功', '성공', 'Thành công', 'Éxito', 'Sucesso', 'Успех', 'Succès', 'Erfolg'), warning: _('警告', 'Warning', '', '警告', '경고', 'Cảnh báo', 'Advertencia', 'Aviso', 'Предупреждение', 'Avertissement', 'Warnung'), info: _('提示', 'Info', '', '情報', '알림', 'Thông tin', 'Información', 'Informação', 'Информация', 'Information'), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index f3cb88f..e0e75f9 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -313,7 +313,6 @@ export default { dashGatewayStarted: _('Gateway 已启动', 'Gateway started', 'Gateway 已啟動'), dashConfigPatched: _('config.yaml 已自动修复', 'config.yaml was auto-repaired', 'config.yaml 已自動修復'), configModelRequired: _('请输入模型名', 'Enter a model name', '請輸入模型名稱'), - configSaved: _('配置已保存', 'Config saved', '配置已儲存'), envDetectFailed: _('探测失败', 'Detection failed', '探測失敗'), connWslGatewayMissing: _('WSL2 Gateway 未运行,请先在 WSL 中启动', 'WSL2 Gateway is not running. Start it in WSL first.', 'WSL2 Gateway 未執行,請先在 WSL 中啟動'), connDockerCustomHint: _('请切换到“自定义”模式并输入容器的 Gateway URL', 'Switch to Custom mode and enter the container Gateway URL.', '請切換到「自訂」模式並輸入容器的 Gateway URL'), diff --git a/src/pages/chat.js b/src/pages/chat.js index afd16e1..3fe2c85 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -1856,7 +1856,7 @@ function handleChatEvent(payload) { if (payload.state === 'error' && _hostedSessionConfig?.enabled) { _hostedRuntime.errorCount = (_hostedRuntime.errorCount || 0) + 1 - _hostedRuntime.lastError = payload.errorMessage || payload.error?.message || t('common.error') + _hostedRuntime.lastError = payload.errorMessage || payload.error?.message || t('common.errorLabel') _hostedRuntime.pending = false if (_hostedRuntime.errorCount >= _hostedSessionConfig.retryLimit) { _hostedRuntime.status = HOSTED_STATUS.ERROR @@ -2031,7 +2031,7 @@ function handleChatEvent(payload) { } if (state === 'error') { - const errMsg = payload.errorMessage || payload.error?.message || t('common.error') + const errMsg = payload.errorMessage || payload.error?.message || t('common.errorLabel') // 连接级错误(origin/pairing/auth)拦截,不作为聊天消息显示 if (/origin not allowed|NOT_PAIRED|PAIRING_REQUIRED|auth.*fail/i.test(errMsg)) { diff --git a/src/pages/services.js b/src/pages/services.js index 8ab93b1..31bf216 100644 --- a/src/pages/services.js +++ b/src/pages/services.js @@ -967,7 +967,7 @@ async function doUpgradeWithModal(source, page, version = null, method = 'auto') // 后台任务失败事件 unlistenError = await listen('upgrade-error', (e) => { cleanup() - const errStr = String(e.payload || t('common.error')) + const errStr = String(e.payload || t('common.errorLabel')) modal.appendLog(errStr) const fullLog = modal.getLogText() + '\n' + errStr const diagnosis = diagnoseInstallError(fullLog) diff --git a/tests/hermes-dashboard-ui.test.js b/tests/hermes-dashboard-ui.test.js index 742e569..58c1d1a 100644 --- a/tests/hermes-dashboard-ui.test.js +++ b/tests/hermes-dashboard-ui.test.js @@ -3,6 +3,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' const css = readFileSync(new URL('../src/engines/hermes/style/hermes.css', import.meta.url), 'utf8') +const dashboard = readFileSync(new URL('../src/engines/hermes/pages/dashboard.js', import.meta.url), 'utf8') function cssBlock(selector) { const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') @@ -17,11 +18,9 @@ test('Hermes dashboard 图标按钮必须满足移动端触控尺寸', () => { assert.match(block, /min-height:\s*44px/, '图标按钮需要显式保留 44px 最小高度') }) -test('Hermes dashboard 原生面板入口必须满足移动端触控尺寸', () => { - const block = cssBlock('[data-engine="hermes"] button.hm-native-dashboard-link') - assert.match(block, /min-width:\s*44px/, '原生 Dashboard 入口宽度必须至少 44px') - assert.match(block, /min-height:\s*44px/, '原生 Dashboard 入口高度必须至少 44px') - assert.match(block, /inline-flex/, '原生 Dashboard 入口需要扩展可点区域并居中内容') +test('Hermes dashboard 不展示未默认运行的 9119 外部入口', () => { + assert.doesNotMatch(dashboard, /
/) + assert.doesNotMatch(dashboard, /