diff --git a/src-tauri/src/core_config/commands.rs b/src-tauri/src/core_config/commands.rs index 2034662..4820b8d 100644 --- a/src-tauri/src/core_config/commands.rs +++ b/src-tauri/src/core_config/commands.rs @@ -103,16 +103,14 @@ 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.request_retry = settings.request_retry; + next.max_retry_credentials = settings.max_retry_credentials; + next.max_retry_interval = settings.max_retry_interval; + next.streaming_bootstrap_retries = settings.streaming_bootstrap_retries; validate_gui_config(&next)?; patch_core_network_routing_settings(&next)?; - let config = match gui_config_state.update_network_routing( - settings.port, - settings.allow_lan, - &proxy_url, - settings.routing_session_affinity, - &routing_session_affinity_ttl, - ) { + let config = match gui_config_state.update_network_routing(&next) { Ok(config) => config, Err(error) => { let rollback_error = patch_core_network_routing_settings(&previous).err(); diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index ad7a879..7ffb6a2 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -341,6 +341,31 @@ 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, + "request-retry", + serde_norway::to_value(config.request_retry) + .map_err(|err| format!("序列化请求重试次数失败: {err}"))?, + )?; + set_core_yaml_top_level_value( + document, + "max-retry-credentials", + serde_norway::to_value(config.max_retry_credentials) + .map_err(|err| format!("序列化最大重试凭据数失败: {err}"))?, + )?; + set_core_yaml_top_level_value( + document, + "max-retry-interval", + serde_norway::to_value(config.max_retry_interval) + .map_err(|err| format!("序列化最大重试等待时间失败: {err}"))?, + )?; + set_core_yaml_nested_value( + document, + "streaming", + "bootstrap-retries", + serde_norway::to_value(config.streaming_bootstrap_retries) + .map_err(|err| format!("序列化流式启动重试次数失败: {err}"))?, + )?; Ok(*document != original) }) } @@ -446,6 +471,31 @@ 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, + "request-retry", + serde_norway::to_value(config.request_retry) + .map_err(|err| format!("序列化请求重试次数失败: {err}"))?, + )?; + changed |= set_core_yaml_top_level_value( + document, + "max-retry-credentials", + serde_norway::to_value(config.max_retry_credentials) + .map_err(|err| format!("序列化最大重试凭据数失败: {err}"))?, + )?; + changed |= set_core_yaml_top_level_value( + document, + "max-retry-interval", + serde_norway::to_value(config.max_retry_interval) + .map_err(|err| format!("序列化最大重试等待时间失败: {err}"))?, + )?; + changed |= set_core_yaml_nested_value( + document, + "streaming", + "bootstrap-retries", + serde_norway::to_value(config.streaming_bootstrap_retries) + .map_err(|err| format!("序列化流式启动重试次数失败: {err}"))?, + )?; Ok(changed) })? .unwrap_or_else(|| content.to_string()); @@ -711,7 +761,11 @@ pub(crate) fn load_or_create_gui_config() -> Result { || presence.routing_strategy.is_none() || presence.proxy_url.is_none() || presence.routing_session_affinity.is_none() - || presence.routing_session_affinity_ttl.is_none(); + || presence.routing_session_affinity_ttl.is_none() + || presence.request_retry.is_none() + || presence.max_retry_credentials.is_none() + || presence.max_retry_interval.is_none() + || presence.streaming_bootstrap_retries.is_none(); if missing_core_settings && !gui_parse_failed { if let Ok(core_settings) = read_installed_core_config_settings() { if presence.host.is_none() { @@ -759,6 +813,18 @@ 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.request_retry.is_none() { + config.request_retry = core_settings.request_retry; + } + if presence.max_retry_credentials.is_none() { + config.max_retry_credentials = core_settings.max_retry_credentials; + } + if presence.max_retry_interval.is_none() { + config.max_retry_interval = core_settings.max_retry_interval; + } + if presence.streaming_bootstrap_retries.is_none() { + config.streaming_bootstrap_retries = core_settings.streaming_bootstrap_retries; + } } changed = true; } @@ -828,6 +894,10 @@ 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.request_retry = core_settings.request_retry; + config.max_retry_credentials = core_settings.max_retry_credentials; + config.max_retry_interval = core_settings.max_retry_interval; + config.streaming_bootstrap_retries = core_settings.streaming_bootstrap_retries; } pub(crate) fn default_api_key_entry() -> GuiApiKeyEntry { @@ -1130,6 +1200,19 @@ pub(crate) fn write_gui_config_to_path( "routing-session-affinity-ttl", value(config.routing_session_affinity_ttl.as_str()), ), + ("request-retry", value(i64::from(config.request_retry))), + ( + "max-retry-credentials", + value(i64::from(config.max_retry_credentials)), + ), + ( + "max-retry-interval", + value(i64::from(config.max_retry_interval)), + ), + ( + "streaming-bootstrap-retries", + value(i64::from(config.streaming_bootstrap_retries)), + ), ] { set_codex_table_item(root, key, item); } diff --git a/src-tauri/src/core_config/yaml.rs b/src-tauri/src/core_config/yaml.rs index 9f0974f..48934d6 100644 --- a/src-tauri/src/core_config/yaml.rs +++ b/src-tauri/src/core_config/yaml.rs @@ -1056,6 +1056,42 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or_default(); + let request_retry = yaml_mapping_value(root, "request-retry") + .map(|value| { + value + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| "request-retry 必须是非负整数".to_string()) + }) + .transpose()? + .unwrap_or(DEFAULT_REQUEST_RETRY); + let max_retry_credentials = yaml_mapping_value(root, "max-retry-credentials") + .map(|value| { + value + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| "max-retry-credentials 必须是非负整数".to_string()) + }) + .transpose()? + .unwrap_or(DEFAULT_MAX_RETRY_CREDENTIALS); + let max_retry_interval = yaml_mapping_value(root, "max-retry-interval") + .map(|value| { + value + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| "max-retry-interval 必须是非负整数".to_string()) + }) + .transpose()? + .unwrap_or(DEFAULT_MAX_RETRY_INTERVAL); + let streaming_bootstrap_retries = nested_yaml_value(root, &["streaming", "bootstrap-retries"]) + .map(|value| { + value + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| "streaming.bootstrap-retries 必须是非负整数".to_string()) + }) + .transpose()? + .unwrap_or(DEFAULT_STREAMING_BOOTSTRAP_RETRIES); Ok(CoreConfigSettings { host, @@ -1071,6 +1107,10 @@ pub(crate) fn core_config_settings_from_value( proxy_url, routing_session_affinity, routing_session_affinity_ttl, + request_retry, + max_retry_credentials, + max_retry_interval, + streaming_bootstrap_retries, management_secret_key, }) } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ff8b9a5..774e9e8 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -120,6 +120,10 @@ const OAUTH_DIR_NAME: &str = "oauth"; const DEFAULT_AUTH_DIR: &str = "../oauth"; const DEFAULT_API_KEY: &str = "123456"; const DEFAULT_API_KEY_INITIAL_REMARK: &str = "默认密钥"; +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 LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY: &str = "123456"; const MANAGED_AGENT_PROVIDER_ID: &str = "cpa-gui"; const PI_AGENT_ID: &str = "pi"; @@ -561,6 +565,10 @@ struct GuiConfigFile { proxy_url: String, routing_session_affinity: bool, routing_session_affinity_ttl: String, + request_retry: u32, + max_retry_credentials: u32, + max_retry_interval: u32, + streaming_bootstrap_retries: u32, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] @@ -668,6 +676,10 @@ impl Default for GuiConfigFile { proxy_url: String::new(), routing_session_affinity: false, routing_session_affinity_ttl: String::new(), + request_retry: DEFAULT_REQUEST_RETRY, + max_retry_credentials: DEFAULT_MAX_RETRY_CREDENTIALS, + max_retry_interval: DEFAULT_MAX_RETRY_INTERVAL, + streaming_bootstrap_retries: DEFAULT_STREAMING_BOOTSTRAP_RETRIES, } } } @@ -691,6 +703,10 @@ struct GuiConfigPresence { proxy_url: Option, routing_session_affinity: Option, routing_session_affinity_ttl: Option, + request_retry: Option, + max_retry_credentials: Option, + max_retry_interval: Option, + streaming_bootstrap_retries: Option, } #[derive(Clone, Serialize)] @@ -1043,6 +1059,10 @@ struct GuiNetworkRoutingSettings { proxy_url: String, routing_session_affinity: bool, routing_session_affinity_ttl: String, + request_retry: u32, + max_retry_credentials: u32, + max_retry_interval: u32, + streaming_bootstrap_retries: u32, } #[derive(Clone, Serialize)] @@ -1063,6 +1083,10 @@ struct CoreConfigSettings { proxy_url: String, routing_session_affinity: bool, routing_session_affinity_ttl: String, + request_retry: u32, + max_retry_credentials: u32, + max_retry_interval: u32, + streaming_bootstrap_retries: u32, // Kept for internal config migration/tests; never exposed to the WebView. #[allow(dead_code)] #[serde(skip_serializing)] @@ -1088,6 +1112,10 @@ struct CoreConfigView { proxy_url: String, routing_session_affinity: bool, routing_session_affinity_ttl: String, + request_retry: u32, + max_retry_credentials: u32, + max_retry_interval: u32, + streaming_bootstrap_retries: u32, } impl Default for CoreInstallTask { @@ -1439,21 +1467,18 @@ impl GuiConfigState { }) } - fn update_network_routing( - &self, - port: u16, - allow_lan: bool, - proxy_url: &str, - routing_session_affinity: bool, - routing_session_affinity_ttl: &str, - ) -> Result { + fn update_network_routing(&self, settings: &GuiConfigFile) -> Result { self.update(|config| { - config.port = port; - config.allow_lan = allow_lan; - config.host = if allow_lan { "0.0.0.0" } else { "127.0.0.1" }.to_string(); - config.proxy_url = proxy_url.to_string(); - config.routing_session_affinity = routing_session_affinity; - config.routing_session_affinity_ttl = routing_session_affinity_ttl.to_string(); + config.port = settings.port; + config.allow_lan = settings.allow_lan; + config.host = settings.host.clone(); + 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.request_retry = settings.request_retry; + config.max_retry_credentials = settings.max_retry_credentials; + config.max_retry_interval = settings.max_retry_interval; + config.streaming_bootstrap_retries = settings.streaming_bootstrap_retries; Ok(()) }) } @@ -1542,6 +1567,10 @@ 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.request_retry = settings.request_retry; + config.max_retry_credentials = settings.max_retry_credentials; + config.max_retry_interval = settings.max_retry_interval; + config.streaming_bootstrap_retries = settings.streaming_bootstrap_retries; Ok(()) }) } @@ -1587,6 +1616,10 @@ 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(), + request_retry: config.request_retry, + max_retry_credentials: config.max_retry_credentials, + max_retry_interval: config.max_retry_interval, + streaming_bootstrap_retries: config.streaming_bootstrap_retries, management_secret_key: Some(config.management_secret_key.clone()), } } @@ -1611,6 +1644,10 @@ 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(), + request_retry: config.request_retry, + max_retry_credentials: config.max_retry_credentials, + max_retry_interval: config.max_retry_interval, + streaming_bootstrap_retries: config.streaming_bootstrap_retries, } } } diff --git a/src-tauri/src/tests/app_settings.rs b/src-tauri/src/tests/app_settings.rs index 67c5667..8149a67 100644 --- a/src-tauri/src/tests/app_settings.rs +++ b/src-tauri/src/tests/app_settings.rs @@ -88,6 +88,10 @@ fn gui_config_defaults_are_stable() { assert!(content.contains("management-secret-key = \"\"")); assert!(content.contains("plugins-enabled = false")); assert!(content.contains("routing-strategy = \"round-robin\"")); + assert!(content.contains("request-retry = 3")); + assert!(content.contains("max-retry-credentials = 0")); + assert!(content.contains("max-retry-interval = 30")); + assert!(content.contains("streaming-bootstrap-retries = 0")); } #[test] diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index 04745c6..ee0db50 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -213,6 +213,10 @@ 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(), + request_retry: 1, + max_retry_credentials: 2, + max_retry_interval: 5, + streaming_bootstrap_retries: 1, management_secret_key: Some("management-secret".to_string()), }; @@ -233,6 +237,10 @@ fn legacy_gui_config_can_seed_managed_core_settings() { assert_eq!(config.proxy_url, "http://127.0.0.1:8080"); assert!(config.routing_session_affinity); assert_eq!(config.routing_session_affinity_ttl, "45m"); + assert_eq!(config.request_retry, 1); + assert_eq!(config.max_retry_credentials, 2); + assert_eq!(config.max_retry_interval, 5); + assert_eq!(config.streaming_bootstrap_retries, 1); assert!(config.run_on_startup); } @@ -343,6 +351,10 @@ 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(), + request_retry: 1, + max_retry_credentials: 2, + max_retry_interval: 5, + streaming_bootstrap_retries: 1, ..GuiConfigFile::default() }; let input = "# network\nhost: 127.0.0.1\nport: 8317\nproxy-url: \"\"\n# routing\nrouting:\n strategy: round-robin\n# unrelated\ndebug: true\n"; @@ -356,6 +368,10 @@ 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["request-retry"], 1); + assert_eq!(document["max-retry-credentials"], 2); + assert_eq!(document["max-retry-interval"], 5); + assert_eq!(document["streaming"]["bootstrap-retries"], 1); assert_eq!(document["routing"]["strategy"], "round-robin"); assert_eq!(document["debug"], true); assert!(updated.contains("# network")); @@ -634,6 +650,32 @@ fn core_config_reads_proxy_and_session_affinity_fields() { assert_eq!(defaults.routing_session_affinity_ttl, ""); } +#[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", + ) + .unwrap(); + let settings = core_config_settings_from_value(&document).unwrap(); + + 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.request_retry, DEFAULT_REQUEST_RETRY); + assert_eq!( + defaults.max_retry_credentials, + DEFAULT_MAX_RETRY_CREDENTIALS + ); + assert_eq!(defaults.max_retry_interval, DEFAULT_MAX_RETRY_INTERVAL); + assert_eq!( + defaults.streaming_bootstrap_retries, + DEFAULT_STREAMING_BOOTSTRAP_RETRIES + ); +} + #[test] fn managed_session_settings_use_canonical_yaml_and_preserve_unrelated_content() { let input = "# global proxy\nproxy-url: old\n# routing options\nrouting:\n strategy: round-robin\n sessionAffinity: false\n sessionAffinityTTL: 10m\n# unrelated option\ndebug: true\n"; @@ -641,6 +683,10 @@ 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(), + request_retry: 1, + max_retry_credentials: 2, + max_retry_interval: 5, + streaming_bootstrap_retries: 1, ..GuiConfigFile::default() }; let rendered = apply_gui_managed_settings(input, &config).unwrap(); @@ -649,6 +695,10 @@ 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["request-retry"], 1); + assert_eq!(document["max-retry-credentials"], 2); + assert_eq!(document["max-retry-interval"], 5); + assert_eq!(document["streaming"]["bootstrap-retries"], 1); assert!(rendered.contains("# global proxy")); assert!(rendered.contains("# unrelated option")); assert_eq!(document["debug"], true); @@ -737,6 +787,10 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() { proxy_url: "socks5://127.0.0.1:7890".to_string(), routing_session_affinity: true, routing_session_affinity_ttl: "1h".to_string(), + request_retry: 1, + max_retry_credentials: 2, + max_retry_interval: 5, + streaming_bootstrap_retries: 1, }; let merged = merge_core_config_yaml(template, Some(current), &config).unwrap(); let current_document = serde_norway::from_str::(current).unwrap(); @@ -759,6 +813,10 @@ 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["request-retry"], 1); + assert_eq!(document["max-retry-credentials"], 2); + assert_eq!(document["max-retry-interval"], 5); + assert_eq!(document["streaming"]["bootstrap-retries"], 1); assert_eq!(document["usage-statistics-enabled"], false); assert!(document.get("new-option").is_none()); assert_eq!(document["nested"], current_document["nested"]); diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 384ccf8..a225ec5 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -3038,6 +3038,10 @@ mod tests { proxy_url: String::new(), routing_session_affinity: false, routing_session_affinity_ttl: String::new(), + request_retry: crate::DEFAULT_REQUEST_RETRY, + max_retry_credentials: crate::DEFAULT_MAX_RETRY_CREDENTIALS, + max_retry_interval: crate::DEFAULT_MAX_RETRY_INTERVAL, + streaming_bootstrap_retries: crate::DEFAULT_STREAMING_BOOTSTRAP_RETRIES, }; let record = normalize_usage_record( serde_json::json!({ diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 0fc0a57..a57608e 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -274,6 +274,7 @@ export const jaOverrides = { 'config.error.duplicateKey': '同じ認証キーがすでに存在します', 'config.error.remarkTooLong': 'メモは 80 文字以内で入力してください', 'config.error.portRange': 'ポートは 1〜65535 の整数で入力してください', + 'config.error.retryRange': '再試行設定は 0〜4294967295 の整数で入力してください', 'config.error.networkRestartFailed': '設定は保存されましたが、コアの再起動に失敗しました:{error}', 'config.error.saveFailed': '保存に失敗しました:{error}', 'config.notice.keyAdded': '認証キーを追加しました', @@ -330,6 +331,7 @@ export const jaOverrides = { 'config.network.title': 'ネットワークとセッションルーティング', 'config.network.networkSection': 'ネットワーク設定', 'config.network.routingSection': 'セッションルーティング', + 'config.network.retrySection': '失敗時の再試行', 'config.network.saving': '保存中', 'config.network.saved': '保存済み', 'config.network.unsaved': '未保存の変更あり', @@ -345,6 +347,14 @@ export const jaOverrides = { 'config.network.sessionAffinityHint': '同じセッションでは以前選択した認証情報を優先します', 'config.network.sessionTtl': 'セッションアフィニティ TTL', 'config.network.sessionTtlHint': '例:1h。空欄の場合はコアのデフォルト値を使用します', + 'config.network.requestRetry': 'リクエスト再試行回数', + 'config.network.requestRetryHint': '403、408、500、502、503、504 応答の再試行回数です。0 で無効になります', + 'config.network.maxRetryCredentials': '試行する認証情報の上限', + 'config.network.maxRetryCredentialsHint': '失敗した1リクエストで試す認証情報の上限です。0 ですべてを試します', + 'config.network.maxRetryInterval': '再試行の最大待機時間(秒)', + 'config.network.maxRetryIntervalHint': 'クールダウン中の認証情報を待つ上限です。小さくすると累積待機時間を短縮できますが、リクエストの強制タイムアウトではありません', + 'config.network.streamingBootstrapRetries': 'ストリーミング開始時の再試行回数', + 'config.network.streamingBootstrapRetriesHint': '最初の応答を送信する前に許可する安全な再試行回数です。0 で無効になります', 'config.notice.softwareUpdated': 'ソフトウェア設定を保存しました', 'config.software.title': 'ソフトウェア設定', 'config.software.autostart': 'システム起動時に開始', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 8505ded..85654b9 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -274,6 +274,7 @@ export const en: Record = { 'config.error.duplicateKey': 'This authentication key already exists', 'config.error.remarkTooLong': 'The remark cannot exceed 80 characters', 'config.error.portRange': 'The port must be an integer from 1 to 65535', + 'config.error.retryRange': 'Retry settings must be integers from 0 to 4294967295', 'config.error.networkRestartFailed': 'Settings were saved, but the core failed to restart: {error}', 'config.error.saveFailed': 'Failed to save: {error}', 'config.notice.keyAdded': 'Authentication key added', @@ -341,6 +342,7 @@ export const en: Record = { 'config.network.title': 'Network & Session Routing', 'config.network.networkSection': 'Network Settings', 'config.network.routingSection': 'Session Routing', + 'config.network.retrySection': 'Failure Retries', 'config.network.saving': 'Saving', 'config.network.saved': 'Saved', 'config.network.unsaved': 'Unsaved changes', @@ -356,6 +358,14 @@ 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.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', + 'config.network.maxRetryCredentialsHint': 'Maximum credentials tried for one failed request; 0 tries all available credentials', + 'config.network.maxRetryInterval': 'Maximum Retry Wait (seconds)', + 'config.network.maxRetryIntervalHint': 'Limits how long to wait for cooled-down credentials; lowering it reduces cumulative wait, but is not a hard request timeout', + 'config.network.streamingBootstrapRetries': 'Streaming Bootstrap Retries', + 'config.network.streamingBootstrapRetriesHint': 'Safe retries allowed before the first response is sent; 0 disables them', 'config.keys.addTitle': 'Add Authentication Key', 'config.keys.editTitle': 'Edit Authentication Key', 'common.close': 'Close', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index a656c6c..081d2d2 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -272,6 +272,7 @@ export const zhCN = { 'config.error.duplicateKey': '该鉴权密钥已经存在', 'config.error.remarkTooLong': '备注不能超过 80 个字符', 'config.error.portRange': '端口必须是 1–65535 之间的整数', + 'config.error.retryRange': '重试设置必须是 0–4294967295 之间的整数', 'config.error.networkRestartFailed': '设置已保存,但内核重启失败:{error}', 'config.error.saveFailed': '保存失败:{error}', 'config.notice.keyAdded': '鉴权密钥已新增', @@ -339,6 +340,7 @@ export const zhCN = { 'config.network.title': '网络与会话路由', 'config.network.networkSection': '网络设置', 'config.network.routingSection': '会话路由', + 'config.network.retrySection': '失败重试', 'config.network.saving': '正在保存', 'config.network.saved': '已保存', 'config.network.unsaved': '有未保存修改', @@ -354,6 +356,14 @@ export const zhCN = { 'config.network.sessionAffinityHint': '同一会话优先复用已选择的凭据', 'config.network.sessionTtl': '会话粘性 TTL', 'config.network.sessionTtlHint': '例如 1h;留空使用内核默认值', + 'config.network.requestRetry': '请求重试次数', + 'config.network.requestRetryHint': '对 403、408、500、502、503、504 响应的重试次数;0 表示不重试', + 'config.network.maxRetryCredentials': '最大尝试凭据数', + 'config.network.maxRetryCredentialsHint': '单次失败请求最多尝试的凭据数;0 表示尝试全部可用凭据', + 'config.network.maxRetryInterval': '最大重试等待(秒)', + 'config.network.maxRetryIntervalHint': '等待冷却凭据的最长时间;减小可缩短累计等待,但不是单次请求硬超时', + 'config.network.streamingBootstrapRetries': '流式启动重试次数', + 'config.network.streamingBootstrapRetriesHint': '发送首个响应前允许的安全重试次数;0 表示禁用', 'config.keys.addTitle': '新增鉴权密钥', 'config.keys.editTitle': '编辑鉴权密钥', 'common.close': '关闭', diff --git a/src/pages/ConfigPanel.tsx b/src/pages/ConfigPanel.tsx index b356228..c5b1515 100644 --- a/src/pages/ConfigPanel.tsx +++ b/src/pages/ConfigPanel.tsx @@ -35,6 +35,10 @@ type CoreConfigSettings = { proxyUrl: string; routingSessionAffinity: boolean; routingSessionAffinityTtl: string; + requestRetry: number; + maxRetryCredentials: number; + maxRetryInterval: number; + streamingBootstrapRetries: number; }; type CoreApiKey = { @@ -54,7 +58,16 @@ type ConfigAction = type NoticeTone = 'success' | 'error'; type ConfigSubpage = 'general' | 'network' | 'software' | 'aliases'; type CloseBehavior = 'ask' | 'exit' | 'minimize-to-tray'; -type NetworkDraftField = 'port' | 'allowLan' | 'proxyUrl' | 'sessionAffinity' | 'sessionTtl'; +type NetworkDraftField = + | 'port' + | 'allowLan' + | 'proxyUrl' + | 'sessionAffinity' + | 'sessionTtl' + | 'requestRetry' + | 'maxRetryCredentials' + | 'maxRetryInterval' + | 'streamingBootstrapRetries'; type DraftRefreshMode = 'replace' | 'preserve'; type NetworkDraftDirty = Record; @@ -71,6 +84,10 @@ const cleanNetworkDraft = (): NetworkDraftDirty => ({ proxyUrl: false, sessionAffinity: false, sessionTtl: false, + requestRetry: false, + maxRetryCredentials: false, + maxRetryInterval: false, + streamingBootstrapRetries: false, }); const ROUTING_OPTIONS = [ @@ -110,7 +127,12 @@ export function ConfigPanelPage() { const [proxyUrlDraft, setProxyUrlDraft] = useState(''); const [sessionAffinityDraft, setSessionAffinityDraft] = useState(false); const [sessionTtlDraft, setSessionTtlDraft] = useState(''); + const [requestRetryDraft, setRequestRetryDraft] = useState('3'); + const [maxRetryCredentialsDraft, setMaxRetryCredentialsDraft] = useState('0'); + const [maxRetryIntervalDraft, setMaxRetryIntervalDraft] = useState('30'); + const [streamingBootstrapRetriesDraft, setStreamingBootstrapRetriesDraft] = useState('0'); const [portError, setPortError] = useState(''); + const [retryError, setRetryError] = useState(''); const [savedStatusVisible, setSavedStatusVisible] = useState(false); const networkDraftDirtyRef = useRef(cleanNetworkDraft()); const noticeTimerRef = useRef(null); @@ -162,6 +184,12 @@ export function ConfigPanelPage() { if (!dirty.proxyUrl) setProxyUrlDraft(result.proxyUrl); if (!dirty.sessionAffinity) setSessionAffinityDraft(result.routingSessionAffinity); if (!dirty.sessionTtl) setSessionTtlDraft(result.routingSessionAffinityTtl); + if (!dirty.requestRetry) setRequestRetryDraft(String(result.requestRetry)); + if (!dirty.maxRetryCredentials) setMaxRetryCredentialsDraft(String(result.maxRetryCredentials)); + if (!dirty.maxRetryInterval) setMaxRetryIntervalDraft(String(result.maxRetryInterval)); + if (!dirty.streamingBootstrapRetries) { + setStreamingBootstrapRetriesDraft(String(result.streamingBootstrapRetries)); + } return; } networkDraftDirtyRef.current = cleanNetworkDraft(); @@ -170,7 +198,12 @@ export function ConfigPanelPage() { setProxyUrlDraft(result.proxyUrl); setSessionAffinityDraft(result.routingSessionAffinity); setSessionTtlDraft(result.routingSessionAffinityTtl); + setRequestRetryDraft(String(result.requestRetry)); + setMaxRetryCredentialsDraft(String(result.maxRetryCredentials)); + setMaxRetryIntervalDraft(String(result.maxRetryInterval)); + setStreamingBootstrapRetriesDraft(String(result.streamingBootstrapRetries)); setPortError(''); + setRetryError(''); }; const markDraftDirty = (field: NetworkDraftField) => { @@ -426,8 +459,25 @@ export function ConfigPanelPage() { const proxyUrl = proxyUrlDraft.trim(); const routingSessionAffinityTtl = sessionTtlDraft.trim(); + const retryDrafts = [ + requestRetryDraft, + maxRetryCredentialsDraft, + maxRetryIntervalDraft, + streamingBootstrapRetriesDraft, + ]; + const retryValues = retryDrafts.map(Number); + if ( + retryDrafts.some((value) => value.length === 0) + || retryValues.some((value) => !Number.isInteger(value) || value < 0 || value > 4294967295) + ) { + setRetryError(t('config.error.retryRange')); + showNotice(t('config.error.retryRange'), 'error'); + return; + } + const [requestRetry, maxRetryCredentials, maxRetryInterval, streamingBootstrapRetries] = retryValues; const networkChanged = port !== settings.port || allowLanDraft !== settings.allowLan; setPortError(''); + setRetryError(''); setSavedStatusVisible(false); setBusyAction('network'); try { @@ -438,6 +488,10 @@ export function ConfigPanelPage() { proxyUrl, routingSessionAffinity: sessionAffinityDraft, routingSessionAffinityTtl, + requestRetry, + maxRetryCredentials, + maxRetryInterval, + streamingBootstrapRetries, }, }); applySettings(result); @@ -509,6 +563,10 @@ export function ConfigPanelPage() { || proxyUrlDraft.trim() !== settings?.proxyUrl || sessionAffinityDraft !== settings?.routingSessionAffinity || sessionTtlDraft.trim() !== settings?.routingSessionAffinityTtl + || requestRetryDraft !== String(settings?.requestRetry) + || maxRetryCredentialsDraft !== String(settings?.maxRetryCredentials) + || maxRetryIntervalDraft !== String(settings?.maxRetryInterval) + || streamingBootstrapRetriesDraft !== String(settings?.streamingBootstrapRetries) ); const networkStatusLabel = loading ? t('common.loading') @@ -1034,6 +1092,126 @@ export function ConfigPanelPage() { + +
+
+
+
+ + + + + + + +
+