Merge branch 'refactor/simplify-proxy-logs' into feature/global-proxy

Resolve conflict in skill.rs: keep global HTTP client for proxy support
This commit is contained in:
YoVinchen
2026-01-11 16:58:40 +08:00
22 changed files with 473 additions and 92 deletions
+9 -1
View File
@@ -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 路径
+8 -3
View File
@@ -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;
@@ -139,11 +141,14 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op
}
}
/// 预编译的版本号正则表达式
static VERSION_RE: Lazy<Regex> =
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())
}
+10 -6
View File
@@ -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<PathBuf> {
+9 -3
View File
@@ -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 文件路径
+27
View File
@@ -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<String, Value>, key: &str, multiplier: u64| -> Option<u64> {
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));
}
+24 -7
View File
@@ -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;
}
+12
View File
@@ -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())
+6 -1
View File
@@ -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)
}
+13 -2
View File
@@ -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": {
+6 -1
View File
@@ -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)
}
+20 -8
View File
@@ -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 {
+9
View File
@@ -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(())
}
+23 -10
View File
@@ -92,12 +92,9 @@ impl Default for AppSettings {
}
impl AppSettings {
fn settings_path() -> PathBuf {
fn settings_path() -> Option<PathBuf> {
// 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::<AppSettings>(&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))?;
@@ -193,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(())
}
@@ -209,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(())
}
+1
View File
@@ -808,6 +808,7 @@ function App() {
{effectiveUsageProvider && (
<UsageScriptModal
key={effectiveUsageProvider.id}
provider={effectiveUsageProvider}
appId={activeApp}
isOpen={Boolean(usageProvider)}
@@ -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}
/>
@@ -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);
@@ -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
+111 -18
View File
@@ -56,29 +56,122 @@ 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);
};
// 定义各字段的有效范围
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: 0, max: 300 },
circuitErrorRateThreshold: { min: 0, max: 100 },
circuitMinRequests: { min: 5, max: 100 },
};
// 解析原始值
const raw = {
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 (isNaN(value) || 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", "自动故障转移配置已保存"),
@@ -327,7 +420,7 @@ export function AutoFailoverConfigPanel({
<Input
id={`timeoutSeconds-${appType}`}
type="number"
min="10"
min="0"
max="300"
value={formData.circuitTimeoutSeconds}
onChange={(e) =>
@@ -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();
@@ -39,23 +41,95 @@ 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: 0, max: 300 },
errorRateThreshold: { min: 0, max: 100 },
minRequests: { min: 5, max: 100 },
};
// 解析原始值
const raw = {
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 (isNaN(value) || 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),
);
}
};
@@ -111,7 +185,7 @@ export function CircuitBreakerConfigPanel() {
<Input
id="timeoutSeconds"
type="number"
min="10"
min="0"
max="300"
value={formData.timeoutSeconds}
onChange={(e) =>
+56 -6
View File
@@ -102,14 +102,57 @@ export function ProxyPanel() {
const handleSaveBasicConfig = async () => {
if (!globalConfig) return;
const port = parseInt(listenPort);
const validPort = isNaN(port) || port < 1024 || port > 65535 ? 15721 : port;
// 校验地址格式(简单的 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 =
normalizedAddress === "0.0.0.0" ||
(ipv4Regex.test(normalizedAddress) &&
normalizedAddress.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 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", {
defaultValue: "端口无效,请输入 1024-65535 之间的数字",
}),
);
return;
}
try {
await updateGlobalConfig.mutateAsync({
...globalConfig,
listenAddress,
listenPort: validPort,
listenAddress: normalizedAddress,
listenPort: port,
});
// 同步更新本地状态为规范化后的值
setListenAddress(normalizedAddress);
toast.success(
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
{ closeButton: true },
@@ -135,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 (
<>
<section className="space-y-6">
@@ -149,14 +199,14 @@ export function ProxyPanel() {
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60">
http://{status.address}:{status.port}
{formatAddressForUrl(status.address, status.port)}
</code>
<Button
size="sm"
variant="outline"
onClick={() => {
navigator.clipboard.writeText(
`http://${status.address}:${status.port}`,
formatAddressForUrl(status.address, status.port),
);
toast.success(
t("proxy.panel.addressCopied", {
+17 -1
View File
@@ -1116,7 +1116,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": {
@@ -1143,6 +1148,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)",
@@ -1194,6 +1200,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.",
+17 -1
View File
@@ -1116,7 +1116,12 @@
"toast": {
"saved": "代理配置已保存",
"saveFailed": "保存失败: {{error}}"
}
},
"invalidPort": "端口无效,请输入 1024-65535 之间的数字",
"invalidAddress": "地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost",
"configSaved": "代理配置已保存",
"configSaveFailed": "保存配置失败",
"restartRequired": "修改地址或端口后需要重启代理服务才能生效"
},
"switchFailed": "切换失败: {{error}}",
"failover": {
@@ -1143,6 +1148,7 @@
"info": "当故障转移队列中配置了多个供应商时,系统会在请求失败时按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会打开并在一段时间内跳过该供应商。",
"configSaved": "自动故障转移配置已保存",
"configSaveFailed": "保存失败",
"validationFailed": "以下字段超出有效范围: {{fields}}",
"retrySettings": "重试与超时设置",
"failureThreshold": "失败阈值",
"failureThresholdHint": "连续失败多少次后打开熔断器(建议: 3-10)",
@@ -1194,6 +1200,16 @@
"streamingIdle": "流式静默超时",
"nonStreaming": "非流式超时"
},
"circuitBreaker": {
"failureThreshold": "失败阈值",
"successThreshold": "成功阈值",
"timeoutSeconds": "超时时间",
"errorRateThreshold": "错误率阈值",
"minRequests": "最小请求数",
"validationFailed": "以下字段超出有效范围: {{fields}}",
"configSaved": "熔断器配置已保存",
"saveFailed": "保存失败"
},
"universalProvider": {
"title": "统一供应商",
"description": "统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。",