Compare commits

...

3 Commits

Author SHA1 Message Date
YoVinchen 6af2b8671c feat(stream-check): enhance health check with configurable prompt and CLI-compatible requests
- Add configurable test prompt field to StreamCheckConfig
- Implement Claude CLI-compatible request format with proper headers:
  - Authorization + x-api-key dual auth
  - anthropic-beta, anthropic-version headers
  - x-stainless-* SDK headers with dynamic OS/arch detection
  - URL with ?beta=true parameter
- Implement Codex CLI-compatible Responses API format:
  - /v1/responses endpoint
  - input array format with reasoning effort support
  - codex_cli_rs user-agent and originator headers
- Add dynamic OS name and CPU architecture detection
- Internationalize error messages (Chinese -> English)
- Add test prompt Textarea UI component with i18n support
- Remove obsolete testPromptDesc translation key
2026-01-13 11:32:24 +08:00
Dex Miller 8b92982112 Feature/global proxy (#596)
* 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.

* 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).

* 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.

* 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

* chore: bump version to 3.9.1

* style: format code with prettier and rustfmt

* 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

* 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

* feat(proxy): add global proxy settings support

Add ability to configure a global HTTP/HTTPS proxy for all outbound
requests including provider API calls, speed tests, and stream checks.

* 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

* 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

* feat(proxy): add local proxy auto-scan and fix hot-reload

- Add scan_local_proxies command to detect common proxy ports
- Fix SkillService not using updated proxy after hot-reload
- Move global proxy settings to advanced tab
- Add error handling for scan failures

* fix(proxy): allow localhost input in proxy address field

* fix(proxy): restore request timeout and fix proxy hot-reload issues

- Add URL scheme validation in build_client (http/https/socks5/socks5h)
- Restore per-request timeout for speedtest, stream_check, usage_script, forwarder
- Fix set_global_proxy_url to validate before persisting to DB
- Mask proxy credentials in all log outputs
- Fix forwarder hot-reload by fetching client on each request

* style: format code with prettier

* fix(proxy): improve global proxy stability and error handling

- Fix RwLock silent failures with explicit error propagation
- Handle init() duplicate calls gracefully with warning log
- Align fallback client config with build_client settings
- Make scan_local_proxies async to avoid UI blocking
- Add mixed mode support for Clash 7890 port (http+socks5)
- Use multiple test targets for better proxy connectivity test
- Clear invalid proxy config on init failure
- Restore timeout constraints in usage_script
- Fix mask_url output for URLs without port
- Add structured error codes [GP-001 to GP-009]

* feat(proxy): add username/password authentication support

- Add separate username and password input fields
- Implement password visibility toggle with eye icon
- Add clear button to reset all proxy fields
- Auto-extract auth info from saved URL and merge on save
- Update i18n translations (zh/en/ja)

* fix(proxy): fix double encoding issue in proxy auth and add debug logs

- Remove encodeURIComponent in mergeAuth() since URL object's
  username/password setters already do percent-encoding automatically
- Add GP-010 debug log for database read operations
- Add GP-011 debug log to track incoming URL info (length, has_auth)
- Fix username.trim() in fallback branch for consistent behavior
2026-01-13 10:55:53 +08:00
Dex Miller 74b4d4ecbb fix(ui): auto-adapt usage block offset based on action buttons width (#613) 2026-01-12 23:07:02 +08:00
24 changed files with 1456 additions and 168 deletions
+247
View File
@@ -0,0 +1,247 @@
//! 全局出站代理相关命令
//!
//! 提供获取、设置和测试全局代理的 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()
}
+2 -5
View File
@@ -91,11 +91,8 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
let tools = vec!["claude", "codex", "gemini"];
let mut results = Vec::new();
// 用于获取远程版本的 client
let client = reqwest::Client::builder()
.user_agent("cc-switch/1.0")
.build()
.map_err(|e| e.to_string())?;
// 使用全局 HTTP 客户端(已包含代理配置)
let client = crate::proxy::http_client::get();
for tool in tools {
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
+2
View File
@@ -4,6 +4,7 @@ mod config;
mod deeplink;
mod env;
mod failover;
mod global_proxy;
mod import_export;
mod mcp;
mod misc;
@@ -20,6 +21,7 @@ pub use config::*;
pub use deeplink::*;
pub use env::*;
pub use failover::*;
pub use global_proxy::*;
pub use import_export::*;
pub use mcp::*;
pub use misc::*;
+35
View File
@@ -63,6 +63,41 @@ 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 替代)---
/// 获取指定应用的代理接管状态
+37
View File
@@ -644,6 +644,37 @@ pub fn run() {
let skill_service = SkillService::new();
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();
tauri::async_runtime::spawn(async move {
@@ -841,6 +872,12 @@ pub fn run() {
commands::upsert_universal_provider,
commands::delete_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
+13 -50
View File
@@ -12,10 +12,9 @@ use super::{
ProxyError,
};
use crate::{app_config::AppType, provider::Provider};
use reqwest::{Client, Response};
use reqwest::Response;
use serde_json::Value;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
/// Headers 黑名单 - 不透传到上游的 Headers
@@ -81,8 +80,6 @@ pub struct ForwardError {
}
pub struct RequestForwarder {
client: Option<Client>,
client_init_error: Option<String>,
/// 共享的 ProviderRouter(持有熔断器状态)
router: Arc<ProviderRouter>,
status: Arc<RwLock<ProxyStatus>>,
@@ -93,6 +90,8 @@ pub struct RequestForwarder {
app_handle: Option<tauri::AppHandle>,
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
current_provider_id_at_start: String,
/// 非流式请求超时(秒)
non_streaming_timeout: std::time::Duration,
}
impl RequestForwarder {
@@ -108,51 +107,14 @@ impl RequestForwarder {
_streaming_first_byte_timeout: u64,
_streaming_idle_timeout: u64,
) -> 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 {
client,
client_init_error,
router,
status,
current_providers,
failover_manager,
app_handle,
current_provider_id_at_start,
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
}
}
@@ -416,16 +378,17 @@ impl RequestForwarder {
// 默认使用空白名单,过滤所有 _ 前缀字段
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
// 构建请求
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()),
)
})?;
// 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置)
let client = super::http_client::get();
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,保护隐私并避免冲突
for (key, value) in headers {
if HEADER_BLACKLIST
+301
View File
@@ -0,0 +1,301 @@
//! 全局 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");
}
}
+1
View File
@@ -12,6 +12,7 @@ pub mod handler_config;
pub mod handler_context;
mod handlers;
mod health;
pub mod http_client;
pub mod log_codes;
pub mod model_mapper;
pub mod provider_router;
+4 -12
View File
@@ -7,7 +7,6 @@
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
@@ -143,9 +142,7 @@ pub struct SkillMetadata {
// ========== SkillService ==========
pub struct SkillService {
http_client: Client,
}
pub struct SkillService;
impl Default for SkillService {
fn default() -> Self {
@@ -155,13 +152,7 @@ impl Default for SkillService {
impl SkillService {
pub fn new() -> Self {
Self {
http_client: Client::builder()
.user_agent("cc-switch")
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
}
Self
}
// ========== 路径管理 ==========
@@ -863,7 +854,8 @@ impl SkillService {
/// 下载并解压 ZIP
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
let response = self.http_client.get(url).send().await?;
let client = crate::proxy::http_client::get();
let response = client.get(url).send().await?;
if !response.status().is_success() {
let status = response.status().as_u16().to_string();
return Err(anyhow::anyhow!(format_skill_error(
+13 -17
View File
@@ -1,7 +1,7 @@
use futures::future::join_all;
use reqwest::{Client, Url};
use serde::Serialize;
use std::time::{Duration, Instant};
use std::time::Instant;
use crate::error::AppError;
@@ -65,17 +65,21 @@ impl SpeedtestService {
}
let timeout = Self::sanitize_timeout(timeout_secs);
let client = Self::build_client(timeout)?;
let (client, request_timeout) = Self::build_client(timeout)?;
let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {
let client = client.clone();
async move {
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
let _ = client.get(parsed_url.clone()).send().await;
let _ = client
.get(parsed_url.clone())
.timeout(request_timeout)
.send()
.await;
// 第二次请求开始计时,并将其作为结果返回。
let start = Instant::now();
let latency = match client.get(parsed_url).send().await {
let latency = match client.get(parsed_url).timeout(request_timeout).send().await {
Ok(resp) => EndpointLatency {
url: trimmed,
latency: Some(start.elapsed().as_millis()),
@@ -112,19 +116,11 @@ impl SpeedtestService {
Ok(results.into_iter().flatten().collect::<Vec<_>>())
}
fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.redirect(reqwest::redirect::Policy::limited(5))
.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 build_client(timeout_secs: u64) -> Result<(Client, std::time::Duration), AppError> {
// 使用全局 HTTP 客户端(已包含代理配置)
// 返回 timeout Duration 供请求级别使用
let timeout = std::time::Duration::from_secs(timeout_secs);
Ok((crate::proxy::http_client::get(), timeout))
}
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
+177 -46
View File
@@ -7,7 +7,7 @@ use regex::Regex;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::time::{Duration, Instant};
use std::time::Instant;
use crate::app_config::AppType;
use crate::error::AppError;
@@ -36,6 +36,13 @@ pub struct StreamCheckConfig {
pub codex_model: String,
/// Gemini 测试模型
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 {
@@ -47,6 +54,7 @@ impl Default for StreamCheckConfig {
claude_model: "claude-haiku-4-5-20251001".to_string(),
codex_model: "gpt-5.1-codex@low".to_string(),
gemini_model: "gemini-3-pro-preview".to_string(),
test_prompt: default_test_prompt(),
}
}
}
@@ -110,7 +118,7 @@ impl StreamCheckService {
Ok(last_result.unwrap_or_else(|| StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message: "检查失败".to_string(),
message: "Check failed".to_string(),
response_time_ms: None,
http_status: None,
model_used: String::new(),
@@ -130,29 +138,52 @@ impl StreamCheckService {
let base_url = adapter
.extract_base_url(provider)
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?;
let auth = adapter
.extract_auth(provider)
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.user_agent("cc-switch/1.0")
.build()
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
// 使用全局 HTTP 客户端(已包含代理配置)
let client = crate::proxy::http_client::get();
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
let model_to_test = Self::resolve_test_model(app_type, provider, config);
let test_prompt = &config.test_prompt;
let result = match app_type {
AppType::Claude => {
Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await
Self::check_claude_stream(
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
}
AppType::Codex => {
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await
Self::check_codex_stream(
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
}
AppType::Gemini => {
Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await
Self::check_gemini_stream(
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
}
};
@@ -166,7 +197,7 @@ impl StreamCheckService {
Ok(StreamCheckResult {
status: health_status,
success: true,
message: "检查成功".to_string(),
message: "Check succeeded".to_string(),
response_time_ms: Some(response_time),
http_status: Some(status_code),
model_used: model,
@@ -188,31 +219,69 @@ impl StreamCheckService {
}
/// Claude 流式检查
///
/// 严格按照 Claude CLI 真实请求格式构建请求
async fn check_claude_stream(
client: &Client,
base_url: &str,
auth: &AuthInfo,
model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
// URL 必须包含 ?beta=true 参数(某些中转服务依赖此参数验证请求来源)
let url = if base.ends_with("/v1") {
format!("{base}/messages")
format!("{base}/messages?beta=true")
} else {
format!("{base}/v1/messages")
format!("{base}/v1/messages?beta=true")
};
let body = json!({
"model": model,
"max_tokens": 1,
"messages": [{ "role": "user", "content": "hi" }],
"messages": [{ "role": "user", "content": test_prompt }],
"stream": true
});
// 获取本地系统信息
let os_name = Self::get_os_name();
let arch_name = Self::get_arch_name();
// 严格按照 Claude CLI 请求格式设置 headers
let response = client
.post(&url)
// 认证 headers(双重认证)
.header("authorization", format!("Bearer {}", auth.api_key))
.header("x-api-key", &auth.api_key)
// Anthropic 必需 headers
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.header(
"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)
.send()
.await
@@ -230,51 +299,64 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
}
} else {
Err(AppError::Message("未收到响应数据".to_string()))
Err(AppError::Message("No response data received".to_string()))
}
}
/// Codex 流式检查
///
/// 严格按照 Codex CLI 真实请求格式构建请求 (Responses API)
async fn check_codex_stream(
client: &Client,
base_url: &str,
auth: &AuthInfo,
model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
// Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API)
let url = if base.ends_with("/v1") {
format!("{base}/chat/completions")
format!("{base}/responses")
} else {
format!("{base}/v1/chat/completions")
format!("{base}/v1/responses")
};
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
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!({
"model": actual_model,
"messages": [
{ "role": "system", "content": "" },
{ "role": "assistant", "content": "" },
{ "role": "user", "content": "hi" }
],
"max_tokens": 1,
"temperature": 0,
"input": [{ "role": "user", "content": test_prompt }],
"stream": true
});
// 如果是推理模型,添加 reasoning_effort
if let Some(effort) = reasoning_effort {
body["reasoning_effort"] = json!(effort);
body["reasoning"] = json!({ "effort": effort });
}
// 严格按照 Codex CLI 请求格式设置 headers
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("Content-Type", "application/json")
.header("authorization", format!("Bearer {}", auth.api_key))
.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)
.send()
.await
@@ -291,10 +373,10 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
}
} else {
Err(AppError::Message("未收到响应数据".to_string()))
Err(AppError::Message("No response data received".to_string()))
}
}
@@ -304,13 +386,15 @@ impl StreamCheckService {
base_url: &str,
auth: &AuthInfo,
model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
let url = format!("{base}/v1/chat/completions");
let body = json!({
"model": model,
"messages": [{ "role": "user", "content": "hi" }],
"messages": [{ "role": "user", "content": test_prompt }],
"max_tokens": 1,
"temperature": 0,
"stream": true
@@ -320,6 +404,7 @@ impl StreamCheckService {
.post(&url)
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("Content-Type", "application/json")
.timeout(timeout)
.json(&body)
.send()
.await
@@ -336,10 +421,10 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
}
} else {
Err(AppError::Message("未收到响应数据".to_string()))
Err(AppError::Message("No response data received".to_string()))
}
}
@@ -354,7 +439,6 @@ impl StreamCheckService {
/// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
/// 返回 (实际模型名, Option<推理等级>)
fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
// 查找 @ 或 # 分隔符
if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
let actual_model = model[..pos].to_string();
let effort = model[pos + 1..].to_string();
@@ -367,17 +451,14 @@ impl StreamCheckService {
fn should_retry(msg: &str) -> bool {
let lower = msg.to_lowercase();
lower.contains("timeout")
|| lower.contains("abort")
|| lower.contains("中断")
|| lower.contains("超时")
lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out")
}
fn map_request_error(e: reqwest::Error) -> AppError {
if e.is_timeout() {
AppError::Message("请求超时".to_string())
AppError::Message("Request timeout".to_string())
} else if e.is_connect() {
AppError::Message(format!("连接失败: {e}"))
AppError::Message(format!("Connection failed: {e}"))
} else {
AppError::Message(e.to_string())
}
@@ -424,6 +505,26 @@ impl StreamCheckService {
.map(|m| m.as_str().trim().to_string())
.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)]
@@ -448,9 +549,10 @@ mod tests {
#[test]
fn test_should_retry() {
assert!(StreamCheckService::should_retry("请求超时"));
assert!(StreamCheckService::should_retry("request timeout"));
assert!(!StreamCheckService::should_retry("API Key 无效"));
assert!(StreamCheckService::should_retry("Request timeout"));
assert!(StreamCheckService::should_retry("request timed out"));
assert!(StreamCheckService::should_retry("connection abort"));
assert!(!StreamCheckService::should_retry("API Key invalid"));
}
#[test]
@@ -478,4 +580,33 @@ mod tests {
assert_eq!(model, "gpt-4o-mini");
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");
}
}
+7 -15
View File
@@ -1,8 +1,6 @@
use reqwest::Client;
use rquickjs::{Context, Function, Runtime};
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
use url::{Host, Url};
use crate::error::AppError;
@@ -215,18 +213,10 @@ struct RequestConfig {
/// 发送 HTTP 请求
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {
// 约束超时范围,防止异常配置导致长时间阻塞
let timeout = timeout_secs.clamp(2, 30);
let client = Client::builder()
.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 客户端(已包含代理配置)
let client = crate::proxy::http_client::get();
// 约束超时范围,防止异常配置导致长时间阻塞(最小 2 秒,最大 30 秒)
let request_timeout = std::time::Duration::from_secs(timeout_secs.clamp(2, 30));
// 严格校验 HTTP 方法,非法值不回退为 GET
let method: reqwest::Method = config.method.parse().map_err(|_| {
@@ -237,7 +227,9 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
)
})?;
let mut req = client.request(method.clone(), &config.url);
let mut req = client
.request(method.clone(), &config.url)
.timeout(request_timeout);
// 添加请求头
for (k, v) in &config.headers {
+3 -1
View File
@@ -502,7 +502,9 @@ function App() {
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onOpenTerminal={activeApp === "claude" ? handleOpenTerminal : undefined}
onOpenTerminal={
activeApp === "claude" ? handleOpenTerminal : undefined
}
onCreate={() => setIsAddOpen(true)}
/>
</motion.div>
+33 -7
View File
@@ -1,4 +1,4 @@
import { useMemo, useState, useEffect } from "react";
import { useMemo, useState, useEffect, useRef } from "react";
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import type {
@@ -149,6 +149,10 @@ export function ProviderCard({
// 多套餐默认展开
const [isExpanded, setIsExpanded] = useState(false);
// 操作按钮容器 ref,用于动态计算宽度
const actionsRef = useRef<HTMLDivElement>(null);
const [actionsWidth, setActionsWidth] = useState(0);
// 当检测到多套餐时自动展开
useEffect(() => {
if (hasMultiplePlans) {
@@ -156,6 +160,20 @@ export function ProviderCard({
}
}, [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 = () => {
if (!isClickableUrl) {
return;
@@ -281,10 +299,13 @@ export function ProviderCard({
</div>
</div>
<div className="relative flex items-center ml-auto min-w-0">
<div
className="relative flex items-center ml-auto min-w-0 gap-3"
style={{ "--actions-width": `${actionsWidth || 320}px` } as React.CSSProperties}
>
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
<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">
<div className="ml-auto">
<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)]">
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
{hasMultiplePlans ? (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
@@ -329,8 +350,11 @@ export function ProviderCard({
</div>
</div>
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入 */}
<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">
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */}
<div
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
isCurrent={isCurrent}
isTesting={isTesting}
@@ -341,7 +365,9 @@ export function ProviderCard({
onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)}
onOpenTerminal={onOpenTerminal ? () => onOpenTerminal(provider) : undefined}
onOpenTerminal={
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
}
// 故障转移相关
isAutoFailoverEnabled={isAutoFailoverEnabled}
isInFailoverQueue={isInFailoverQueue}
+5 -9
View File
@@ -106,13 +106,11 @@ export function ProxyPanel() {
// 校验地址格式(简单的 IP 地址或 localhost 校验)
const addressTrimmed = listenAddress.trim();
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
// 规范化 localhost 为 127.0.0.1
const normalizedAddress =
addressTrimmed === "localhost" ? "127.0.0.1" : addressTrimmed;
const isValidAddress =
normalizedAddress === "0.0.0.0" ||
(ipv4Regex.test(normalizedAddress) &&
normalizedAddress.split(".").every((n) => {
addressTrimmed === "localhost" ||
addressTrimmed === "0.0.0.0" ||
(ipv4Regex.test(addressTrimmed) &&
addressTrimmed.split(".").every((n) => {
const num = parseInt(n);
return num >= 0 && num <= 255;
}));
@@ -148,11 +146,9 @@ export function ProxyPanel() {
try {
await updateGlobalConfig.mutateAsync({
...globalConfig,
listenAddress: normalizedAddress,
listenAddress: addressTrimmed,
listenPort: port,
});
// 同步更新本地状态为规范化后的值
setListenAddress(normalizedAddress);
toast.success(
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
{ closeButton: true },
@@ -0,0 +1,275 @@
/**
* 全局出站代理设置组件
*
* 提供配置全局代理的输入界面,支持用户名密码认证。
*/
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>
);
}
+24
View File
@@ -9,6 +9,7 @@ import {
Database,
Server,
ChevronDown,
Globe,
} from "lucide-react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { toast } from "sonner";
@@ -34,6 +35,7 @@ import { WindowSettings } from "@/components/settings/WindowSettings";
import { DirectorySettings } from "@/components/settings/DirectorySettings";
import { ImportExportSection } from "@/components/settings/ImportExportSection";
import { AboutSection } from "@/components/settings/AboutSection";
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
import { ProxyPanel } from "@/components/proxy";
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
@@ -495,6 +497,28 @@ export function SettingsPage({
</AccordionContent>
</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
value="data"
className="rounded-xl glass-card overflow-hidden"
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Save, Loader2 } from "lucide-react";
@@ -25,6 +26,7 @@ export function ModelTestConfigPanel() {
claudeModel: "claude-haiku-4-5-20251001",
codexModel: "gpt-5.1-codex@low",
geminiModel: "gemini-3-pro-preview",
testPrompt: "Who are you?",
});
useEffect(() => {
@@ -43,6 +45,7 @@ export function ModelTestConfigPanel() {
claudeModel: data.claudeModel,
codexModel: data.codexModel,
geminiModel: data.geminiModel,
testPrompt: data.testPrompt || "Who are you?",
});
} catch (e) {
setError(String(e));
@@ -66,6 +69,7 @@ export function ModelTestConfigPanel() {
claudeModel: config.claudeModel,
codexModel: config.codexModel,
geminiModel: config.geminiModel,
testPrompt: config.testPrompt || "Who are you?",
};
await saveStreamCheckConfig(parsed);
toast.success(t("streamCheck.configSaved"), {
@@ -189,6 +193,21 @@ export function ModelTestConfigPanel() {
/>
</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 className="flex justify-end">
+109
View File
@@ -0,0 +1,109 @@
/**
* 全局出站代理 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 };
+21 -2
View File
@@ -183,6 +183,10 @@
"title": "Cost Pricing",
"description": "Manage token pricing rules for each model"
},
"globalProxy": {
"title": "Global Outbound Proxy",
"description": "Configure proxy for CC Switch to access external APIs"
},
"data": {
"title": "Data Management",
"description": "Import/export configurations and backup/restore"
@@ -271,7 +275,21 @@
"restartLater": "Restart Later",
"restartFailed": "Application restart failed, please manually close and reopen.",
"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": {
"claude": "Claude Code",
@@ -1174,7 +1192,8 @@
"checkParams": "Check Parameters",
"timeout": "Timeout (seconds)",
"maxRetries": "Max Retries",
"degradedThreshold": "Degraded Threshold (ms)"
"degradedThreshold": "Degraded Threshold (ms)",
"testPrompt": "Test Prompt"
},
"proxyConfig": {
"proxyEnabled": "Proxy Enabled",
+21 -2
View File
@@ -183,6 +183,10 @@
"title": "コスト計算",
"description": "各モデルのトークン料金ルールを管理"
},
"globalProxy": {
"title": "グローバル送信プロキシ",
"description": "CC Switch が外部 API にアクセスする際のプロキシを設定"
},
"data": {
"title": "データ管理",
"description": "設定のインポート/エクスポートとバックアップ/復元"
@@ -271,7 +275,21 @@
"restartLater": "後で再起動",
"restartFailed": "アプリの再起動に失敗しました。手動で閉じて再度開いてください。",
"devModeRestartHint": "開発モードでは自動再起動をサポートしていません。手動で再起動してください。",
"saving": "保存中..."
"saving": "保存中...",
"globalProxy": {
"label": "グローバルプロキシ",
"hint": "すべてのリクエスト(API、Skills ダウンロードなど)をプロキシ経由で送信します。空欄で直接接続。",
"username": "ユーザー名(任意)",
"password": "パスワード(任意)",
"test": "接続テスト",
"scan": "ローカルプロキシをスキャン",
"clear": "クリア",
"scanFailed": "スキャンに失敗しました: {{error}}",
"saved": "プロキシ設定を保存しました",
"saveFailed": "保存に失敗しました: {{error}}",
"testSuccess": "接続成功!遅延 {{latency}}ms",
"testFailed": "接続に失敗しました: {{error}}"
}
},
"apps": {
"claude": "Claude Code",
@@ -1168,7 +1186,8 @@
"checkParams": "チェックパラメーター",
"timeout": "タイムアウト(秒)",
"maxRetries": "最大リトライ回数",
"degradedThreshold": "劣化しきい値(ミリ秒)"
"degradedThreshold": "劣化しきい値(ミリ秒)",
"testPrompt": "テストプロンプト"
},
"proxyConfig": {
"proxyEnabled": "プロキシ有効",
+21 -2
View File
@@ -183,6 +183,10 @@
"title": "成本定价",
"description": "管理各模型 Token 计费规则"
},
"globalProxy": {
"title": "全局出站代理",
"description": "配置 CC Switch 访问外部 API 时使用的代理"
},
"data": {
"title": "数据管理",
"description": "导入导出配置与备份恢复"
@@ -271,7 +275,21 @@
"restartLater": "稍后重启",
"restartFailed": "应用重启失败,请手动关闭后重新打开。",
"devModeRestartHint": "开发模式下不支持自动重启,请手动重新启动应用。",
"saving": "正在保存..."
"saving": "正在保存...",
"globalProxy": {
"label": "全局代理",
"hint": "代理所有请求(API、Skills 下载等)。留空表示直连。",
"username": "用户名(可选)",
"password": "密码(可选)",
"test": "测试连接",
"scan": "扫描本地代理",
"clear": "清除",
"scanFailed": "扫描失败:{{error}}",
"saved": "代理设置已保存",
"saveFailed": "保存失败:{{error}}",
"testSuccess": "连接成功!延迟 {{latency}}ms",
"testFailed": "连接失败:{{error}}"
}
},
"apps": {
"claude": "Claude Code",
@@ -1174,7 +1192,8 @@
"checkParams": "检查参数",
"timeout": "超时时间(秒)",
"maxRetries": "最大重试次数",
"degradedThreshold": "降级阈值(毫秒)"
"degradedThreshold": "降级阈值(毫秒)",
"testPrompt": "检查提示词"
},
"proxyConfig": {
"proxyEnabled": "代理总开关",
+85
View File
@@ -0,0 +1,85 @@
/**
* 全局出站代理 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,6 +12,7 @@ export interface StreamCheckConfig {
claudeModel: string;
codexModel: string;
geminiModel: string;
testPrompt: string;
}
export interface StreamCheckResult {