Compare commits

..

11 Commits

Author SHA1 Message Date
YoVinchen c1e27d3cf2 Merge branch 'main' into refactor/simplify-proxy-logs 2026-01-11 16:46:53 +08:00
YoVinchen c5d3732b9f 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
2026-01-11 16:45:05 +08:00
YoVinchen d33f99aca4 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
2026-01-11 14:12:57 +08:00
YoVinchen 7bc9dbbbb0 feat(pricing): support @ separator in model name matching
- Refactor model name cleaning into chained method calls
- Add @ to - replacement (e.g., gpt-5.2-codex@low → gpt-5.2-codex-low)
- Add test case for @ separator matching
2026-01-11 01:53:23 +08:00
YoVinchen e002bdba25 fix(ui): allow number inputs to be fully cleared before saving
- Convert numeric state to string type for controlled inputs
- Use isNaN() check instead of || fallback to allow 0 values
- Apply fix to ProxyPanel, CircuitBreakerConfigPanel,
  AutoFailoverConfigPanel, and ModelTestConfigPanel
2026-01-11 01:33:41 +08:00
YoVinchen 5694353798 style: format code with prettier and rustfmt 2026-01-11 01:00:04 +08:00
YoVinchen 17c3dffe8c chore: bump version to 3.9.1 2026-01-11 00:55:02 +08:00
YoVinchen 07ba3df0f4 feat(proxy): add structured log codes for i18n support
Add error code system to proxy module logs for multi-language support:

- CB-001~006: Circuit breaker state transitions and triggers
- SRV-001~004: Proxy server lifecycle events
- FWD-001~002: Request forwarding and failover
- FO-001~005: Failover switch operations
- USG-001~002: Usage logging errors

Log format: [CODE] Chinese message
Frontend/log tools can map codes to any language.

New file: src/proxy/log_codes.rs - centralized code definitions
2026-01-11 00:44:54 +08:00
YoVinchen 1fe16aa388 refactor(proxy): simplify verbose logging output
- Remove response JSON full output logging in response_processor
- Remove per-request INFO logs in provider_router (failover status, provider selection)
- Change model mapping log from INFO to DEBUG
- Change usage logging failure from INFO to WARN
- Remove redundant debug logs for circuit breaker operations

Reduces log noise significantly while preserving important warnings and errors.
2026-01-11 00:41:28 +08:00
YoVinchen f738871ad1 fix: replace unsafe unwrap() calls with proper error handling
- database/dao/mcp.rs: Use map_err for serde_json serialization
- database/dao/providers.rs: Use map_err for settings_config and meta serialization
- commands/misc.rs: Use expect() for compile-time regex pattern
- services/prompt.rs: Use unwrap_or_default() for SystemTime
- deeplink/provider.rs: Replace unwrap() with is_none_or pattern for Option checks

Reduces potential panic points from 26 to 1 (static regex init, safe).
2026-01-10 23:31:35 +08:00
YoVinchen 753190a879 refactor(proxy): simplify logging for better readability
- Delete 17 verbose debug logs from handlers, streaming, and response_processor
- Convert excessive INFO logs to DEBUG level for internal processing details
- Add 2 critical INFO logs in forwarder.rs for failover scenarios:
  - Log when switching to next provider after failure
  - Log when all providers have been exhausted
- Fix clippy uninlined_format_args warning

This reduces log noise while maintaining visibility into key user-facing decisions.
2026-01-10 16:07:53 +08:00
59 changed files with 1243 additions and 3887 deletions
+776 -239
View File
File diff suppressed because it is too large Load Diff
+2 -97
View File
@@ -65,30 +65,6 @@ fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
// 非 Windows 平台不做任何处理 // 非 Windows 平台不做任何处理
} }
/// 检测路径是否为 WSL 网络路径(如 \\wsl$\Ubuntu\... 或 \\wsl.localhost\Ubuntu\...
/// WSL 环境运行的是 Linux,不需要 cmd /c 包装
/// 注意:仅检测直接 UNC 路径,映射磁盘符(如 Z: -> \\wsl$\...)无法检测
#[cfg(windows)]
fn is_wsl_path(path: &Path) -> bool {
use std::path::{Component, Prefix};
if let Some(Component::Prefix(prefix)) = path.components().next() {
match prefix.kind() {
Prefix::UNC(server, _) | Prefix::VerbatimUNC(server, _) => {
let s = server.to_string_lossy();
s.eq_ignore_ascii_case("wsl$") || s.eq_ignore_ascii_case("wsl.localhost")
}
_ => false,
}
} else {
false
}
}
#[cfg(not(windows))]
fn is_wsl_path(_path: &Path) -> bool {
false
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct McpStatus { pub struct McpStatus {
@@ -395,11 +371,6 @@ pub fn set_mcp_servers_map(
}; };
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范 // 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
// 检测目标路径是否为 WSL,若是则跳过 cmd /c 包装
let is_wsl_target = is_wsl_path(&path);
if is_wsl_target {
log::info!("检测到 WSL 路径,跳过 cmd /c 包装: {}", path.display());
}
let mut out: Map<String, Value> = Map::new(); let mut out: Map<String, Value> = Map::new();
for (id, spec) in servers.iter() { for (id, spec) in servers.iter() {
let mut obj = if let Some(map) = spec.as_object() { let mut obj = if let Some(map) = spec.as_object() {
@@ -426,10 +397,8 @@ pub fn set_mcp_servers_map(
obj.remove("homepage"); obj.remove("homepage");
obj.remove("docs"); obj.remove("docs");
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式WSL 路径除外) // Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
if !is_wsl_target { wrap_command_for_windows(&mut obj);
wrap_command_for_windows(&mut obj);
}
out.insert(id.clone(), Value::Object(obj)); out.insert(id.clone(), Value::Object(obj));
} }
@@ -576,68 +545,4 @@ mod tests {
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"])); assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
} }
} }
/// 测试 WSL 路径检测功能
#[test]
fn test_is_wsl_path_wsl_dollar() {
// wsl$ 格式 - 各种发行版
#[cfg(windows)]
{
assert!(is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
assert!(is_wsl_path(Path::new(r"\\wsl$\Debian\home\user\.claude")));
assert!(is_wsl_path(Path::new(
r"\\wsl$\openSUSE-Leap-15.2\home\user"
)));
assert!(is_wsl_path(Path::new(r"\\wsl$\kali-linux\home\user")));
assert!(is_wsl_path(Path::new(r"\\wsl$\Arch\home\user")));
assert!(is_wsl_path(Path::new(r"\\wsl$\Alpine\home\user")));
assert!(is_wsl_path(Path::new(r"\\wsl$\Fedora\home\user")));
}
#[cfg(not(windows))]
{
// 非 Windows 平台始终返回 false
assert!(!is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
}
}
#[test]
fn test_is_wsl_path_wsl_localhost() {
// wsl.localhost 格式
#[cfg(windows)]
{
assert!(is_wsl_path(Path::new(
r"\\wsl.localhost\Ubuntu\home\user\.claude"
)));
assert!(is_wsl_path(Path::new(r"\\wsl.localhost\Debian\home\user")));
assert!(is_wsl_path(Path::new(
r"\\wsl.localhost\openSUSE-Leap-15.2\home\user"
)));
}
}
#[test]
fn test_is_wsl_path_case_insensitive() {
// 大小写不敏感
#[cfg(windows)]
{
assert!(is_wsl_path(Path::new(r"\\WSL$\Ubuntu\home\user")));
assert!(is_wsl_path(Path::new(r"\\Wsl$\Ubuntu\home\user")));
assert!(is_wsl_path(Path::new(r"\\WSL.LOCALHOST\Ubuntu\home\user")));
assert!(is_wsl_path(Path::new(r"\\Wsl.Localhost\Ubuntu\home\user")));
}
}
#[test]
fn test_is_wsl_path_non_wsl() {
// 非 WSL 路径
assert!(!is_wsl_path(Path::new(r"C:\Users\user\.claude")));
assert!(!is_wsl_path(Path::new(r"D:\Workspace\project")));
#[cfg(windows)]
{
assert!(!is_wsl_path(Path::new(r"\\server\share\path")));
assert!(!is_wsl_path(Path::new(r"\\localhost\c$\Users")));
assert!(!is_wsl_path(Path::new(r"\\192.168.1.1\share")));
}
}
} }
-247
View File
@@ -1,247 +0,0 @@
//! 全局出站代理相关命令
//!
//! 提供获取、设置和测试全局代理的 Tauri 命令。
use crate::proxy::http_client;
use crate::store::AppState;
use serde::Serialize;
use std::net::{Ipv4Addr, SocketAddrV4, TcpStream};
use std::time::{Duration, Instant};
/// 获取全局代理 URL
///
/// 返回当前配置的代理 URL,null 表示直连。
#[tauri::command]
pub fn get_global_proxy_url(state: tauri::State<'_, AppState>) -> Result<Option<String>, String> {
let result = state.db.get_global_proxy_url().map_err(|e| e.to_string())?;
log::debug!(
"[GlobalProxy] [GP-010] Read from database: {}",
result
.as_ref()
.map(|u| http_client::mask_url(u))
.unwrap_or_else(|| "None".to_string())
);
Ok(result)
}
/// 设置全局代理 URL
///
/// - 传入非空字符串:启用代理
/// - 传入空字符串:清除代理(直连)
///
/// 执行顺序:先验证 → 写 DB → 再应用
/// 这样确保 DB 写失败时不会出现运行态与持久化不一致的问题
#[tauri::command]
pub fn set_global_proxy_url(state: tauri::State<'_, AppState>, url: String) -> Result<(), String> {
// 调试:显示接收到的 URL 信息(不包含敏感内容)
let has_auth = url.contains('@') && (url.starts_with("http://") || url.starts_with("socks"));
log::debug!(
"[GlobalProxy] [GP-011] Received URL: length={}, has_auth={}",
url.len(),
has_auth
);
let url_opt = if url.trim().is_empty() {
None
} else {
Some(url.as_str())
};
// 1. 先验证代理配置是否有效(不应用)
http_client::validate_proxy(url_opt)?;
// 2. 验证成功后保存到数据库
state
.db
.set_global_proxy_url(url_opt)
.map_err(|e| e.to_string())?;
// 3. DB 写入成功后再应用到运行态
http_client::apply_proxy(url_opt)?;
log::info!(
"[GlobalProxy] [GP-009] Configuration updated: {}",
url_opt
.map(http_client::mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
Ok(())
}
/// 代理测试结果
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyTestResult {
/// 是否连接成功
pub success: bool,
/// 延迟(毫秒)
pub latency_ms: u64,
/// 错误信息
pub error: Option<String>,
}
/// 测试代理连接
///
/// 通过指定的代理 URL 发送测试请求,返回连接结果和延迟。
/// 使用多个测试目标,任一成功即认为代理可用。
#[tauri::command]
pub async fn test_proxy_url(url: String) -> Result<ProxyTestResult, String> {
if url.trim().is_empty() {
return Err("Proxy URL is empty".to_string());
}
let start = Instant::now();
// 构建带代理的临时客户端
let proxy = reqwest::Proxy::all(&url).map_err(|e| format!("Invalid proxy URL: {e}"))?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to build client: {e}"))?;
// 使用多个测试目标,提高兼容性
// 优先使用 httpbin(专门用于 HTTP 测试),回退到其他公共端点
let test_urls = [
"https://httpbin.org/get",
"https://www.google.com",
"https://api.anthropic.com",
];
let mut last_error = None;
for test_url in test_urls {
match client.head(test_url).send().await {
Ok(resp) => {
let latency = start.elapsed().as_millis() as u64;
log::debug!(
"[GlobalProxy] Test successful: {} -> {} via {} ({}ms)",
http_client::mask_url(&url),
test_url,
resp.status(),
latency
);
return Ok(ProxyTestResult {
success: true,
latency_ms: latency,
error: None,
});
}
Err(e) => {
log::debug!("[GlobalProxy] Test to {test_url} failed: {e}");
last_error = Some(e);
}
}
}
// 所有测试目标都失败
let latency = start.elapsed().as_millis() as u64;
let error_msg = last_error
.map(|e| e.to_string())
.unwrap_or_else(|| "All test targets failed".to_string());
log::debug!(
"[GlobalProxy] Test failed: {} -> {} ({}ms)",
http_client::mask_url(&url),
error_msg,
latency
);
Ok(ProxyTestResult {
success: false,
latency_ms: latency,
error: Some(error_msg),
})
}
/// 获取当前出站代理状态
///
/// 返回当前是否启用了出站代理以及代理 URL。
#[tauri::command]
pub fn get_upstream_proxy_status() -> UpstreamProxyStatus {
let url = http_client::get_current_proxy_url();
UpstreamProxyStatus {
enabled: url.is_some(),
proxy_url: url,
}
}
/// 出站代理状态信息
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpstreamProxyStatus {
/// 是否启用代理
pub enabled: bool,
/// 代理 URL
pub proxy_url: Option<String>,
}
/// 检测到的代理信息
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DetectedProxy {
/// 代理 URL
pub url: String,
/// 代理类型 (http/socks5)
pub proxy_type: String,
/// 端口
pub port: u16,
}
/// 常见代理端口配置
/// 格式:(端口, 主要类型, 是否同时支持 http 和 socks5)
/// 对于 mixed 端口,会同时返回两种协议供用户选择
const PROXY_PORTS: &[(u16, &str, bool)] = &[
(7890, "http", true), // Clash (mixed mode)
(7891, "socks5", false), // Clash SOCKS only
(1080, "socks5", false), // 通用 SOCKS5
(8080, "http", false), // 通用 HTTP
(8888, "http", false), // Charles/Fiddler
(3128, "http", false), // Squid
(10808, "socks5", false), // V2Ray SOCKS
(10809, "http", false), // V2Ray HTTP
];
/// 扫描本地代理
///
/// 检测常见端口是否有代理服务在运行。
/// 使用异步任务避免阻塞 UI 线程。
#[tauri::command]
pub async fn scan_local_proxies() -> Vec<DetectedProxy> {
// 使用 spawn_blocking 避免阻塞主线程
tokio::task::spawn_blocking(|| {
let mut found = Vec::new();
for &(port, primary_type, is_mixed) in PROXY_PORTS {
let addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port);
if TcpStream::connect_timeout(&addr.into(), Duration::from_millis(100)).is_ok() {
// 添加主要类型
found.push(DetectedProxy {
url: format!("{primary_type}://127.0.0.1:{port}"),
proxy_type: primary_type.to_string(),
port,
});
// 对于 mixed 端口,同时添加另一种协议
if is_mixed {
let alt_type = if primary_type == "http" {
"socks5"
} else {
"http"
};
found.push(DetectedProxy {
url: format!("{alt_type}://127.0.0.1:{port}"),
proxy_type: alt_type.to_string(),
port,
});
}
}
}
found
})
.await
.unwrap_or_default()
}
+9 -418
View File
@@ -1,14 +1,9 @@
#![allow(non_snake_case)] #![allow(non_snake_case)]
use crate::app_config::AppType;
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload}; use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
use crate::services::ProviderService;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use std::path::Path;
use std::str::FromStr;
use tauri::AppHandle; use tauri::AppHandle;
use tauri::State;
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -92,14 +87,15 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
let tools = vec!["claude", "codex", "gemini"]; let tools = vec!["claude", "codex", "gemini"];
let mut results = Vec::new(); let mut results = Vec::new();
// 使用全局 HTTP 客户端(已包含代理配置) // 用于获取远程版本的 client
let client = crate::proxy::http_client::get(); let client = reqwest::Client::builder()
.user_agent("cc-switch/1.0")
.build()
.map_err(|e| e.to_string())?;
for tool in tools { for tool in tools {
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径 // 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) { let (local_version, local_error) = {
try_get_version_wsl(tool, &distro)
} else {
// 先尝试直接执行 // 先尝试直接执行
let direct_result = try_get_version(tool); let direct_result = try_get_version(tool);
@@ -187,7 +183,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
if out.status.success() { if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout }; let raw = if stdout.is_empty() { &stderr } else { &stdout };
if raw.is_empty() { if raw.is_empty() {
(None, Some("not installed or not executable".to_string())) (None, Some("未安装或无法执行".to_string()))
} else { } else {
(Some(extract_version(raw)), None) (Some(extract_version(raw)), None)
} }
@@ -196,7 +192,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
( (
None, None,
Some(if err.is_empty() { Some(if err.is_empty() {
"not installed or not executable".to_string() "未安装或无法执行".to_string()
} else { } else {
err err
}), }),
@@ -207,88 +203,6 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
} }
} }
/// 校验 WSL 发行版名称是否合法
/// WSL 发行版名称只允许字母、数字、连字符和下划线
#[cfg(target_os = "windows")]
fn is_valid_wsl_distro_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
#[cfg(target_os = "windows")]
fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<String>) {
use std::process::Command;
// 防御性断言:tool 只能是预定义的值
debug_assert!(
["claude", "codex", "gemini"].contains(&tool),
"unexpected tool name: {tool}"
);
// 校验 distro 名称,防止命令注入
if !is_valid_wsl_distro_name(distro) {
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
}
let output = Command::new("wsl.exe")
.args([
"-d",
distro,
"--",
"sh",
"-lc",
&format!("{tool} --version"),
])
.creation_flags(CREATE_NO_WINDOW)
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if raw.is_empty() {
(
None,
Some(format!("[WSL:{distro}] not installed or not executable")),
)
} else {
(Some(extract_version(raw)), None)
}
} else {
let err = if stderr.is_empty() { stdout } else { stderr };
(
None,
Some(format!(
"[WSL:{distro}] {}",
if err.is_empty() {
"not installed or not executable".to_string()
} else {
err
}
)),
)
}
}
Err(e) => (None, Some(format!("[WSL:{distro}] exec failed: {e}"))),
}
}
/// 非 Windows 平台的 WSL 版本检测存根
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
#[cfg(not(target_os = "windows"))]
fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<String>) {
(
None,
Some("WSL check not supported on this platform".to_string()),
)
}
/// 扫描常见路径查找 CLI /// 扫描常见路径查找 CLI
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) { fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
use std::process::Command; use std::process::Command;
@@ -384,328 +298,5 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
} }
} }
(None, Some("not installed or not executable".to_string())) (None, Some("未安装或无法执行".to_string()))
}
fn wsl_distro_for_tool(tool: &str) -> Option<String> {
let override_dir = match tool {
"claude" => crate::settings::get_claude_override_dir(),
"codex" => crate::settings::get_codex_override_dir(),
"gemini" => crate::settings::get_gemini_override_dir(),
_ => None,
}?;
wsl_distro_from_path(&override_dir)
}
/// 从 UNC 路径中提取 WSL 发行版名称
/// 支持 `\\wsl$\Ubuntu\...` 和 `\\wsl.localhost\Ubuntu\...` 两种格式
#[cfg(target_os = "windows")]
fn wsl_distro_from_path(path: &Path) -> Option<String> {
use std::path::{Component, Prefix};
let Some(Component::Prefix(prefix)) = path.components().next() else {
return None;
};
match prefix.kind() {
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
let server_name = server.to_string_lossy();
if server_name.eq_ignore_ascii_case("wsl$")
|| server_name.eq_ignore_ascii_case("wsl.localhost")
{
let distro = share.to_string_lossy().to_string();
if !distro.is_empty() {
return Some(distro);
}
}
None
}
_ => None,
}
}
/// 非 Windows 平台不支持 WSL 路径解析
#[cfg(not(target_os = "windows"))]
fn wsl_distro_from_path(_path: &Path) -> Option<String> {
None
}
/// 打开指定提供商的终端
///
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端
#[allow(non_snake_case)]
#[tauri::command]
pub async fn open_provider_terminal(
state: State<'_, crate::store::AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
// 获取提供商配置
let providers = ProviderService::list(state.inner(), app_type.clone())
.map_err(|e| format!("获取提供商列表失败: {e}"))?;
let provider = providers
.get(&providerId)
.ok_or_else(|| format!("提供商 {providerId} 不存在"))?;
// 从提供商配置中提取环境变量
let config = &provider.settings_config;
let env_vars = extract_env_vars_from_config(config, &app_type);
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
Ok(true)
}
/// 从提供商配置中提取环境变量
fn extract_env_vars_from_config(
config: &serde_json::Value,
app_type: &AppType,
) -> Vec<(String, String)> {
let mut env_vars = Vec::new();
let Some(obj) = config.as_object() else {
return env_vars;
};
// 处理 env 字段(Claude/Gemini 通用)
if let Some(env) = obj.get("env").and_then(|v| v.as_object()) {
for (key, value) in env {
if let Some(str_val) = value.as_str() {
env_vars.push((key.clone(), str_val.to_string()));
}
}
// 处理 base_url: 根据应用类型添加对应的环境变量
let base_url_key = match app_type {
AppType::Claude => Some("ANTHROPIC_BASE_URL"),
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
_ => None,
};
if let Some(key) = base_url_key {
if let Some(url_str) = env.get(key).and_then(|v| v.as_str()) {
env_vars.push((key.to_string(), url_str.to_string()));
}
}
}
// Codex 使用 auth 字段转换为 OPENAI_API_KEY
if *app_type == AppType::Codex {
if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) {
env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string()));
}
}
// Gemini 使用 api_key 字段转换为 GEMINI_API_KEY
if *app_type == AppType::Gemini {
if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) {
env_vars.push(("GEMINI_API_KEY".to_string(), api_key.to_string()));
}
}
env_vars
}
/// 创建临时配置文件并启动 claude 终端
/// 使用 --settings 参数传入提供商特定的 API 配置
fn launch_terminal_with_env(
env_vars: Vec<(String, String)>,
provider_id: &str,
) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let config_file = temp_dir.join(format!(
"claude_{}_{}.json",
provider_id,
std::process::id()
));
// 创建并写入配置文件
write_claude_config(&config_file, &env_vars)?;
// 转义配置文件路径用于 shell
let config_path_escaped = escape_shell_path(&config_file);
#[cfg(target_os = "macos")]
{
launch_macos_terminal(&config_file, &config_path_escaped)?;
Ok(())
}
#[cfg(target_os = "linux")]
{
launch_linux_terminal(&config_file, &config_path_escaped)?;
Ok(())
}
#[cfg(target_os = "windows")]
{
launch_windows_terminal(&temp_dir, &config_file)?;
return Ok(());
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("不支持的操作系统".to_string())
}
/// 写入 claude 配置文件
fn write_claude_config(
config_file: &std::path::Path,
env_vars: &[(String, String)],
) -> Result<(), String> {
let mut config_obj = serde_json::Map::new();
let mut env_obj = serde_json::Map::new();
for (key, value) in env_vars {
env_obj.insert(key.clone(), serde_json::Value::String(value.clone()));
}
config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj));
let config_json =
serde_json::to_string_pretty(&config_obj).map_err(|e| format!("序列化配置失败: {e}"))?;
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
}
/// 转义 shell 路径
fn escape_shell_path(path: &std::path::Path) -> String {
path.to_string_lossy()
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "\\$")
.replace(' ', "\\ ")
}
/// 生成 bash 包装脚本,用于清理临时文件
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
format!(
"bash -c 'trap \"rm -f \\\"{config_path}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{escaped_path}\"; claude --settings \"{escaped_path}\"; exec bash --norc --noprofile'"
)
}
/// macOS: 使用 Terminal.app 启动
#[cfg(target_os = "macos")]
fn launch_macos_terminal(
config_file: &std::path::Path,
config_path_escaped: &str,
) -> Result<(), String> {
use std::process::Command;
let config_path_for_script = config_file.to_string_lossy().replace('\"', "\\\"");
let shell_script = generate_wrapper_script(&config_path_for_script, config_path_escaped);
let script = format!(
r#"tell application "Terminal"
activate
do script "{}"
end tell"#,
shell_script.replace('\"', "\\\"")
);
Command::new("osascript")
.arg("-e")
.arg(&script)
.spawn()
.map_err(|e| format!("启动 macOS 终端失败: {e}"))?;
Ok(())
}
/// Linux: 尝试使用常见终端启动
#[cfg(target_os = "linux")]
fn launch_linux_terminal(
config_file: &std::path::Path,
config_path_escaped: &str,
) -> Result<(), String> {
use std::process::Command;
let terminals = [
"gnome-terminal",
"konsole",
"xfce4-terminal",
"mate-terminal",
"lxterminal",
"alacritty",
"kitty",
];
let config_path_for_bash = config_file.to_string_lossy();
let shell_cmd = generate_wrapper_script(&config_path_for_bash, config_path_escaped);
let mut last_error = String::from("未找到可用的终端");
for terminal in terminals {
// 检查终端是否存在
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
{
let result = match terminal {
"gnome-terminal" | "mate-terminal" => Command::new(terminal)
.arg("--")
.arg("bash")
.arg("-c")
.arg(&shell_cmd)
.spawn(),
_ => Command::new(terminal)
.arg("-e")
.arg("bash")
.arg("-c")
.arg(&shell_cmd)
.spawn(),
};
match result {
Ok(_) => return Ok(()),
Err(e) => {
last_error = format!("启动 {} 失败: {}", terminal, e);
}
}
}
}
// 清理配置文件
let _ = std::fs::remove_file(config_file);
Err(last_error)
}
/// Windows: 创建临时批处理文件启动
#[cfg(target_os = "windows")]
fn launch_windows_terminal(
temp_dir: &std::path::Path,
config_file: &std::path::Path,
) -> Result<(), String> {
use std::process::Command;
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
let content = format!(
"@echo off
echo Using provider-specific claude config:
echo {}
claude --settings \"{}\"
del \"{}\" >nul 2>&1
del \"%~f0\" >nul 2>&1
if errorlevel 1 (
echo.
echo Press any key to close...
pause >nul
)",
config_path_for_batch, config_path_for_batch, config_path_for_batch
);
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
Command::new("cmd")
.args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy()])
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| format!("启动 Windows 终端失败: {e}"))?;
Ok(())
} }
-2
View File
@@ -4,7 +4,6 @@ mod config;
mod deeplink; mod deeplink;
mod env; mod env;
mod failover; mod failover;
mod global_proxy;
mod import_export; mod import_export;
mod mcp; mod mcp;
mod misc; mod misc;
@@ -21,7 +20,6 @@ pub use config::*;
pub use deeplink::*; pub use deeplink::*;
pub use env::*; pub use env::*;
pub use failover::*; pub use failover::*;
pub use global_proxy::*;
pub use import_export::*; pub use import_export::*;
pub use mcp::*; pub use mcp::*;
pub use misc::*; pub use misc::*;
-2
View File
@@ -133,7 +133,6 @@ pub async fn testUsageScript(
#[allow(non_snake_case)] baseUrl: Option<String>, #[allow(non_snake_case)] baseUrl: Option<String>,
#[allow(non_snake_case)] accessToken: Option<String>, #[allow(non_snake_case)] accessToken: Option<String>,
#[allow(non_snake_case)] userId: Option<String>, #[allow(non_snake_case)] userId: Option<String>,
#[allow(non_snake_case)] templateType: Option<String>,
) -> Result<crate::provider::UsageResult, String> { ) -> Result<crate::provider::UsageResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::test_usage_script( ProviderService::test_usage_script(
@@ -146,7 +145,6 @@ pub async fn testUsageScript(
baseUrl.as_deref(), baseUrl.as_deref(),
accessToken.as_deref(), accessToken.as_deref(),
userId.as_deref(), userId.as_deref(),
templateType.as_deref(),
) )
.await .await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
-21
View File
@@ -59,24 +59,3 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
pub async fn get_auto_launch_status() -> Result<bool, String> { pub async fn get_auto_launch_status() -> Result<bool, String> {
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}")) crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
} }
/// 获取整流器配置
#[tauri::command]
pub async fn get_rectifier_config(
state: tauri::State<'_, crate::AppState>,
) -> Result<crate::proxy::types::RectifierConfig, String> {
state.db.get_rectifier_config().map_err(|e| e.to_string())
}
/// 设置整流器配置
#[tauri::command]
pub async fn set_rectifier_config(
state: tauri::State<'_, crate::AppState>,
config: crate::proxy::types::RectifierConfig,
) -> Result<bool, String> {
state
.db
.set_rectifier_config(&config)
.map_err(|e| e.to_string())?;
Ok(true)
}
-58
View File
@@ -63,41 +63,6 @@ impl Database {
} }
} }
// --- 全局出站代理 ---
/// 全局代理 URL 的存储键名
const GLOBAL_PROXY_URL_KEY: &'static str = "global_proxy_url";
/// 获取全局出站代理 URL
///
/// 返回 None 表示未配置或已清除代理(直连)
/// 返回 Some(url) 表示已配置代理
pub fn get_global_proxy_url(&self) -> Result<Option<String>, AppError> {
self.get_setting(Self::GLOBAL_PROXY_URL_KEY)
}
/// 设置全局出站代理 URL
///
/// - 传入非空字符串:启用代理
/// - 传入空字符串或 None:清除代理设置(直连)
pub fn set_global_proxy_url(&self, url: Option<&str>) -> Result<(), AppError> {
match url {
Some(u) if !u.trim().is_empty() => {
self.set_setting(Self::GLOBAL_PROXY_URL_KEY, u.trim())
}
_ => {
// 清除代理设置
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM settings WHERE key = ?1",
params![Self::GLOBAL_PROXY_URL_KEY],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
}
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)--- // --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
/// 获取指定应用的代理接管状态 /// 获取指定应用的代理接管状态
@@ -163,27 +128,4 @@ impl Database {
log::info!("已清除所有代理接管状态"); log::info!("已清除所有代理接管状态");
Ok(()) Ok(())
} }
// --- 整流器配置 ---
/// 获取整流器配置
///
/// 返回整流器配置,如果不存在则返回默认值(全部启用)
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
match self.get_setting("rectifier_config")? {
Some(json) => serde_json::from_str(&json)
.map_err(|e| AppError::Database(format!("解析整流器配置失败: {e}"))),
None => Ok(crate::proxy::types::RectifierConfig::default()),
}
}
/// 更新整流器配置
pub fn set_rectifier_config(
&self,
config: &crate::proxy::types::RectifierConfig,
) -> Result<(), AppError> {
let json = serde_json::to_string(config)
.map_err(|e| AppError::Database(format!("序列化整流器配置失败: {e}")))?;
self.set_setting("rectifier_config", &json)
}
} }
+1 -1
View File
@@ -55,7 +55,7 @@ pub struct DeepLinkImportRequest {
/// Provider homepage URL /// Provider homepage URL
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>, pub homepage: Option<String>,
/// API endpoint/base URL (supports comma-separated multiple URLs) /// API endpoint/base URL
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>, pub endpoint: Option<String>,
/// API key /// API key
+2 -6
View File
@@ -101,13 +101,9 @@ fn parse_provider_deeplink(
validate_url(hp, "homepage")?; validate_url(hp, "homepage")?;
} }
} }
// Validate each endpoint (supports comma-separated multiple URLs)
if let Some(ref ep) = endpoint { if let Some(ref ep) = endpoint {
for (i, url) in ep.split(',').enumerate() { if !ep.is_empty() {
let trimmed = url.trim(); validate_url(ep, "endpoint")?;
if !trimmed.is_empty() {
validate_url(trimmed, &format!("endpoint[{i}]"))?;
}
} }
} }
+21 -68
View File
@@ -33,12 +33,12 @@ pub fn import_provider_from_deeplink(
} }
// Step 1: Merge config file if provided (v3.8+) // Step 1: Merge config file if provided (v3.8+)
let mut merged_request = parse_and_merge_config(&request)?; let merged_request = parse_and_merge_config(&request)?;
// Extract required fields (now as Option) // Extract required fields (now as Option)
let app_str = merged_request let app_str = merged_request
.app .app
.clone() .as_ref()
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?; .ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
let api_key = merged_request.api_key.as_ref().ok_or_else(|| { let api_key = merged_request.api_key.as_ref().ok_or_else(|| {
@@ -51,29 +51,14 @@ pub fn import_provider_from_deeplink(
)); ));
} }
// Get endpoint: supports comma-separated multiple URLs (first is primary) let endpoint = merged_request.endpoint.as_ref().ok_or_else(|| {
let endpoint_str = merged_request.endpoint.as_ref().ok_or_else(|| {
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string()) AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
})?; })?;
// Parse endpoints: split by comma, first is primary if endpoint.is_empty() {
let all_endpoints: Vec<String> = endpoint_str return Err(AppError::InvalidInput(
.split(',') "Endpoint cannot be empty".to_string(),
.map(|e| e.trim().to_string()) ));
.filter(|e| !e.is_empty())
.collect();
let primary_endpoint = all_endpoints
.first()
.ok_or_else(|| AppError::InvalidInput("Endpoint cannot be empty".to_string()))?;
// Auto-infer homepage from endpoint if not provided
if merged_request
.homepage
.as_ref()
.is_none_or(|s| s.is_empty())
{
merged_request.homepage = infer_homepage_from_endpoint(primary_endpoint);
} }
let homepage = merged_request.homepage.as_ref().ok_or_else(|| { let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
@@ -88,11 +73,11 @@ pub fn import_provider_from_deeplink(
let name = merged_request let name = merged_request
.name .name
.clone() .as_ref()
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?; .ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
// Parse app type // Parse app type
let app_type = AppType::from_str(&app_str) let app_type = AppType::from_str(app_str)
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?; .map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
// Build provider configuration based on app type // Build provider configuration based on app type
@@ -112,21 +97,6 @@ pub fn import_provider_from_deeplink(
// Use ProviderService to add the provider // Use ProviderService to add the provider
ProviderService::add(state, app_type.clone(), provider)?; ProviderService::add(state, app_type.clone(), provider)?;
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
for ep in all_endpoints.iter().skip(1) {
let normalized = ep.trim().trim_end_matches('/').to_string();
if !normalized.is_empty() {
if let Err(e) = ProviderService::add_custom_endpoint(
state,
app_type.clone(),
&provider_id,
normalized.clone(),
) {
log::warn!("Failed to add custom endpoint '{normalized}': {e}");
}
}
}
// If enabled=true, set as current provider // If enabled=true, set as current provider
if merged_request.enabled.unwrap_or(false) { if merged_request.enabled.unwrap_or(false) {
ProviderService::switch(state, app_type.clone(), &provider_id)?; ProviderService::switch(state, app_type.clone(), &provider_id)?;
@@ -168,16 +138,6 @@ pub(crate) fn build_provider_from_request(
Ok(provider) Ok(provider)
} }
/// Get primary endpoint from request (first one if comma-separated)
fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
request
.endpoint
.as_ref()
.and_then(|ep| ep.split(',').next())
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
/// Build provider meta with usage script configuration /// Build provider meta with usage script configuration
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> { fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
// Check if any usage script fields are provided // Check if any usage script fields are provided
@@ -205,7 +165,6 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
let enabled = request.usage_enabled.unwrap_or(!code.is_empty()); let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
// Build UsageScript - use provider's API key and endpoint as defaults // Build UsageScript - use provider's API key and endpoint as defaults
// Note: use primary endpoint only (first one if comma-separated)
let usage_script = UsageScript { let usage_script = UsageScript {
enabled, enabled,
language: "javascript".to_string(), language: "javascript".to_string(),
@@ -215,17 +174,12 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
.usage_api_key .usage_api_key
.clone() .clone()
.or_else(|| request.api_key.clone()), .or_else(|| request.api_key.clone()),
base_url: request.usage_base_url.clone().or_else(|| { base_url: request
let primary = get_primary_endpoint(request); .usage_base_url
if primary.is_empty() { .clone()
None .or_else(|| request.endpoint.clone()),
} else {
Some(primary)
}
}),
access_token: request.usage_access_token.clone(), access_token: request.usage_access_token.clone(),
user_id: request.usage_user_id.clone(), user_id: request.usage_user_id.clone(),
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
auto_query_interval: request.usage_auto_interval, auto_query_interval: request.usage_auto_interval,
}; };
@@ -244,7 +198,7 @@ fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
); );
env.insert( env.insert(
"ANTHROPIC_BASE_URL".to_string(), "ANTHROPIC_BASE_URL".to_string(),
json!(get_primary_endpoint(request)), json!(request.endpoint.clone().unwrap_or_default()),
); );
// Add default model if provided // Add default model if provided
@@ -317,8 +271,11 @@ fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
.unwrap_or("gpt-5-codex") .unwrap_or("gpt-5-codex")
.to_string(); .to_string();
// Endpoint: normalize trailing slashes (use primary endpoint only) // Endpoint: normalize trailing slashes
let endpoint = get_primary_endpoint(request) let endpoint = request
.endpoint
.as_deref()
.unwrap_or("")
.trim() .trim()
.trim_end_matches('/') .trim_end_matches('/')
.to_string(); .to_string();
@@ -352,7 +309,7 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key)); env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
env.insert( env.insert(
"GOOGLE_GEMINI_BASE_URL".to_string(), "GOOGLE_GEMINI_BASE_URL".to_string(),
json!(get_primary_endpoint(request)), json!(request.endpoint),
); );
// Add model if provided // Add model if provided
@@ -566,11 +523,7 @@ fn merge_gemini_config(
} }
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) { if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(base_url) = config if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
.get("GOOGLE_GEMINI_BASE_URL")
.or_else(|| config.get("GEMINI_BASE_URL"))
.and_then(|v| v.as_str())
{
request.endpoint = Some(base_url.to_string()); request.endpoint = Some(base_url.to_string());
} }
} }
-54
View File
@@ -404,57 +404,3 @@ fn test_parse_skill_deeplink() {
assert_eq!(request.directory.unwrap(), "skills"); assert_eq!(request.directory.unwrap(), "skills");
assert_eq!(request.branch.unwrap(), "dev"); assert_eq!(request.branch.unwrap(), "dev");
} }
// =============================================================================
// Multiple Endpoints Tests
// =============================================================================
#[test]
fn test_parse_multiple_endpoints_comma_separated() {
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com,https%3A%2F%2Fapi2.example.com,https%3A%2F%2Fapi3.example.com&apiKey=sk-test";
let request = parse_deeplink_url(url).unwrap();
assert!(request.endpoint.is_some());
let endpoint = request.endpoint.unwrap();
// Should contain all endpoints comma-separated
assert!(endpoint.contains("https://api1.example.com"));
assert!(endpoint.contains("https://api2.example.com"));
assert!(endpoint.contains("https://api3.example.com"));
}
#[test]
fn test_parse_single_endpoint_backward_compatible() {
// Old format with single endpoint should still work
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(
request.endpoint,
Some("https://api.example.com".to_string())
);
}
#[test]
fn test_parse_endpoints_with_spaces_trimmed() {
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com%20,%20https%3A%2F%2Fapi2.example.com&apiKey=sk-test";
let request = parse_deeplink_url(url).unwrap();
// Validation should pass (spaces are trimmed during validation)
assert!(request.endpoint.is_some());
}
#[test]
fn test_infer_homepage_from_endpoint_without_homepage() {
// Test that homepage is auto-inferred from endpoint when not provided
assert_eq!(
infer_homepage_from_endpoint("https://api.cubence.com/v1"),
Some("https://cubence.com".to_string())
);
assert_eq!(
infer_homepage_from_endpoint("https://cubence.com"),
Some("https://cubence.com".to_string())
);
}
-42
View File
@@ -27,7 +27,6 @@ mod usage_script;
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig}; pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic}; pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
pub use commands::open_provider_terminal;
pub use commands::*; pub use commands::*;
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
pub use database::Database; pub use database::Database;
@@ -644,37 +643,6 @@ pub fn run() {
let skill_service = SkillService::new(); let skill_service = SkillService::new();
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service))); app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
// 初始化全局出站代理 HTTP 客户端
{
let db = &app.state::<AppState>().db;
let proxy_url = db.get_global_proxy_url().ok().flatten();
if let Err(e) = crate::proxy::http_client::init(proxy_url.as_deref()) {
log::error!(
"[GlobalProxy] [GP-005] Failed to initialize with saved config: {e}"
);
// 清除无效的代理配置
if proxy_url.is_some() {
log::warn!(
"[GlobalProxy] [GP-006] Clearing invalid proxy config from database"
);
if let Err(clear_err) = db.set_global_proxy_url(None) {
log::error!(
"[GlobalProxy] [GP-007] Failed to clear invalid config: {clear_err}"
);
}
}
// 使用直连模式重新初始化
if let Err(fallback_err) = crate::proxy::http_client::init(None) {
log::error!(
"[GlobalProxy] [GP-008] Failed to initialize direct connection: {fallback_err}"
);
}
}
}
// 异常退出恢复 + 代理状态自动恢复 // 异常退出恢复 + 代理状态自动恢复
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
@@ -734,8 +702,6 @@ pub fn run() {
commands::read_live_provider_settings, commands::read_live_provider_settings,
commands::get_settings, commands::get_settings,
commands::save_settings, commands::save_settings,
commands::get_rectifier_config,
commands::set_rectifier_config,
commands::restart_app, commands::restart_app,
commands::check_for_updates, commands::check_for_updates,
commands::is_portable_mode, commands::is_portable_mode,
@@ -866,20 +832,12 @@ pub fn run() {
commands::get_stream_check_config, commands::get_stream_check_config,
commands::save_stream_check_config, commands::save_stream_check_config,
commands::get_tool_versions, commands::get_tool_versions,
// Provider terminal
commands::open_provider_terminal,
// Universal Provider management // Universal Provider management
commands::get_universal_providers, commands::get_universal_providers,
commands::get_universal_provider, commands::get_universal_provider,
commands::upsert_universal_provider, commands::upsert_universal_provider,
commands::delete_universal_provider, commands::delete_universal_provider,
commands::sync_universal_provider, commands::sync_universal_provider,
// Global upstream proxy
commands::get_global_proxy_url,
commands::set_global_proxy_url,
commands::test_proxy_url,
commands::get_upstream_proxy_status,
commands::scan_local_proxies,
]); ]);
let app = builder let app = builder
-7
View File
@@ -98,10 +98,6 @@ pub struct UsageScript {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "userId")] #[serde(rename = "userId")]
pub user_id: Option<String>, pub user_id: Option<String>,
/// 模板类型(用于后端判断验证规则)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "templateType")]
pub template_type: Option<String>,
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询) /// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "autoQueryInterval")] #[serde(rename = "autoQueryInterval")]
@@ -151,9 +147,6 @@ pub struct ProviderMeta {
/// 用量查询脚本配置 /// 用量查询脚本配置
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub usage_script: Option<UsageScript>, pub usage_script: Option<UsageScript>,
/// 请求地址管理:测速后自动选择最佳端点
#[serde(rename = "endpointAutoSelect", skip_serializing_if = "Option::is_none")]
pub endpoint_auto_select: Option<bool>,
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致) /// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")] #[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
pub is_partner: Option<bool>, pub is_partner: Option<bool>,
+1 -5
View File
@@ -319,11 +319,7 @@ impl CircuitBreaker {
} }
} }
/// 仅释放 HalfOpen permit,不影响健康统计 fn release_half_open_permit(&self) {
///
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
pub fn release_half_open_permit(&self) {
let mut current = self.half_open_requests.load(Ordering::SeqCst); let mut current = self.half_open_requests.load(Ordering::SeqCst);
loop { loop {
if current == 0 { if current == 0 {
+53 -231
View File
@@ -7,15 +7,15 @@ use super::{
error::*, error::*,
failover_switch::FailoverSwitchManager, failover_switch::FailoverSwitchManager,
provider_router::ProviderRouter, provider_router::ProviderRouter,
providers::{get_adapter, ProviderAdapter, ProviderType}, providers::{get_adapter, ProviderAdapter},
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature}, types::ProxyStatus,
types::{ProxyStatus, RectifierConfig},
ProxyError, ProxyError,
}; };
use crate::{app_config::AppType, provider::Provider}; use crate::{app_config::AppType, provider::Provider};
use reqwest::Response; use reqwest::{Client, Response};
use serde_json::Value; use serde_json::Value;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock; use tokio::sync::RwLock;
/// Headers 黑名单 - 不透传到上游的 Headers /// Headers 黑名单 - 不透传到上游的 Headers
@@ -81,6 +81,8 @@ pub struct ForwardError {
} }
pub struct RequestForwarder { pub struct RequestForwarder {
client: Option<Client>,
client_init_error: Option<String>,
/// 共享的 ProviderRouter(持有熔断器状态) /// 共享的 ProviderRouter(持有熔断器状态)
router: Arc<ProviderRouter>, router: Arc<ProviderRouter>,
status: Arc<RwLock<ProxyStatus>>, status: Arc<RwLock<ProxyStatus>>,
@@ -91,10 +93,6 @@ pub struct RequestForwarder {
app_handle: Option<tauri::AppHandle>, app_handle: Option<tauri::AppHandle>,
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘) /// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
current_provider_id_at_start: String, current_provider_id_at_start: String,
/// 整流器配置
rectifier_config: RectifierConfig,
/// 非流式请求超时(秒)
non_streaming_timeout: std::time::Duration,
} }
impl RequestForwarder { impl RequestForwarder {
@@ -109,17 +107,52 @@ impl RequestForwarder {
current_provider_id_at_start: String, current_provider_id_at_start: String,
_streaming_first_byte_timeout: u64, _streaming_first_byte_timeout: u64,
_streaming_idle_timeout: u64, _streaming_idle_timeout: u64,
rectifier_config: RectifierConfig,
) -> Self { ) -> Self {
// 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作
// 参考 Claude Code Hub 的 undici 全局超时设计
const GLOBAL_TIMEOUT_SECS: u64 = 1800;
let timeout_secs = if non_streaming_timeout > 0 {
non_streaming_timeout
} else {
GLOBAL_TIMEOUT_SECS
};
// 注意:这里不能用 expect/unwrap。
// release 配置为 panic=abort,一旦 build 失败会导致整个应用闪退。
// 常见原因:用户环境变量里存在不合法/不支持的代理(HTTP(S)_PROXY/ALL_PROXY 等)。
let (client, client_init_error) = match Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
{
Ok(client) => (Some(client), None),
Err(e) => {
// 降级:忽略系统/环境代理,避免因代理配置问题导致整个应用崩溃
match Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.no_proxy()
.build()
{
Ok(client) => (Some(client), Some(e.to_string())),
Err(fallback_err) => (
None,
Some(format!(
"Failed to create HTTP client: {e}; no_proxy fallback failed: {fallback_err}"
)),
),
}
}
};
Self { Self {
client,
client_init_error,
router, router,
status, status,
current_providers, current_providers,
failover_manager, failover_manager,
app_handle, app_handle,
current_provider_id_at_start, current_provider_id_at_start,
rectifier_config,
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
} }
} }
@@ -135,7 +168,7 @@ impl RequestForwarder {
&self, &self,
app_type: &AppType, app_type: &AppType,
endpoint: &str, endpoint: &str,
mut body: Value, body: Value,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
providers: Vec<Provider>, providers: Vec<Provider>,
) -> Result<ForwardResult, ForwardError> { ) -> Result<ForwardResult, ForwardError> {
@@ -154,9 +187,6 @@ impl RequestForwarder {
let mut last_provider = None; let mut last_provider = None;
let mut attempted_providers = 0usize; let mut attempted_providers = 0usize;
// 整流器重试标记:确保整流最多触发一次
let mut rectifier_retried = false;
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时) // 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
let bypass_circuit_breaker = providers.len() == 1; let bypass_circuit_breaker = providers.len() == 1;
@@ -251,205 +281,6 @@ impl RequestForwarder {
}); });
} }
Err(e) => { Err(e) => {
// 检测是否需要触发整流器(仅 Claude/ClaudeAuth 供应商)
let provider_type = ProviderType::from_app_type_and_config(app_type, provider);
let is_anthropic_provider = matches!(
provider_type,
ProviderType::Claude | ProviderType::ClaudeAuth
);
if is_anthropic_provider {
let error_message = extract_error_message(&e);
if should_rectify_thinking_signature(
error_message.as_deref(),
&self.rectifier_config,
) {
// 已经重试过:直接返回错误(不可重试客户端错误)
if rectifier_retried {
log::warn!("[{app_type_str}] [RECT-005] 整流器已触发过,不再重试");
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
// 首次触发:整流请求体
let rectified = rectify_anthropic_request(&mut body);
// 整流未生效:直接返回错误(不可重试客户端错误)
if !rectified.applied {
log::warn!(
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
);
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
log::info!(
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
app_type_str,
rectified.removed_thinking_blocks,
rectified.removed_redacted_thinking_blocks,
rectified.removed_signature_fields
);
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
let _ = std::mem::replace(&mut rectifier_retried, true);
// 使用同一供应商重试(不计入熔断器)
match self
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
.await
{
Ok(response) => {
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
// 记录成功
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
.await;
// 更新当前应用类型使用的 provider
{
let mut current_providers =
self.current_providers.write().await;
current_providers.insert(
app_type_str.to_string(),
(provider.id.clone(), provider.name.clone()),
);
}
// 更新成功统计
{
let mut status = self.status.write().await;
status.success_requests += 1;
status.last_error = None;
let should_switch =
self.current_provider_id_at_start.as_str()
!= provider.id.as_str();
if should_switch {
status.failover_count += 1;
// 异步触发供应商切换,更新 UI/托盘
let fm = self.failover_manager.clone();
let ah = self.app_handle.clone();
let pid = provider.id.clone();
let pname = provider.name.clone();
let at = app_type_str.to_string();
tokio::spawn(async move {
let _ = fm
.try_switch(ah.as_ref(), &at, &pid, &pname)
.await;
});
}
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
}
return Ok(ForwardResult {
response,
provider: provider.clone(),
});
}
Err(retry_err) => {
// 整流重试仍失败:区分错误类型决定是否记录熔断器
log::warn!(
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
);
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
let is_provider_error = match &retry_err {
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
true
}
ProxyError::UpstreamError { status, .. } => *status >= 500,
_ => false,
};
if is_provider_error {
// Provider 问题:记录失败到熔断器
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
false,
Some(retry_err.to_string()),
)
.await;
} else {
// 客户端问题:仅释放 permit,不记录熔断器
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
}
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(retry_err.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: retry_err,
provider: Some(provider.clone()),
});
}
}
}
}
// 失败:记录失败并更新熔断器 // 失败:记录失败并更新熔断器
let _ = self let _ = self
.router .router
@@ -585,17 +416,16 @@ impl RequestForwarder {
// 默认使用空白名单,过滤所有 _ 前缀字段 // 默认使用空白名单,过滤所有 _ 前缀字段
let filtered_body = filter_private_params_with_whitelist(request_body, &[]); let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
// 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置) // 构建请求
let client = super::http_client::get(); let client = self.client.as_ref().ok_or_else(|| {
ProxyError::ForwardFailed(
self.client_init_error
.clone()
.unwrap_or_else(|| "HTTP client is not initialized".to_string()),
)
})?;
let mut request = client.post(&url); let mut request = client.post(&url);
// 只有当 timeout > 0 时才设置请求超时
// Duration::ZERO 在 reqwest 中表示"立刻超时"而不是"禁用超时"
// 故障转移关闭时会传入 0,此时应该使用 client 的默认超时(600秒)
if !self.non_streaming_timeout.is_zero() {
request = request.timeout(self.non_streaming_timeout);
}
// 过滤黑名单 Headers,保护隐私并避免冲突 // 过滤黑名单 Headers,保护隐私并避免冲突
for (key, value) in headers { for (key, value) in headers {
if HEADER_BLACKLIST if HEADER_BLACKLIST
@@ -711,11 +541,3 @@ impl RequestForwarder {
} }
} }
} }
/// 从 ProxyError 中提取错误消息
fn extract_error_message(error: &ProxyError) -> Option<String> {
match error {
ProxyError::UpstreamError { body, .. } => body.clone(),
_ => Some(error.to_string()),
}
}
+1 -11
View File
@@ -5,10 +5,7 @@
use crate::app_config::AppType; use crate::app_config::AppType;
use crate::provider::Provider; use crate::provider::Provider;
use crate::proxy::{ use crate::proxy::{
extract_session_id, extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig,
forwarder::RequestForwarder,
server::ProxyState,
types::{AppProxyConfig, RectifierConfig},
ProxyError, ProxyError,
}; };
use axum::http::HeaderMap; use axum::http::HeaderMap;
@@ -57,8 +54,6 @@ pub struct RequestContext {
pub app_type: AppType, pub app_type: AppType,
/// Session ID(从客户端请求提取或新生成) /// Session ID(从客户端请求提取或新生成)
pub session_id: String, pub session_id: String,
/// 整流器配置
pub rectifier_config: RectifierConfig,
} }
impl RequestContext { impl RequestContext {
@@ -91,9 +86,6 @@ impl RequestContext {
.await .await
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?; .map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
// 从数据库读取整流器配置
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
let current_provider_id = let current_provider_id =
crate::settings::get_current_provider(&app_type).unwrap_or_default(); crate::settings::get_current_provider(&app_type).unwrap_or_default();
@@ -155,7 +147,6 @@ impl RequestContext {
app_type_str, app_type_str,
app_type, app_type,
session_id, session_id,
rectifier_config,
}) })
} }
@@ -215,7 +206,6 @@ impl RequestContext {
self.current_provider_id.clone(), self.current_provider_id.clone(),
first_byte_timeout, first_byte_timeout,
idle_timeout, idle_timeout,
self.rectifier_config.clone(),
) )
} }
-301
View File
@@ -1,301 +0,0 @@
//! 全局 HTTP 客户端模块
//!
//! 提供支持全局代理配置的 HTTP 客户端。
//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。
use once_cell::sync::OnceCell;
use reqwest::Client;
use std::sync::RwLock;
use std::time::Duration;
/// 全局 HTTP 客户端实例
static GLOBAL_CLIENT: OnceCell<RwLock<Client>> = OnceCell::new();
/// 当前代理 URL(用于日志和状态查询)
static CURRENT_PROXY_URL: OnceCell<RwLock<Option<String>>> = OnceCell::new();
/// 初始化全局 HTTP 客户端
///
/// 应在应用启动时调用一次。
///
/// # Arguments
/// * `proxy_url` - 代理 URL,如 `http://127.0.0.1:7890` 或 `socks5://127.0.0.1:1080`
/// 传入 None 或空字符串表示直连
pub fn init(proxy_url: Option<&str>) -> Result<(), String> {
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
let client = build_client(effective_url)?;
// 尝试初始化全局客户端,如果已存在则记录警告并使用 apply_proxy 更新
if GLOBAL_CLIENT.set(RwLock::new(client.clone())).is_err() {
log::warn!(
"[GlobalProxy] [GP-003] Already initialized, updating instead: {}",
effective_url
.map(mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
// 已初始化,改用 apply_proxy 更新
return apply_proxy(proxy_url);
}
// 初始化代理 URL 记录
let _ = CURRENT_PROXY_URL.set(RwLock::new(effective_url.map(|s| s.to_string())));
log::info!(
"[GlobalProxy] Initialized: {}",
effective_url
.map(mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
Ok(())
}
/// 验证代理配置(不应用)
///
/// 只验证代理 URL 是否有效,不实际更新全局客户端。
/// 用于在持久化之前验证配置的有效性。
///
/// # Arguments
/// * `proxy_url` - 代理 URLNone 或空字符串表示直连
///
/// # Returns
/// 验证成功返回 Ok(()),失败返回错误信息
pub fn validate_proxy(proxy_url: Option<&str>) -> Result<(), String> {
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
// 只调用 build_client 来验证,但不应用
build_client(effective_url)?;
Ok(())
}
/// 应用代理配置(假设已验证)
///
/// 直接应用代理配置到全局客户端,不做额外验证。
/// 应在 validate_proxy 成功后调用。
///
/// # Arguments
/// * `proxy_url` - 代理 URLNone 或空字符串表示直连
pub fn apply_proxy(proxy_url: Option<&str>) -> Result<(), String> {
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
let new_client = build_client(effective_url)?;
// 更新客户端
if let Some(lock) = GLOBAL_CLIENT.get() {
let mut client = lock.write().map_err(|e| {
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
"Failed to update proxy: lock poisoned".to_string()
})?;
*client = new_client;
} else {
// 如果还没初始化,则初始化
return init(proxy_url);
}
// 更新代理 URL 记录
if let Some(lock) = CURRENT_PROXY_URL.get() {
let mut url = lock.write().map_err(|e| {
log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}");
"Failed to update proxy URL record: lock poisoned".to_string()
})?;
*url = effective_url.map(|s| s.to_string());
}
log::info!(
"[GlobalProxy] Applied: {}",
effective_url
.map(mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
Ok(())
}
/// 更新代理配置(热更新)
///
/// 可在运行时调用以更改代理设置,无需重启应用。
/// 注意:此函数同时验证和应用,如果需要先验证后持久化再应用,
/// 请使用 validate_proxy + apply_proxy 组合。
///
/// # Arguments
/// * `proxy_url` - 新的代理 URLNone 或空字符串表示直连
#[allow(dead_code)]
pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> {
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
let new_client = build_client(effective_url)?;
// 更新客户端
if let Some(lock) = GLOBAL_CLIENT.get() {
let mut client = lock.write().map_err(|e| {
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
"Failed to update proxy: lock poisoned".to_string()
})?;
*client = new_client;
} else {
// 如果还没初始化,则初始化
return init(proxy_url);
}
// 更新代理 URL 记录
if let Some(lock) = CURRENT_PROXY_URL.get() {
let mut url = lock.write().map_err(|e| {
log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}");
"Failed to update proxy URL record: lock poisoned".to_string()
})?;
*url = effective_url.map(|s| s.to_string());
}
log::info!(
"[GlobalProxy] Updated: {}",
effective_url
.map(mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
Ok(())
}
/// 获取全局 HTTP 客户端
///
/// 返回配置了代理的客户端(如果已配置代理),否则返回直连客户端。
pub fn get() -> Client {
GLOBAL_CLIENT
.get()
.and_then(|lock| lock.read().ok())
.map(|c| c.clone())
.unwrap_or_else(|| {
// 如果还没初始化,创建一个默认客户端(配置与 build_client 一致)
log::warn!("[GlobalProxy] [GP-004] Client not initialized, using fallback");
Client::builder()
.timeout(Duration::from_secs(600))
.connect_timeout(Duration::from_secs(30))
.pool_max_idle_per_host(10)
.tcp_keepalive(Duration::from_secs(60))
.no_proxy()
.build()
.unwrap_or_default()
})
}
/// 获取当前代理 URL
///
/// 返回当前配置的代理 URL,None 表示直连。
pub fn get_current_proxy_url() -> Option<String> {
CURRENT_PROXY_URL
.get()
.and_then(|lock| lock.read().ok())
.and_then(|url| url.clone())
}
/// 检查是否正在使用代理
#[allow(dead_code)]
pub fn is_proxy_enabled() -> bool {
get_current_proxy_url().is_some()
}
/// 构建 HTTP 客户端
fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
let mut builder = Client::builder()
.timeout(Duration::from_secs(600))
.connect_timeout(Duration::from_secs(30))
.pool_max_idle_per_host(10)
.tcp_keepalive(Duration::from_secs(60));
// 有代理地址则使用代理,否则直连
if let Some(url) = proxy_url {
// 先验证 URL 格式和 scheme
let parsed = url::Url::parse(url)
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
let scheme = parsed.scheme();
if !["http", "https", "socks5", "socks5h"].contains(&scheme) {
return Err(format!(
"Invalid proxy scheme '{}' in URL '{}'. Supported: http, https, socks5, socks5h",
scheme,
mask_url(url)
));
}
let proxy = reqwest::Proxy::all(url)
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
builder = builder.proxy(proxy);
log::debug!("[GlobalProxy] Proxy configured: {}", mask_url(url));
} else {
builder = builder.no_proxy();
log::debug!("[GlobalProxy] Direct connection (no proxy)");
}
builder
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))
}
/// 隐藏 URL 中的敏感信息(用于日志)
pub fn mask_url(url: &str) -> String {
if let Ok(parsed) = url::Url::parse(url) {
// 隐藏用户名和密码,保留 scheme、host 和端口
let host = parsed.host_str().unwrap_or("?");
match parsed.port() {
Some(port) => format!("{}://{}:{}", parsed.scheme(), host, port),
None => format!("{}://{}", parsed.scheme(), host),
}
} else {
// URL 解析失败,返回部分内容
if url.len() > 20 {
format!("{}...", &url[..20])
} else {
url.to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mask_url() {
assert_eq!(mask_url("http://127.0.0.1:7890"), "http://127.0.0.1:7890");
assert_eq!(
mask_url("http://user:pass@127.0.0.1:7890"),
"http://127.0.0.1:7890"
);
assert_eq!(
mask_url("socks5://admin:secret@proxy.example.com:1080"),
"socks5://proxy.example.com:1080"
);
// 无端口的 URL 不应显示 ":?"
assert_eq!(
mask_url("http://proxy.example.com"),
"http://proxy.example.com"
);
assert_eq!(
mask_url("https://user:pass@proxy.example.com"),
"https://proxy.example.com"
);
}
#[test]
fn test_build_client_direct() {
let result = build_client(None);
assert!(result.is_ok());
}
#[test]
fn test_build_client_with_http_proxy() {
let result = build_client(Some("http://127.0.0.1:7890"));
assert!(result.is_ok());
}
#[test]
fn test_build_client_with_socks5_proxy() {
let result = build_client(Some("socks5://127.0.0.1:1080"));
assert!(result.is_ok());
}
#[test]
fn test_build_client_invalid_url() {
// reqwest::Proxy::all 对某些无效 URL 不会立即报错
// 使用明确无效的 scheme 来触发错误
let result = build_client(Some("invalid-scheme://127.0.0.1:7890"));
assert!(result.is_err(), "Should reject invalid proxy scheme");
}
}
-2
View File
@@ -12,7 +12,6 @@ pub mod handler_config;
pub mod handler_context; pub mod handler_context;
mod handlers; mod handlers;
mod health; mod health;
pub mod http_client;
pub mod log_codes; pub mod log_codes;
pub mod model_mapper; pub mod model_mapper;
pub mod provider_router; pub mod provider_router;
@@ -21,7 +20,6 @@ pub mod response_handler;
pub mod response_processor; pub mod response_processor;
pub(crate) mod server; pub(crate) mod server;
pub mod session; pub mod session;
pub mod thinking_rectifier;
pub(crate) mod types; pub(crate) mod types;
pub mod usage; pub mod usage;
-69
View File
@@ -151,24 +151,6 @@ impl ProviderRouter {
self.reset_circuit_breaker(&circuit_key).await; self.reset_circuit_breaker(&circuit_key).await;
} }
/// 仅释放 HalfOpen permit,不影响健康统计(neutral 接口)
///
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
pub async fn release_permit_neutral(
&self,
provider_id: &str,
app_type: &str,
used_half_open_permit: bool,
) {
if !used_half_open_permit {
return;
}
let circuit_key = format!("{app_type}:{provider_id}");
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
breaker.release_half_open_permit();
}
/// 更新所有熔断器的配置(热更新) /// 更新所有熔断器的配置(热更新)
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) { pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
let breakers = self.circuit_breakers.read().await; let breakers = self.circuit_breakers.read().await;
@@ -343,55 +325,4 @@ mod tests {
assert!(router.allow_provider_request("b", "claude").await.allowed); assert!(router.allow_provider_request("b", "claude").await.allowed);
} }
#[tokio::test]
async fn test_release_permit_neutral_frees_half_open_slot() {
let db = Arc::new(Database::memory().unwrap());
// 配置熔断器:1 次失败即熔断,0 秒超时立即进入 HalfOpen
db.update_circuit_breaker_config(&CircuitBreakerConfig {
failure_threshold: 1,
timeout_seconds: 0,
..Default::default()
})
.await
.unwrap();
let provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
db.save_provider("claude", &provider_a).unwrap();
db.add_to_failover_queue("claude", "a").unwrap();
// 启用自动故障转移
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
config.auto_failover_enabled = true;
db.update_proxy_config_for_app(config).await.unwrap();
let router = ProviderRouter::new(db.clone());
// 触发熔断:1 次失败
router
.record_result("a", "claude", false, false, Some("fail".to_string()))
.await
.unwrap();
// 第一次请求:获取 HalfOpen 探测名额
let first = router.allow_provider_request("a", "claude").await;
assert!(first.allowed);
assert!(first.used_half_open_permit);
// 第二次请求应被拒绝(名额已被占用)
let second = router.allow_provider_request("a", "claude").await;
assert!(!second.allowed);
// 使用 release_permit_neutral 释放名额(不影响健康统计)
router
.release_permit_neutral("a", "claude", first.used_half_open_permit)
.await;
// 第三次请求应被允许(名额已释放)
let third = router.allow_provider_request("a", "claude").await;
assert!(third.allowed);
assert!(third.used_half_open_permit);
}
} }
-421
View File
@@ -1,421 +0,0 @@
//! Thinking Signature 整流器
//!
//! 用于自动修复 Anthropic API 中因签名校验失败导致的请求错误。
//! 当上游 API 返回签名相关错误时,系统会自动移除有问题的签名字段并重试请求。
use super::types::RectifierConfig;
use serde_json::Value;
/// 整流结果
#[derive(Debug, Clone, Default)]
pub struct RectifyResult {
/// 是否应用了整流
pub applied: bool,
/// 移除的 thinking block 数量
pub removed_thinking_blocks: usize,
/// 移除的 redacted_thinking block 数量
pub removed_redacted_thinking_blocks: usize,
/// 移除的 signature 字段数量
pub removed_signature_fields: usize,
}
/// 检测是否需要触发 thinking 签名整流器
///
/// 返回 `true` 表示需要触发整流器,`false` 表示不需要。
/// 会检查配置开关。
pub fn should_rectify_thinking_signature(
error_message: Option<&str>,
config: &RectifierConfig,
) -> bool {
// 检查总开关
if !config.enabled {
return false;
}
// 检查子开关
if !config.request_thinking_signature {
return false;
}
// 检测错误类型
let Some(msg) = error_message else {
return false;
};
let lower = msg.to_lowercase();
// 场景1: thinking block 中的签名无效
// 错误示例: "Invalid 'signature' in 'thinking' block"
if lower.contains("invalid")
&& lower.contains("signature")
&& lower.contains("thinking")
&& lower.contains("block")
{
return true;
}
// 场景2: assistant 消息必须以 thinking block 开头
// 错误示例: "must start with a thinking block"
if lower.contains("must start with a thinking block") {
return true;
}
// 场景3: expected thinking or redacted_thinking, found tool_use
// 错误示例: "Expected `thinking` or `redacted_thinking`, but found `tool_use`"
if lower.contains("expected")
&& (lower.contains("thinking") || lower.contains("redacted_thinking"))
&& lower.contains("found")
{
return true;
}
// 场景4: signature 字段必需但缺失
// 错误示例: "signature: Field required"
if lower.contains("signature") && lower.contains("field required") {
return true;
}
false
}
/// 对 Anthropic 请求体做最小侵入整流
///
/// - 移除 messages[*].content 中的 thinking/redacted_thinking block
/// - 移除非 thinking block 上遗留的 signature 字段
/// - 特定条件下删除顶层 thinking 字段
///
/// 注意:该函数会原地修改 body 对象
pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
let mut result = RectifyResult::default();
let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) {
Some(m) => m,
None => return result,
};
// 遍历所有消息
for msg in messages.iter_mut() {
let content = match msg.get_mut("content").and_then(|c| c.as_array_mut()) {
Some(c) => c,
None => continue,
};
let mut new_content = Vec::with_capacity(content.len());
let mut content_modified = false;
for block in content.iter() {
let block_type = block.get("type").and_then(|t| t.as_str());
match block_type {
Some("thinking") => {
result.removed_thinking_blocks += 1;
content_modified = true;
continue;
}
Some("redacted_thinking") => {
result.removed_redacted_thinking_blocks += 1;
content_modified = true;
continue;
}
_ => {}
}
// 移除非 thinking block 上的 signature 字段
if block.get("signature").is_some() {
let mut block_clone = block.clone();
if let Some(obj) = block_clone.as_object_mut() {
obj.remove("signature");
result.removed_signature_fields += 1;
content_modified = true;
new_content.push(Value::Object(obj.clone()));
continue;
}
}
new_content.push(block.clone());
}
if content_modified {
result.applied = true;
*content = new_content;
}
}
// 兜底处理:thinking 启用 + 工具调用链路中最后一条 assistant 消息未以 thinking 开头
let messages_snapshot: Vec<Value> = body
.get("messages")
.and_then(|m| m.as_array())
.map(|a| a.to_vec())
.unwrap_or_default();
if should_remove_top_level_thinking(body, &messages_snapshot) {
if let Some(obj) = body.as_object_mut() {
obj.remove("thinking");
result.applied = true;
}
}
result
}
/// 判断是否需要删除顶层 thinking 字段
fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
// 检查 thinking 是否启用
let thinking_enabled = body
.get("thinking")
.and_then(|t| t.get("type"))
.and_then(|t| t.as_str())
== Some("enabled");
if !thinking_enabled {
return false;
}
// 找到最后一条 assistant 消息
let last_assistant = messages
.iter()
.rev()
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"));
let last_assistant_content = match last_assistant
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
{
Some(c) if !c.is_empty() => c,
_ => return false,
};
// 检查首块是否为 thinking/redacted_thinking
let first_block_type = last_assistant_content
.first()
.and_then(|b| b.get("type"))
.and_then(|t| t.as_str());
let missing_thinking_prefix =
first_block_type != Some("thinking") && first_block_type != Some("redacted_thinking");
if !missing_thinking_prefix {
return false;
}
// 检查是否存在 tool_use
last_assistant_content
.iter()
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn enabled_config() -> RectifierConfig {
RectifierConfig {
enabled: true,
request_thinking_signature: true,
}
}
fn disabled_config() -> RectifierConfig {
RectifierConfig {
enabled: true,
request_thinking_signature: false,
}
}
fn master_disabled_config() -> RectifierConfig {
RectifierConfig {
enabled: false,
request_thinking_signature: true,
}
}
// ==================== should_rectify_thinking_signature 测试 ====================
#[test]
fn test_detect_invalid_signature() {
assert!(should_rectify_thinking_signature(
Some("messages.1.content.0: Invalid `signature` in `thinking` block"),
&enabled_config()
));
}
#[test]
fn test_detect_invalid_signature_no_backticks() {
assert!(should_rectify_thinking_signature(
Some("Messages.1.Content.0: invalid signature in thinking block"),
&enabled_config()
));
}
#[test]
fn test_detect_invalid_signature_nested_json() {
// 测试嵌套 JSON 格式的错误消息(第三方渠道常见格式)
let nested_error = r#"{"error":{"message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_xxx\"}"}}"#;
assert!(should_rectify_thinking_signature(
Some(nested_error),
&enabled_config()
));
}
#[test]
fn test_detect_thinking_expected() {
assert!(should_rectify_thinking_signature(
Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `tool_use`."),
&enabled_config()
));
}
#[test]
fn test_detect_must_start_with_thinking() {
assert!(should_rectify_thinking_signature(
Some("a final `assistant` message must start with a thinking block"),
&enabled_config()
));
}
#[test]
fn test_no_trigger_for_unrelated_error() {
assert!(!should_rectify_thinking_signature(
Some("Request timeout"),
&enabled_config()
));
assert!(!should_rectify_thinking_signature(
Some("Connection refused"),
&enabled_config()
));
assert!(!should_rectify_thinking_signature(None, &enabled_config()));
}
#[test]
fn test_detect_signature_field_required() {
// 场景4: signature 字段缺失
assert!(should_rectify_thinking_signature(
Some("***.***.***.***.***.signature: Field required"),
&enabled_config()
));
// 嵌套 JSON 格式
let nested_error = r#"{"error":{"type":"<nil>","message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.***.***.***.***.signature: Field required\"},\"request_id\":\"req_xxx\"}"}}"#;
assert!(should_rectify_thinking_signature(
Some(nested_error),
&enabled_config()
));
}
#[test]
fn test_disabled_config() {
// 即使错误匹配,配置关闭时也不触发
assert!(!should_rectify_thinking_signature(
Some("Invalid `signature` in `thinking` block"),
&disabled_config()
));
}
#[test]
fn test_master_disabled() {
// 总开关关闭时,即使子开关开启也不触发
assert!(!should_rectify_thinking_signature(
Some("Invalid `signature` in `thinking` block"),
&master_disabled_config()
));
}
// ==================== rectify_anthropic_request 测试 ====================
#[test]
fn test_rectify_removes_thinking_blocks() {
let mut body = json!({
"model": "claude-test",
"messages": [{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "t", "signature": "sig" },
{ "type": "text", "text": "hello", "signature": "sig_text" },
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {}, "signature": "sig_tool" },
{ "type": "redacted_thinking", "data": "r", "signature": "sig_redacted" }
]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(result.applied);
assert_eq!(result.removed_thinking_blocks, 1);
assert_eq!(result.removed_redacted_thinking_blocks, 1);
assert_eq!(result.removed_signature_fields, 2);
let content = body["messages"][0]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert!(content[0].get("signature").is_none());
assert_eq!(content[1]["type"], "tool_use");
assert!(content[1].get("signature").is_none());
}
#[test]
fn test_rectify_removes_top_level_thinking() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 1024 },
"messages": [{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {} }
]
}, {
"role": "user",
"content": [{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "ok" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(result.applied);
assert!(body.get("thinking").is_none());
}
#[test]
fn test_rectify_no_change_when_no_issues() {
let mut body = json!({
"model": "claude-test",
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": "hello" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(result.removed_thinking_blocks, 0);
}
#[test]
fn test_rectify_no_messages() {
let mut body = json!({ "model": "claude-test" });
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
}
#[test]
fn test_rectify_preserves_thinking_when_prefix_exists() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled" },
"messages": [{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "some thought" },
{ "type": "tool_use", "id": "toolu_1", "name": "Test", "input": {} }
]
}]
});
let result = rectify_anthropic_request(&mut body);
// thinking block 被移除,但顶层 thinking 不应被移除(因为原本有 thinking 前缀)
assert!(result.applied);
assert_eq!(result.removed_thinking_blocks, 1);
// 注意:由于 thinking block 被移除后,首块变成了 tool_use,
// 此时会触发删除顶层 thinking 的逻辑
// 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking
}
}
-64
View File
@@ -191,67 +191,3 @@ pub struct AppProxyConfig {
/// 计算错误率的最小请求数 /// 计算错误率的最小请求数
pub circuit_min_requests: u32, pub circuit_min_requests: u32,
} }
/// 整流器配置
///
/// 存储在 settings 表中
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RectifierConfig {
/// 总开关:是否启用整流器
#[serde(default = "default_true")]
pub enabled: bool,
/// 请求整流:启用 thinking 签名整流器
///
/// 处理错误:Invalid 'signature' in 'thinking' block
#[serde(default = "default_true")]
pub request_thinking_signature: bool,
}
impl Default for RectifierConfig {
fn default() -> Self {
Self {
enabled: true,
request_thinking_signature: true,
}
}
}
fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rectifier_config_default_enabled() {
// 验证 RectifierConfig::default() 返回全启用状态
// 防止回归:#[derive(Default)] 会使 bool 默认为 false
let config = RectifierConfig::default();
assert!(config.enabled, "整流器总开关默认应为 true");
assert!(
config.request_thinking_signature,
"thinking 签名整流器默认应为 true"
);
}
#[test]
fn test_rectifier_config_serde_default() {
// 验证反序列化缺字段时使用 default_true
let json = "{}";
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert!(config.request_thinking_signature);
}
#[test]
fn test_rectifier_config_serde_explicit_false() {
// 验证显式设置 false 时正确反序列化
let json = r#"{"enabled": false, "requestThinkingSignature": false}"#;
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(!config.enabled);
assert!(!config.request_thinking_signature);
}
}
+1 -1
View File
@@ -152,7 +152,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// Skill sync // Skill sync
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] { 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) { if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}"); log::warn!("同步 Skill 到 {:?} 失败: {}", app_type, e);
// Continue syncing other apps, don't abort // Continue syncing other apps, don't abort
} }
} }
-2
View File
@@ -615,7 +615,6 @@ impl ProviderService {
base_url: Option<&str>, base_url: Option<&str>,
access_token: Option<&str>, access_token: Option<&str>,
user_id: Option<&str>, user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> { ) -> Result<UsageResult, AppError> {
usage::test_usage_script( usage::test_usage_script(
state, state,
@@ -627,7 +626,6 @@ impl ProviderService {
base_url, base_url,
access_token, access_token,
user_id, user_id,
template_type,
) )
.await .await
} }
+1 -7
View File
@@ -17,7 +17,6 @@ pub(crate) async fn execute_and_format_usage_result(
timeout: u64, timeout: u64,
access_token: Option<&str>, access_token: Option<&str>,
user_id: Option<&str>, user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> { ) -> Result<UsageResult, AppError> {
match usage_script::execute_usage_script( match usage_script::execute_usage_script(
script_code, script_code,
@@ -26,7 +25,6 @@ pub(crate) async fn execute_and_format_usage_result(
timeout, timeout,
access_token, access_token,
user_id, user_id,
template_type,
) )
.await .await
{ {
@@ -115,7 +113,7 @@ pub async fn query_usage(
app_type: AppType, app_type: AppType,
provider_id: &str, provider_id: &str,
) -> Result<UsageResult, AppError> { ) -> Result<UsageResult, AppError> {
let (script_code, timeout, api_key, base_url, access_token, user_id, template_type) = { let (script_code, timeout, api_key, base_url, access_token, user_id) = {
let providers = state.db.get_all_providers(app_type.as_str())?; let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers.get(provider_id).ok_or_else(|| { let provider = providers.get(provider_id).ok_or_else(|| {
AppError::localized( AppError::localized(
@@ -166,7 +164,6 @@ pub async fn query_usage(
base_url, base_url,
usage_script.access_token.clone(), usage_script.access_token.clone(),
usage_script.user_id.clone(), usage_script.user_id.clone(),
usage_script.template_type.clone(),
) )
}; };
@@ -177,7 +174,6 @@ pub async fn query_usage(
timeout, timeout,
access_token.as_deref(), access_token.as_deref(),
user_id.as_deref(), user_id.as_deref(),
template_type.as_deref(),
) )
.await .await
} }
@@ -194,7 +190,6 @@ pub async fn test_usage_script(
base_url: Option<&str>, base_url: Option<&str>,
access_token: Option<&str>, access_token: Option<&str>,
user_id: Option<&str>, user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> { ) -> Result<UsageResult, AppError> {
// Use provided credential parameters directly for testing // Use provided credential parameters directly for testing
execute_and_format_usage_result( execute_and_format_usage_result(
@@ -204,7 +199,6 @@ pub async fn test_usage_script(
timeout, timeout,
access_token, access_token,
user_id, user_id,
template_type,
) )
.await .await
} }
+12 -4
View File
@@ -7,6 +7,7 @@
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
@@ -142,7 +143,9 @@ pub struct SkillMetadata {
// ========== SkillService ========== // ========== SkillService ==========
pub struct SkillService; pub struct SkillService {
http_client: Client,
}
impl Default for SkillService { impl Default for SkillService {
fn default() -> Self { fn default() -> Self {
@@ -152,7 +155,13 @@ impl Default for SkillService {
impl SkillService { impl SkillService {
pub fn new() -> Self { pub fn new() -> Self {
Self Self {
http_client: Client::builder()
.user_agent("cc-switch")
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
}
} }
// ========== 路径管理 ========== // ========== 路径管理 ==========
@@ -854,8 +863,7 @@ impl SkillService {
/// 下载并解压 ZIP /// 下载并解压 ZIP
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> { async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
let client = crate::proxy::http_client::get(); let response = self.http_client.get(url).send().await?;
let response = client.get(url).send().await?;
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status().as_u16().to_string(); let status = response.status().as_u16().to_string();
return Err(anyhow::anyhow!(format_skill_error( return Err(anyhow::anyhow!(format_skill_error(
+17 -13
View File
@@ -1,7 +1,7 @@
use futures::future::join_all; use futures::future::join_all;
use reqwest::{Client, Url}; use reqwest::{Client, Url};
use serde::Serialize; use serde::Serialize;
use std::time::Instant; use std::time::{Duration, Instant};
use crate::error::AppError; use crate::error::AppError;
@@ -65,21 +65,17 @@ impl SpeedtestService {
} }
let timeout = Self::sanitize_timeout(timeout_secs); let timeout = Self::sanitize_timeout(timeout_secs);
let (client, request_timeout) = Self::build_client(timeout)?; let client = Self::build_client(timeout)?;
let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| { let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {
let client = client.clone(); let client = client.clone();
async move { async move {
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。 // 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
let _ = client let _ = client.get(parsed_url.clone()).send().await;
.get(parsed_url.clone())
.timeout(request_timeout)
.send()
.await;
// 第二次请求开始计时,并将其作为结果返回。 // 第二次请求开始计时,并将其作为结果返回。
let start = Instant::now(); let start = Instant::now();
let latency = match client.get(parsed_url).timeout(request_timeout).send().await { let latency = match client.get(parsed_url).send().await {
Ok(resp) => EndpointLatency { Ok(resp) => EndpointLatency {
url: trimmed, url: trimmed,
latency: Some(start.elapsed().as_millis()), latency: Some(start.elapsed().as_millis()),
@@ -116,11 +112,19 @@ impl SpeedtestService {
Ok(results.into_iter().flatten().collect::<Vec<_>>()) Ok(results.into_iter().flatten().collect::<Vec<_>>())
} }
fn build_client(timeout_secs: u64) -> Result<(Client, std::time::Duration), AppError> { fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
// 使用全局 HTTP 客户端(已包含代理配置) Client::builder()
// 返回 timeout Duration 供请求级别使用 .timeout(Duration::from_secs(timeout_secs))
let timeout = std::time::Duration::from_secs(timeout_secs); .redirect(reqwest::redirect::Policy::limited(5))
Ok((crate::proxy::http_client::get(), timeout)) .user_agent("cc-switch-speedtest/1.0")
.build()
.map_err(|e| {
AppError::localized(
"speedtest.client_create_failed",
format!("创建 HTTP 客户端失败: {e}"),
format!("Failed to create HTTP client: {e}"),
)
})
} }
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 { fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
+46 -177
View File
@@ -7,7 +7,7 @@ use regex::Regex;
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::time::Instant; use std::time::{Duration, Instant};
use crate::app_config::AppType; use crate::app_config::AppType;
use crate::error::AppError; use crate::error::AppError;
@@ -36,13 +36,6 @@ pub struct StreamCheckConfig {
pub codex_model: String, pub codex_model: String,
/// Gemini 测试模型 /// Gemini 测试模型
pub gemini_model: String, pub gemini_model: String,
/// 检查提示词
#[serde(default = "default_test_prompt")]
pub test_prompt: String,
}
fn default_test_prompt() -> String {
"Who are you?".to_string()
} }
impl Default for StreamCheckConfig { impl Default for StreamCheckConfig {
@@ -54,7 +47,6 @@ impl Default for StreamCheckConfig {
claude_model: "claude-haiku-4-5-20251001".to_string(), claude_model: "claude-haiku-4-5-20251001".to_string(),
codex_model: "gpt-5.1-codex@low".to_string(), codex_model: "gpt-5.1-codex@low".to_string(),
gemini_model: "gemini-3-pro-preview".to_string(), gemini_model: "gemini-3-pro-preview".to_string(),
test_prompt: default_test_prompt(),
} }
} }
} }
@@ -118,7 +110,7 @@ impl StreamCheckService {
Ok(last_result.unwrap_or_else(|| StreamCheckResult { Ok(last_result.unwrap_or_else(|| StreamCheckResult {
status: HealthStatus::Failed, status: HealthStatus::Failed,
success: false, success: false,
message: "Check failed".to_string(), message: "检查失败".to_string(),
response_time_ms: None, response_time_ms: None,
http_status: None, http_status: None,
model_used: String::new(), model_used: String::new(),
@@ -138,52 +130,29 @@ impl StreamCheckService {
let base_url = adapter let base_url = adapter
.extract_base_url(provider) .extract_base_url(provider)
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?; .map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
let auth = adapter let auth = adapter
.extract_auth(provider) .extract_auth(provider)
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?; .ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
// 使用全局 HTTP 客户端(已包含代理配置) let client = Client::builder()
let client = crate::proxy::http_client::get(); .timeout(Duration::from_secs(config.timeout_secs))
let request_timeout = std::time::Duration::from_secs(config.timeout_secs); .user_agent("cc-switch/1.0")
.build()
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
let model_to_test = Self::resolve_test_model(app_type, provider, config); let model_to_test = Self::resolve_test_model(app_type, provider, config);
let test_prompt = &config.test_prompt;
let result = match app_type { let result = match app_type {
AppType::Claude => { AppType::Claude => {
Self::check_claude_stream( Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
} }
AppType::Codex => { AppType::Codex => {
Self::check_codex_stream( Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
} }
AppType::Gemini => { AppType::Gemini => {
Self::check_gemini_stream( Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
} }
}; };
@@ -197,7 +166,7 @@ impl StreamCheckService {
Ok(StreamCheckResult { Ok(StreamCheckResult {
status: health_status, status: health_status,
success: true, success: true,
message: "Check succeeded".to_string(), message: "检查成功".to_string(),
response_time_ms: Some(response_time), response_time_ms: Some(response_time),
http_status: Some(status_code), http_status: Some(status_code),
model_used: model, model_used: model,
@@ -219,69 +188,31 @@ impl StreamCheckService {
} }
/// Claude 流式检查 /// Claude 流式检查
///
/// 严格按照 Claude CLI 真实请求格式构建请求
async fn check_claude_stream( async fn check_claude_stream(
client: &Client, client: &Client,
base_url: &str, base_url: &str,
auth: &AuthInfo, auth: &AuthInfo,
model: &str, model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> { ) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
// URL 必须包含 ?beta=true 参数(某些中转服务依赖此参数验证请求来源)
let url = if base.ends_with("/v1") { let url = if base.ends_with("/v1") {
format!("{base}/messages?beta=true") format!("{base}/messages")
} else { } else {
format!("{base}/v1/messages?beta=true") format!("{base}/v1/messages")
}; };
let body = json!({ let body = json!({
"model": model, "model": model,
"max_tokens": 1, "max_tokens": 1,
"messages": [{ "role": "user", "content": test_prompt }], "messages": [{ "role": "user", "content": "hi" }],
"stream": true "stream": true
}); });
// 获取本地系统信息
let os_name = Self::get_os_name();
let arch_name = Self::get_arch_name();
// 严格按照 Claude CLI 请求格式设置 headers
let response = client let response = client
.post(&url) .post(&url)
// 认证 headers(双重认证)
.header("authorization", format!("Bearer {}", auth.api_key))
.header("x-api-key", &auth.api_key) .header("x-api-key", &auth.api_key)
// Anthropic 必需 headers
.header("anthropic-version", "2023-06-01") .header("anthropic-version", "2023-06-01")
.header( .header("Content-Type", "application/json")
"anthropic-beta",
"claude-code-20250219,interleaved-thinking-2025-05-14",
)
.header("anthropic-dangerous-direct-browser-access", "true")
// 内容类型 headers
.header("content-type", "application/json")
.header("accept", "application/json")
.header("accept-encoding", "identity")
.header("accept-language", "*")
// 客户端标识 headers
.header("user-agent", "claude-cli/2.1.2 (external, cli)")
.header("x-app", "cli")
// x-stainless SDK headers(动态获取本地系统信息)
.header("x-stainless-lang", "js")
.header("x-stainless-package-version", "0.70.0")
.header("x-stainless-os", os_name)
.header("x-stainless-arch", arch_name)
.header("x-stainless-runtime", "node")
.header("x-stainless-runtime-version", "v22.20.0")
.header("x-stainless-retry-count", "0")
.header("x-stainless-timeout", "600")
// 其他 headers
.header("sec-fetch-mode", "cors")
.header("connection", "keep-alive")
.timeout(timeout)
.json(&body) .json(&body)
.send() .send()
.await .await
@@ -299,64 +230,51 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await { if let Some(chunk) = stream.next().await {
match chunk { match chunk {
Ok(_) => Ok((status, model.to_string())), Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))), Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
} }
} else { } else {
Err(AppError::Message("No response data received".to_string())) Err(AppError::Message("未收到响应数据".to_string()))
} }
} }
/// Codex 流式检查 /// Codex 流式检查
///
/// 严格按照 Codex CLI 真实请求格式构建请求 (Responses API)
async fn check_codex_stream( async fn check_codex_stream(
client: &Client, client: &Client,
base_url: &str, base_url: &str,
auth: &AuthInfo, auth: &AuthInfo,
model: &str, model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> { ) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
// Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API)
let url = if base.ends_with("/v1") { let url = if base.ends_with("/v1") {
format!("{base}/responses") format!("{base}/chat/completions")
} else { } else {
format!("{base}/v1/responses") format!("{base}/v1/chat/completions")
}; };
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式) // 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model); let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
// 获取本地系统信息
let os_name = Self::get_os_name();
let arch_name = Self::get_arch_name();
// Responses API 请求体格式 (input 必须是数组)
let mut body = json!({ let mut body = json!({
"model": actual_model, "model": actual_model,
"input": [{ "role": "user", "content": test_prompt }], "messages": [
{ "role": "system", "content": "" },
{ "role": "assistant", "content": "" },
{ "role": "user", "content": "hi" }
],
"max_tokens": 1,
"temperature": 0,
"stream": true "stream": true
}); });
// 如果是推理模型,添加 reasoning_effort // 如果是推理模型,添加 reasoning_effort
if let Some(effort) = reasoning_effort { if let Some(effort) = reasoning_effort {
body["reasoning"] = json!({ "effort": effort }); body["reasoning_effort"] = json!(effort);
} }
// 严格按照 Codex CLI 请求格式设置 headers
let response = client let response = client
.post(&url) .post(&url)
.header("authorization", format!("Bearer {}", auth.api_key)) .header("Authorization", format!("Bearer {}", auth.api_key))
.header("content-type", "application/json") .header("Content-Type", "application/json")
.header("accept", "text/event-stream")
.header("accept-encoding", "identity")
.header(
"user-agent",
format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"),
)
.header("originator", "codex_cli_rs")
.timeout(timeout)
.json(&body) .json(&body)
.send() .send()
.await .await
@@ -373,10 +291,10 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await { if let Some(chunk) = stream.next().await {
match chunk { match chunk {
Ok(_) => Ok((status, model.to_string())), Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))), Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
} }
} else { } else {
Err(AppError::Message("No response data received".to_string())) Err(AppError::Message("未收到响应数据".to_string()))
} }
} }
@@ -386,15 +304,13 @@ impl StreamCheckService {
base_url: &str, base_url: &str,
auth: &AuthInfo, auth: &AuthInfo,
model: &str, model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> { ) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let url = format!("{base}/v1/chat/completions"); let url = format!("{base}/v1/chat/completions");
let body = json!({ let body = json!({
"model": model, "model": model,
"messages": [{ "role": "user", "content": test_prompt }], "messages": [{ "role": "user", "content": "hi" }],
"max_tokens": 1, "max_tokens": 1,
"temperature": 0, "temperature": 0,
"stream": true "stream": true
@@ -404,7 +320,6 @@ impl StreamCheckService {
.post(&url) .post(&url)
.header("Authorization", format!("Bearer {}", auth.api_key)) .header("Authorization", format!("Bearer {}", auth.api_key))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.timeout(timeout)
.json(&body) .json(&body)
.send() .send()
.await .await
@@ -421,10 +336,10 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await { if let Some(chunk) = stream.next().await {
match chunk { match chunk {
Ok(_) => Ok((status, model.to_string())), Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))), Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
} }
} else { } else {
Err(AppError::Message("No response data received".to_string())) Err(AppError::Message("未收到响应数据".to_string()))
} }
} }
@@ -439,6 +354,7 @@ impl StreamCheckService {
/// 解析模型名和推理等级 (支持 model@level 或 model#level 格式) /// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
/// 返回 (实际模型名, Option<推理等级>) /// 返回 (实际模型名, Option<推理等级>)
fn parse_model_with_effort(model: &str) -> (String, Option<String>) { fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
// 查找 @ 或 # 分隔符
if let Some(pos) = model.find('@').or_else(|| model.find('#')) { if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
let actual_model = model[..pos].to_string(); let actual_model = model[..pos].to_string();
let effort = model[pos + 1..].to_string(); let effort = model[pos + 1..].to_string();
@@ -451,14 +367,17 @@ impl StreamCheckService {
fn should_retry(msg: &str) -> bool { fn should_retry(msg: &str) -> bool {
let lower = msg.to_lowercase(); let lower = msg.to_lowercase();
lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out") lower.contains("timeout")
|| lower.contains("abort")
|| lower.contains("中断")
|| lower.contains("超时")
} }
fn map_request_error(e: reqwest::Error) -> AppError { fn map_request_error(e: reqwest::Error) -> AppError {
if e.is_timeout() { if e.is_timeout() {
AppError::Message("Request timeout".to_string()) AppError::Message("请求超时".to_string())
} else if e.is_connect() { } else if e.is_connect() {
AppError::Message(format!("Connection failed: {e}")) AppError::Message(format!("连接失败: {e}"))
} else { } else {
AppError::Message(e.to_string()) AppError::Message(e.to_string())
} }
@@ -505,26 +424,6 @@ impl StreamCheckService {
.map(|m| m.as_str().trim().to_string()) .map(|m| m.as_str().trim().to_string())
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
} }
/// 获取操作系统名称(映射为 Claude CLI 使用的格式)
fn get_os_name() -> &'static str {
match std::env::consts::OS {
"macos" => "MacOS",
"linux" => "Linux",
"windows" => "Windows",
other => other,
}
}
/// 获取 CPU 架构名称(映射为 Claude CLI 使用的格式)
fn get_arch_name() -> &'static str {
match std::env::consts::ARCH {
"aarch64" => "arm64",
"x86_64" => "x86_64",
"x86" => "x86",
other => other,
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -549,10 +448,9 @@ mod tests {
#[test] #[test]
fn test_should_retry() { fn test_should_retry() {
assert!(StreamCheckService::should_retry("Request timeout")); assert!(StreamCheckService::should_retry("请求超时"));
assert!(StreamCheckService::should_retry("request timed out")); assert!(StreamCheckService::should_retry("request timeout"));
assert!(StreamCheckService::should_retry("connection abort")); assert!(!StreamCheckService::should_retry("API Key 无效"));
assert!(!StreamCheckService::should_retry("API Key invalid"));
} }
#[test] #[test]
@@ -580,33 +478,4 @@ mod tests {
assert_eq!(model, "gpt-4o-mini"); assert_eq!(model, "gpt-4o-mini");
assert_eq!(effort, None); assert_eq!(effort, None);
} }
#[test]
fn test_get_os_name() {
let os_name = StreamCheckService::get_os_name();
// 确保返回非空字符串
assert!(!os_name.is_empty());
// 在 macOS 上应该返回 "MacOS"
#[cfg(target_os = "macos")]
assert_eq!(os_name, "MacOS");
// 在 Linux 上应该返回 "Linux"
#[cfg(target_os = "linux")]
assert_eq!(os_name, "Linux");
// 在 Windows 上应该返回 "Windows"
#[cfg(target_os = "windows")]
assert_eq!(os_name, "Windows");
}
#[test]
fn test_get_arch_name() {
let arch_name = StreamCheckService::get_arch_name();
// 确保返回非空字符串
assert!(!arch_name.is_empty());
// 在 ARM64 上应该返回 "arm64"
#[cfg(target_arch = "aarch64")]
assert_eq!(arch_name, "arm64");
// 在 x86_64 上应该返回 "x86_64"
#[cfg(target_arch = "x86_64")]
assert_eq!(arch_name, "x86_64");
}
} }
+77 -99
View File
@@ -1,6 +1,8 @@
use reqwest::Client;
use rquickjs::{Context, Function, Runtime}; use rquickjs::{Context, Function, Runtime};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration;
use url::{Host, Url}; use url::{Host, Url};
use crate::error::AppError; use crate::error::AppError;
@@ -13,21 +15,13 @@ pub async fn execute_usage_script(
timeout_secs: u64, timeout_secs: u64,
access_token: Option<&str>, access_token: Option<&str>,
user_id: Option<&str>, user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<Value, AppError> { ) -> Result<Value, AppError> {
// 检测是否为自定义模板模式
// 优先使用前端传递的 template_type
let is_custom_template = template_type.map(|t| t == "custom").unwrap_or(false);
// 1. 替换模板变量,避免泄露敏感信息 // 1. 替换模板变量,避免泄露敏感信息
let script_with_vars = let script_with_vars =
build_script_with_vars(script_code, api_key, base_url, access_token, user_id); build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
// 2. 验证 base_url 的安全性(仅当提供了 base_url 时) // 2. 验证 base_url 的安全性
// 自定义模板模式下,用户可能不使用模板变量,而是直接在脚本中写完整 URL validate_base_url(base_url)?;
if !base_url.is_empty() {
validate_base_url(base_url)?;
}
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放) // 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
let request_config = { let request_config = {
@@ -105,8 +99,7 @@ pub async fn execute_usage_script(
})?; })?;
// 5. 验证请求 URL 是否安全(防止 SSRF) // 5. 验证请求 URL 是否安全(防止 SSRF)
// 如果提供了 base_url,则验证同源;否则只做基本安全检查 validate_request_url(&request.url, base_url)?;
validate_request_url(&request.url, base_url, is_custom_template)?;
// 6. 发送 HTTP 请求 // 6. 发送 HTTP 请求
let response_data = send_http_request(&request, timeout_secs).await?; let response_data = send_http_request(&request, timeout_secs).await?;
@@ -222,10 +215,18 @@ struct RequestConfig {
/// 发送 HTTP 请求 /// 发送 HTTP 请求
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> { async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {
// 使用全局 HTTP 客户端(已包含代理配置) // 约束超时范围,防止异常配置导致长时间阻塞
let client = crate::proxy::http_client::get(); let timeout = timeout_secs.clamp(2, 30);
// 约束超时范围,防止异常配置导致长时间阻塞(最小 2 秒,最大 30 秒) let client = Client::builder()
let request_timeout = std::time::Duration::from_secs(timeout_secs.clamp(2, 30)); .timeout(Duration::from_secs(timeout))
.build()
.map_err(|e| {
AppError::localized(
"usage_script.client_create_failed",
format!("创建客户端失败: {e}"),
format!("Failed to create client: {e}"),
)
})?;
// 严格校验 HTTP 方法,非法值不回退为 GET // 严格校验 HTTP 方法,非法值不回退为 GET
let method: reqwest::Method = config.method.parse().map_err(|_| { let method: reqwest::Method = config.method.parse().map_err(|_| {
@@ -236,9 +237,7 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
) )
})?; })?;
let mut req = client let mut req = client.request(method.clone(), &config.url);
.request(method.clone(), &config.url)
.timeout(request_timeout);
// 添加请求头 // 添加请求头
for (k, v) in &config.headers { for (k, v) in &config.headers {
@@ -481,11 +480,7 @@ fn validate_base_url(base_url: &str) -> Result<(), AppError> {
} }
/// 验证请求 URL 是否安全(防止 SSRF) /// 验证请求 URL 是否安全(防止 SSRF)
fn validate_request_url( fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppError> {
request_url: &str,
base_url: &str,
is_custom_template: bool,
) -> Result<(), AppError> {
// 解析请求 URL // 解析请求 URL
let parsed_request = Url::parse(request_url).map_err(|e| { let parsed_request = Url::parse(request_url).map_err(|e| {
AppError::localized( AppError::localized(
@@ -495,11 +490,19 @@ fn validate_request_url(
) )
})?; })?;
// 解析 base URL
let parsed_base = Url::parse(base_url).map_err(|e| {
AppError::localized(
"usage_script.base_url_invalid",
format!("无效的 base_url: {e}"),
format!("Invalid base_url: {e}"),
)
})?;
let is_request_loopback = is_loopback_host(&parsed_request); let is_request_loopback = is_loopback_host(&parsed_request);
// 必须使用 HTTPS(允许 localhost 用于开发) // 必须使用 HTTPS(允许 localhost 用于开发)
// 自定义模板模式下,允许用户自行决定是否使用 HTTP(用户需自行承担安全风险) if parsed_request.scheme() != "https" && !is_request_loopback {
if !is_custom_template && parsed_request.scheme() != "https" && !is_request_loopback {
return Err(AppError::localized( return Err(AppError::localized(
"usage_script.request_https_required", "usage_script.request_https_required",
"请求 URL 必须使用 HTTPS 协议(localhost 除外)", "请求 URL 必须使用 HTTPS 协议(localhost 除外)",
@@ -507,85 +510,60 @@ fn validate_request_url(
)); ));
} }
// 如果提供了 base_url(非空),则进行同源检查 // 核心安全检查:必须与 base_url 同源(相同域名和端口)
// 🔧 自定义模板模式下,用户可以自由访问任意 HTTPS 域名,跳过同源检查 if parsed_request.host_str() != parsed_base.host_str() {
if !base_url.is_empty() && !is_custom_template { return Err(AppError::localized(
// 解析 base URL "usage_script.request_host_mismatch",
let parsed_base = Url::parse(base_url).map_err(|e| { format!(
AppError::localized( "请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
"usage_script.base_url_invalid", parsed_request.host_str().unwrap_or("unknown"),
format!("无效的 base_url: {e}"), parsed_base.host_str().unwrap_or("unknown")
format!("Invalid base_url: {e}"), ),
) format!(
})?; "Request host {} must match base_url host {} (same-origin required)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
));
}
// 核心安全检查:必须与 base_url 同源(相同域名和端口) // 检查端口是否匹配(考虑默认端口)
if parsed_request.host_str() != parsed_base.host_str() { // 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443
match (
parsed_request.port_or_known_default(),
parsed_base.port_or_known_default(),
) {
(Some(request_port), Some(base_port)) if request_port == base_port => {
// 端口匹配,继续执行
}
(Some(request_port), Some(base_port)) => {
return Err(AppError::localized( return Err(AppError::localized(
"usage_script.request_host_mismatch", "usage_script.request_port_mismatch",
format!( format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)", format!("Request port {request_port} must match base_url port {base_port}"),
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
format!(
"Request host {} must match base_url host {} (same-origin required)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
)); ));
} }
_ => {
// 检查端口是否匹配(考虑默认端口) // 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443 return Err(AppError::localized(
match ( "usage_script.request_port_unknown",
parsed_request.port_or_known_default(), "无法确定端口号",
parsed_base.port_or_known_default(), "Unable to determine port number",
) { ));
(Some(request_port), Some(base_port)) if request_port == base_port => {
// 端口匹配,继续执行
}
(Some(request_port), Some(base_port)) => {
return Err(AppError::localized(
"usage_script.request_port_mismatch",
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
format!("Request port {request_port} must match base_url port {base_port}"),
));
}
_ => {
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
return Err(AppError::localized(
"usage_script.request_port_unknown",
"无法确定端口号",
"Unable to determine port number",
));
}
} }
}
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境) // 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
if let Some(host) = parsed_request.host_str() { if let Some(host) = parsed_request.host_str() {
let base_host = parsed_base.host_str().unwrap_or(""); let base_host = parsed_base.host_str().unwrap_or("");
// 如果 base_url 不是私有地址,则禁止访问私有IP // 如果 base_url 不是私有地址,则禁止访问私有IP
if !is_private_ip(base_host) && is_private_ip(host) { if !is_private_ip(base_host) && is_private_ip(host) {
return Err(AppError::localized( return Err(AppError::localized(
"usage_script.private_ip_blocked", "usage_script.private_ip_blocked",
"禁止访问私有 IP 地址", "禁止访问私有 IP 地址",
"Access to private IP addresses is blocked", "Access to private IP addresses is blocked",
)); ));
}
}
} else {
// 自定义模板模式:没有 base_url,需要额外的安全检查
// 禁止访问私有 IP 地址(SSRF 防护)
if let Some(host) = parsed_request.host_str() {
if is_private_ip(host) && !is_request_loopback {
return Err(AppError::localized(
"usage_script.private_ip_blocked",
"禁止访问私有 IP 地址(localhost 除外)",
"Access to private IP addresses is blocked (localhost allowed)",
));
}
} }
} }
@@ -873,7 +851,7 @@ mod tests {
]; ];
for (base_url, request_url, should_match) in test_cases { for (base_url, request_url, should_match) in test_cases {
let result = validate_request_url(request_url, base_url, false); let result = validate_request_url(request_url, base_url);
if should_match { if should_match {
assert!( assert!(
+15 -39
View File
@@ -382,26 +382,6 @@ function App() {
await addProvider(duplicatedProvider); await addProvider(duplicatedProvider);
}; };
// 打开提供商终端
const handleOpenTerminal = async (provider: Provider) => {
try {
await providersApi.openTerminal(provider.id, activeApp);
toast.success(
t("provider.terminalOpened", {
defaultValue: "终端已打开",
}),
);
} catch (error) {
console.error("[App] Failed to open terminal", error);
const errorMessage = extractErrorMessage(error);
toast.error(
t("provider.terminalOpenFailed", {
defaultValue: "打开终端失败",
}) + (errorMessage ? `: ${errorMessage}` : ""),
);
}
};
// 导入配置成功后刷新 // 导入配置成功后刷新
const handleImportSuccess = async () => { const handleImportSuccess = async () => {
try { try {
@@ -502,9 +482,6 @@ function App() {
onDuplicate={handleDuplicateProvider} onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider} onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite} onOpenWebsite={handleOpenWebsite}
onOpenTerminal={
activeApp === "claude" ? handleOpenTerminal : undefined
}
onCreate={() => setIsAddOpen(true)} onCreate={() => setIsAddOpen(true)}
/> />
</motion.div> </motion.div>
@@ -619,8 +596,8 @@ function App() {
</h1> </h1>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2"> <>
<div className="relative inline-flex items-center"> <div className="flex items-center gap-2">
<a <a
href="https://github.com/farion1231/cc-switch" href="https://github.com/farion1231/cc-switch"
target="_blank" target="_blank"
@@ -634,27 +611,26 @@ function App() {
> >
CC Switch CC Switch
</a> </a>
<UpdateBadge <Button
variant="ghost"
size="icon"
onClick={() => { onClick={() => {
setSettingsDefaultTab("about"); setSettingsDefaultTab("general");
setCurrentView("settings"); setCurrentView("settings");
}} }}
className="absolute -top-4 -right-4" title={t("common.settings")}
/> className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="w-4 h-4" />
</Button>
</div> </div>
<Button <UpdateBadge
variant="ghost"
size="icon"
onClick={() => { onClick={() => {
setSettingsDefaultTab("general"); setSettingsDefaultTab("about");
setCurrentView("settings"); setCurrentView("settings");
}} }}
title={t("common.settings")} />
className="hover:bg-black/5 dark:hover:bg-white/5" </>
>
<Settings className="w-4 h-4" />
</Button>
</div>
)} )}
</div> </div>
+4 -19
View File
@@ -389,27 +389,12 @@ export function DeepLinkImportDialog() {
</div> </div>
{/* API Endpoint */} {/* API Endpoint */}
<div className="grid grid-cols-3 items-start gap-4"> <div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground pt-0.5"> <div className="font-medium text-sm text-muted-foreground">
{t("deeplink.endpoint")} {t("deeplink.endpoint")}
</div> </div>
<div className="col-span-2 text-sm break-all space-y-1"> <div className="col-span-2 text-sm break-all">
{request.endpoint?.split(",").map((ep, idx) => ( {request.endpoint}
<div
key={idx}
className={
idx === 0 ? "font-medium" : "text-muted-foreground"
}
>
{idx === 0 ? "🔹 " : "└ "}
{ep.trim()}
{idx === 0 && request.endpoint?.includes(",") && (
<span className="text-xs text-muted-foreground ml-2">
({t("deeplink.primaryEndpoint")})
</span>
)}
</div>
))}
</div> </div>
</div> </div>
+42 -25
View File
@@ -1,6 +1,6 @@
import { X, Download } from "lucide-react";
import { useUpdate } from "@/contexts/UpdateContext"; import { useUpdate } from "@/contexts/UpdateContext";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
interface UpdateBadgeProps { interface UpdateBadgeProps {
className?: string; className?: string;
@@ -8,39 +8,56 @@ interface UpdateBadgeProps {
} }
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) { export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
const { hasUpdate, updateInfo } = useUpdate(); const { hasUpdate, updateInfo, isDismissed, dismissUpdate } = useUpdate();
const { t } = useTranslation(); const { t } = useTranslation();
const isActive = hasUpdate && updateInfo;
const title = isActive
? t("settings.updateAvailable", {
version: updateInfo?.availableVersion ?? "",
})
: t("settings.checkForUpdates");
if (!isActive) { // 如果没有更新或已关闭,不显示
if (!hasUpdate || isDismissed || !updateInfo) {
return null; return null;
} }
return ( return (
<Button <div
type="button"
variant="ghost"
size="icon"
title={title}
aria-label={title}
onClick={onClick}
className={` className={`
relative h-6 w-6 rounded-full flex items-center gap-1.5 px-2.5 py-1
${isActive ? "text-blue-600 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-500/10" : "text-muted-foreground hover:bg-muted/60"} bg-white dark:bg-gray-800
border border-border-default
rounded-lg text-xs
shadow-sm
transition-all duration-200
${onClick ? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750" : ""}
${className} ${className}
`} `}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : -1}
onClick={onClick}
onKeyDown={(e) => {
if (!onClick) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
> >
<span <Download className="w-3 h-3 text-blue-500 dark:text-blue-400" />
className={` <span className="text-gray-700 dark:text-gray-300 font-medium">
absolute inset-0 m-auto h-2 w-2 rounded-full ring-1 ring-background {t("settings.updateBadge")}
${isActive ? "bg-blue-500 dark:bg-blue-400" : "bg-blue-300/70 dark:bg-blue-300/60"} </span>
`} <button
/> onClick={(e) => {
</Button> e.stopPropagation();
dismissUpdate();
}}
className="
ml-1 -mr-0.5 p-0.5 rounded
hover:bg-gray-100 dark:hover:bg-gray-700
transition-colors
focus:outline-none focus:ring-2 focus:ring-blue-500/20
"
aria-label={t("common.close")}
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
</div>
); );
} }
+12 -154
View File
@@ -2,10 +2,8 @@ import React, { useState } from "react";
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react"; import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Provider, UsageScript, UsageData } from "@/types"; import { Provider, UsageScript, UsageData } from "@/types";
import { usageApi, type AppId } from "@/lib/api"; import { usageApi, type AppId } from "@/lib/api";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
import JsonEditor from "./JsonEditor"; import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone"; import * as prettier from "prettier/standalone";
import * as parserBabel from "prettier/parser-babel"; import * as parserBabel from "prettier/parser-babel";
@@ -111,67 +109,19 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
onSave, onSave,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
// 生成带国际化的预设模板 // 生成带国际化的预设模板
const PRESET_TEMPLATES = generatePresetTemplates(t); const PRESET_TEMPLATES = generatePresetTemplates(t);
// 从 provider 的 settingsConfig 中提取 API Key 和 Base URL
const getProviderCredentials = (): {
apiKey: string | undefined;
baseUrl: string | undefined;
} => {
try {
const config = provider.settingsConfig;
if (!config) return { apiKey: undefined, baseUrl: undefined };
// 处理不同应用的配置格式
if (appId === "claude") {
// Claude: { env: { ANTHROPIC_AUTH_TOKEN | ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL } }
const env = (config as any).env || {};
return {
apiKey: env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY,
baseUrl: env.ANTHROPIC_BASE_URL,
};
} else if (appId === "codex") {
// Codex: { auth: { OPENAI_API_KEY }, config: TOML string with base_url }
const auth = (config as any).auth || {};
const configToml = (config as any).config || "";
return {
apiKey: auth.OPENAI_API_KEY,
baseUrl: extractCodexBaseUrl(configToml),
};
} else if (appId === "gemini") {
// Gemini: { env: { GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL } }
const env = (config as any).env || {};
return {
apiKey: env.GEMINI_API_KEY,
baseUrl: env.GOOGLE_GEMINI_BASE_URL,
};
}
return { apiKey: undefined, baseUrl: undefined };
} catch (error) {
console.error("Failed to extract provider credentials:", error);
return { apiKey: undefined, baseUrl: undefined };
}
};
const providerCredentials = getProviderCredentials();
const [script, setScript] = useState<UsageScript>(() => { const [script, setScript] = useState<UsageScript>(() => {
const savedScript = provider.meta?.usage_script; return (
const defaultScript = { provider.meta?.usage_script || {
enabled: false, enabled: false,
language: "javascript" as const, language: "javascript",
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL], code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
timeout: 10, timeout: 10,
}; }
);
if (!savedScript) {
return defaultScript;
}
return savedScript;
}); });
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
@@ -226,11 +176,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const [selectedTemplate, setSelectedTemplate] = useState<string | null>( const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
() => { () => {
const existingScript = provider.meta?.usage_script; const existingScript = provider.meta?.usage_script;
// 优先使用保存的 templateType
if (existingScript?.templateType) {
return existingScript.templateType;
}
// 向后兼容:根据字段推断模板类型
// 检测 NEW_API 模板(有 accessToken 或 userId // 检测 NEW_API 模板(有 accessToken 或 userId
if (existingScript?.accessToken || existingScript?.userId) { if (existingScript?.accessToken || existingScript?.userId) {
return TEMPLATE_KEYS.NEW_API; return TEMPLATE_KEYS.NEW_API;
@@ -256,16 +201,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 }); toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
return; return;
} }
// 保存时记录当前选择的模板类型 onSave(script);
const scriptWithTemplate = {
...script,
templateType: selectedTemplate as
| "custom"
| "general"
| "newapi"
| undefined,
};
onSave(scriptWithTemplate);
onClose(); onClose();
}; };
@@ -281,7 +217,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
script.baseUrl, script.baseUrl,
script.accessToken, script.accessToken,
script.userId, script.userId,
selectedTemplate as "custom" | "general" | "newapi" | undefined,
); );
if (result.success && result.data && result.data.length > 0) { if (result.success && result.data && result.data.length > 0) {
const summary = result.data const summary = result.data
@@ -294,9 +229,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
duration: 3000, duration: 3000,
closeButton: true, closeButton: true,
}); });
// 🔧 测试成功后,更新主界面列表的用量查询缓存
queryClient.setQueryData(["usage", provider.id, appId], result);
} else { } else {
toast.error( toast.error(
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`, `${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
@@ -346,13 +278,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const preset = PRESET_TEMPLATES[presetName]; const preset = PRESET_TEMPLATES[presetName];
if (preset) { if (preset) {
if (presetName === TEMPLATE_KEYS.CUSTOM) { if (presetName === TEMPLATE_KEYS.CUSTOM) {
// 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换
// 这样可以避免同源检查导致的问题
// 如果用户想使用变量,需要手动在配置中设置 baseUrl/apiKey
setScript({ setScript({
...script, ...script,
code: preset, code: preset,
// 清除凭证,用户可选择手动输入或保持空
apiKey: undefined, apiKey: undefined,
baseUrl: undefined, baseUrl: undefined,
accessToken: undefined, accessToken: undefined,
@@ -473,74 +401,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
})} })}
</div> </div>
{/* 自定义模式:变量提示和具体值 */}
{selectedTemplate === TEMPLATE_KEYS.CUSTOM && (
<div className="space-y-2 border-t border-white/10 pt-3">
<h4 className="text-sm font-medium text-foreground">
{t("usageScript.supportedVariables")}
</h4>
<div className="space-y-1 text-xs">
{/* baseUrl */}
<div className="flex items-center gap-2 py-1">
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
{"{{baseUrl}}"}
</code>
<span className="text-muted-foreground/50">=</span>
{providerCredentials.baseUrl ? (
<code className="text-foreground/70 break-all font-mono">
{providerCredentials.baseUrl}
</code>
) : (
<span className="text-muted-foreground/50 italic">
{t("common.notSet") || "未设置"}
</span>
)}
</div>
{/* apiKey */}
<div className="flex items-center gap-2 py-1">
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
{"{{apiKey}}"}
</code>
<span className="text-muted-foreground/50">=</span>
{providerCredentials.apiKey ? (
<>
{showApiKey ? (
<code className="text-foreground/70 break-all font-mono">
{providerCredentials.apiKey}
</code>
) : (
<code className="text-foreground/70 font-mono">
</code>
)}
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="text-muted-foreground hover:text-foreground transition-colors ml-1"
aria-label={
showApiKey
? t("apiKeyInput.hide")
: t("apiKeyInput.show")
}
>
{showApiKey ? (
<EyeOff size={12} />
) : (
<Eye size={12} />
)}
</button>
</>
) : (
<span className="text-muted-foreground/50 italic">
{t("common.notSet") || "未设置"}
</span>
)}
</div>
</div>
</div>
)}
{/* 凭证配置 */} {/* 凭证配置 */}
{shouldShowCredentialsConfig && ( {shouldShowCredentialsConfig && (
<div className="space-y-4"> <div className="space-y-4">
@@ -741,13 +601,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
type="number" type="number"
min={0} min={0}
max={1440} max={1440}
value={ value={script.autoIntervalMinutes ?? 0}
script.autoQueryInterval ?? script.autoIntervalMinutes ?? 0
}
onChange={(e) => onChange={(e) =>
setScript({ setScript({
...script, ...script,
autoQueryInterval: validateAndClampInterval( autoIntervalMinutes: validateAndClampInterval(
e.target.value, e.target.value,
), ),
}) })
@@ -755,7 +613,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
onBlur={(e) => onBlur={(e) =>
setScript({ setScript({
...script, ...script,
autoQueryInterval: validateAndClampInterval( autoIntervalMinutes: validateAndClampInterval(
e.target.value, e.target.value,
), ),
}) })
@@ -6,7 +6,6 @@ import {
Loader2, Loader2,
Play, Play,
Plus, Plus,
Terminal,
TestTube2, TestTube2,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
@@ -24,7 +23,6 @@ interface ProviderActionsProps {
onTest?: () => void; onTest?: () => void;
onConfigureUsage: () => void; onConfigureUsage: () => void;
onDelete: () => void; onDelete: () => void;
onOpenTerminal?: () => void;
// 故障转移相关 // 故障转移相关
isAutoFailoverEnabled?: boolean; isAutoFailoverEnabled?: boolean;
isInFailoverQueue?: boolean; isInFailoverQueue?: boolean;
@@ -41,7 +39,6 @@ export function ProviderActions({
onTest, onTest,
onConfigureUsage, onConfigureUsage,
onDelete, onDelete,
onOpenTerminal,
// 故障转移相关 // 故障转移相关
isAutoFailoverEnabled = false, isAutoFailoverEnabled = false,
isInFailoverQueue = false, isInFailoverQueue = false,
@@ -174,21 +171,6 @@ export function ProviderActions({
<BarChart3 className="h-4 w-4" /> <BarChart3 className="h-4 w-4" />
</Button> </Button>
{onOpenTerminal && (
<Button
size="icon"
variant="ghost"
onClick={onOpenTerminal}
title={t("provider.openTerminal", "打开终端")}
className={cn(
iconButtonClass,
"hover:text-emerald-600 dark:hover:text-emerald-400",
)}
>
<Terminal className="h-4 w-4" />
</Button>
)}
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
+6 -39
View File
@@ -1,4 +1,4 @@
import { useMemo, useState, useEffect, useRef } from "react"; import { useMemo, useState, useEffect } from "react";
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react"; import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { import type {
@@ -33,7 +33,6 @@ interface ProviderCardProps {
onOpenWebsite: (url: string) => void; onOpenWebsite: (url: string) => void;
onDuplicate: (provider: Provider) => void; onDuplicate: (provider: Provider) => void;
onTest?: (provider: Provider) => void; onTest?: (provider: Provider) => void;
onOpenTerminal?: (provider: Provider) => void;
isTesting?: boolean; isTesting?: boolean;
isProxyRunning: boolean; isProxyRunning: boolean;
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换) isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
@@ -92,7 +91,6 @@ export function ProviderCard({
onOpenWebsite, onOpenWebsite,
onDuplicate, onDuplicate,
onTest, onTest,
onOpenTerminal,
isTesting, isTesting,
isProxyRunning, isProxyRunning,
isProxyTakeover = false, isProxyTakeover = false,
@@ -149,10 +147,6 @@ export function ProviderCard({
// 多套餐默认展开 // 多套餐默认展开
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
// 操作按钮容器 ref,用于动态计算宽度
const actionsRef = useRef<HTMLDivElement>(null);
const [actionsWidth, setActionsWidth] = useState(0);
// 当检测到多套餐时自动展开 // 当检测到多套餐时自动展开
useEffect(() => { useEffect(() => {
if (hasMultiplePlans) { if (hasMultiplePlans) {
@@ -160,20 +154,6 @@ export function ProviderCard({
} }
}, [hasMultiplePlans]); }, [hasMultiplePlans]);
// 动态获取操作按钮宽度
useEffect(() => {
if (actionsRef.current) {
const updateWidth = () => {
const width = actionsRef.current?.offsetWidth || 0;
setActionsWidth(width);
};
updateWidth();
// 监听窗口大小变化
window.addEventListener("resize", updateWidth);
return () => window.removeEventListener("resize", updateWidth);
}
}, [onTest, onOpenTerminal]); // 按钮数量可能变化时重新计算
const handleOpenWebsite = () => { const handleOpenWebsite = () => {
if (!isClickableUrl) { if (!isClickableUrl) {
return; return;
@@ -299,17 +279,10 @@ export function ProviderCard({
</div> </div>
</div> </div>
<div <div className="relative flex items-center ml-auto min-w-0">
className="relative flex items-center ml-auto min-w-0 gap-3"
style={
{
"--actions-width": `${actionsWidth || 320}px`,
} as React.CSSProperties
}
>
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */} {/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
<div className="ml-auto"> <div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[14.5rem] group-focus-within:-translate-x-[14.5rem] sm:group-hover:-translate-x-[16rem] sm:group-focus-within:-translate-x-[16rem]">
<div className="flex items-center gap-1 transition-transform duration-200 group-hover:-translate-x-[var(--actions-width)] group-focus-within:-translate-x-[var(--actions-width)]"> <div className="flex items-center gap-1">
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */} {/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
{hasMultiplePlans ? ( {hasMultiplePlans ? (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400"> <div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
@@ -354,11 +327,8 @@ export function ProviderCard({
</div> </div>
</div> </div>
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */} {/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入 */}
<div <div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
ref={actionsRef}
className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
>
<ProviderActions <ProviderActions
isCurrent={isCurrent} isCurrent={isCurrent}
isTesting={isTesting} isTesting={isTesting}
@@ -369,9 +339,6 @@ export function ProviderCard({
onTest={onTest ? () => onTest(provider) : undefined} onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)} onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)} onDelete={() => onDelete(provider)}
onOpenTerminal={
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
}
// 故障转移相关 // 故障转移相关
isAutoFailoverEnabled={isAutoFailoverEnabled} isAutoFailoverEnabled={isAutoFailoverEnabled}
isInFailoverQueue={isInFailoverQueue} isInFailoverQueue={isInFailoverQueue}
@@ -41,7 +41,6 @@ interface ProviderListProps {
onDuplicate: (provider: Provider) => void; onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void; onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void; onOpenWebsite: (url: string) => void;
onOpenTerminal?: (provider: Provider) => void;
onCreate?: () => void; onCreate?: () => void;
isLoading?: boolean; isLoading?: boolean;
isProxyRunning?: boolean; // 代理服务运行状态 isProxyRunning?: boolean; // 代理服务运行状态
@@ -59,7 +58,6 @@ export function ProviderList({
onDuplicate, onDuplicate,
onConfigureUsage, onConfigureUsage,
onOpenWebsite, onOpenWebsite,
onOpenTerminal,
onCreate, onCreate,
isLoading = false, isLoading = false,
isProxyRunning = false, isProxyRunning = false,
@@ -205,7 +203,6 @@ export function ProviderList({
onDuplicate={onDuplicate} onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage} onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite} onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
onTest={handleTest} onTest={handleTest}
isTesting={isChecking(provider.id)} isTesting={isChecking(provider.id)}
isProxyRunning={isProxyRunning} isProxyRunning={isProxyRunning}
@@ -314,7 +311,6 @@ interface SortableProviderCardProps {
onDuplicate: (provider: Provider) => void; onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void; onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void; onOpenWebsite: (url: string) => void;
onOpenTerminal?: (provider: Provider) => void;
onTest: (provider: Provider) => void; onTest: (provider: Provider) => void;
isTesting: boolean; isTesting: boolean;
isProxyRunning: boolean; isProxyRunning: boolean;
@@ -337,7 +333,6 @@ function SortableProviderCard({
onDuplicate, onDuplicate,
onConfigureUsage, onConfigureUsage,
onOpenWebsite, onOpenWebsite,
onOpenTerminal,
onTest, onTest,
isTesting, isTesting,
isProxyRunning, isProxyRunning,
@@ -376,7 +371,6 @@ function SortableProviderCard({
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
} }
onOpenWebsite={onOpenWebsite} onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
onTest={onTest} onTest={onTest}
isTesting={isTesting} isTesting={isTesting}
isProxyRunning={isProxyRunning} isProxyRunning={isProxyRunning}
@@ -36,8 +36,6 @@ interface ClaudeFormFieldsProps {
isEndpointModalOpen: boolean; isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void; onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void; onCustomEndpointsChange?: (endpoints: string[]) => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// Model Selector // Model Selector
shouldShowModelSelector: boolean; shouldShowModelSelector: boolean;
@@ -85,8 +83,6 @@ export function ClaudeFormFields({
isEndpointModalOpen, isEndpointModalOpen,
onEndpointModalToggle, onEndpointModalToggle,
onCustomEndpointsChange, onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
shouldShowModelSelector, shouldShowModelSelector,
claudeModel, claudeModel,
reasoningModel, reasoningModel,
@@ -174,8 +170,6 @@ export function ClaudeFormFields({
initialEndpoints={speedTestEndpoints} initialEndpoints={speedTestEndpoints}
visible={isEndpointModalOpen} visible={isEndpointModalOpen}
onClose={() => onEndpointModalToggle(false)} onClose={() => onEndpointModalToggle(false)}
autoSelect={autoSelect}
onAutoSelectChange={onAutoSelectChange}
onCustomEndpointsChange={onCustomEndpointsChange} onCustomEndpointsChange={onCustomEndpointsChange}
/> />
)} )}
@@ -25,8 +25,6 @@ interface CodexFormFieldsProps {
isEndpointModalOpen: boolean; isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void; onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void; onCustomEndpointsChange?: (endpoints: string[]) => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// Model Name // Model Name
shouldShowModelField?: boolean; shouldShowModelField?: boolean;
@@ -52,8 +50,6 @@ export function CodexFormFields({
isEndpointModalOpen, isEndpointModalOpen,
onEndpointModalToggle, onEndpointModalToggle,
onCustomEndpointsChange, onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
shouldShowModelField = true, shouldShowModelField = true,
modelName = "", modelName = "",
onModelNameChange, onModelNameChange,
@@ -134,8 +130,6 @@ export function CodexFormFields({
initialEndpoints={speedTestEndpoints} initialEndpoints={speedTestEndpoints}
visible={isEndpointModalOpen} visible={isEndpointModalOpen}
onClose={() => onEndpointModalToggle(false)} onClose={() => onEndpointModalToggle(false)}
autoSelect={autoSelect}
onAutoSelectChange={onAutoSelectChange}
onCustomEndpointsChange={onCustomEndpointsChange} onCustomEndpointsChange={onCustomEndpointsChange}
/> />
)} )}
@@ -30,8 +30,6 @@ interface EndpointSpeedTestProps {
initialEndpoints: EndpointCandidate[]; initialEndpoints: EndpointCandidate[];
visible?: boolean; visible?: boolean;
onClose: () => void; onClose: () => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目) // 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目)
// 编辑模式:不使用此回调,端点直接保存到后端 // 编辑模式:不使用此回调,端点直接保存到后端
onCustomEndpointsChange?: (urls: string[]) => void; onCustomEndpointsChange?: (urls: string[]) => void;
@@ -87,8 +85,6 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
initialEndpoints, initialEndpoints,
visible = true, visible = true,
onClose, onClose,
autoSelect,
onAutoSelectChange,
onCustomEndpointsChange, onCustomEndpointsChange,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -97,6 +93,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
); );
const [customUrl, setCustomUrl] = useState(""); const [customUrl, setCustomUrl] = useState("");
const [addError, setAddError] = useState<string | null>(null); const [addError, setAddError] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [isTesting, setIsTesting] = useState(false); const [isTesting, setIsTesting] = useState(false);
const [lastError, setLastError] = useState<string | null>(null); const [lastError, setLastError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -491,9 +488,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
<input <input
type="checkbox" type="checkbox"
checked={autoSelect} checked={autoSelect}
onChange={(event) => { onChange={(event) => setAutoSelect(event.target.checked)}
onAutoSelectChange(event.target.checked);
}}
className="h-3.5 w-3.5 rounded border-border-default bg-background text-primary focus:ring-2 focus:ring-primary/20" className="h-3.5 w-3.5 rounded border-border-default bg-background text-primary focus:ring-2 focus:ring-primary/20"
/> />
{t("endpointTest.autoSelect")} {t("endpointTest.autoSelect")}
@@ -29,8 +29,6 @@ interface GeminiFormFieldsProps {
isEndpointModalOpen: boolean; isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void; onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange: (endpoints: string[]) => void; onCustomEndpointsChange: (endpoints: string[]) => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// Model // Model
shouldShowModelField: boolean; shouldShowModelField: boolean;
@@ -57,8 +55,6 @@ export function GeminiFormFields({
isEndpointModalOpen, isEndpointModalOpen,
onEndpointModalToggle, onEndpointModalToggle,
onCustomEndpointsChange, onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
shouldShowModelField, shouldShowModelField,
model, model,
onModelChange, onModelChange,
@@ -146,8 +142,6 @@ export function GeminiFormFields({
initialEndpoints={speedTestEndpoints} initialEndpoints={speedTestEndpoints}
visible={isEndpointModalOpen} visible={isEndpointModalOpen}
onClose={() => onEndpointModalToggle(false)} onClose={() => onEndpointModalToggle(false)}
autoSelect={autoSelect}
onAutoSelectChange={onAutoSelectChange}
onCustomEndpointsChange={onCustomEndpointsChange} onCustomEndpointsChange={onCustomEndpointsChange}
/> />
)} )}
@@ -124,9 +124,6 @@ export function ProviderForm({
return []; return [];
}, },
); );
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
() => initialData?.meta?.endpointAutoSelect ?? true,
);
// 使用 category hook // 使用 category hook
const { category } = useProviderCategory({ const { category } = useProviderCategory({
@@ -144,7 +141,6 @@ export function ProviderForm({
if (!initialData) { if (!initialData) {
setDraftCustomEndpoints([]); setDraftCustomEndpoints([]);
} }
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
}, [appId, initialData]); }, [appId, initialData]);
const defaultValues: ProviderFormData = useMemo( const defaultValues: ProviderFormData = useMemo(
@@ -651,13 +647,6 @@ export function ProviderForm({
} }
} }
const baseMeta: ProviderMeta | undefined =
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
payload.meta = {
...(baseMeta ?? {}),
endpointAutoSelect,
};
onSubmit(payload); onSubmit(payload);
}; };
@@ -867,8 +856,6 @@ export function ProviderForm({
onCustomEndpointsChange={ onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints isEditMode ? undefined : setDraftCustomEndpoints
} }
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelSelector={category !== "official"} shouldShowModelSelector={category !== "official"}
claudeModel={claudeModel} claudeModel={claudeModel}
reasoningModel={reasoningModel} reasoningModel={reasoningModel}
@@ -902,8 +889,6 @@ export function ProviderForm({
onCustomEndpointsChange={ onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints isEditMode ? undefined : setDraftCustomEndpoints
} }
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelField={category !== "official"} shouldShowModelField={category !== "official"}
modelName={codexModelName} modelName={codexModelName}
onModelNameChange={handleCodexModelNameChange} onModelNameChange={handleCodexModelNameChange}
@@ -932,8 +917,6 @@ export function ProviderForm({
isEndpointModalOpen={isEndpointModalOpen} isEndpointModalOpen={isEndpointModalOpen}
onEndpointModalToggle={setIsEndpointModalOpen} onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={setDraftCustomEndpoints} onCustomEndpointsChange={setDraftCustomEndpoints}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelField={true} shouldShowModelField={true}
model={geminiModel} model={geminiModel}
onModelChange={handleGeminiModelChange} onModelChange={handleGeminiModelChange}
+9 -5
View File
@@ -106,11 +106,13 @@ export function ProxyPanel() {
// 校验地址格式(简单的 IP 地址或 localhost 校验) // 校验地址格式(简单的 IP 地址或 localhost 校验)
const addressTrimmed = listenAddress.trim(); const addressTrimmed = listenAddress.trim();
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; 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 = const isValidAddress =
addressTrimmed === "localhost" || normalizedAddress === "0.0.0.0" ||
addressTrimmed === "0.0.0.0" || (ipv4Regex.test(normalizedAddress) &&
(ipv4Regex.test(addressTrimmed) && normalizedAddress.split(".").every((n) => {
addressTrimmed.split(".").every((n) => {
const num = parseInt(n); const num = parseInt(n);
return num >= 0 && num <= 255; return num >= 0 && num <= 255;
})); }));
@@ -146,9 +148,11 @@ export function ProxyPanel() {
try { try {
await updateGlobalConfig.mutateAsync({ await updateGlobalConfig.mutateAsync({
...globalConfig, ...globalConfig,
listenAddress: addressTrimmed, listenAddress: normalizedAddress,
listenPort: port, listenPort: port,
}); });
// 同步更新本地状态为规范化后的值
setListenAddress(normalizedAddress);
toast.success( toast.success(
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }), t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
{ closeButton: true }, { closeButton: true },
@@ -1,275 +0,0 @@
/**
*
*
*
*/
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Loader2, TestTube2, Search, Eye, EyeOff, X } from "lucide-react";
import {
useGlobalProxyUrl,
useSetGlobalProxyUrl,
useTestProxy,
useScanProxies,
type DetectedProxy,
} from "@/hooks/useGlobalProxy";
/** 从完整 URL 提取认证信息 */
function extractAuth(url: string): {
baseUrl: string;
username: string;
password: string;
} {
if (!url.trim()) return { baseUrl: "", username: "", password: "" };
try {
const parsed = new URL(url);
const username = decodeURIComponent(parsed.username || "");
const password = decodeURIComponent(parsed.password || "");
// 移除认证信息,获取基础 URL
parsed.username = "";
parsed.password = "";
return { baseUrl: parsed.toString(), username, password };
} catch {
return { baseUrl: url, username: "", password: "" };
}
}
/** 将认证信息合并到 URL */
function mergeAuth(
baseUrl: string,
username: string,
password: string,
): string {
if (!baseUrl.trim()) return "";
if (!username.trim()) return baseUrl;
try {
const parsed = new URL(baseUrl);
// URL 对象的 username/password setter 会自动进行 percent-encoding
// 不要使用 encodeURIComponent,否则会导致双重编码
parsed.username = username.trim();
if (password) {
parsed.password = password;
}
return parsed.toString();
} catch {
// URL 解析失败,尝试手动插入(此时需要手动编码)
const match = baseUrl.match(/^(\w+:\/\/)(.+)$/);
if (match) {
const auth = password
? `${encodeURIComponent(username.trim())}:${encodeURIComponent(password)}@`
: `${encodeURIComponent(username.trim())}@`;
return `${match[1]}${auth}${match[2]}`;
}
return baseUrl;
}
}
export function GlobalProxySettings() {
const { t } = useTranslation();
const { data: savedUrl, isLoading } = useGlobalProxyUrl();
const setMutation = useSetGlobalProxyUrl();
const testMutation = useTestProxy();
const scanMutation = useScanProxies();
const [url, setUrl] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [dirty, setDirty] = useState(false);
const [detected, setDetected] = useState<DetectedProxy[]>([]);
// 计算完整 URL(含认证信息)
const fullUrl = useMemo(
() => mergeAuth(url, username, password),
[url, username, password],
);
// 同步远程配置
useEffect(() => {
if (savedUrl !== undefined) {
const { baseUrl, username: u, password: p } = extractAuth(savedUrl || "");
setUrl(baseUrl);
setUsername(u);
setPassword(p);
setDirty(false);
}
}, [savedUrl]);
const handleSave = async () => {
await setMutation.mutateAsync(fullUrl);
setDirty(false);
};
const handleTest = async () => {
if (fullUrl) {
await testMutation.mutateAsync(fullUrl);
}
};
const handleScan = async () => {
const result = await scanMutation.mutateAsync();
setDetected(result);
};
const handleSelect = (proxyUrl: string) => {
const { baseUrl, username: u, password: p } = extractAuth(proxyUrl);
setUrl(baseUrl);
setUsername(u);
setPassword(p);
setDirty(true);
setDetected([]);
};
const handleClear = () => {
setUrl("");
setUsername("");
setPassword("");
setDirty(true);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && dirty && !setMutation.isPending) {
handleSave();
}
};
// 只在首次加载且无数据时显示加载状态
if (isLoading && savedUrl === undefined) {
return (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-3">
{/* 描述 */}
<p className="text-sm text-muted-foreground">
{t("settings.globalProxy.hint")}
</p>
{/* 代理地址输入框和按钮 */}
<div className="flex gap-2">
<Input
placeholder="http://127.0.0.1:7890 / socks5://127.0.0.1:1080"
value={url}
onChange={(e) => {
setUrl(e.target.value);
setDirty(true);
}}
onKeyDown={handleKeyDown}
className="font-mono text-sm flex-1"
/>
<Button
variant="outline"
size="icon"
disabled={scanMutation.isPending}
onClick={handleScan}
title={t("settings.globalProxy.scan")}
>
{scanMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
size="icon"
disabled={!fullUrl || testMutation.isPending}
onClick={handleTest}
title={t("settings.globalProxy.test")}
>
{testMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube2 className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
size="icon"
disabled={!url && !username && !password}
onClick={handleClear}
title={t("settings.globalProxy.clear")}
>
<X className="h-4 w-4" />
</Button>
<Button
onClick={handleSave}
disabled={!dirty || setMutation.isPending}
size="sm"
>
{setMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("common.save")}
</Button>
</div>
{/* 认证信息:用户名 + 密码(可选) */}
<div className="flex gap-2">
<Input
placeholder={t("settings.globalProxy.username")}
value={username}
onChange={(e) => {
setUsername(e.target.value);
setDirty(true);
}}
onKeyDown={handleKeyDown}
className="font-mono text-sm flex-1"
/>
<div className="relative flex-1">
<Input
type={showPassword ? "text" : "password"}
placeholder={t("settings.globalProxy.password")}
value={password}
onChange={(e) => {
setPassword(e.target.value);
setDirty(true);
}}
onKeyDown={handleKeyDown}
className="font-mono text-sm pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
</div>
{/* 扫描结果 */}
{detected.length > 0 && (
<div className="flex flex-wrap gap-2">
{detected.map((p) => (
<Button
key={p.url}
variant="secondary"
size="sm"
onClick={() => handleSelect(p.url)}
className="font-mono text-xs"
>
{p.url}
</Button>
))}
</div>
)}
</div>
);
}
@@ -1,75 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { settingsApi, type RectifierConfig } from "@/lib/api/settings";
export function RectifierConfigPanel() {
const { t } = useTranslation();
const [config, setConfig] = useState<RectifierConfig>({
enabled: true,
requestThinkingSignature: true,
});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
settingsApi
.getRectifierConfig()
.then(setConfig)
.catch((e) => console.error("Failed to load rectifier config:", e))
.finally(() => setIsLoading(false));
}, []);
const handleChange = async (updates: Partial<RectifierConfig>) => {
const newConfig = { ...config, ...updates };
setConfig(newConfig);
try {
await settingsApi.setRectifierConfig(newConfig);
} catch (e) {
console.error("Failed to save rectifier config:", e);
toast.error(String(e));
setConfig(config);
}
};
if (isLoading) return null;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>{t("settings.advanced.rectifier.enabled")}</Label>
<p className="text-xs text-muted-foreground">
{t("settings.advanced.rectifier.enabledDescription")}
</p>
</div>
<Switch
checked={config.enabled}
onCheckedChange={(checked) => handleChange({ enabled: checked })}
/>
</div>
<div className="space-y-4">
<h4 className="text-sm font-medium text-muted-foreground">
{t("settings.advanced.rectifier.requestGroup")}
</h4>
<div className="flex items-center justify-between pl-4">
<div className="space-y-0.5">
<Label>{t("settings.advanced.rectifier.thinkingSignature")}</Label>
<p className="text-xs text-muted-foreground">
{t("settings.advanced.rectifier.thinkingSignatureDescription")}
</p>
</div>
<Switch
checked={config.requestThinkingSignature}
disabled={!config.enabled}
onCheckedChange={(checked) =>
handleChange({ requestThinkingSignature: checked })
}
/>
</div>
</div>
</div>
);
}
-48
View File
@@ -9,8 +9,6 @@ import {
Database, Database,
Server, Server,
ChevronDown, ChevronDown,
Zap,
Globe,
} from "lucide-react"; } from "lucide-react";
import * as AccordionPrimitive from "@radix-ui/react-accordion"; import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -36,14 +34,12 @@ import { WindowSettings } from "@/components/settings/WindowSettings";
import { DirectorySettings } from "@/components/settings/DirectorySettings"; import { DirectorySettings } from "@/components/settings/DirectorySettings";
import { ImportExportSection } from "@/components/settings/ImportExportSection"; import { ImportExportSection } from "@/components/settings/ImportExportSection";
import { AboutSection } from "@/components/settings/AboutSection"; import { AboutSection } from "@/components/settings/AboutSection";
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
import { ProxyPanel } from "@/components/proxy"; import { ProxyPanel } from "@/components/proxy";
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel"; import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel"; import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel"; import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager"; import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
import { UsageDashboard } from "@/components/usage/UsageDashboard"; import { UsageDashboard } from "@/components/usage/UsageDashboard";
import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport"; import { useImportExport } from "@/hooks/useImportExport";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -499,28 +495,6 @@ export function SettingsPage({
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem
value="globalProxy"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Globe className="h-5 w-5 text-cyan-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.globalProxy.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.globalProxy.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<GlobalProxySettings />
</AccordionContent>
</AccordionItem>
<AccordionItem <AccordionItem
value="data" value="data"
className="rounded-xl glass-card overflow-hidden" className="rounded-xl glass-card overflow-hidden"
@@ -552,28 +526,6 @@ export function SettingsPage({
/> />
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem
value="rectifier"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Zap className="h-5 w-5 text-purple-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.rectifier.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.rectifier.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<RectifierConfigPanel />
</AccordionContent>
</AccordionItem>
</Accordion> </Accordion>
<div className="pt-4"> <div className="pt-4">
@@ -2,7 +2,6 @@ import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Save, Loader2 } from "lucide-react"; import { Save, Loader2 } from "lucide-react";
@@ -26,7 +25,6 @@ export function ModelTestConfigPanel() {
claudeModel: "claude-haiku-4-5-20251001", claudeModel: "claude-haiku-4-5-20251001",
codexModel: "gpt-5.1-codex@low", codexModel: "gpt-5.1-codex@low",
geminiModel: "gemini-3-pro-preview", geminiModel: "gemini-3-pro-preview",
testPrompt: "Who are you?",
}); });
useEffect(() => { useEffect(() => {
@@ -45,7 +43,6 @@ export function ModelTestConfigPanel() {
claudeModel: data.claudeModel, claudeModel: data.claudeModel,
codexModel: data.codexModel, codexModel: data.codexModel,
geminiModel: data.geminiModel, geminiModel: data.geminiModel,
testPrompt: data.testPrompt || "Who are you?",
}); });
} catch (e) { } catch (e) {
setError(String(e)); setError(String(e));
@@ -69,7 +66,6 @@ export function ModelTestConfigPanel() {
claudeModel: config.claudeModel, claudeModel: config.claudeModel,
codexModel: config.codexModel, codexModel: config.codexModel,
geminiModel: config.geminiModel, geminiModel: config.geminiModel,
testPrompt: config.testPrompt || "Who are you?",
}; };
await saveStreamCheckConfig(parsed); await saveStreamCheckConfig(parsed);
toast.success(t("streamCheck.configSaved"), { toast.success(t("streamCheck.configSaved"), {
@@ -193,21 +189,6 @@ export function ModelTestConfigPanel() {
/> />
</div> </div>
</div> </div>
{/* 检查提示词配置 */}
<div className="space-y-2">
<Label htmlFor="testPrompt">{t("streamCheck.testPrompt")}</Label>
<Textarea
id="testPrompt"
value={config.testPrompt}
onChange={(e) =>
setConfig({ ...config, testPrompt: e.target.value })
}
placeholder="Who are you?"
rows={2}
className="min-h-[60px]"
/>
</div>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
@@ -202,7 +202,6 @@ export function PricingConfigPanel() {
{editingModel && ( {editingModel && (
<PricingEditModal <PricingEditModal
open={!!editingModel}
model={editingModel} model={editingModel}
isNew={isAddingNew} isNew={isAddingNew}
onClose={() => { onClose={() => {
+127 -127
View File
@@ -1,8 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { Save, Plus } from "lucide-react"; import {
import { FullScreenPanel } from "@/components/common/FullScreenPanel"; Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -10,14 +15,12 @@ import { useUpdateModelPricing } from "@/lib/query/usage";
import type { ModelPricing } from "@/types/usage"; import type { ModelPricing } from "@/types/usage";
interface PricingEditModalProps { interface PricingEditModalProps {
open: boolean;
model: ModelPricing; model: ModelPricing;
isNew?: boolean; isNew?: boolean;
onClose: () => void; onClose: () => void;
} }
export function PricingEditModal({ export function PricingEditModal({
open,
model, model,
isNew = false, isNew = false,
onClose, onClose,
@@ -83,142 +86,139 @@ export function PricingEditModal({
}; };
return ( return (
<FullScreenPanel <Dialog open onOpenChange={onClose}>
isOpen={open} <DialogContent>
title={ <DialogHeader>
isNew <DialogTitle>
? t("usage.addPricing", "新增定价") {isNew
: `${t("usage.editPricing", "编辑定价")} - ${model.modelId}` ? t("usage.addPricing", "新增定价")
} : `${t("usage.editPricing", "编辑定价")} - ${model.modelId}`}
onClose={onClose} </DialogTitle>
footer={ </DialogHeader>
<Button
type="submit" <form onSubmit={handleSubmit} className="space-y-4">
form="pricing-form" {isNew && (
disabled={updatePricing.isPending} <div className="space-y-2">
> <Label htmlFor="modelId">{t("usage.modelId", "模型 ID")}</Label>
{isNew ? ( <Input
<Plus className="h-4 w-4 mr-2" /> id="modelId"
) : ( value={formData.modelId}
<Save className="h-4 w-4 mr-2" /> onChange={(e) =>
setFormData({ ...formData, modelId: e.target.value })
}
placeholder={t("usage.modelIdPlaceholder", {
defaultValue: "例如: claude-3-5-sonnet-20241022",
})}
required
/>
</div>
)} )}
{updatePricing.isPending
? t("common.saving", "保存中...")
: isNew
? t("common.add", "新增")
: t("common.save", "保存")}
</Button>
}
>
<form id="pricing-form" onSubmit={handleSubmit} className="space-y-6">
{isNew && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="modelId">{t("usage.modelId", "模型 ID")}</Label> <Label htmlFor="displayName">
{t("usage.displayName", "显示名称")}
</Label>
<Input <Input
id="modelId" id="displayName"
value={formData.modelId} value={formData.displayName}
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, modelId: e.target.value }) setFormData({ ...formData, displayName: e.target.value })
} }
placeholder={t("usage.modelIdPlaceholder", { placeholder={t("usage.displayNamePlaceholder", {
defaultValue: "例如: claude-3-5-sonnet-20241022", defaultValue: "例如: Claude 3.5 Sonnet",
})} })}
required required
/> />
</div> </div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="displayName"> <Label htmlFor="inputCost">
{t("usage.displayName", "显示名称")} {t("usage.inputCostPerMillion", "输入成本 (每百万 tokens, USD)")}
</Label> </Label>
<Input <Input
id="displayName" id="inputCost"
value={formData.displayName} type="number"
onChange={(e) => step="0.01"
setFormData({ ...formData, displayName: e.target.value }) min="0"
} value={formData.inputCost}
placeholder={t("usage.displayNamePlaceholder", { onChange={(e) =>
defaultValue: "例如: Claude 3.5 Sonnet", setFormData({ ...formData, inputCost: e.target.value })
})} }
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="inputCost"> <Label htmlFor="outputCost">
{t("usage.inputCostPerMillion", "输成本 (每百万 tokens, USD)")} {t("usage.outputCostPerMillion", "输成本 (每百万 tokens, USD)")}
</Label> </Label>
<Input <Input
id="inputCost" id="outputCost"
type="number" type="number"
step="0.01" step="0.01"
min="0" min="0"
value={formData.inputCost} value={formData.outputCost}
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, inputCost: e.target.value }) setFormData({ ...formData, outputCost: e.target.value })
} }
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="outputCost"> <Label htmlFor="cacheReadCost">
{t("usage.outputCostPerMillion", "输出成本 (每百万 tokens, USD)")} {t(
</Label> "usage.cacheReadCostPerMillion",
<Input "缓存读取成本 (每百万 tokens, USD)",
id="outputCost" )}
type="number" </Label>
step="0.01" <Input
min="0" id="cacheReadCost"
value={formData.outputCost} type="number"
onChange={(e) => step="0.01"
setFormData({ ...formData, outputCost: e.target.value }) min="0"
} value={formData.cacheReadCost}
required onChange={(e) =>
/> setFormData({ ...formData, cacheReadCost: e.target.value })
</div> }
required
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cacheReadCost"> <Label htmlFor="cacheCreationCost">
{t( {t(
"usage.cacheReadCostPerMillion", "usage.cacheCreationCostPerMillion",
"缓存读取成本 (每百万 tokens, USD)", "缓存写入成本 (每百万 tokens, USD)",
)} )}
</Label> </Label>
<Input <Input
id="cacheReadCost" id="cacheCreationCost"
type="number" type="number"
step="0.01" step="0.01"
min="0" min="0"
value={formData.cacheReadCost} value={formData.cacheCreationCost}
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, cacheReadCost: e.target.value }) setFormData({ ...formData, cacheCreationCost: e.target.value })
} }
required required
/> />
</div> </div>
<div className="space-y-2"> <DialogFooter>
<Label htmlFor="cacheCreationCost"> <Button type="button" variant="outline" onClick={onClose}>
{t( {t("common.cancel", "取消")}
"usage.cacheCreationCostPerMillion", </Button>
"缓存写入成本 (每百万 tokens, USD)", <Button type="submit" disabled={updatePricing.isPending}>
)} {updatePricing.isPending
</Label> ? t("common.saving", "保存中...")
<Input : isNew
id="cacheCreationCost" ? t("common.add", "新增")
type="number" : t("common.save", "保存")}
step="0.01" </Button>
min="0" </DialogFooter>
value={formData.cacheCreationCost} </form>
onChange={(e) => </DialogContent>
setFormData({ ...formData, cacheCreationCost: e.target.value }) </Dialog>
}
required
/>
</div>
</form>
</FullScreenPanel>
); );
} }
-109
View File
@@ -1,109 +0,0 @@
/**
* React Hooks
*
* React Query hooks
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
getGlobalProxyUrl,
setGlobalProxyUrl,
testProxyUrl,
getUpstreamProxyStatus,
scanLocalProxies,
type ProxyTestResult,
type UpstreamProxyStatus,
type DetectedProxy,
} from "@/lib/api/globalProxy";
/**
* URL
*/
export function useGlobalProxyUrl() {
return useQuery({
queryKey: ["globalProxyUrl"],
queryFn: getGlobalProxyUrl,
staleTime: 30 * 1000, // 30秒内不重新获取,避免展开时闪烁
});
}
/**
* URL
*/
export function useSetGlobalProxyUrl() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: setGlobalProxyUrl,
onSuccess: () => {
toast.success(t("settings.globalProxy.saved"));
queryClient.invalidateQueries({ queryKey: ["globalProxyUrl"] });
queryClient.invalidateQueries({ queryKey: ["upstreamProxyStatus"] });
},
onError: (error: unknown) => {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "Unknown error";
toast.error(t("settings.globalProxy.saveFailed", { error: message }));
},
});
}
/**
*
*/
export function useTestProxy() {
const { t } = useTranslation();
return useMutation({
mutationFn: testProxyUrl,
onSuccess: (result: ProxyTestResult) => {
if (result.success) {
toast.success(
t("settings.globalProxy.testSuccess", { latency: result.latencyMs }),
);
} else {
toast.error(
t("settings.globalProxy.testFailed", { error: result.error }),
);
}
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
/**
*
*/
export function useUpstreamProxyStatus() {
return useQuery<UpstreamProxyStatus>({
queryKey: ["upstreamProxyStatus"],
queryFn: getUpstreamProxyStatus,
});
}
/**
*
*/
export function useScanProxies() {
const { t } = useTranslation();
return useMutation({
mutationFn: scanLocalProxies,
onError: (error: Error) => {
toast.error(
t("settings.globalProxy.scanFailed", { error: error.message }),
);
},
});
}
export type { DetectedProxy };
-5
View File
@@ -115,11 +115,6 @@ export function useProviderActions(activeApp: AppId) {
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ["providers", activeApp], queryKey: ["providers", activeApp],
}); });
// 🔧 保存用量脚本后,也应该失效该 provider 的用量查询缓存
// 这样主页列表会使用新配置重新查询,而不是使用测试时的缓存
await queryClient.invalidateQueries({
queryKey: ["usage", provider.id, activeApp],
});
toast.success( toast.success(
t("provider.usageSaved", { t("provider.usageSaved", {
defaultValue: "用量查询配置已保存", defaultValue: "用量查询配置已保存",
+2 -33
View File
@@ -183,23 +183,9 @@
"title": "Cost Pricing", "title": "Cost Pricing",
"description": "Manage token pricing rules for each model" "description": "Manage token pricing rules for each model"
}, },
"globalProxy": {
"title": "Global Outbound Proxy",
"description": "Configure proxy for CC Switch to access external APIs"
},
"data": { "data": {
"title": "Data Management", "title": "Data Management",
"description": "Import/export configurations and backup/restore" "description": "Import/export configurations and backup/restore"
},
"rectifier": {
"title": "Rectifier",
"description": "Automatically fix API request compatibility issues",
"enabled": "Enable Rectifier",
"enabledDescription": "Master switch, all rectification features will be disabled when turned off",
"requestGroup": "Request Rectification",
"responseGroup": "Response Rectification",
"thinkingSignature": "Thinking Signature Rectification",
"thinkingSignatureDescription": "Automatically fix Claude API errors caused by thinking signature validation failures"
} }
}, },
"language": "Language", "language": "Language",
@@ -285,21 +271,7 @@
"restartLater": "Restart Later", "restartLater": "Restart Later",
"restartFailed": "Application restart failed, please manually close and reopen.", "restartFailed": "Application restart failed, please manually close and reopen.",
"devModeRestartHint": "Dev Mode: Automatic restart not supported, please manually restart the application.", "devModeRestartHint": "Dev Mode: Automatic restart not supported, please manually restart the application.",
"saving": "Saving...", "saving": "Saving..."
"globalProxy": {
"label": "Global Proxy",
"hint": "Proxy all requests (API, Skills download, etc.). Leave empty for direct connection.",
"username": "Username (optional)",
"password": "Password (optional)",
"test": "Test Connection",
"scan": "Scan Local Proxies",
"clear": "Clear",
"scanFailed": "Scan failed: {{error}}",
"saved": "Proxy settings saved",
"saveFailed": "Save failed: {{error}}",
"testSuccess": "Connected! Latency {{latency}}ms",
"testFailed": "Connection failed: {{error}}"
}
}, },
"apps": { "apps": {
"claude": "Claude Code", "claude": "Claude Code",
@@ -584,7 +556,6 @@
"testFailed": "Test failed", "testFailed": "Test failed",
"formatSuccess": "Format successful", "formatSuccess": "Format successful",
"formatFailed": "Format failed", "formatFailed": "Format failed",
"supportedVariables": "Supported Variables",
"variablesHint": "Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object", "variablesHint": "Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object",
"scriptConfig": "Request configuration", "scriptConfig": "Request configuration",
"extractorCode": "Extractor code", "extractorCode": "Extractor code",
@@ -987,7 +958,6 @@
"configDetails": "Config Details", "configDetails": "Config Details",
"configUrl": "Config File URL", "configUrl": "Config File URL",
"configMergeError": "Failed to merge configuration file", "configMergeError": "Failed to merge configuration file",
"primaryEndpoint": "Primary",
"mcp": { "mcp": {
"title": "Batch Import MCP Servers", "title": "Batch Import MCP Servers",
"targetApps": "Target Apps", "targetApps": "Target Apps",
@@ -1203,8 +1173,7 @@
"checkParams": "Check Parameters", "checkParams": "Check Parameters",
"timeout": "Timeout (seconds)", "timeout": "Timeout (seconds)",
"maxRetries": "Max Retries", "maxRetries": "Max Retries",
"degradedThreshold": "Degraded Threshold (ms)", "degradedThreshold": "Degraded Threshold (ms)"
"testPrompt": "Test Prompt"
}, },
"proxyConfig": { "proxyConfig": {
"proxyEnabled": "Proxy Enabled", "proxyEnabled": "Proxy Enabled",
+2 -33
View File
@@ -183,23 +183,9 @@
"title": "コスト計算", "title": "コスト計算",
"description": "各モデルのトークン料金ルールを管理" "description": "各モデルのトークン料金ルールを管理"
}, },
"globalProxy": {
"title": "グローバル送信プロキシ",
"description": "CC Switch が外部 API にアクセスする際のプロキシを設定"
},
"data": { "data": {
"title": "データ管理", "title": "データ管理",
"description": "設定のインポート/エクスポートとバックアップ/復元" "description": "設定のインポート/エクスポートとバックアップ/復元"
},
"rectifier": {
"title": "整流器",
"description": "API リクエストの互換性問題を自動修正",
"enabled": "整流器を有効化",
"enabledDescription": "マスタースイッチ、オフにするとすべての整流機能が無効になります",
"requestGroup": "リクエスト整流",
"responseGroup": "レスポンス整流",
"thinkingSignature": "Thinking 署名整流",
"thinkingSignatureDescription": "Claude API の thinking 署名検証エラーを自動修正"
} }
}, },
"language": "言語", "language": "言語",
@@ -285,21 +271,7 @@
"restartLater": "後で再起動", "restartLater": "後で再起動",
"restartFailed": "アプリの再起動に失敗しました。手動で閉じて再度開いてください。", "restartFailed": "アプリの再起動に失敗しました。手動で閉じて再度開いてください。",
"devModeRestartHint": "開発モードでは自動再起動をサポートしていません。手動で再起動してください。", "devModeRestartHint": "開発モードでは自動再起動をサポートしていません。手動で再起動してください。",
"saving": "保存中...", "saving": "保存中..."
"globalProxy": {
"label": "グローバルプロキシ",
"hint": "すべてのリクエスト(API、Skills ダウンロードなど)をプロキシ経由で送信します。空欄で直接接続。",
"username": "ユーザー名(任意)",
"password": "パスワード(任意)",
"test": "接続テスト",
"scan": "ローカルプロキシをスキャン",
"clear": "クリア",
"scanFailed": "スキャンに失敗しました: {{error}}",
"saved": "プロキシ設定を保存しました",
"saveFailed": "保存に失敗しました: {{error}}",
"testSuccess": "接続成功!遅延 {{latency}}ms",
"testFailed": "接続に失敗しました: {{error}}"
}
}, },
"apps": { "apps": {
"claude": "Claude Code", "claude": "Claude Code",
@@ -584,7 +556,6 @@
"testFailed": "テストに失敗しました", "testFailed": "テストに失敗しました",
"formatSuccess": "整形に成功しました", "formatSuccess": "整形に成功しました",
"formatFailed": "整形に失敗しました", "formatFailed": "整形に失敗しました",
"supportedVariables": "使用可能な変数",
"variablesHint": "使用可能な変数: {{apiKey}}, {{baseUrl}} | extractor 関数には API 応答の JSON オブジェクトが渡されます", "variablesHint": "使用可能な変数: {{apiKey}}, {{baseUrl}} | extractor 関数には API 応答の JSON オブジェクトが渡されます",
"scriptConfig": "リクエスト設定", "scriptConfig": "リクエスト設定",
"extractorCode": "抽出コード", "extractorCode": "抽出コード",
@@ -987,7 +958,6 @@
"configDetails": "設定の詳細", "configDetails": "設定の詳細",
"configUrl": "設定ファイル URL", "configUrl": "設定ファイル URL",
"configMergeError": "設定ファイルのマージに失敗しました", "configMergeError": "設定ファイルのマージに失敗しました",
"primaryEndpoint": "メイン",
"mcp": { "mcp": {
"title": "MCP サーバーを一括インポート", "title": "MCP サーバーを一括インポート",
"targetApps": "ターゲットアプリ", "targetApps": "ターゲットアプリ",
@@ -1197,8 +1167,7 @@
"checkParams": "チェックパラメーター", "checkParams": "チェックパラメーター",
"timeout": "タイムアウト(秒)", "timeout": "タイムアウト(秒)",
"maxRetries": "最大リトライ回数", "maxRetries": "最大リトライ回数",
"degradedThreshold": "劣化しきい値(ミリ秒)", "degradedThreshold": "劣化しきい値(ミリ秒)"
"testPrompt": "テストプロンプト"
}, },
"proxyConfig": { "proxyConfig": {
"proxyEnabled": "プロキシ有効", "proxyEnabled": "プロキシ有効",
+2 -33
View File
@@ -183,23 +183,9 @@
"title": "成本定价", "title": "成本定价",
"description": "管理各模型 Token 计费规则" "description": "管理各模型 Token 计费规则"
}, },
"globalProxy": {
"title": "全局出站代理",
"description": "配置 CC Switch 访问外部 API 时使用的代理"
},
"data": { "data": {
"title": "数据管理", "title": "数据管理",
"description": "导入导出配置与备份恢复" "description": "导入导出配置与备份恢复"
},
"rectifier": {
"title": "整流器",
"description": "自动修复 API 请求中的兼容性问题",
"enabled": "启用整流器",
"enabledDescription": "总开关,关闭后所有整流功能将被禁用",
"requestGroup": "请求整流",
"responseGroup": "响应整流",
"thinkingSignature": "Thinking 签名整流",
"thinkingSignatureDescription": "自动修复 Claude API 中因 thinking 签名校验失败导致的请求错误"
} }
}, },
"language": "界面语言", "language": "界面语言",
@@ -285,21 +271,7 @@
"restartLater": "稍后重启", "restartLater": "稍后重启",
"restartFailed": "应用重启失败,请手动关闭后重新打开。", "restartFailed": "应用重启失败,请手动关闭后重新打开。",
"devModeRestartHint": "开发模式下不支持自动重启,请手动重新启动应用。", "devModeRestartHint": "开发模式下不支持自动重启,请手动重新启动应用。",
"saving": "正在保存...", "saving": "正在保存..."
"globalProxy": {
"label": "全局代理",
"hint": "代理所有请求(API、Skills 下载等)。留空表示直连。",
"username": "用户名(可选)",
"password": "密码(可选)",
"test": "测试连接",
"scan": "扫描本地代理",
"clear": "清除",
"scanFailed": "扫描失败:{{error}}",
"saved": "代理设置已保存",
"saveFailed": "保存失败:{{error}}",
"testSuccess": "连接成功!延迟 {{latency}}ms",
"testFailed": "连接失败:{{error}}"
}
}, },
"apps": { "apps": {
"claude": "Claude Code", "claude": "Claude Code",
@@ -584,7 +556,6 @@
"testFailed": "测试失败", "testFailed": "测试失败",
"formatSuccess": "格式化成功", "formatSuccess": "格式化成功",
"formatFailed": "格式化失败", "formatFailed": "格式化失败",
"supportedVariables": "支持的变量",
"variablesHint": "支持变量: {{apiKey}}, {{baseUrl}} | extractor 函数接收 API 响应的 JSON 对象", "variablesHint": "支持变量: {{apiKey}}, {{baseUrl}} | extractor 函数接收 API 响应的 JSON 对象",
"scriptConfig": "请求配置", "scriptConfig": "请求配置",
"extractorCode": "提取器代码", "extractorCode": "提取器代码",
@@ -987,7 +958,6 @@
"configDetails": "配置详情", "configDetails": "配置详情",
"configUrl": "配置文件 URL", "configUrl": "配置文件 URL",
"configMergeError": "合并配置文件失败", "configMergeError": "合并配置文件失败",
"primaryEndpoint": "主",
"mcp": { "mcp": {
"title": "批量导入 MCP Servers", "title": "批量导入 MCP Servers",
"targetApps": "目标应用", "targetApps": "目标应用",
@@ -1203,8 +1173,7 @@
"checkParams": "检查参数", "checkParams": "检查参数",
"timeout": "超时时间(秒)", "timeout": "超时时间(秒)",
"maxRetries": "最大重试次数", "maxRetries": "最大重试次数",
"degradedThreshold": "降级阈值(毫秒)", "degradedThreshold": "降级阈值(毫秒)"
"testPrompt": "检查提示词"
}, },
"proxyConfig": { "proxyConfig": {
"proxyEnabled": "代理总开关", "proxyEnabled": "代理总开关",
-85
View File
@@ -1,85 +0,0 @@
/**
* API
*
*
*/
import { invoke } from "@tauri-apps/api/core";
/**
*
*/
export interface ProxyTestResult {
success: boolean;
latencyMs: number;
error: string | null;
}
/**
*
*/
export interface UpstreamProxyStatus {
enabled: boolean;
proxyUrl: string | null;
}
/**
*
*/
export interface DetectedProxy {
url: string;
proxyType: string;
port: number;
}
/**
* URL
*
* @returns URLnull
*/
export async function getGlobalProxyUrl(): Promise<string | null> {
return invoke<string | null>("get_global_proxy_url");
}
/**
* URL
*
* @param url - URL http://127.0.0.1:7890 或 socks5://127.0.0.1:1080
*
*/
export async function setGlobalProxyUrl(url: string): Promise<void> {
try {
return await invoke("set_global_proxy_url", { url });
} catch (error) {
// Tauri invoke 错误可能是字符串
throw new Error(typeof error === "string" ? error : String(error));
}
}
/**
*
*
* @param url - URL
* @returns
*/
export async function testProxyUrl(url: string): Promise<ProxyTestResult> {
return invoke<ProxyTestResult>("test_proxy_url", { url });
}
/**
*
*
* @returns URL
*/
export async function getUpstreamProxyStatus(): Promise<UpstreamProxyStatus> {
return invoke<UpstreamProxyStatus>("get_upstream_proxy_status");
}
/**
*
*
* @returns
*/
export async function scanLocalProxies(): Promise<DetectedProxy[]> {
return invoke<DetectedProxy[]>("scan_local_proxies");
}
-1
View File
@@ -12,7 +12,6 @@ export interface StreamCheckConfig {
claudeModel: string; claudeModel: string;
codexModel: string; codexModel: string;
geminiModel: string; geminiModel: string;
testPrompt: string;
} }
export interface StreamCheckResult { export interface StreamCheckResult {
-9
View File
@@ -65,15 +65,6 @@ export const providersApi = {
handler(payload); handler(payload);
}); });
}, },
/**
*
*
* 使 API
*/
async openTerminal(providerId: string, appId: AppId): Promise<boolean> {
return await invoke("open_provider_terminal", { providerId, app: appId });
},
}; };
// ============================================================================ // ============================================================================
-13
View File
@@ -134,17 +134,4 @@ export const settingsApi = {
> { > {
return await invoke("get_tool_versions"); return await invoke("get_tool_versions");
}, },
async getRectifierConfig(): Promise<RectifierConfig> {
return await invoke("get_rectifier_config");
},
async setRectifierConfig(config: RectifierConfig): Promise<boolean> {
return await invoke("set_rectifier_config", { config });
},
}; };
export interface RectifierConfig {
enabled: boolean;
requestThinkingSignature: boolean;
}
-2
View File
@@ -28,7 +28,6 @@ export const usageApi = {
baseUrl?: string, baseUrl?: string,
accessToken?: string, accessToken?: string,
userId?: string, userId?: string,
templateType?: "custom" | "general" | "newapi",
): Promise<UsageResult> => { ): Promise<UsageResult> => {
return invoke("testUsageScript", { return invoke("testUsageScript", {
providerId, providerId,
@@ -39,7 +38,6 @@ export const usageApi = {
baseUrl, baseUrl,
accessToken, accessToken,
userId, userId,
templateType,
}); });
}, },
-3
View File
@@ -52,7 +52,6 @@ export interface UsageScript {
language: "javascript"; // 脚本语言 language: "javascript"; // 脚本语言
code: string; // 脚本代码(JSON 格式配置) code: string; // 脚本代码(JSON 格式配置)
timeout?: number; // 超时时间(秒,默认 10 timeout?: number; // 超时时间(秒,默认 10
templateType?: "custom" | "general" | "newapi"; // 模板类型(用于后端判断验证规则)
apiKey?: string; // 用量查询专用的 API Key(通用模板使用) apiKey?: string; // 用量查询专用的 API Key(通用模板使用)
baseUrl?: string; // 用量查询专用的 Base URL(通用和 NewAPI 模板使用) baseUrl?: string; // 用量查询专用的 Base URL(通用和 NewAPI 模板使用)
accessToken?: string; // 访问令牌(NewAPI 模板使用) accessToken?: string; // 访问令牌(NewAPI 模板使用)
@@ -93,8 +92,6 @@ export interface ProviderMeta {
custom_endpoints?: Record<string, CustomEndpoint>; custom_endpoints?: Record<string, CustomEndpoint>;
// 用量查询脚本配置 // 用量查询脚本配置
usage_script?: UsageScript; usage_script?: UsageScript;
// 请求地址管理:测速后自动选择最佳端点
endpointAutoSelect?: boolean;
// 是否为官方合作伙伴 // 是否为官方合作伙伴
isPartner?: boolean; isPartner?: boolean;
// 合作伙伴促销 key(用于后端识别 PackyCode 等) // 合作伙伴促销 key(用于后端识别 PackyCode 等)