From 708a834e245ad6eb69484c2dc249354df1d9d03c Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Wed, 2 Sep 2026 16:59:09 +0800 Subject: [PATCH] Dev (#198) * fix(gitignore): add .codex to ignored files * feat(settings): add configurable default terminal for CLI agents - Add default terminal selector under Advanced Settings > Software Settings - Detect available terminals across macOS, Windows, and Linux - Persist and migrate the default-terminal configuration - Apply the selected terminal when launching CLI agents - Add localized labels and regression tests Fixes #179 * feat(easy-mode): improve API onboarding and agent configuration flow - Fix management API 404 errors when saving API providers - Simplify the model list and support custom model IDs - Adjust API field order and default provider selection - Reuse the full agent configuration page and restore agent detection * refactor(agents): simplify embedded beginner mode UI * style(easy-mode): align onboarding heading typography * fix(easy-mode): refine API onboarding actions and notifications - Reveal model selection and save actions only after fetching models - Move the API save success toast to the bottom-right corner - Auto-dismiss the success notification after a short delay - Remove icons from the model fetch and save buttons * feat(easy-mode): refine guided setup flow and spotlight positioning - Enforce completion of each selection, input, and configuration step - Align OAuth, API access, and agent setup guidance with actual UI actions - Prevent guidance cards from overlapping highlighted areas - Improve bilingual step descriptions and completion states * feat(easy-mode): refine onboarding flow and navigation controls - Enforce accurate step validation for OAuth and API setup - Select all fetched API models by default - Prevent guide cards from overlapping highlighted areas - Improve onboarding copy and remove unnecessary dashboard actions - Reposition and highlight guide and exit controls * fix(usage): filter control messages and repair unknown records - filter empty, null, refresh, and support_refresh messages before persistence - require request_id for usage records - repair Claude cache token accounting - delete historical unknown records - show deleted record count in usage management * fix(ui): improve auth file and core install notifications - show auth file operation notices as auto-dismiss toasts - prevent duplicate core install completion notifications - close the install dialog automatically after successful installation - bump core version to 7.2.146 * fix(ui): improve responsive layouts and core install notifications - add workspace container breakpoints for management and usage pages - make the usage table fill the available viewport height - reduce unnecessary blank space in usage records - silence automatic bundled core install completion notifications - deduplicate manual core install completion feedback * chore(version): update core version to 7.2.147 * fix(management): bypass system proxy for local Core requests * fix(claude-desktop): support Claude OAuth model aliases - Resolve Claude Desktop model sources from all OAuth definitions - Write managed aliases to the matching OAuth channel - Preserve alias metadata and add regression coverage * test(claude-desktop): cover xAI OAuth model aliases * feat(config): add disable cooling toggle to retry settings * feat(config): add disable cooling toggle and refine settings layout * fix(dependencies): update cpa-gui version to 0.2.69 --- core-version.txt | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/core_config/aliases.rs | 83 +++++++++++------- src-tauri/src/core_config/commands.rs | 2 + src-tauri/src/core_config/settings.rs | 21 +++++ src-tauri/src/core_config/yaml.rs | 9 ++ src-tauri/src/main.rs | 19 ++++- src-tauri/src/management_api.rs | 52 +++++++++--- src-tauri/src/tests/agent_configuration.rs | 98 ++++++++++++++++++++++ src-tauri/src/tests/app_settings.rs | 2 + src-tauri/src/tests/core_config.rs | 17 +++- src-tauri/src/usage.rs | 10 ++- src/i18n/ja.ts | 2 + src/i18n/locales/en.ts | 2 + src/i18n/locales/zh-CN.ts | 2 + src/pages/ConfigPanel.tsx | 34 +++++++- src/styles.css | 5 ++ 18 files changed, 313 insertions(+), 51 deletions(-) diff --git a/core-version.txt b/core-version.txt index c36f358..1ca3b58 100644 --- a/core-version.txt +++ b/core-version.txt @@ -1 +1 @@ -7.2.146 +7.2.147 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e2b0cb0..d3b328c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -593,7 +593,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "cpa-gui" -version = "0.2.66" +version = "0.2.69" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1fc7a58..ca559e0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cpa-gui" # Single source of truth for the desktop app version. -version = "0.2.66" +version = "0.2.69" description = "CPA desktop management shell" authors = ["lzt404"] edition = "2021" diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index 6ab227c..3f98363 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -140,7 +140,7 @@ pub(crate) async fn fetch_active_oauth_alias_channels( .header(reqwest::header::ACCEPT, "application/json") .send() .await - .map_err(|error| format!("读取 OAuth 凭据来源失败: {error}"))?; + .map_err(|error| format_management_request_error("读取 OAuth 凭据来源失败", &error))?; let payload = read_management_value(response).await?; let files = payload .get("files") @@ -218,7 +218,7 @@ pub(crate) async fn fetch_management_config_yaml(config: &GuiConfigFile) -> Resu ) .send() .await - .map_err(|error| format!("读取内核 YAML 配置失败: {error}"))?; + .map_err(|error| format_management_request_error("读取内核 YAML 配置失败", &error))?; read_management_text(response).await } @@ -234,7 +234,7 @@ pub(crate) async fn put_management_config_yaml( .body(content.to_string()) .send() .await - .map_err(|error| format!("保存内核 YAML 配置失败: {error}"))?; + .map_err(|error| format_management_request_error("保存内核 YAML 配置失败", &error))?; read_management_value(response).await.map(|_| ()) } @@ -249,7 +249,7 @@ pub(crate) async fn put_management_oauth_model_aliases( .json(aliases) .send() .await - .map_err(|error| format!("保存 OAuth 模型别名失败: {error}"))?; + .map_err(|error| format_management_request_error("保存 OAuth 模型别名失败", &error))?; read_management_value(response).await.map(|_| ()) } @@ -319,11 +319,9 @@ pub(crate) async fn ensure_claude_desktop_model_aliases( let content = fetch_management_config_yaml(config).await?; let updated = match ensure_claude_desktop_model_aliases_in_yaml(&content, mappings, models) { Ok(updated) => updated, - Err(source_error) => { - let definitions = fetch_codex_model_definitions(config) - .await - .map_err(|error| format!("{source_error};{error}"))?; - ensure_claude_desktop_model_aliases_with_codex_oauth_in_yaml( + Err(_) => { + let definitions = fetch_oauth_model_definitions(config).await; + ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml( &content, mappings, models, @@ -347,6 +345,26 @@ pub(crate) fn ensure_claude_desktop_model_aliases_with_codex_oauth_in_yaml( mappings: &ClaudeDesktopModelMappings, models: &[AgentModelOption], codex_oauth_models: &[CodexModelDefinition], +) -> Result { + let codex_channel = oauth_alias_channel("codex") + .ok_or_else(|| "缺少 Codex OAuth 别名 channel 定义".to_string())?; + let definitions = [OAuthModelDefinitions { + channel: codex_channel, + models: codex_oauth_models.to_vec(), + }]; + ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml( + content, + mappings, + models, + &definitions, + ) +} + +pub(crate) fn ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml( + content: &str, + mappings: &ClaudeDesktopModelMappings, + models: &[AgentModelOption], + oauth_model_definitions: &[OAuthModelDefinitions], ) -> Result { let mut document = yaml_serde_edit::YamlValue::parse(content) .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; @@ -368,7 +386,7 @@ pub(crate) fn ensure_claude_desktop_model_aliases_with_codex_oauth_in_yaml( remove_managed_claude_model_alias(root, alias)?; } } else { - ensure_claude_desktop_model_alias(root, source_model, alias, codex_oauth_models)?; + ensure_claude_desktop_model_alias(root, source_model, alias, oauth_model_definitions)?; } } render_updated_core_yaml(&mut document, updated) @@ -559,7 +577,7 @@ pub(crate) fn ensure_claude_desktop_model_alias( root: &mut serde_norway::Mapping, source_model: &str, alias: &str, - codex_oauth_models: &[CodexModelDefinition], + oauth_model_definitions: &[OAuthModelDefinitions], ) -> Result<(), String> { if let Some((existing_source, _)) = configured_model_client_identity(root, alias) { if existing_source.eq_ignore_ascii_case(source_model) @@ -572,11 +590,19 @@ pub(crate) fn ensure_claude_desktop_model_alias( if append_claude_desktop_model_alias(root, source_model, alias)? { return Ok(()); } - if codex_oauth_models - .iter() - .any(|model| model.id.eq_ignore_ascii_case(source_model)) - { - append_managed_codex_oauth_model_alias(root, source_model, alias)?; + if let Some(definition) = oauth_model_definitions.iter().find(|definition| { + definition + .models + .iter() + .any(|model| model.id.eq_ignore_ascii_case(source_model)) + }) { + append_managed_oauth_model_alias( + root, + definition.channel.key, + source_model, + alias, + definition.channel.force_mapping, + )?; return Ok(()); } Err(format!( @@ -695,21 +721,23 @@ pub(crate) fn append_claude_desktop_model_alias( Ok(false) } -pub(crate) fn append_managed_codex_oauth_model_alias( +pub(crate) fn append_managed_oauth_model_alias( root: &mut serde_norway::Mapping, + channel: &str, source_model: &str, alias: &str, + force_mapping: bool, ) -> Result<(), String> { let oauth_aliases = root .entry(yaml_key("oauth-model-alias")) .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) .as_mapping_mut() .ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?; - let codex_aliases = oauth_aliases - .entry(yaml_key("codex")) + let channel_aliases = oauth_aliases + .entry(yaml_key(channel)) .or_insert_with(|| serde_norway::Value::Sequence(Vec::new())) .as_sequence_mut() - .ok_or_else(|| "oauth-model-alias.codex 必须是数组".to_string())?; + .ok_or_else(|| format!("oauth-model-alias.{channel} 必须是数组"))?; let display_name = managed_claude_alias_display_name(alias) .ok_or_else(|| format!("不支持的 Claude 托管别名: {alias}"))?; let mut alias_mapping = serde_norway::Mapping::new(); @@ -722,20 +750,17 @@ pub(crate) fn append_managed_codex_oauth_model_alias( serde_norway::Value::String(alias.to_string()), ); alias_mapping.insert(yaml_key("fork"), serde_norway::Value::Bool(true)); + if force_mapping { + alias_mapping.insert(yaml_key("force-mapping"), serde_norway::Value::Bool(true)); + } alias_mapping.insert( yaml_key("display-name"), serde_norway::Value::String(display_name.to_string()), ); - codex_aliases.push(serde_norway::Value::Mapping(alias_mapping)); + channel_aliases.push(serde_norway::Value::Mapping(alias_mapping)); Ok(()) } -pub(crate) async fn fetch_codex_model_definitions( - config: &GuiConfigFile, -) -> Result, String> { - fetch_oauth_channel_model_definitions(config, "codex").await -} - pub(crate) async fn fetch_oauth_channel_model_definitions( config: &GuiConfigFile, channel: &str, @@ -750,7 +775,9 @@ pub(crate) async fn fetch_oauth_channel_model_definitions( .header(reqwest::header::ACCEPT, "application/json") .send() .await - .map_err(|error| format!("读取 {channel} OAuth 模型定义失败: {error}"))?; + .map_err(|error| { + format_management_request_error(&format!("读取 {channel} OAuth 模型定义失败"), &error) + })?; let payload = read_management_value(response).await?; parse_codex_model_definitions(&payload) } diff --git a/src-tauri/src/core_config/commands.rs b/src-tauri/src/core_config/commands.rs index 580fd06..59c07db 100644 --- a/src-tauri/src/core_config/commands.rs +++ b/src-tauri/src/core_config/commands.rs @@ -119,6 +119,7 @@ pub(crate) fn save_network_routing_settings( next.proxy_url = proxy_url.clone(); next.routing_session_affinity = settings.routing_session_affinity; next.routing_session_affinity_ttl = routing_session_affinity_ttl.clone(); + next.disable_cooling = settings.disable_cooling; next.request_retry = settings.request_retry; next.max_retry_credentials = settings.max_retry_credentials; next.max_retry_interval = settings.max_retry_interval; @@ -187,6 +188,7 @@ pub(crate) fn save_retry_settings( ) -> Result { let previous = gui_config_state.snapshot()?; let mut next = previous.clone(); + next.disable_cooling = settings.disable_cooling; next.request_retry = settings.request_retry; next.max_retry_credentials = settings.max_retry_credentials; next.max_retry_interval = settings.max_retry_interval; diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index 52272b1..0309f1d 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -415,6 +415,11 @@ pub(crate) fn patch_core_network_routing_yaml( "session-affinity-ttl", serde_norway::Value::String(config.routing_session_affinity_ttl.clone()), )?; + set_core_yaml_top_level_value( + document, + "disable-cooling", + serde_norway::Value::Bool(config.disable_cooling), + )?; set_core_yaml_top_level_value( document, "request-retry", @@ -466,6 +471,11 @@ pub(crate) fn patch_core_retry_yaml( ) -> Result, String> { patch_core_yaml_document(content, |document| { let original = document.clone(); + set_core_yaml_top_level_value( + document, + "disable-cooling", + serde_norway::Value::Bool(config.disable_cooling), + )?; set_core_yaml_top_level_value( document, "request-retry", @@ -615,6 +625,11 @@ pub(crate) fn apply_gui_managed_settings( "session-affinity-ttl", serde_norway::Value::String(config.routing_session_affinity_ttl.clone()), )?; + changed |= set_core_yaml_top_level_value( + document, + "disable-cooling", + serde_norway::Value::Bool(config.disable_cooling), + )?; changed |= set_core_yaml_top_level_value( document, "request-retry", @@ -1146,6 +1161,7 @@ pub(crate) fn load_or_create_gui_config() -> Result { || presence.proxy_url.is_none() || presence.routing_session_affinity.is_none() || presence.routing_session_affinity_ttl.is_none() + || presence.disable_cooling.is_none() || presence.request_retry.is_none() || presence.max_retry_credentials.is_none() || presence.max_retry_interval.is_none() @@ -1197,6 +1213,9 @@ pub(crate) fn load_or_create_gui_config() -> Result { if presence.routing_session_affinity_ttl.is_none() { config.routing_session_affinity_ttl = core_settings.routing_session_affinity_ttl; } + if presence.disable_cooling.is_none() { + config.disable_cooling = core_settings.disable_cooling; + } if presence.request_retry.is_none() { config.request_retry = core_settings.request_retry; } @@ -1301,6 +1320,7 @@ pub(crate) fn apply_core_settings_to_gui_config( config.proxy_url = core_settings.proxy_url.clone(); config.routing_session_affinity = core_settings.routing_session_affinity; config.routing_session_affinity_ttl = core_settings.routing_session_affinity_ttl.clone(); + config.disable_cooling = core_settings.disable_cooling; config.request_retry = core_settings.request_retry; config.max_retry_credentials = core_settings.max_retry_credentials; config.max_retry_interval = core_settings.max_retry_interval; @@ -1676,6 +1696,7 @@ pub(crate) fn write_gui_config_to_path( "routing-session-affinity-ttl", value(config.routing_session_affinity_ttl.as_str()), ), + ("disable-cooling", value(config.disable_cooling)), ("request-retry", value(i64::from(config.request_retry))), ( "max-retry-credentials", diff --git a/src-tauri/src/core_config/yaml.rs b/src-tauri/src/core_config/yaml.rs index 1964139..3a25ca1 100644 --- a/src-tauri/src/core_config/yaml.rs +++ b/src-tauri/src/core_config/yaml.rs @@ -1197,6 +1197,14 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or_default(); + let disable_cooling = yaml_mapping_value(root, "disable-cooling") + .map(|value| { + value + .as_bool() + .ok_or_else(|| "disable-cooling 必须是布尔值".to_string()) + }) + .transpose()? + .unwrap_or(DEFAULT_DISABLE_COOLING); let request_retry = yaml_mapping_value(root, "request-retry") .map(|value| { value @@ -1248,6 +1256,7 @@ pub(crate) fn core_config_settings_from_value( proxy_url, routing_session_affinity, routing_session_affinity_ttl, + disable_cooling, request_retry, max_retry_credentials, max_retry_interval, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index cd18b4e..8f332b9 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -20,8 +20,8 @@ mod usage; #[cfg(test)] use configuration_watcher::nearest_existing_watch_directory; use management_api::{ - management_authorization, management_endpoint, management_http_client, read_management_text, - read_management_value, + format_management_request_error, management_authorization, management_endpoint, + management_http_client, read_management_text, read_management_value, }; use oauth_browser::*; #[cfg(test)] @@ -137,6 +137,7 @@ const DEFAULT_REQUEST_RETRY: u32 = 3; const DEFAULT_MAX_RETRY_CREDENTIALS: u32 = 0; const DEFAULT_MAX_RETRY_INTERVAL: u32 = 30; const DEFAULT_STREAMING_BOOTSTRAP_RETRIES: u32 = 0; +const DEFAULT_DISABLE_COOLING: bool = false; const LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY: &str = "123456"; const MANAGED_AGENT_PROVIDER_ID: &str = "cpa-gui"; const ZCODE_CONFIG_FILE: &str = "config.json"; @@ -605,6 +606,7 @@ struct GuiConfigFile { prefer_gitcode_downloads: bool, routing_session_affinity: bool, routing_session_affinity_ttl: String, + disable_cooling: bool, request_retry: u32, max_retry_credentials: u32, max_retry_interval: u32, @@ -889,6 +891,7 @@ impl Default for GuiConfigFile { prefer_gitcode_downloads: false, routing_session_affinity: false, routing_session_affinity_ttl: String::new(), + disable_cooling: DEFAULT_DISABLE_COOLING, request_retry: DEFAULT_REQUEST_RETRY, max_retry_credentials: DEFAULT_MAX_RETRY_CREDENTIALS, max_retry_interval: DEFAULT_MAX_RETRY_INTERVAL, @@ -922,6 +925,7 @@ struct GuiConfigPresence { prefer_gitcode_downloads: Option, routing_session_affinity: Option, routing_session_affinity_ttl: Option, + disable_cooling: Option, request_retry: Option, max_retry_credentials: Option, max_retry_interval: Option, @@ -1330,6 +1334,8 @@ struct GuiNetworkRoutingSettings { proxy_url: String, routing_session_affinity: bool, routing_session_affinity_ttl: String, + #[serde(default)] + disable_cooling: bool, request_retry: u32, max_retry_credentials: u32, max_retry_interval: u32, @@ -1347,6 +1353,8 @@ struct GuiNetworkEndpointSettings { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct GuiRetrySettings { + #[serde(default)] + disable_cooling: bool, request_retry: u32, max_retry_credentials: u32, max_retry_interval: u32, @@ -1386,6 +1394,7 @@ struct CoreConfigSettings { proxy_url: String, routing_session_affinity: bool, routing_session_affinity_ttl: String, + disable_cooling: bool, request_retry: u32, max_retry_credentials: u32, max_retry_interval: u32, @@ -1416,6 +1425,7 @@ struct CoreConfigView { proxy_url: String, routing_session_affinity: bool, routing_session_affinity_ttl: String, + disable_cooling: bool, request_retry: u32, max_retry_credentials: u32, max_retry_interval: u32, @@ -1860,6 +1870,7 @@ impl GuiConfigState { config.proxy_url = settings.proxy_url.clone(); config.routing_session_affinity = settings.routing_session_affinity; config.routing_session_affinity_ttl = settings.routing_session_affinity_ttl.clone(); + config.disable_cooling = settings.disable_cooling; config.request_retry = settings.request_retry; config.max_retry_credentials = settings.max_retry_credentials; config.max_retry_interval = settings.max_retry_interval; @@ -1880,6 +1891,7 @@ impl GuiConfigState { fn update_retry_settings(&self, settings: &GuiConfigFile) -> Result { self.update(|config| { + config.disable_cooling = settings.disable_cooling; config.request_retry = settings.request_retry; config.max_retry_credentials = settings.max_retry_credentials; config.max_retry_interval = settings.max_retry_interval; @@ -2035,6 +2047,7 @@ impl GuiConfigState { config.proxy_url = settings.proxy_url.clone(); config.routing_session_affinity = settings.routing_session_affinity; config.routing_session_affinity_ttl = settings.routing_session_affinity_ttl.clone(); + config.disable_cooling = settings.disable_cooling; config.request_retry = settings.request_retry; config.max_retry_credentials = settings.max_retry_credentials; config.max_retry_interval = settings.max_retry_interval; @@ -2086,6 +2099,7 @@ impl From<&GuiConfigFile> for CoreConfigSettings { proxy_url: config.proxy_url.clone(), routing_session_affinity: config.routing_session_affinity, routing_session_affinity_ttl: config.routing_session_affinity_ttl.clone(), + disable_cooling: config.disable_cooling, request_retry: config.request_retry, max_retry_credentials: config.max_retry_credentials, max_retry_interval: config.max_retry_interval, @@ -2115,6 +2129,7 @@ impl From<&GuiConfigFile> for CoreConfigView { proxy_url: config.proxy_url.clone(), routing_session_affinity: config.routing_session_affinity, routing_session_affinity_ttl: config.routing_session_affinity_ttl.clone(), + disable_cooling: config.disable_cooling, request_retry: config.request_retry, max_retry_credentials: config.max_retry_credentials, max_retry_interval: config.max_retry_interval, diff --git a/src-tauri/src/management_api.rs b/src-tauri/src/management_api.rs index 21173ae..00e1d6c 100644 --- a/src-tauri/src/management_api.rs +++ b/src-tauri/src/management_api.rs @@ -8,6 +8,7 @@ use super::{ use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, + error::Error, fs, path::Path, process::{Command, Stdio}, @@ -94,7 +95,7 @@ pub(crate) async fn management_request( let response = builder .send() .await - .map_err(|err| format!("请求管理 API 失败: {err}"))?; + .map_err(|err| format_management_request_error("请求管理 API 失败", &err))?; read_management_value(response).await } @@ -121,7 +122,7 @@ pub(crate) async fn upload_auth_file( .body(data) .send() .await - .map_err(|err| format!("上传认证文件失败: {err}"))?; + .map_err(|err| format_management_request_error("上传认证文件失败", &err))?; read_management_value(response).await } @@ -179,7 +180,7 @@ pub(crate) async fn start_oauth_login( let response = request .send() .await - .map_err(|err| format!("请求 OAuth 登录链接失败: {err}"))?; + .map_err(|err| format_management_request_error("请求 OAuth 登录链接失败", &err))?; let payload = read_management_json::(response).await?; if let Some(error) = payload .error @@ -228,7 +229,7 @@ pub(crate) async fn get_oauth_status( .query(&[("state", state)]) .send() .await - .map_err(|err| format!("查询 OAuth 状态失败: {err}"))?; + .map_err(|err| format_management_request_error("查询 OAuth 状态失败", &err))?; let payload = read_management_json::(response).await?; let status = payload .status @@ -268,12 +269,12 @@ pub(crate) async fn submit_oauth_callback( .json(&body) .send() .await - .map_err(|err| format!("提交 OAuth 回调失败: {err}"))?; + .map_err(|err| format_management_request_error("提交 OAuth 回调失败", &err))?; let status = response.status(); let text = response .text() .await - .map_err(|err| format!("读取 OAuth 回调响应失败: {err}"))?; + .map_err(|err| format_management_request_error("读取 OAuth 回调响应失败", &err))?; if !status.is_success() { return Err(format_management_error(status.as_u16(), &text)); } @@ -282,13 +283,44 @@ pub(crate) async fn submit_oauth_callback( pub(crate) fn management_http_client() -> Result { reqwest::Client::builder() + // GUI-to-Core management traffic must always connect directly. Upstream + // traffic still uses Core's proxy-url, and other GUI HTTP clients keep + // their independently configured proxy behavior. + .no_proxy() .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(30)) // Management requests target the configured local listener. This keeps // self-signed/private-CA certificates usable without weakening upstream clients. .danger_accept_invalid_certs(true) .build() - .map_err(|err| format!("创建管理 API 客户端失败: {err}")) + .map_err(|err| format_management_request_error("创建管理 API 客户端失败", &err)) +} + +pub(crate) fn format_management_request_error( + action: &str, + error: &(dyn Error + 'static), +) -> String { + let mut messages = Vec::new(); + let mut current = Some(error); + + while let Some(error) = current { + let message = error.to_string(); + if !message.is_empty() + && messages + .last() + .map(|previous| previous != &message) + .unwrap_or(true) + { + messages.push(message); + } + current = error.source(); + } + + if messages.is_empty() { + action.to_string() + } else { + format!("{action}: {}", messages.join(": ")) + } } pub(crate) fn management_authorization(config: &GuiConfigFile) -> Result { @@ -342,7 +374,7 @@ where let text = response .text() .await - .map_err(|err| format!("读取管理 API 响应失败: {err}"))?; + .map_err(|err| format_management_request_error("读取管理 API 响应失败", &err))?; if !status.is_success() { return Err(format_management_error(status.as_u16(), &text)); } @@ -364,7 +396,7 @@ pub(crate) async fn read_management_value( let text = response .text() .await - .map_err(|err| format!("读取管理 API 响应失败: {err}"))?; + .map_err(|err| format_management_request_error("读取管理 API 响应失败", &err))?; if !status.is_success() { return Err(format_management_error(status.as_u16(), &text)); } @@ -382,7 +414,7 @@ pub(crate) async fn read_management_text(response: reqwest::Response) -> Result< let text = response .text() .await - .map_err(|err| format!("读取管理 API 响应失败: {err}"))?; + .map_err(|err| format_management_request_error("读取管理 API 响应失败", &err))?; if !status.is_success() { return Err(format_management_error(status.as_u16(), &text)); } diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 5650c24..76e5ef5 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -1674,6 +1674,104 @@ fn claude_desktop_aliases_support_codex_oauth_models() { ); } +#[test] +fn claude_desktop_aliases_support_claude_oauth_models() { + let input = "port: 8317\n"; + let mappings = ClaudeDesktopModelMappings::all("claude-sonnet-4-6"); + let available_models = test_agent_models(&["claude-sonnet-4-6"]); + let definitions = vec![OAuthModelDefinitions { + channel: oauth_alias_channel("claude").unwrap(), + models: vec![CodexModelDefinition { + id: "claude-sonnet-4-6".to_string(), + display_name: None, + description: None, + context_window: Some(200_000), + reasoning_levels: vec!["high".to_string()], + supports_tools: Some(true), + }], + }]; + + let rendered = ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml( + input, + &mappings, + &available_models, + &definitions, + ) + .unwrap(); + let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap(); + let root = value.as_mapping().unwrap(); + let aliases = yaml_mapping_value(root, "oauth-model-alias") + .and_then(serde_norway::Value::as_mapping) + .and_then(|channels| yaml_mapping_value(channels, "claude")) + .and_then(serde_norway::Value::as_sequence) + .unwrap(); + + assert_eq!(aliases.len(), 3); + assert!(aliases.iter().all(|entry| { + configured_model_identity(entry).is_some_and(|(source, _, _)| source == "claude-sonnet-4-6") + })); + assert!(aliases.iter().all(|entry| { + entry + .as_mapping() + .and_then(|entry| yaml_mapping_value(entry, "fork")) + == Some(&serde_norway::Value::Bool(true)) + })); + assert_eq!( + ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml( + &rendered, + &mappings, + &available_models, + &definitions, + ) + .unwrap(), + rendered + ); +} + +#[test] +fn claude_desktop_aliases_support_xai_oauth_models() { + let input = "port: 8317\n"; + let mappings = ClaudeDesktopModelMappings::all("grok-4"); + let available_models = test_agent_models(&["grok-4"]); + let definitions = vec![OAuthModelDefinitions { + channel: oauth_alias_channel("xai").unwrap(), + models: vec![CodexModelDefinition { + id: "grok-4".to_string(), + display_name: None, + description: None, + context_window: Some(131_072), + reasoning_levels: vec!["high".to_string()], + supports_tools: Some(true), + }], + }]; + + let rendered = ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml( + input, + &mappings, + &available_models, + &definitions, + ) + .unwrap(); + let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap(); + let root = value.as_mapping().unwrap(); + let aliases = yaml_mapping_value(root, "oauth-model-alias") + .and_then(serde_norway::Value::as_mapping) + .and_then(|channels| yaml_mapping_value(channels, "xai")) + .and_then(serde_norway::Value::as_sequence) + .unwrap(); + + assert_eq!(aliases.len(), 3); + assert!(aliases.iter().all(|entry| { + configured_model_identity(entry).is_some_and(|(source, _, _)| source == "grok-4") + })); + assert!(aliases.iter().all(|entry| { + entry + .as_mapping() + .and_then(|entry| yaml_mapping_value(entry, "fork")) + == Some(&serde_norway::Value::Bool(true)) + })); +} + #[test] fn claude_oauth_alias_changes_use_the_runtime_refresh_endpoint() { let current = "port: 8317\nopenai-compatibility: []\n"; diff --git a/src-tauri/src/tests/app_settings.rs b/src-tauri/src/tests/app_settings.rs index 6adba21..d005f8a 100644 --- a/src-tauri/src/tests/app_settings.rs +++ b/src-tauri/src/tests/app_settings.rs @@ -18,6 +18,7 @@ fn gui_field_edit_preserves_comments_and_unknown_configuration() { auth_dir: path_to_string(&home.join("custom-auth")), management_secret_key: "custom-secret".to_string(), usage_statistics_enabled: false, + disable_cooling: true, download_source: VersionDownloadSource::Gitcode, prefer_gitcode_downloads: true, ..GuiConfigFile::default() @@ -33,6 +34,7 @@ fn gui_field_edit_preserves_comments_and_unknown_configuration() { assert!(content.contains("silent-start = true")); assert!(content.contains("management-secret-key = \"custom-secret\"")); assert!(content.contains("usage-statistics-enabled = false")); + assert!(content.contains("disable-cooling = true")); assert!(content.contains("download-source = \"gitcode\"")); assert!(content.contains("prefer-gitcode-downloads = true")); assert!(!content.contains("codex-session-repair-on-launch")); diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index cefc7ae..ec617f4 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -341,6 +341,7 @@ fn legacy_gui_config_can_seed_managed_core_settings() { proxy_url: "http://127.0.0.1:8080".to_string(), routing_session_affinity: true, routing_session_affinity_ttl: "45m".to_string(), + disable_cooling: true, request_retry: 1, max_retry_credentials: 2, max_retry_interval: 5, @@ -358,6 +359,7 @@ fn legacy_gui_config_can_seed_managed_core_settings() { assert_eq!(config.management_secret_key, "management-secret"); assert_eq!(config.host, "0.0.0.0"); assert_eq!(config.port, 9000); + assert!(config.disable_cooling); assert_eq!(config.auth_dir, "/tmp/external-auth"); assert!(!config.usage_statistics_enabled); assert!(config.plugins_enabled); @@ -479,6 +481,7 @@ fn confirmed_network_routing_patch_updates_all_fields_together() { proxy_url: "socks5://127.0.0.1:7890".to_string(), routing_session_affinity: true, routing_session_affinity_ttl: "2h".to_string(), + disable_cooling: true, request_retry: 1, max_retry_credentials: 2, max_retry_interval: 5, @@ -496,6 +499,7 @@ fn confirmed_network_routing_patch_updates_all_fields_together() { assert_eq!(document["proxy-url"], "socks5://127.0.0.1:7890"); assert_eq!(document["routing"]["session-affinity"], true); assert_eq!(document["routing"]["session-affinity-ttl"], "2h"); + assert_eq!(document["disable-cooling"], true); assert_eq!(document["request-retry"], 1); assert_eq!(document["max-retry-credentials"], 2); assert_eq!(document["max-retry-interval"], 5); @@ -515,13 +519,14 @@ fn independent_network_modules_only_patch_their_own_fields() { proxy_url: "socks5://127.0.0.1:7890".to_string(), routing_session_affinity: true, routing_session_affinity_ttl: "2h".to_string(), + disable_cooling: true, request_retry: 7, max_retry_credentials: 8, max_retry_interval: 9, streaming_bootstrap_retries: 10, ..GuiConfigFile::default() }; - let input = "host: 127.0.0.1\nport: 8317\nproxy-url: \"\"\nrouting:\n session-affinity: false\n session-affinity-ttl: 1h\nrequest-retry: 1\nmax-retry-credentials: 2\nmax-retry-interval: 3\nstreaming:\n bootstrap-retries: 4\n"; + let input = "host: 127.0.0.1\nport: 8317\nproxy-url: \"\"\nrouting:\n session-affinity: false\n session-affinity-ttl: 1h\ndisable-cooling: false\nrequest-retry: 1\nmax-retry-credentials: 2\nmax-retry-interval: 3\nstreaming:\n bootstrap-retries: 4\n"; let network = patch_core_network_endpoint_yaml(input, &config) .unwrap() @@ -530,6 +535,7 @@ fn independent_network_modules_only_patch_their_own_fields() { assert_eq!(network["host"], "192.168.1.20"); assert_eq!(network["port"], 9527); assert_eq!(network["proxy-url"], "socks5://127.0.0.1:7890"); + assert_eq!(network["disable-cooling"], false); assert_eq!(network["request-retry"], 1); assert_eq!(network["routing"]["session-affinity"], false); @@ -537,6 +543,7 @@ fn independent_network_modules_only_patch_their_own_fields() { .unwrap() .expect("retry settings should change"); let retry = serde_norway::from_str::(&retry).unwrap(); + assert_eq!(retry["disable-cooling"], true); assert_eq!(retry["request-retry"], 7); assert_eq!(retry["max-retry-credentials"], 8); assert_eq!(retry["max-retry-interval"], 9); @@ -826,17 +833,19 @@ fn core_config_reads_proxy_and_session_affinity_fields() { #[test] fn core_config_reads_retry_fields_and_uses_core_defaults() { let document = serde_norway::from_str::( - "request-retry: 1\nmax-retry-credentials: 2\nmax-retry-interval: 5\nstreaming:\n bootstrap-retries: 4\n", + "disable-cooling: true\nrequest-retry: 1\nmax-retry-credentials: 2\nmax-retry-interval: 5\nstreaming:\n bootstrap-retries: 4\n", ) .unwrap(); let settings = core_config_settings_from_value(&document).unwrap(); + assert!(settings.disable_cooling); assert_eq!(settings.request_retry, 1); assert_eq!(settings.max_retry_credentials, 2); assert_eq!(settings.max_retry_interval, 5); assert_eq!(settings.streaming_bootstrap_retries, 4); let defaults = core_config_settings_from_value(&serde_norway::from_str("{}").unwrap()).unwrap(); + assert_eq!(defaults.disable_cooling, DEFAULT_DISABLE_COOLING); assert_eq!(defaults.request_retry, DEFAULT_REQUEST_RETRY); assert_eq!( defaults.max_retry_credentials, @@ -856,6 +865,7 @@ fn managed_session_settings_use_canonical_yaml_and_preserve_unrelated_content() proxy_url: "http://127.0.0.1:8080".to_string(), routing_session_affinity: true, routing_session_affinity_ttl: "1h".to_string(), + disable_cooling: true, request_retry: 1, max_retry_credentials: 2, max_retry_interval: 5, @@ -868,6 +878,7 @@ fn managed_session_settings_use_canonical_yaml_and_preserve_unrelated_content() assert_eq!(document["proxy-url"], "http://127.0.0.1:8080"); assert_eq!(document["routing"]["session-affinity"], true); assert_eq!(document["routing"]["session-affinity-ttl"], "1h"); + assert_eq!(document["disable-cooling"], true); assert_eq!(document["request-retry"], 1); assert_eq!(document["max-retry-credentials"], 2); assert_eq!(document["max-retry-interval"], 5); @@ -1046,6 +1057,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() { prefer_gitcode_downloads: false, routing_session_affinity: true, routing_session_affinity_ttl: "1h".to_string(), + disable_cooling: true, request_retry: 1, max_retry_credentials: 2, max_retry_interval: 5, @@ -1072,6 +1084,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() { assert_eq!(document["proxy-url"], "socks5://127.0.0.1:7890"); assert_eq!(document["routing"]["session-affinity"], true); assert_eq!(document["routing"]["session-affinity-ttl"], "1h"); + assert_eq!(document["disable-cooling"], true); assert_eq!(document["request-retry"], 1); assert_eq!(document["max-retry-credentials"], 2); assert_eq!(document["max-retry-interval"], 5); diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index b21484b..c3ccbbd 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -5,8 +5,9 @@ use super::executable_dir; #[cfg(test)] use super::VersionDownloadSource; use super::{ - apply_configured_proxy, core_base_dir, current_core_status, management_authorization, - management_endpoint, management_http_client, CoreProcessState, GuiConfigFile, GuiConfigState, + apply_configured_proxy, core_base_dir, current_core_status, format_management_request_error, + management_authorization, management_endpoint, management_http_client, CoreProcessState, + GuiConfigFile, GuiConfigState, }; use chrono::{DateTime, Local}; use rusqlite::{ @@ -1650,12 +1651,12 @@ async fn fetch_usage_queue(config: &GuiConfigFile) -> Result, String> .query(&[("count", USAGE_QUEUE_BATCH_SIZE)]) .send() .await - .map_err(|error| format!("读取 CPA 使用记录队列失败: {error}"))?; + .map_err(|error| format_management_request_error("读取 CPA 使用记录队列失败", &error))?; let status = response.status(); let text = response .text() .await - .map_err(|error| format!("读取 CPA 使用记录响应失败: {error}"))?; + .map_err(|error| format_management_request_error("读取 CPA 使用记录响应失败", &error))?; if !status.is_success() { return Err(format!( "CPA 使用记录队列返回 HTTP {}: {}", @@ -3864,6 +3865,7 @@ mod tests { prefer_gitcode_downloads: false, routing_session_affinity: false, routing_session_affinity_ttl: String::new(), + disable_cooling: crate::DEFAULT_DISABLE_COOLING, request_retry: crate::DEFAULT_REQUEST_RETRY, max_retry_credentials: crate::DEFAULT_MAX_RETRY_CREDENTIALS, max_retry_interval: crate::DEFAULT_MAX_RETRY_INTERVAL, diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 47b001e..2f9c57e 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -406,6 +406,8 @@ export const jaOverrides = { 'config.network.sessionAffinityHint': '同じセッションでは以前選択した認証情報を優先します', 'config.network.sessionTtl': 'セッションアフィニティ TTL', 'config.network.sessionTtlHint': '例:1h。空欄の場合はコアのデフォルト値を使用します', + 'config.network.disableCooling': 'クールダウンを無効化', + 'config.network.disableCoolingHint': '認証情報とモデルのクールダウン制御を全体で無効にします', 'config.network.requestRetry': 'リクエスト再試行回数', 'config.network.requestRetryHint': '403、408、500、502、503、504 応答の再試行回数です。0 で無効になります', 'config.network.maxRetryCredentials': '試行する認証情報の上限', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7cd0867..53533c8 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -478,6 +478,8 @@ export const en: Record = { 'config.network.sessionAffinityHint': 'Prefer the previously selected credential for the same session', 'config.network.sessionTtl': 'Session Affinity TTL', 'config.network.sessionTtlHint': 'For example, 1h; leave blank to use the core default', + 'config.network.disableCooling': 'Disable Cooling', + 'config.network.disableCoolingHint': 'Globally disables cooldown scheduling for credentials and models', 'config.network.requestRetry': 'Request Retries', 'config.network.requestRetryHint': 'Retries responses with status 403, 408, 500, 502, 503, or 504; 0 disables retries', 'config.network.maxRetryCredentials': 'Maximum Credentials Tried', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 5211b44..cdb553e 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -476,6 +476,8 @@ export const zhCN = { 'config.network.sessionAffinityHint': '同一会话优先复用已选择的凭据', 'config.network.sessionTtl': '会话粘性 TTL', 'config.network.sessionTtlHint': '例如 1h;留空使用内核默认值', + 'config.network.disableCooling': '禁用冷却', + 'config.network.disableCoolingHint': '全局禁用认证凭据和模型的冷却调度', 'config.network.requestRetry': '请求重试次数', 'config.network.requestRetryHint': '对 403、408、500、502、503、504 响应的重试次数;0 表示不重试', 'config.network.maxRetryCredentials': '最大尝试凭据数', diff --git a/src/pages/ConfigPanel.tsx b/src/pages/ConfigPanel.tsx index 2dd5dad..86418eb 100644 --- a/src/pages/ConfigPanel.tsx +++ b/src/pages/ConfigPanel.tsx @@ -41,6 +41,7 @@ type CoreConfigSettings = { proxyUrl: string; routingSessionAffinity: boolean; routingSessionAffinityTtl: string; + disableCooling: boolean; requestRetry: number; maxRetryCredentials: number; maxRetryInterval: number; @@ -72,6 +73,7 @@ type NetworkDraftField = | 'proxyUrl' | 'sessionAffinity' | 'sessionTtl' + | 'disableCooling' | 'requestRetry' | 'maxRetryCredentials' | 'maxRetryInterval' @@ -106,6 +108,7 @@ const cleanNetworkDraft = (): NetworkDraftDirty => ({ proxyUrl: false, sessionAffinity: false, sessionTtl: false, + disableCooling: false, requestRetry: false, maxRetryCredentials: false, maxRetryInterval: false, @@ -159,6 +162,7 @@ export function ConfigPanelPage() { const [proxyUrlDraft, setProxyUrlDraft] = useState(''); const [sessionAffinityDraft, setSessionAffinityDraft] = useState(false); const [sessionTtlDraft, setSessionTtlDraft] = useState(''); + const [disableCoolingDraft, setDisableCoolingDraft] = useState(false); const [requestRetryDraft, setRequestRetryDraft] = useState('3'); const [maxRetryCredentialsDraft, setMaxRetryCredentialsDraft] = useState('0'); const [maxRetryIntervalDraft, setMaxRetryIntervalDraft] = useState('30'); @@ -218,6 +222,7 @@ export function ConfigPanelPage() { if (!dirty.proxyUrl) setProxyUrlDraft(result.proxyUrl); if (!dirty.sessionAffinity) setSessionAffinityDraft(result.routingSessionAffinity); if (!dirty.sessionTtl) setSessionTtlDraft(result.routingSessionAffinityTtl); + if (!dirty.disableCooling) setDisableCoolingDraft(result.disableCooling); if (!dirty.requestRetry) setRequestRetryDraft(String(result.requestRetry)); if (!dirty.maxRetryCredentials) setMaxRetryCredentialsDraft(String(result.maxRetryCredentials)); if (!dirty.maxRetryInterval) setMaxRetryIntervalDraft(String(result.maxRetryInterval)); @@ -232,6 +237,7 @@ export function ConfigPanelPage() { setProxyUrlDraft(result.proxyUrl); setSessionAffinityDraft(result.routingSessionAffinity); setSessionTtlDraft(result.routingSessionAffinityTtl); + setDisableCoolingDraft(result.disableCooling); setRequestRetryDraft(String(result.requestRetry)); setMaxRetryCredentialsDraft(String(result.maxRetryCredentials)); setMaxRetryIntervalDraft(String(result.maxRetryInterval)); @@ -648,12 +654,14 @@ export function ConfigPanelPage() { try { const result = await invoke('save_retry_settings', { settings: { + disableCooling: disableCoolingDraft, requestRetry, maxRetryCredentials, maxRetryInterval, streamingBootstrapRetries, }, }); + clearDraftDirty('disableCooling'); clearDraftDirty('requestRetry'); clearDraftDirty('maxRetryCredentials'); clearDraftDirty('maxRetryInterval'); @@ -747,7 +755,8 @@ export function ConfigPanelPage() { || sessionTtlDraft.trim() !== settings?.routingSessionAffinityTtl ); const retrySettingsDirty = Boolean(settings) && ( - requestRetryDraft !== String(settings?.requestRetry) + disableCoolingDraft !== settings?.disableCooling + || requestRetryDraft !== String(settings?.requestRetry) || maxRetryCredentialsDraft !== String(settings?.maxRetryCredentials) || maxRetryIntervalDraft !== String(settings?.maxRetryInterval) || streamingBootstrapRetriesDraft !== String(settings?.streamingBootstrapRetries) @@ -871,7 +880,7 @@ export function ConfigPanelPage() { {activeSubpage === 'general' ? (
+
+
+ {t('config.network.disableCooling')} + {t('config.network.disableCoolingHint')} +
+ +
+