mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68e07b350d | |||
| 26486d543c | |||
| fe49a0c189 | |||
| 813d6adb06 | |||
| 99c910e58e | |||
| 8f7423f011 | |||
| d745ab58c4 | |||
| c2adb1af46 | |||
| fdd539759e | |||
| 054a5e9e3b | |||
| c56523c9c0 | |||
| cba8e8fdb3 | |||
| b18be24384 | |||
| 6aef472fd2 | |||
| 4a8883ecc3 | |||
| 6dd809701b | |||
| 86288ee77e | |||
| 42d1d23618 | |||
| f363bb1dd0 | |||
| a2becf0917 | |||
| debe4232bc | |||
| 49a2e52b20 | |||
| ed9d9b5436 | |||
| 390839a8d5 | |||
| 3dcbe313be |
+239
-776
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,30 @@ 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 {
|
||||||
@@ -371,6 +395,11 @@ 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() {
|
||||||
@@ -397,8 +426,10 @@ pub fn set_mcp_servers_map(
|
|||||||
obj.remove("homepage");
|
obj.remove("homepage");
|
||||||
obj.remove("docs");
|
obj.remove("docs");
|
||||||
|
|
||||||
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
|
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式(WSL 路径除外)
|
||||||
wrap_command_for_windows(&mut obj);
|
if !is_wsl_target {
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
}
|
||||||
|
|
||||||
out.insert(id.clone(), Value::Object(obj));
|
out.insert(id.clone(), Value::Object(obj));
|
||||||
}
|
}
|
||||||
@@ -545,4 +576,68 @@ 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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
#![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::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")]
|
||||||
@@ -87,11 +91,8 @@ 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();
|
||||||
|
|
||||||
// 用于获取远程版本的 client
|
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||||
let client = reqwest::Client::builder()
|
let client = crate::proxy::http_client::get();
|
||||||
.user_agent("cc-switch/1.0")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
for tool in tools {
|
for tool in tools {
|
||||||
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
||||||
@@ -300,3 +301,285 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
|
|
||||||
(None, Some("未安装或无法执行".to_string()))
|
(None, Some("未安装或无法执行".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 打开指定提供商的终端
|
||||||
|
///
|
||||||
|
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
|
||||||
|
/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端
|
||||||
|
#[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)?;
|
||||||
|
return 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 \\\"{}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{}\"; claude --settings \"{}\"; exec bash --norc --noprofile'",
|
||||||
|
config_path, escaped_path, escaped_path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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;
|
||||||
@@ -20,6 +21,7 @@ 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::*;
|
||||||
|
|||||||
@@ -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 替代)---
|
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
|
||||||
|
|
||||||
/// 获取指定应用的代理接管状态
|
/// 获取指定应用的代理接管状态
|
||||||
|
|||||||
@@ -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
|
/// API endpoint/base URL (supports comma-separated multiple URLs)
|
||||||
#[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
|
||||||
|
|||||||
@@ -101,9 +101,13 @@ 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 {
|
||||||
if !ep.is_empty() {
|
for (i, url) in ep.split(',').enumerate() {
|
||||||
validate_url(ep, "endpoint")?;
|
let trimmed = url.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
validate_url(trimmed, &format!("endpoint[{i}]"))?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 merged_request = parse_and_merge_config(&request)?;
|
let mut 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
|
||||||
.as_ref()
|
.clone()
|
||||||
.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,14 +51,29 @@ pub fn import_provider_from_deeplink(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let endpoint = merged_request.endpoint.as_ref().ok_or_else(|| {
|
// Get endpoint: supports comma-separated multiple URLs (first is primary)
|
||||||
|
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())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if endpoint.is_empty() {
|
// Parse endpoints: split by comma, first is primary
|
||||||
return Err(AppError::InvalidInput(
|
let all_endpoints: Vec<String> = endpoint_str
|
||||||
"Endpoint cannot be empty".to_string(),
|
.split(',')
|
||||||
));
|
.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(|| {
|
||||||
@@ -73,11 +88,11 @@ pub fn import_provider_from_deeplink(
|
|||||||
|
|
||||||
let name = merged_request
|
let name = merged_request
|
||||||
.name
|
.name
|
||||||
.as_ref()
|
.clone()
|
||||||
.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
|
||||||
@@ -97,6 +112,21 @@ 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)?;
|
||||||
@@ -138,6 +168,16 @@ 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
|
||||||
@@ -165,6 +205,7 @@ 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(),
|
||||||
@@ -174,10 +215,14 @@ 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
|
base_url: request.usage_base_url.clone().or_else(|| {
|
||||||
.usage_base_url
|
let primary = get_primary_endpoint(request);
|
||||||
.clone()
|
if primary.is_empty() {
|
||||||
.or_else(|| request.endpoint.clone()),
|
None
|
||||||
|
} 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(),
|
||||||
auto_query_interval: request.usage_auto_interval,
|
auto_query_interval: request.usage_auto_interval,
|
||||||
@@ -198,7 +243,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!(request.endpoint.clone().unwrap_or_default()),
|
json!(get_primary_endpoint(request)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add default model if provided
|
// Add default model if provided
|
||||||
@@ -271,11 +316,8 @@ 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
|
// Endpoint: normalize trailing slashes (use primary endpoint only)
|
||||||
let endpoint = request
|
let endpoint = get_primary_endpoint(request)
|
||||||
.endpoint
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim()
|
.trim()
|
||||||
.trim_end_matches('/')
|
.trim_end_matches('/')
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -309,7 +351,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!(request.endpoint),
|
json!(get_primary_endpoint(request)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add model if provided
|
// Add model if provided
|
||||||
@@ -523,7 +565,11 @@ 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.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
|
if let Some(base_url) = config
|
||||||
|
.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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -404,3 +404,57 @@ 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())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ 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;
|
||||||
@@ -643,6 +644,37 @@ 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 {
|
||||||
@@ -832,12 +864,20 @@ 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
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ 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>,
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ use super::{
|
|||||||
ProxyError,
|
ProxyError,
|
||||||
};
|
};
|
||||||
use crate::{app_config::AppType, provider::Provider};
|
use crate::{app_config::AppType, provider::Provider};
|
||||||
use reqwest::{Client, Response};
|
use reqwest::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,8 +80,6 @@ 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>>,
|
||||||
@@ -93,6 +90,8 @@ 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,
|
||||||
|
/// 非流式请求超时(秒)
|
||||||
|
non_streaming_timeout: std::time::Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RequestForwarder {
|
impl RequestForwarder {
|
||||||
@@ -108,51 +107,14 @@ impl RequestForwarder {
|
|||||||
_streaming_first_byte_timeout: u64,
|
_streaming_first_byte_timeout: u64,
|
||||||
_streaming_idle_timeout: u64,
|
_streaming_idle_timeout: u64,
|
||||||
) -> 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,
|
||||||
|
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 filtered_body = filter_private_params_with_whitelist(request_body, &[]);
|
||||||
|
|
||||||
// 构建请求
|
// 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置)
|
||||||
let client = self.client.as_ref().ok_or_else(|| {
|
let client = super::http_client::get();
|
||||||
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
|
||||||
|
|||||||
@@ -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` - 代理 URL,None 或空字符串表示直连
|
||||||
|
///
|
||||||
|
/// # 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` - 代理 URL,None 或空字符串表示直连
|
||||||
|
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` - 新的代理 URL,None 或空字符串表示直连
|
||||||
|
#[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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ 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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
@@ -143,9 +142,7 @@ 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 {
|
||||||
@@ -155,13 +152,7 @@ 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()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 路径管理 ==========
|
// ========== 路径管理 ==========
|
||||||
@@ -863,7 +854,8 @@ 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 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() {
|
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(
|
||||||
|
|||||||
@@ -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::{Duration, Instant};
|
use std::time::Instant;
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
@@ -65,17 +65,21 @@ impl SpeedtestService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let timeout = Self::sanitize_timeout(timeout_secs);
|
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 tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
async move {
|
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 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 {
|
Ok(resp) => EndpointLatency {
|
||||||
url: trimmed,
|
url: trimmed,
|
||||||
latency: Some(start.elapsed().as_millis()),
|
latency: Some(start.elapsed().as_millis()),
|
||||||
@@ -112,19 +116,11 @@ impl SpeedtestService {
|
|||||||
Ok(results.into_iter().flatten().collect::<Vec<_>>())
|
Ok(results.into_iter().flatten().collect::<Vec<_>>())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
|
fn build_client(timeout_secs: u64) -> Result<(Client, std::time::Duration), AppError> {
|
||||||
Client::builder()
|
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||||
.timeout(Duration::from_secs(timeout_secs))
|
// 返回 timeout Duration 供请求级别使用
|
||||||
.redirect(reqwest::redirect::Policy::limited(5))
|
let timeout = std::time::Duration::from_secs(timeout_secs);
|
||||||
.user_agent("cc-switch-speedtest/1.0")
|
Ok((crate::proxy::http_client::get(), timeout))
|
||||||
.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 {
|
||||||
|
|||||||
@@ -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::{Duration, Instant};
|
use std::time::Instant;
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@@ -136,23 +136,36 @@ impl StreamCheckService {
|
|||||||
.extract_auth(provider)
|
.extract_auth(provider)
|
||||||
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
|
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
|
||||||
|
|
||||||
let client = Client::builder()
|
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||||
.timeout(Duration::from_secs(config.timeout_secs))
|
let client = crate::proxy::http_client::get();
|
||||||
.user_agent("cc-switch/1.0")
|
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
|
||||||
.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 result = match app_type {
|
let result = match app_type {
|
||||||
AppType::Claude => {
|
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,
|
||||||
|
request_timeout,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
AppType::Codex => {
|
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, request_timeout)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
AppType::Gemini => {
|
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,
|
||||||
|
request_timeout,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -193,6 +206,7 @@ impl StreamCheckService {
|
|||||||
base_url: &str,
|
base_url: &str,
|
||||||
auth: &AuthInfo,
|
auth: &AuthInfo,
|
||||||
model: &str,
|
model: &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 = if base.ends_with("/v1") {
|
let url = if base.ends_with("/v1") {
|
||||||
@@ -213,6 +227,7 @@ impl StreamCheckService {
|
|||||||
.header("x-api-key", &auth.api_key)
|
.header("x-api-key", &auth.api_key)
|
||||||
.header("anthropic-version", "2023-06-01")
|
.header("anthropic-version", "2023-06-01")
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
|
.timeout(timeout)
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -243,6 +258,7 @@ impl StreamCheckService {
|
|||||||
base_url: &str,
|
base_url: &str,
|
||||||
auth: &AuthInfo,
|
auth: &AuthInfo,
|
||||||
model: &str,
|
model: &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 = if base.ends_with("/v1") {
|
let url = if base.ends_with("/v1") {
|
||||||
@@ -275,6 +291,7 @@ 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
|
||||||
@@ -304,6 +321,7 @@ impl StreamCheckService {
|
|||||||
base_url: &str,
|
base_url: &str,
|
||||||
auth: &AuthInfo,
|
auth: &AuthInfo,
|
||||||
model: &str,
|
model: &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");
|
||||||
@@ -320,6 +338,7 @@ 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
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
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;
|
||||||
@@ -215,18 +213,10 @@ 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 timeout = timeout_secs.clamp(2, 30);
|
let client = crate::proxy::http_client::get();
|
||||||
let client = Client::builder()
|
// 约束超时范围,防止异常配置导致长时间阻塞(最小 2 秒,最大 30 秒)
|
||||||
.timeout(Duration::from_secs(timeout))
|
let request_timeout = std::time::Duration::from_secs(timeout_secs.clamp(2, 30));
|
||||||
.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(|_| {
|
||||||
@@ -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 {
|
for (k, v) in &config.headers {
|
||||||
|
|||||||
+23
@@ -382,6 +382,26 @@ 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 {
|
||||||
@@ -482,6 +502,9 @@ 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>
|
||||||
|
|||||||
@@ -389,12 +389,27 @@ export function DeepLinkImportDialog() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Endpoint */}
|
{/* API Endpoint */}
|
||||||
<div className="grid grid-cols-3 items-center gap-4">
|
<div className="grid grid-cols-3 items-start gap-4">
|
||||||
<div className="font-medium text-sm text-muted-foreground">
|
<div className="font-medium text-sm text-muted-foreground pt-0.5">
|
||||||
{t("deeplink.endpoint")}
|
{t("deeplink.endpoint")}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 text-sm break-all">
|
<div className="col-span-2 text-sm break-all space-y-1">
|
||||||
{request.endpoint}
|
{request.endpoint?.split(",").map((ep, idx) => (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
|
Terminal,
|
||||||
TestTube2,
|
TestTube2,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -23,6 +24,7 @@ interface ProviderActionsProps {
|
|||||||
onTest?: () => void;
|
onTest?: () => void;
|
||||||
onConfigureUsage: () => void;
|
onConfigureUsage: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onOpenTerminal?: () => void;
|
||||||
// 故障转移相关
|
// 故障转移相关
|
||||||
isAutoFailoverEnabled?: boolean;
|
isAutoFailoverEnabled?: boolean;
|
||||||
isInFailoverQueue?: boolean;
|
isInFailoverQueue?: boolean;
|
||||||
@@ -39,6 +41,7 @@ export function ProviderActions({
|
|||||||
onTest,
|
onTest,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onOpenTerminal,
|
||||||
// 故障转移相关
|
// 故障转移相关
|
||||||
isAutoFailoverEnabled = false,
|
isAutoFailoverEnabled = false,
|
||||||
isInFailoverQueue = false,
|
isInFailoverQueue = false,
|
||||||
@@ -171,6 +174,21 @@ 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"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ 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配置已被接管,切换为热切换)
|
||||||
@@ -91,6 +92,7 @@ export function ProviderCard({
|
|||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onTest,
|
onTest,
|
||||||
|
onOpenTerminal,
|
||||||
isTesting,
|
isTesting,
|
||||||
isProxyRunning,
|
isProxyRunning,
|
||||||
isProxyTakeover = false,
|
isProxyTakeover = false,
|
||||||
@@ -339,6 +341,9 @@ 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,6 +41,7 @@ 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; // 代理服务运行状态
|
||||||
@@ -58,6 +59,7 @@ export function ProviderList({
|
|||||||
onDuplicate,
|
onDuplicate,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
|
onOpenTerminal,
|
||||||
onCreate,
|
onCreate,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isProxyRunning = false,
|
isProxyRunning = false,
|
||||||
@@ -203,6 +205,7 @@ 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}
|
||||||
@@ -311,6 +314,7 @@ 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;
|
||||||
@@ -333,6 +337,7 @@ function SortableProviderCard({
|
|||||||
onDuplicate,
|
onDuplicate,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
|
onOpenTerminal,
|
||||||
onTest,
|
onTest,
|
||||||
isTesting,
|
isTesting,
|
||||||
isProxyRunning,
|
isProxyRunning,
|
||||||
@@ -371,6 +376,7 @@ 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,6 +36,8 @@ 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;
|
||||||
@@ -83,6 +85,8 @@ export function ClaudeFormFields({
|
|||||||
isEndpointModalOpen,
|
isEndpointModalOpen,
|
||||||
onEndpointModalToggle,
|
onEndpointModalToggle,
|
||||||
onCustomEndpointsChange,
|
onCustomEndpointsChange,
|
||||||
|
autoSelect,
|
||||||
|
onAutoSelectChange,
|
||||||
shouldShowModelSelector,
|
shouldShowModelSelector,
|
||||||
claudeModel,
|
claudeModel,
|
||||||
reasoningModel,
|
reasoningModel,
|
||||||
@@ -170,6 +174,8 @@ 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,6 +25,8 @@ 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;
|
||||||
@@ -50,6 +52,8 @@ export function CodexFormFields({
|
|||||||
isEndpointModalOpen,
|
isEndpointModalOpen,
|
||||||
onEndpointModalToggle,
|
onEndpointModalToggle,
|
||||||
onCustomEndpointsChange,
|
onCustomEndpointsChange,
|
||||||
|
autoSelect,
|
||||||
|
onAutoSelectChange,
|
||||||
shouldShowModelField = true,
|
shouldShowModelField = true,
|
||||||
modelName = "",
|
modelName = "",
|
||||||
onModelNameChange,
|
onModelNameChange,
|
||||||
@@ -130,6 +134,8 @@ 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,6 +30,8 @@ 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;
|
||||||
@@ -85,6 +87,8 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
initialEndpoints,
|
initialEndpoints,
|
||||||
visible = true,
|
visible = true,
|
||||||
onClose,
|
onClose,
|
||||||
|
autoSelect,
|
||||||
|
onAutoSelectChange,
|
||||||
onCustomEndpointsChange,
|
onCustomEndpointsChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -93,7 +97,6 @@ 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);
|
||||||
@@ -488,7 +491,9 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={autoSelect}
|
checked={autoSelect}
|
||||||
onChange={(event) => setAutoSelect(event.target.checked)}
|
onChange={(event) => {
|
||||||
|
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,6 +29,8 @@ 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;
|
||||||
@@ -55,6 +57,8 @@ export function GeminiFormFields({
|
|||||||
isEndpointModalOpen,
|
isEndpointModalOpen,
|
||||||
onEndpointModalToggle,
|
onEndpointModalToggle,
|
||||||
onCustomEndpointsChange,
|
onCustomEndpointsChange,
|
||||||
|
autoSelect,
|
||||||
|
onAutoSelectChange,
|
||||||
shouldShowModelField,
|
shouldShowModelField,
|
||||||
model,
|
model,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
@@ -142,6 +146,8 @@ 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,6 +124,9 @@ export function ProviderForm({
|
|||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
|
||||||
|
() => initialData?.meta?.endpointAutoSelect ?? true,
|
||||||
|
);
|
||||||
|
|
||||||
// 使用 category hook
|
// 使用 category hook
|
||||||
const { category } = useProviderCategory({
|
const { category } = useProviderCategory({
|
||||||
@@ -141,6 +144,7 @@ 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(
|
||||||
@@ -647,6 +651,13 @@ export function ProviderForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseMeta: ProviderMeta | undefined =
|
||||||
|
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
|
||||||
|
payload.meta = {
|
||||||
|
...(baseMeta ?? {}),
|
||||||
|
endpointAutoSelect,
|
||||||
|
};
|
||||||
|
|
||||||
onSubmit(payload);
|
onSubmit(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -856,6 +867,8 @@ 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}
|
||||||
@@ -889,6 +902,8 @@ 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}
|
||||||
@@ -917,6 +932,8 @@ 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}
|
||||||
|
|||||||
@@ -106,13 +106,11 @@ 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 =
|
||||||
normalizedAddress === "0.0.0.0" ||
|
addressTrimmed === "localhost" ||
|
||||||
(ipv4Regex.test(normalizedAddress) &&
|
addressTrimmed === "0.0.0.0" ||
|
||||||
normalizedAddress.split(".").every((n) => {
|
(ipv4Regex.test(addressTrimmed) &&
|
||||||
|
addressTrimmed.split(".").every((n) => {
|
||||||
const num = parseInt(n);
|
const num = parseInt(n);
|
||||||
return num >= 0 && num <= 255;
|
return num >= 0 && num <= 255;
|
||||||
}));
|
}));
|
||||||
@@ -148,11 +146,9 @@ export function ProxyPanel() {
|
|||||||
try {
|
try {
|
||||||
await updateGlobalConfig.mutateAsync({
|
await updateGlobalConfig.mutateAsync({
|
||||||
...globalConfig,
|
...globalConfig,
|
||||||
listenAddress: normalizedAddress,
|
listenAddress: addressTrimmed,
|
||||||
listenPort: port,
|
listenPort: port,
|
||||||
});
|
});
|
||||||
// 同步更新本地状态为规范化后的值
|
|
||||||
setListenAddress(normalizedAddress);
|
|
||||||
toast.success(
|
toast.success(
|
||||||
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
|
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
|
||||||
{ closeButton: true },
|
{ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
Server,
|
Server,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
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";
|
||||||
@@ -34,6 +35,7 @@ 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";
|
||||||
@@ -495,6 +497,28 @@ 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"
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -183,6 +183,10 @@
|
|||||||
"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"
|
||||||
@@ -271,7 +275,21 @@
|
|||||||
"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",
|
||||||
@@ -958,6 +976,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -183,6 +183,10 @@
|
|||||||
"title": "コスト計算",
|
"title": "コスト計算",
|
||||||
"description": "各モデルのトークン料金ルールを管理"
|
"description": "各モデルのトークン料金ルールを管理"
|
||||||
},
|
},
|
||||||
|
"globalProxy": {
|
||||||
|
"title": "グローバル送信プロキシ",
|
||||||
|
"description": "CC Switch が外部 API にアクセスする際のプロキシを設定"
|
||||||
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"title": "データ管理",
|
"title": "データ管理",
|
||||||
"description": "設定のインポート/エクスポートとバックアップ/復元"
|
"description": "設定のインポート/エクスポートとバックアップ/復元"
|
||||||
@@ -271,7 +275,21 @@
|
|||||||
"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",
|
||||||
@@ -958,6 +976,7 @@
|
|||||||
"configDetails": "設定の詳細",
|
"configDetails": "設定の詳細",
|
||||||
"configUrl": "設定ファイル URL",
|
"configUrl": "設定ファイル URL",
|
||||||
"configMergeError": "設定ファイルのマージに失敗しました",
|
"configMergeError": "設定ファイルのマージに失敗しました",
|
||||||
|
"primaryEndpoint": "メイン",
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"title": "MCP サーバーを一括インポート",
|
"title": "MCP サーバーを一括インポート",
|
||||||
"targetApps": "ターゲットアプリ",
|
"targetApps": "ターゲットアプリ",
|
||||||
|
|||||||
@@ -183,6 +183,10 @@
|
|||||||
"title": "成本定价",
|
"title": "成本定价",
|
||||||
"description": "管理各模型 Token 计费规则"
|
"description": "管理各模型 Token 计费规则"
|
||||||
},
|
},
|
||||||
|
"globalProxy": {
|
||||||
|
"title": "全局出站代理",
|
||||||
|
"description": "配置 CC Switch 访问外部 API 时使用的代理"
|
||||||
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"title": "数据管理",
|
"title": "数据管理",
|
||||||
"description": "导入导出配置与备份恢复"
|
"description": "导入导出配置与备份恢复"
|
||||||
@@ -271,7 +275,21 @@
|
|||||||
"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",
|
||||||
@@ -958,6 +976,7 @@
|
|||||||
"configDetails": "配置详情",
|
"configDetails": "配置详情",
|
||||||
"configUrl": "配置文件 URL",
|
"configUrl": "配置文件 URL",
|
||||||
"configMergeError": "合并配置文件失败",
|
"configMergeError": "合并配置文件失败",
|
||||||
|
"primaryEndpoint": "主",
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"title": "批量导入 MCP Servers",
|
"title": "批量导入 MCP Servers",
|
||||||
"targetApps": "目标应用",
|
"targetApps": "目标应用",
|
||||||
|
|||||||
@@ -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 代理 URL,null 表示未配置(直连)
|
||||||
|
*/
|
||||||
|
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");
|
||||||
|
}
|
||||||
@@ -65,6 +65,15 @@ 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 });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ 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 等)
|
||||||
|
|||||||
Reference in New Issue
Block a user