From 392756e37330b3b55aff76a2d22fc403300a41a9 Mon Sep 17 00:00:00 2001
From: Xyfer <39858937+cxyfer@users.noreply.github.com>
Date: Sun, 11 Jan 2026 10:42:44 +0800
Subject: [PATCH 1/7] fix(gemini): convert timeout params to Gemini CLI format
(#580)
Claude Code/Codex uses startup_timeout_sec and tool_timeout_sec,
but Gemini CLI only supports a single timeout param in milliseconds.
- Collect startup and tool timeout separately with defaults
- startup_timeout_sec default: 10s
- tool_timeout_sec default: 60s
- Take max of both values as final timeout
- Support both sec and ms variants
- Remove original fields and insert Gemini-compatible timeout
---
src-tauri/src/gemini_mcp.rs | 27 +++++++++++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/src-tauri/src/gemini_mcp.rs b/src-tauri/src/gemini_mcp.rs
index 62a959c18..72d7a86d5 100644
--- a/src-tauri/src/gemini_mcp.rs
+++ b/src-tauri/src/gemini_mcp.rs
@@ -134,6 +134,33 @@ pub fn set_mcp_servers_map(
obj.remove("homepage");
obj.remove("docs");
+ // Timeout 转换:Claude/Codex 使用 startup_timeout_sec/tool_timeout_sec
+ // Gemini CLI 只支持 timeout(单位 ms)
+ // 默认值:startup=10s, tool=60s
+ const DEFAULT_STARTUP_MS: u64 = 10_000;
+ const DEFAULT_TOOL_MS: u64 = 60_000;
+
+ let extract_timeout =
+ |obj: &mut Map, key: &str, multiplier: u64| -> Option {
+ obj.remove(key).and_then(|val| {
+ val.as_u64()
+ .map(|n| n * multiplier)
+ .or_else(|| val.as_f64().map(|f| (f * multiplier as f64) as u64))
+ })
+ };
+
+ // 分别收集 startup 和 tool timeout,未设置时使用默认值
+ let startup_ms = extract_timeout(&mut obj, "startup_timeout_sec", 1000)
+ .or_else(|| extract_timeout(&mut obj, "startup_timeout_ms", 1))
+ .unwrap_or(DEFAULT_STARTUP_MS);
+ let tool_ms = extract_timeout(&mut obj, "tool_timeout_sec", 1000)
+ .or_else(|| extract_timeout(&mut obj, "tool_timeout_ms", 1))
+ .unwrap_or(DEFAULT_TOOL_MS);
+
+ // 取最大值作为 Gemini timeout
+ let final_timeout = startup_ms.max(tool_ms);
+ obj.insert("timeout".to_string(), Value::Number(final_timeout.into()));
+
out.insert(id.clone(), Value::Object(obj));
}
From d33f99aca48a0f88f95dbfe3538a57ad73b325ce Mon Sep 17 00:00:00 2001
From: YoVinchen
Date: Sun, 11 Jan 2026 14:12:57 +0800
Subject: [PATCH 2/7] fix(proxy): improve validation and error handling in
proxy config panels
- Add StopTimeout/StopFailed error types for proper stop() error reporting
- Replace silent clamp with validation-and-block in config panels
- Add listenAddress format validation in ProxyPanel
- Use log_codes constants instead of hardcoded strings
- Use once_cell::Lazy for regex precompilation
---
src-tauri/src/commands/misc.rs | 11 +-
src-tauri/src/proxy/circuit_breaker.rs | 31 ++++-
src-tauri/src/proxy/error.rs | 12 ++
src-tauri/src/proxy/server.rs | 28 ++--
src-tauri/src/services/skill.rs | 2 +-
src-tauri/src/settings.rs | 15 ++-
.../proxy/AutoFailoverConfigPanel.tsx | 123 ++++++++++++++++--
.../proxy/CircuitBreakerConfigPanel.tsx | 92 +++++++++++--
src/components/proxy/ProxyPanel.tsx | 35 ++++-
src/i18n/locales/en.json | 18 ++-
src/i18n/locales/zh.json | 18 ++-
11 files changed, 331 insertions(+), 54 deletions(-)
diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs
index 36ffd2b63..0365619c0 100644
--- a/src-tauri/src/commands/misc.rs
+++ b/src-tauri/src/commands/misc.rs
@@ -1,6 +1,8 @@
#![allow(non_snake_case)]
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
+use once_cell::sync::Lazy;
+use regex::Regex;
use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt;
@@ -142,11 +144,14 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op
}
}
+/// 预编译的版本号正则表达式
+static VERSION_RE: Lazy =
+ Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
+
/// 从版本输出中提取纯版本号
fn extract_version(raw: &str) -> String {
- // 匹配 semver 格式: x.y.z 或 x.y.z-xxx
- let re = regex::Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex pattern");
- re.find(raw)
+ VERSION_RE
+ .find(raw)
.map(|m| m.as_str().to_string())
.unwrap_or_else(|| raw.to_string())
}
diff --git a/src-tauri/src/proxy/circuit_breaker.rs b/src-tauri/src/proxy/circuit_breaker.rs
index def493442..310ce9bcb 100644
--- a/src-tauri/src/proxy/circuit_breaker.rs
+++ b/src-tauri/src/proxy/circuit_breaker.rs
@@ -2,6 +2,7 @@
//!
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
+use super::log_codes::cb as log_cb;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
@@ -126,7 +127,10 @@ impl CircuitBreaker {
if let Some(opened_at) = *self.last_opened_at.read().await {
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
drop(config); // 释放读锁再转换状态
- log::info!("[CB-001] 熔断器 Open → HalfOpen (超时恢复)");
+ log::info!(
+ "[{}] 熔断器 Open → HalfOpen (超时恢复)",
+ log_cb::OPEN_TO_HALF_OPEN
+ );
self.transition_to_half_open().await;
return true;
}
@@ -151,7 +155,10 @@ impl CircuitBreaker {
if let Some(opened_at) = *self.last_opened_at.read().await {
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
drop(config); // 释放读锁再转换状态
- log::info!("熔断器 Open → HalfOpen (超时恢复)");
+ log::info!(
+ "[{}] 熔断器 Open → HalfOpen (超时恢复)",
+ log_cb::OPEN_TO_HALF_OPEN
+ );
self.transition_to_half_open().await;
// 转换后按当前状态决定是否需要获取 HalfOpen 探测名额
@@ -197,7 +204,10 @@ impl CircuitBreaker {
if successes >= config.success_threshold {
drop(config); // 释放读锁再转换状态
- log::info!("[CB-002] 熔断器 HalfOpen → Closed (恢复正常)");
+ log::info!(
+ "[{}] 熔断器 HalfOpen → Closed (恢复正常)",
+ log_cb::HALF_OPEN_TO_CLOSED
+ );
self.transition_to_closed().await;
}
}
@@ -224,14 +234,20 @@ impl CircuitBreaker {
match state {
CircuitState::HalfOpen => {
// HalfOpen 状态下失败,立即转为 Open
- log::warn!("[CB-003] 熔断器 HalfOpen 探测失败 → Open");
+ log::warn!(
+ "[{}] 熔断器 HalfOpen 探测失败 → Open",
+ log_cb::HALF_OPEN_PROBE_FAILED
+ );
drop(config);
self.transition_to_open().await;
}
CircuitState::Closed => {
// 检查连续失败次数
if failures >= config.failure_threshold {
- log::warn!("[CB-004] 熔断器触发: 连续失败 {failures} 次 → Open");
+ log::warn!(
+ "[{}] 熔断器触发: 连续失败 {failures} 次 → Open",
+ log_cb::TRIGGERED_FAILURES
+ );
drop(config); // 释放读锁再转换状态
self.transition_to_open().await;
} else {
@@ -244,7 +260,8 @@ impl CircuitBreaker {
if error_rate >= config.error_rate_threshold {
log::warn!(
- "[CB-005] 熔断器触发: 错误率 {:.1}% → Open",
+ "[{}] 熔断器触发: 错误率 {:.1}% → Open",
+ log_cb::TRIGGERED_ERROR_RATE,
error_rate * 100.0
);
drop(config); // 释放读锁再转换状态
@@ -278,7 +295,7 @@ impl CircuitBreaker {
/// 重置熔断器(手动恢复)
#[allow(dead_code)]
pub async fn reset(&self) {
- log::info!("[CB-006] 熔断器手动重置 → Closed");
+ log::info!("[{}] 熔断器手动重置 → Closed", log_cb::MANUAL_RESET);
self.transition_to_closed().await;
}
diff --git a/src-tauri/src/proxy/error.rs b/src-tauri/src/proxy/error.rs
index 1b0531a3a..cc66d68e6 100644
--- a/src-tauri/src/proxy/error.rs
+++ b/src-tauri/src/proxy/error.rs
@@ -17,6 +17,12 @@ pub enum ProxyError {
#[error("地址绑定失败: {0}")]
BindFailed(String),
+ #[error("停止超时")]
+ StopTimeout,
+
+ #[error("停止失败: {0}")]
+ StopFailed(String),
+
#[error("请求转发失败: {0}")]
ForwardFailed(String),
@@ -113,6 +119,12 @@ impl IntoResponse for ProxyError {
ProxyError::BindFailed(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
+ ProxyError::StopTimeout => {
+ (StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
+ }
+ ProxyError::StopFailed(_) => {
+ (StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
+ }
ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
ProxyError::NoAvailableProvider => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs
index 2a4b2867f..8cd742182 100644
--- a/src-tauri/src/proxy/server.rs
+++ b/src-tauri/src/proxy/server.rs
@@ -3,8 +3,8 @@
//! 基于Axum的HTTP服务器,处理代理请求
use super::{
- failover_switch::FailoverSwitchManager, handlers, provider_router::ProviderRouter, types::*,
- ProxyError,
+ failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv,
+ provider_router::ProviderRouter, types::*, ProxyError,
};
use crate::database::Database;
use axum::{
@@ -95,7 +95,7 @@ impl ProxyServer {
.await
.map_err(|e| ProxyError::BindFailed(e.to_string()))?;
- log::info!("[SRV-001] 代理服务器启动于 {addr}");
+ log::info!("[{}] 代理服务器启动于 {addr}", log_srv::STARTED);
// 保存关闭句柄
*self.shutdown_tx.write().await = Some(shutdown_tx);
@@ -146,13 +146,25 @@ impl ProxyServer {
// 2. 等待服务器任务结束(带 5 秒超时保护)
if let Some(handle) = self.server_handle.write().await.take() {
match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await {
- Ok(Ok(())) => log::info!("[SRV-002] 代理服务器已完全停止"),
- Ok(Err(e)) => log::warn!("[SRV-003] 代理服务器任务异常终止: {e}"),
- Err(_) => log::warn!("[SRV-004] 代理服务器停止超时(5秒),强制继续"),
+ Ok(Ok(())) => {
+ log::info!("[{}] 代理服务器已完全停止", log_srv::STOPPED);
+ Ok(())
+ }
+ Ok(Err(e)) => {
+ log::warn!("[{}] 代理服务器任务异常终止: {e}", log_srv::TASK_ERROR);
+ Err(ProxyError::StopFailed(e.to_string()))
+ }
+ Err(_) => {
+ log::warn!(
+ "[{}] 代理服务器停止超时(5秒),强制继续",
+ log_srv::STOP_TIMEOUT
+ );
+ Err(ProxyError::StopTimeout)
+ }
}
+ } else {
+ Ok(())
}
-
- Ok(())
}
pub async fn get_status(&self) -> ProxyStatus {
diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs
index 1729eda5e..4f5bf7488 100644
--- a/src-tauri/src/services/skill.rs
+++ b/src-tauri/src/services/skill.rs
@@ -160,7 +160,7 @@ impl SkillService {
.user_agent("cc-switch")
.timeout(std::time::Duration::from_secs(10))
.build()
- .expect("Failed to create HTTP client"),
+ .unwrap_or_else(|_| Client::new()),
}
}
diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs
index 30f4c5de9..3eb49547b 100644
--- a/src-tauri/src/settings.rs
+++ b/src-tauri/src/settings.rs
@@ -92,12 +92,9 @@ impl Default for AppSettings {
}
impl AppSettings {
- fn settings_path() -> PathBuf {
+ fn settings_path() -> Option {
// settings.json 保留用于旧版本迁移和无数据库场景
- dirs::home_dir()
- .expect("无法获取用户主目录")
- .join(".cc-switch")
- .join("settings.json")
+ dirs::home_dir().map(|h| h.join(".cc-switch").join("settings.json"))
}
fn normalize_paths(&mut self) {
@@ -131,7 +128,9 @@ impl AppSettings {
}
fn load_from_file() -> Self {
- let path = Self::settings_path();
+ let Some(path) = Self::settings_path() else {
+ return Self::default();
+ };
if let Ok(content) = fs::read_to_string(&path) {
match serde_json::from_str::(&content) {
Ok(mut settings) => {
@@ -156,7 +155,9 @@ impl AppSettings {
fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
let mut normalized = settings.clone();
normalized.normalize_paths();
- let path = AppSettings::settings_path();
+ let Some(path) = AppSettings::settings_path() else {
+ return Err(AppError::Config("无法获取用户主目录".to_string()));
+ };
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
diff --git a/src/components/proxy/AutoFailoverConfigPanel.tsx b/src/components/proxy/AutoFailoverConfigPanel.tsx
index b80130fc1..2436c6ee1 100644
--- a/src/components/proxy/AutoFailoverConfigPanel.tsx
+++ b/src/components/proxy/AutoFailoverConfigPanel.tsx
@@ -61,24 +61,121 @@ export function AutoFailoverConfigPanel({
const n = parseInt(val);
return isNaN(n) ? defaultVal : n;
};
+
+ // 定义各字段的有效范围
+ const ranges = {
+ maxRetries: { min: 0, max: 10 },
+ streamingFirstByteTimeout: { min: 0, max: 180 },
+ streamingIdleTimeout: { min: 0, max: 600 },
+ nonStreamingTimeout: { min: 0, max: 1800 },
+ circuitFailureThreshold: { min: 1, max: 20 },
+ circuitSuccessThreshold: { min: 1, max: 10 },
+ circuitTimeoutSeconds: { min: 10, max: 300 },
+ circuitErrorRateThreshold: { min: 0, max: 100 },
+ circuitMinRequests: { min: 5, max: 100 },
+ };
+
+ // 解析原始值
+ const raw = {
+ maxRetries: parseNum(formData.maxRetries, 3),
+ streamingFirstByteTimeout: parseNum(
+ formData.streamingFirstByteTimeout,
+ 30,
+ ),
+ streamingIdleTimeout: parseNum(formData.streamingIdleTimeout, 60),
+ nonStreamingTimeout: parseNum(formData.nonStreamingTimeout, 300),
+ circuitFailureThreshold: parseNum(formData.circuitFailureThreshold, 5),
+ circuitSuccessThreshold: parseNum(formData.circuitSuccessThreshold, 2),
+ circuitTimeoutSeconds: parseNum(formData.circuitTimeoutSeconds, 60),
+ circuitErrorRateThreshold: parseNum(
+ formData.circuitErrorRateThreshold,
+ 50,
+ ),
+ circuitMinRequests: parseNum(formData.circuitMinRequests, 10),
+ };
+
+ // 校验是否超出范围
+ const errors: string[] = [];
+ const checkRange = (
+ value: number,
+ range: { min: number; max: number },
+ label: string,
+ ) => {
+ if (value < range.min || value > range.max) {
+ errors.push(`${label}: ${range.min}-${range.max}`);
+ }
+ };
+
+ checkRange(
+ raw.maxRetries,
+ ranges.maxRetries,
+ t("proxy.autoFailover.maxRetries", "最大重试次数"),
+ );
+ checkRange(
+ raw.streamingFirstByteTimeout,
+ ranges.streamingFirstByteTimeout,
+ t("proxy.autoFailover.streamingFirstByte", "流式首字节超时"),
+ );
+ checkRange(
+ raw.streamingIdleTimeout,
+ ranges.streamingIdleTimeout,
+ t("proxy.autoFailover.streamingIdle", "流式静默超时"),
+ );
+ checkRange(
+ raw.nonStreamingTimeout,
+ ranges.nonStreamingTimeout,
+ t("proxy.autoFailover.nonStreaming", "非流式超时"),
+ );
+ checkRange(
+ raw.circuitFailureThreshold,
+ ranges.circuitFailureThreshold,
+ t("proxy.autoFailover.failureThreshold", "失败阈值"),
+ );
+ checkRange(
+ raw.circuitSuccessThreshold,
+ ranges.circuitSuccessThreshold,
+ t("proxy.autoFailover.successThreshold", "恢复成功阈值"),
+ );
+ checkRange(
+ raw.circuitTimeoutSeconds,
+ ranges.circuitTimeoutSeconds,
+ t("proxy.autoFailover.timeout", "恢复等待时间"),
+ );
+ checkRange(
+ raw.circuitErrorRateThreshold,
+ ranges.circuitErrorRateThreshold,
+ t("proxy.autoFailover.errorRate", "错误率阈值"),
+ );
+ checkRange(
+ raw.circuitMinRequests,
+ ranges.circuitMinRequests,
+ t("proxy.autoFailover.minRequests", "最小请求数"),
+ );
+
+ if (errors.length > 0) {
+ toast.error(
+ t("proxy.autoFailover.validationFailed", {
+ fields: errors.join("; "),
+ defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`,
+ }),
+ );
+ return;
+ }
+
try {
await updateConfig.mutateAsync({
appType,
enabled: config.enabled,
autoFailoverEnabled: formData.autoFailoverEnabled,
- maxRetries: parseNum(formData.maxRetries, 3),
- streamingFirstByteTimeout: parseNum(
- formData.streamingFirstByteTimeout,
- 30,
- ),
- streamingIdleTimeout: parseNum(formData.streamingIdleTimeout, 60),
- nonStreamingTimeout: parseNum(formData.nonStreamingTimeout, 300),
- circuitFailureThreshold: parseNum(formData.circuitFailureThreshold, 5),
- circuitSuccessThreshold: parseNum(formData.circuitSuccessThreshold, 2),
- circuitTimeoutSeconds: parseNum(formData.circuitTimeoutSeconds, 60),
- circuitErrorRateThreshold:
- parseNum(formData.circuitErrorRateThreshold, 50) / 100,
- circuitMinRequests: parseNum(formData.circuitMinRequests, 10),
+ maxRetries: raw.maxRetries,
+ streamingFirstByteTimeout: raw.streamingFirstByteTimeout,
+ streamingIdleTimeout: raw.streamingIdleTimeout,
+ nonStreamingTimeout: raw.nonStreamingTimeout,
+ circuitFailureThreshold: raw.circuitFailureThreshold,
+ circuitSuccessThreshold: raw.circuitSuccessThreshold,
+ circuitTimeoutSeconds: raw.circuitTimeoutSeconds,
+ circuitErrorRateThreshold: raw.circuitErrorRateThreshold / 100,
+ circuitMinRequests: raw.circuitMinRequests,
});
toast.success(
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
diff --git a/src/components/proxy/CircuitBreakerConfigPanel.tsx b/src/components/proxy/CircuitBreakerConfigPanel.tsx
index 5341df331..9a0a55cc4 100644
--- a/src/components/proxy/CircuitBreakerConfigPanel.tsx
+++ b/src/components/proxy/CircuitBreakerConfigPanel.tsx
@@ -7,12 +7,14 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useState, useEffect } from "react";
import { toast } from "sonner";
+import { useTranslation } from "react-i18next";
/**
* 熔断器配置面板
* 允许用户调整熔断器参数
*/
export function CircuitBreakerConfigPanel() {
+ const { t } = useTranslation();
const { data: config, isLoading } = useCircuitBreakerConfig();
const updateConfig = useUpdateCircuitBreakerConfig();
@@ -44,18 +46,88 @@ export function CircuitBreakerConfigPanel() {
const n = parseInt(val);
return isNaN(n) ? defaultVal : n;
};
+
+ // 定义各字段的有效范围
+ const ranges = {
+ failureThreshold: { min: 1, max: 20 },
+ successThreshold: { min: 1, max: 10 },
+ timeoutSeconds: { min: 10, max: 300 },
+ errorRateThreshold: { min: 0, max: 100 },
+ minRequests: { min: 5, max: 100 },
+ };
+
+ // 解析原始值
+ const raw = {
+ failureThreshold: parseNum(formData.failureThreshold, 5),
+ successThreshold: parseNum(formData.successThreshold, 2),
+ timeoutSeconds: parseNum(formData.timeoutSeconds, 60),
+ errorRateThreshold: parseNum(formData.errorRateThreshold, 50),
+ minRequests: parseNum(formData.minRequests, 10),
+ };
+
+ // 校验是否超出范围
+ const errors: string[] = [];
+ const checkRange = (
+ value: number,
+ range: { min: number; max: number },
+ label: string,
+ ) => {
+ if (value < range.min || value > range.max) {
+ errors.push(`${label}: ${range.min}-${range.max}`);
+ }
+ };
+
+ checkRange(
+ raw.failureThreshold,
+ ranges.failureThreshold,
+ t("circuitBreaker.failureThreshold", "失败阈值"),
+ );
+ checkRange(
+ raw.successThreshold,
+ ranges.successThreshold,
+ t("circuitBreaker.successThreshold", "成功阈值"),
+ );
+ checkRange(
+ raw.timeoutSeconds,
+ ranges.timeoutSeconds,
+ t("circuitBreaker.timeoutSeconds", "超时时间"),
+ );
+ checkRange(
+ raw.errorRateThreshold,
+ ranges.errorRateThreshold,
+ t("circuitBreaker.errorRateThreshold", "错误率阈值"),
+ );
+ checkRange(
+ raw.minRequests,
+ ranges.minRequests,
+ t("circuitBreaker.minRequests", "最小请求数"),
+ );
+
+ if (errors.length > 0) {
+ toast.error(
+ t("circuitBreaker.validationFailed", {
+ fields: errors.join("; "),
+ defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`,
+ }),
+ );
+ return;
+ }
+
try {
- const parsed = {
- failureThreshold: parseNum(formData.failureThreshold, 5),
- successThreshold: parseNum(formData.successThreshold, 2),
- timeoutSeconds: parseNum(formData.timeoutSeconds, 60),
- errorRateThreshold: parseNum(formData.errorRateThreshold, 50) / 100,
- minRequests: parseNum(formData.minRequests, 10),
- };
- await updateConfig.mutateAsync(parsed);
- toast.success("熔断器配置已保存", { closeButton: true });
+ await updateConfig.mutateAsync({
+ failureThreshold: raw.failureThreshold,
+ successThreshold: raw.successThreshold,
+ timeoutSeconds: raw.timeoutSeconds,
+ errorRateThreshold: raw.errorRateThreshold / 100,
+ minRequests: raw.minRequests,
+ });
+ toast.success(t("circuitBreaker.configSaved", "熔断器配置已保存"), {
+ closeButton: true,
+ });
} catch (error) {
- toast.error("保存失败: " + String(error));
+ toast.error(
+ t("circuitBreaker.saveFailed", "保存失败") + ": " + String(error),
+ );
}
};
diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx
index 1cf88a146..702bd3cbf 100644
--- a/src/components/proxy/ProxyPanel.tsx
+++ b/src/components/proxy/ProxyPanel.tsx
@@ -102,13 +102,42 @@ export function ProxyPanel() {
const handleSaveBasicConfig = async () => {
if (!globalConfig) return;
+
+ // 校验地址格式(简单的 IP 地址或 localhost 校验)
+ const addressTrimmed = listenAddress.trim();
+ const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
+ const isValidAddress =
+ addressTrimmed === "localhost" ||
+ addressTrimmed === "0.0.0.0" ||
+ (ipv4Regex.test(addressTrimmed) &&
+ addressTrimmed.split(".").every((n) => {
+ const num = parseInt(n);
+ return num >= 0 && num <= 255;
+ }));
+ if (!isValidAddress) {
+ toast.error(
+ t("proxy.settings.invalidAddress", {
+ defaultValue:
+ "地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost",
+ }),
+ );
+ return;
+ }
+
const port = parseInt(listenPort);
- const validPort = isNaN(port) || port < 1024 || port > 65535 ? 15721 : port;
+ if (isNaN(port) || port < 1024 || port > 65535) {
+ toast.error(
+ t("proxy.settings.invalidPort", {
+ defaultValue: "端口无效,请输入 1024-65535 之间的数字",
+ }),
+ );
+ return;
+ }
try {
await updateGlobalConfig.mutateAsync({
...globalConfig,
- listenAddress,
- listenPort: validPort,
+ listenAddress: addressTrimmed,
+ listenPort: port,
});
toast.success(
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 51e5356ed..8923534fb 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -1107,7 +1107,12 @@
"toast": {
"saved": "Proxy configuration saved",
"saveFailed": "Save failed: {{error}}"
- }
+ },
+ "invalidPort": "Invalid port, please enter a number between 1024-65535",
+ "invalidAddress": "Invalid address, please enter a valid IP address (e.g. 127.0.0.1) or localhost",
+ "configSaved": "Proxy configuration saved",
+ "configSaveFailed": "Failed to save configuration",
+ "restartRequired": "Restart proxy service for address or port changes to take effect"
},
"switchFailed": "Switch failed: {{error}}",
"failover": {
@@ -1134,6 +1139,7 @@
"info": "When the failover queue has multiple providers, the system will try them in priority order when requests fail. When a provider reaches the consecutive failure threshold, the circuit breaker will open and skip it temporarily.",
"configSaved": "Auto failover config saved",
"configSaveFailed": "Failed to save",
+ "validationFailed": "The following fields are out of valid range: {{fields}}",
"retrySettings": "Retry & Timeout Settings",
"failureThreshold": "Failure Threshold",
"failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)",
@@ -1185,6 +1191,16 @@
"streamingIdle": "Streaming Idle Timeout",
"nonStreaming": "Non-Streaming Timeout"
},
+ "circuitBreaker": {
+ "failureThreshold": "Failure Threshold",
+ "successThreshold": "Success Threshold",
+ "timeoutSeconds": "Timeout (seconds)",
+ "errorRateThreshold": "Error Rate Threshold",
+ "minRequests": "Min Requests",
+ "validationFailed": "The following fields are out of valid range: {{fields}}",
+ "configSaved": "Circuit breaker config saved",
+ "saveFailed": "Failed to save"
+ },
"universalProvider": {
"title": "Universal Provider",
"description": "Universal providers manage Claude, Codex, and Gemini configurations simultaneously. Changes are automatically synced to all enabled apps.",
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index d1dcec2bf..d7011167c 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -1107,7 +1107,12 @@
"toast": {
"saved": "代理配置已保存",
"saveFailed": "保存失败: {{error}}"
- }
+ },
+ "invalidPort": "端口无效,请输入 1024-65535 之间的数字",
+ "invalidAddress": "地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost",
+ "configSaved": "代理配置已保存",
+ "configSaveFailed": "保存配置失败",
+ "restartRequired": "修改地址或端口后需要重启代理服务才能生效"
},
"switchFailed": "切换失败: {{error}}",
"failover": {
@@ -1134,6 +1139,7 @@
"info": "当故障转移队列中配置了多个供应商时,系统会在请求失败时按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会打开并在一段时间内跳过该供应商。",
"configSaved": "自动故障转移配置已保存",
"configSaveFailed": "保存失败",
+ "validationFailed": "以下字段超出有效范围: {{fields}}",
"retrySettings": "重试与超时设置",
"failureThreshold": "失败阈值",
"failureThresholdHint": "连续失败多少次后打开熔断器(建议: 3-10)",
@@ -1185,6 +1191,16 @@
"streamingIdle": "流式静默超时",
"nonStreaming": "非流式超时"
},
+ "circuitBreaker": {
+ "failureThreshold": "失败阈值",
+ "successThreshold": "成功阈值",
+ "timeoutSeconds": "超时时间",
+ "errorRateThreshold": "错误率阈值",
+ "minRequests": "最小请求数",
+ "validationFailed": "以下字段超出有效范围: {{fields}}",
+ "configSaved": "熔断器配置已保存",
+ "saveFailed": "保存失败"
+ },
"universalProvider": {
"title": "统一供应商",
"description": "统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。",
From 83db457b1044c039304a8d0dbae287bb7d6aedcf Mon Sep 17 00:00:00 2001
From: Jason
Date: Sat, 10 Jan 2026 11:03:34 +0800
Subject: [PATCH 3/7] refactor(proxy): disable OpenRouter compat mode by
default and hide UI toggle
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
OpenRouter now natively supports Claude Code compatible API (/v1/messages),
so format transformation (Anthropic ↔ OpenAI) is no longer needed by default.
- Change default value from `true` to `false` in both frontend and backend
- Hide the "OpenRouter Compatibility Mode" toggle in provider form
- Users can still enable it manually by adding `"openrouter_compat_mode": true` in config JSON
- Update unit tests to reflect new default behavior
---
src-tauri/src/proxy/providers/claude.rs | 15 +++++++++++++--
src/components/providers/forms/ProviderForm.tsx | 4 ++--
2 files changed, 15 insertions(+), 4 deletions(-)
diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs
index 87a0d85c9..04d4ec6ff 100644
--- a/src-tauri/src/proxy/providers/claude.rs
+++ b/src-tauri/src/proxy/providers/claude.rs
@@ -62,7 +62,8 @@ impl ClaudeAdapter {
let normalized = value.trim().to_lowercase();
normalized == "true" || normalized == "1"
}
- _ => true,
+ // OpenRouter now supports Claude Code compatible API, default to passthrough
+ _ => false,
}
}
@@ -465,12 +466,22 @@ mod tests {
}));
assert!(!adapter.needs_transform(&anthropic_provider));
+ // OpenRouter provider without explicit setting now defaults to passthrough (no transform)
let openrouter_provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
}
}));
- assert!(adapter.needs_transform(&openrouter_provider));
+ assert!(!adapter.needs_transform(&openrouter_provider));
+
+ // OpenRouter provider with explicit compat mode enabled should transform
+ let openrouter_enabled = create_provider(json!({
+ "env": {
+ "ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
+ },
+ "openrouter_compat_mode": true
+ }));
+ assert!(adapter.needs_transform(&openrouter_enabled));
let openrouter_disabled = create_provider(json!({
"env": {
diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx
index 514367740..cb97473d5 100644
--- a/src/components/providers/forms/ProviderForm.tsx
+++ b/src/components/providers/forms/ProviderForm.tsx
@@ -236,7 +236,7 @@ export function ProviderForm({
} catch {
// ignore
}
- return true;
+ return false; // OpenRouter now supports Claude Code compatible API, no need for transform
}, [isOpenRouterProvider, settingsConfigValue]);
const handleOpenRouterCompatChange = useCallback(
@@ -864,7 +864,7 @@ export function ProviderForm({
defaultOpusModel={defaultOpusModel}
onModelChange={handleModelChange}
speedTestEndpoints={speedTestEndpoints}
- showOpenRouterCompatToggle={isOpenRouterProvider}
+ showOpenRouterCompatToggle={false}
openRouterCompatEnabled={openRouterCompatEnabled}
onOpenRouterCompatChange={handleOpenRouterCompatChange}
/>
From 95ed6d6903a3f933d498aa697911a7bfceef67d8 Mon Sep 17 00:00:00 2001
From: Jason
Date: Sat, 10 Jan 2026 11:05:18 +0800
Subject: [PATCH 4/7] fix(usage): prevent usage script config from leaking
between providers
Add key prop to UsageScriptModal to ensure component remounts when
switching between different providers. This fixes issue #569 where
configuring usage query for one provider would incorrectly apply
the same configuration to all providers.
The root cause was that useState initialization only runs on first
mount, and due to useLastValidValue hook keeping the modal rendered
during close animation, the component might not fully unmount when
switching providers rapidly.
Closes #569
---
src-tauri/Cargo.lock | 2 +-
src/App.tsx | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 51a67662e..e48e0090e 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -701,7 +701,7 @@ dependencies = [
[[package]]
name = "cc-switch"
-version = "3.9.0"
+version = "3.9.1"
dependencies = [
"anyhow",
"async-stream",
diff --git a/src/App.tsx b/src/App.tsx
index 65e08ba4b..a78d9ce18 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -806,6 +806,7 @@ function App() {
{effectiveUsageProvider && (
Date: Sat, 10 Jan 2026 11:10:23 +0800
Subject: [PATCH 5/7] fix(provider-form): reset baseUrl and apiKey states when
switching presets
Fix state synchronization in useBaseUrlState and useApiKeyState hooks
to properly clear values when config is reset. Previously, when switching
from a preset to "custom", the baseUrl and apiKey states would retain
their old values because the sync logic only updated when new values
existed, not when they were cleared.
Changes:
- useBaseUrlState: Always sync baseUrl to config value (empty if undefined)
- useApiKeyState: Remove hasApiKeyField check that prevented clearing
---
src/components/providers/forms/hooks/useApiKeyState.ts | 6 +-----
src/components/providers/forms/hooks/useBaseUrlState.ts | 5 +++--
2 files changed, 4 insertions(+), 7 deletions(-)
diff --git a/src/components/providers/forms/hooks/useApiKeyState.ts b/src/components/providers/forms/hooks/useApiKeyState.ts
index 9cd5fde39..9101d2e35 100644
--- a/src/components/providers/forms/hooks/useApiKeyState.ts
+++ b/src/components/providers/forms/hooks/useApiKeyState.ts
@@ -43,11 +43,7 @@ export function useApiKeyState({
return;
}
- // 仅当配置确实包含 API Key 字段时才同步(避免无意清空用户正在输入的 key)
- if (!hasApiKeyField(initialConfig, appType)) {
- return;
- }
-
+ // 从配置中提取 API Key(如果不存在则返回空字符串)
const extracted = getApiKeyFromConfig(initialConfig, appType);
if (extracted !== apiKey) {
setApiKey(extracted);
diff --git a/src/components/providers/forms/hooks/useBaseUrlState.ts b/src/components/providers/forms/hooks/useBaseUrlState.ts
index 61b1ad997..2deffcbac 100644
--- a/src/components/providers/forms/hooks/useBaseUrlState.ts
+++ b/src/components/providers/forms/hooks/useBaseUrlState.ts
@@ -41,8 +41,9 @@ export function useBaseUrlState({
try {
const config = JSON.parse(settingsConfig || "{}");
const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL;
- if (typeof envUrl === "string" && envUrl && envUrl.trim() !== baseUrl) {
- setBaseUrl(envUrl.trim());
+ const nextUrl = typeof envUrl === "string" ? envUrl.trim() : "";
+ if (nextUrl !== baseUrl) {
+ setBaseUrl(nextUrl);
}
} catch {
// ignore
From 76fa8306886c49626de3820ef66758f508bde9c5 Mon Sep 17 00:00:00 2001
From: Jason
Date: Sun, 11 Jan 2026 16:37:18 +0800
Subject: [PATCH 6/7] fix(live): sync skills to app directories on config path
change
When users change app config directories (claudeConfigDir, codexConfigDir,
geminiConfigDir), MCP servers were being synced to the new paths but Skills
were not. This adds Skill synchronization to sync_current_to_live() to ensure
installed Skills are also copied to the new app directories.
---
src-tauri/src/services/provider/live.rs | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs
index 6389827b9..0192f6ecd 100644
--- a/src-tauri/src/services/provider/live.rs
+++ b/src-tauri/src/services/provider/live.rs
@@ -148,6 +148,15 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// MCP sync
McpService::sync_all_enabled(state)?;
+
+ // Skill sync
+ for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
+ if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
+ log::warn!("同步 Skill 到 {:?} 失败: {}", app_type, e);
+ // Continue syncing other apps, don't abort
+ }
+ }
+
Ok(())
}
From c5d3732b9fc70dae4e71cfe976b0e389d2e37cb8 Mon Sep 17 00:00:00 2001
From: YoVinchen
Date: Sun, 11 Jan 2026 16:45:05 +0800
Subject: [PATCH 7/7] fix(proxy): harden error handling and input validation
- Handle RwLock poisoning in settings.rs with unwrap_or_else
- Add fallback for dirs::home_dir() in config modules
- Normalize localhost to 127.0.0.1 in ProxyPanel
- Format IPv6 addresses with brackets for valid URLs
- Strict port validation with pure digit regex
- Treat NaN as validation failure in config panels
- Log warning on cost_multiplier parse failure
- Align timeoutSeconds range to [0, 300] across all panels
---
src-tauri/src/codex_config.rs | 10 ++++-
src-tauri/src/config.rs | 16 ++++---
src-tauri/src/gemini_config.rs | 12 ++++--
src-tauri/src/proxy/handlers.rs | 7 +++-
src-tauri/src/proxy/response_processor.rs | 7 +++-
src-tauri/src/settings.rs | 18 ++++++--
.../proxy/AutoFailoverConfigPanel.tsx | 42 +++++++++----------
.../proxy/CircuitBreakerConfigPanel.tsx | 28 +++++++------
src/components/proxy/ProxyPanel.tsx | 37 ++++++++++++----
9 files changed, 118 insertions(+), 59 deletions(-)
diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs
index 1d74b4338..040e5f0c3 100644
--- a/src-tauri/src/codex_config.rs
+++ b/src-tauri/src/codex_config.rs
@@ -9,13 +9,21 @@ use serde_json::Value;
use std::fs;
use std::path::Path;
+/// 获取用户主目录,带回退和日志
+fn get_home_dir() -> PathBuf {
+ dirs::home_dir().unwrap_or_else(|| {
+ log::warn!("无法获取用户主目录,回退到当前目录");
+ PathBuf::from(".")
+ })
+}
+
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() {
return custom;
}
- dirs::home_dir().expect("无法获取用户主目录").join(".codex")
+ get_home_dir().join(".codex")
}
/// 获取 Codex auth.json 路径
diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs
index 0b3cc9af0..6eae3a2cd 100644
--- a/src-tauri/src/config.rs
+++ b/src-tauri/src/config.rs
@@ -5,22 +5,26 @@ use std::path::{Path, PathBuf};
use crate::error::AppError;
+/// 获取用户主目录,带回退和日志
+fn get_home_dir() -> PathBuf {
+ dirs::home_dir().unwrap_or_else(|| {
+ log::warn!("无法获取用户主目录,回退到当前目录");
+ PathBuf::from(".")
+ })
+}
+
/// 获取 Claude Code 配置目录路径
pub fn get_claude_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_claude_override_dir() {
return custom;
}
- dirs::home_dir()
- .expect("无法获取用户主目录")
- .join(".claude")
+ get_home_dir().join(".claude")
}
/// 默认 Claude MCP 配置文件路径 (~/.claude.json)
pub fn get_default_claude_mcp_path() -> PathBuf {
- dirs::home_dir()
- .expect("无法获取用户主目录")
- .join(".claude.json")
+ get_home_dir().join(".claude.json")
}
fn derive_mcp_path_from_override(dir: &Path) -> Option {
diff --git a/src-tauri/src/gemini_config.rs b/src-tauri/src/gemini_config.rs
index 8e453babf..6ea476662 100644
--- a/src-tauri/src/gemini_config.rs
+++ b/src-tauri/src/gemini_config.rs
@@ -5,15 +5,21 @@ use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
+/// 获取用户主目录,带回退和日志
+fn get_home_dir() -> PathBuf {
+ dirs::home_dir().unwrap_or_else(|| {
+ log::warn!("无法获取用户主目录,回退到当前目录");
+ PathBuf::from(".")
+ })
+}
+
/// 获取 Gemini 配置目录路径(支持设置覆盖)
pub fn get_gemini_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_gemini_override_dir() {
return custom;
}
- dirs::home_dir()
- .expect("无法获取用户主目录")
- .join(".gemini")
+ get_home_dir().join(".gemini")
}
/// 获取 Gemini .env 文件路径
diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs
index 286113dc2..664b849b4 100644
--- a/src-tauri/src/proxy/handlers.rs
+++ b/src-tauri/src/proxy/handlers.rs
@@ -456,7 +456,12 @@ async fn log_usage(
Ok(Some(p)) => {
if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier {
- Decimal::from_str(&cm).unwrap_or(Decimal::from(1))
+ Decimal::from_str(&cm).unwrap_or_else(|e| {
+ log::warn!(
+ "cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
+ );
+ Decimal::from(1)
+ })
} else {
Decimal::from(1)
}
diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs
index b393211f1..f6a2eeb8a 100644
--- a/src-tauri/src/proxy/response_processor.rs
+++ b/src-tauri/src/proxy/response_processor.rs
@@ -372,7 +372,12 @@ async fn log_usage_internal(
Ok(Some(p)) => {
if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier {
- Decimal::from_str(&cm).unwrap_or(Decimal::from(1))
+ Decimal::from_str(&cm).unwrap_or_else(|e| {
+ log::warn!(
+ "cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
+ );
+ Decimal::from(1)
+ })
} else {
Decimal::from(1)
}
diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs
index 3eb49547b..21e15d528 100644
--- a/src-tauri/src/settings.rs
+++ b/src-tauri/src/settings.rs
@@ -194,14 +194,23 @@ fn resolve_override_path(raw: &str) -> PathBuf {
}
pub fn get_settings() -> AppSettings {
- settings_store().read().expect("读取设置锁失败").clone()
+ settings_store()
+ .read()
+ .unwrap_or_else(|e| {
+ log::warn!("设置锁已毒化,使用恢复值: {e}");
+ e.into_inner()
+ })
+ .clone()
}
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
new_settings.normalize_paths();
save_settings_file(&new_settings)?;
- let mut guard = settings_store().write().expect("写入设置锁失败");
+ let mut guard = settings_store().write().unwrap_or_else(|e| {
+ log::warn!("设置锁已毒化,使用恢复值: {e}");
+ e.into_inner()
+ });
*guard = new_settings;
Ok(())
}
@@ -210,7 +219,10 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
/// 用于导入配置等场景,确保内存缓存与文件同步
pub fn reload_settings() -> Result<(), AppError> {
let fresh_settings = AppSettings::load_from_file();
- let mut guard = settings_store().write().expect("写入设置锁失败");
+ let mut guard = settings_store().write().unwrap_or_else(|e| {
+ log::warn!("设置锁已毒化,使用恢复值: {e}");
+ e.into_inner()
+ });
*guard = fresh_settings;
Ok(())
}
diff --git a/src/components/proxy/AutoFailoverConfigPanel.tsx b/src/components/proxy/AutoFailoverConfigPanel.tsx
index 2436c6ee1..4186e358e 100644
--- a/src/components/proxy/AutoFailoverConfigPanel.tsx
+++ b/src/components/proxy/AutoFailoverConfigPanel.tsx
@@ -56,10 +56,12 @@ export function AutoFailoverConfigPanel({
const handleSave = async () => {
if (!config) return;
- // 解析数字,空值使用默认值,0 是有效值
- const parseNum = (val: string, defaultVal: number) => {
- const n = parseInt(val);
- return isNaN(n) ? defaultVal : n;
+ // 解析数字,返回 NaN 表示无效输入
+ const parseNum = (val: string) => {
+ const trimmed = val.trim();
+ // 必须是纯数字
+ if (!/^-?\d+$/.test(trimmed)) return NaN;
+ return parseInt(trimmed);
};
// 定义各字段的有效范围
@@ -70,38 +72,32 @@ export function AutoFailoverConfigPanel({
nonStreamingTimeout: { min: 0, max: 1800 },
circuitFailureThreshold: { min: 1, max: 20 },
circuitSuccessThreshold: { min: 1, max: 10 },
- circuitTimeoutSeconds: { min: 10, max: 300 },
+ circuitTimeoutSeconds: { min: 0, max: 300 },
circuitErrorRateThreshold: { min: 0, max: 100 },
circuitMinRequests: { min: 5, max: 100 },
};
// 解析原始值
const raw = {
- maxRetries: parseNum(formData.maxRetries, 3),
- streamingFirstByteTimeout: parseNum(
- formData.streamingFirstByteTimeout,
- 30,
- ),
- streamingIdleTimeout: parseNum(formData.streamingIdleTimeout, 60),
- nonStreamingTimeout: parseNum(formData.nonStreamingTimeout, 300),
- circuitFailureThreshold: parseNum(formData.circuitFailureThreshold, 5),
- circuitSuccessThreshold: parseNum(formData.circuitSuccessThreshold, 2),
- circuitTimeoutSeconds: parseNum(formData.circuitTimeoutSeconds, 60),
- circuitErrorRateThreshold: parseNum(
- formData.circuitErrorRateThreshold,
- 50,
- ),
- circuitMinRequests: parseNum(formData.circuitMinRequests, 10),
+ maxRetries: parseNum(formData.maxRetries),
+ streamingFirstByteTimeout: parseNum(formData.streamingFirstByteTimeout),
+ streamingIdleTimeout: parseNum(formData.streamingIdleTimeout),
+ nonStreamingTimeout: parseNum(formData.nonStreamingTimeout),
+ circuitFailureThreshold: parseNum(formData.circuitFailureThreshold),
+ circuitSuccessThreshold: parseNum(formData.circuitSuccessThreshold),
+ circuitTimeoutSeconds: parseNum(formData.circuitTimeoutSeconds),
+ circuitErrorRateThreshold: parseNum(formData.circuitErrorRateThreshold),
+ circuitMinRequests: parseNum(formData.circuitMinRequests),
};
- // 校验是否超出范围
+ // 校验是否超出范围(NaN 也视为无效)
const errors: string[] = [];
const checkRange = (
value: number,
range: { min: number; max: number },
label: string,
) => {
- if (value < range.min || value > range.max) {
+ if (isNaN(value) || value < range.min || value > range.max) {
errors.push(`${label}: ${range.min}-${range.max}`);
}
};
@@ -424,7 +420,7 @@ export function AutoFailoverConfigPanel({
diff --git a/src/components/proxy/CircuitBreakerConfigPanel.tsx b/src/components/proxy/CircuitBreakerConfigPanel.tsx
index 9a0a55cc4..a57767993 100644
--- a/src/components/proxy/CircuitBreakerConfigPanel.tsx
+++ b/src/components/proxy/CircuitBreakerConfigPanel.tsx
@@ -41,38 +41,40 @@ export function CircuitBreakerConfigPanel() {
}, [config]);
const handleSave = async () => {
- // 解析数字,空值使用默认值,0 是有效值
- const parseNum = (val: string, defaultVal: number) => {
- const n = parseInt(val);
- return isNaN(n) ? defaultVal : n;
+ // 解析数字,返回 NaN 表示无效输入
+ const parseNum = (val: string) => {
+ const trimmed = val.trim();
+ // 必须是纯数字
+ if (!/^-?\d+$/.test(trimmed)) return NaN;
+ return parseInt(trimmed);
};
// 定义各字段的有效范围
const ranges = {
failureThreshold: { min: 1, max: 20 },
successThreshold: { min: 1, max: 10 },
- timeoutSeconds: { min: 10, max: 300 },
+ timeoutSeconds: { min: 0, max: 300 },
errorRateThreshold: { min: 0, max: 100 },
minRequests: { min: 5, max: 100 },
};
// 解析原始值
const raw = {
- failureThreshold: parseNum(formData.failureThreshold, 5),
- successThreshold: parseNum(formData.successThreshold, 2),
- timeoutSeconds: parseNum(formData.timeoutSeconds, 60),
- errorRateThreshold: parseNum(formData.errorRateThreshold, 50),
- minRequests: parseNum(formData.minRequests, 10),
+ failureThreshold: parseNum(formData.failureThreshold),
+ successThreshold: parseNum(formData.successThreshold),
+ timeoutSeconds: parseNum(formData.timeoutSeconds),
+ errorRateThreshold: parseNum(formData.errorRateThreshold),
+ minRequests: parseNum(formData.minRequests),
};
- // 校验是否超出范围
+ // 校验是否超出范围(NaN 也视为无效)
const errors: string[] = [];
const checkRange = (
value: number,
range: { min: number; max: number },
label: string,
) => {
- if (value < range.min || value > range.max) {
+ if (isNaN(value) || value < range.min || value > range.max) {
errors.push(`${label}: ${range.min}-${range.max}`);
}
};
@@ -183,7 +185,7 @@ export function CircuitBreakerConfigPanel() {
diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx
index 702bd3cbf..5bee8590f 100644
--- a/src/components/proxy/ProxyPanel.tsx
+++ b/src/components/proxy/ProxyPanel.tsx
@@ -106,11 +106,13 @@ export function ProxyPanel() {
// 校验地址格式(简单的 IP 地址或 localhost 校验)
const addressTrimmed = listenAddress.trim();
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
+ // 规范化 localhost 为 127.0.0.1
+ const normalizedAddress =
+ addressTrimmed === "localhost" ? "127.0.0.1" : addressTrimmed;
const isValidAddress =
- addressTrimmed === "localhost" ||
- addressTrimmed === "0.0.0.0" ||
- (ipv4Regex.test(addressTrimmed) &&
- addressTrimmed.split(".").every((n) => {
+ normalizedAddress === "0.0.0.0" ||
+ (ipv4Regex.test(normalizedAddress) &&
+ normalizedAddress.split(".").every((n) => {
const num = parseInt(n);
return num >= 0 && num <= 255;
}));
@@ -124,7 +126,17 @@ export function ProxyPanel() {
return;
}
- const port = parseInt(listenPort);
+ // 严格校验端口:必须是纯数字
+ const portTrimmed = listenPort.trim();
+ if (!/^\d+$/.test(portTrimmed)) {
+ toast.error(
+ t("proxy.settings.invalidPort", {
+ defaultValue: "端口无效,请输入 1024-65535 之间的数字",
+ }),
+ );
+ return;
+ }
+ const port = parseInt(portTrimmed);
if (isNaN(port) || port < 1024 || port > 65535) {
toast.error(
t("proxy.settings.invalidPort", {
@@ -136,9 +148,11 @@ export function ProxyPanel() {
try {
await updateGlobalConfig.mutateAsync({
...globalConfig,
- listenAddress: addressTrimmed,
+ listenAddress: normalizedAddress,
listenPort: port,
});
+ // 同步更新本地状态为规范化后的值
+ setListenAddress(normalizedAddress);
toast.success(
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
{ closeButton: true },
@@ -164,6 +178,13 @@ export function ProxyPanel() {
}
};
+ // 格式化地址用于 URL(IPv6 需要方括号)
+ const formatAddressForUrl = (address: string, port: number): string => {
+ const isIPv6 = address.includes(":");
+ const host = isIPv6 ? `[${address}]` : address;
+ return `http://${host}:${port}`;
+ };
+
return (
<>
@@ -178,14 +199,14 @@ export function ProxyPanel() {
- http://{status.address}:{status.port}
+ {formatAddressForUrl(status.address, status.port)}