mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-12 14:51:08 +08:00
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:
@@ -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 路径
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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 文件路径
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 的配置。修改后会自动同步到所有启用的应用。",
|
||||
|
||||
Reference in New Issue
Block a user