Compare commits

..

11 Commits

Author SHA1 Message Date
YoVinchen c1e27d3cf2 Merge branch 'main' into refactor/simplify-proxy-logs 2026-01-11 16:46:53 +08:00
YoVinchen c5d3732b9f fix(proxy): harden error handling and input validation
- Handle RwLock poisoning in settings.rs with unwrap_or_else
- Add fallback for dirs::home_dir() in config modules
- Normalize localhost to 127.0.0.1 in ProxyPanel
- Format IPv6 addresses with brackets for valid URLs
- Strict port validation with pure digit regex
- Treat NaN as validation failure in config panels
- Log warning on cost_multiplier parse failure
- Align timeoutSeconds range to [0, 300] across all panels
2026-01-11 16:45:05 +08:00
YoVinchen d33f99aca4 fix(proxy): improve validation and error handling in proxy config panels
- Add StopTimeout/StopFailed error types for proper stop() error reporting
- Replace silent clamp with validation-and-block in config panels
- Add listenAddress format validation in ProxyPanel
- Use log_codes constants instead of hardcoded strings
- Use once_cell::Lazy for regex precompilation
2026-01-11 14:12:57 +08:00
YoVinchen 7bc9dbbbb0 feat(pricing): support @ separator in model name matching
- Refactor model name cleaning into chained method calls
- Add @ to - replacement (e.g., gpt-5.2-codex@low → gpt-5.2-codex-low)
- Add test case for @ separator matching
2026-01-11 01:53:23 +08:00
YoVinchen e002bdba25 fix(ui): allow number inputs to be fully cleared before saving
- Convert numeric state to string type for controlled inputs
- Use isNaN() check instead of || fallback to allow 0 values
- Apply fix to ProxyPanel, CircuitBreakerConfigPanel,
  AutoFailoverConfigPanel, and ModelTestConfigPanel
2026-01-11 01:33:41 +08:00
YoVinchen 5694353798 style: format code with prettier and rustfmt 2026-01-11 01:00:04 +08:00
YoVinchen 17c3dffe8c chore: bump version to 3.9.1 2026-01-11 00:55:02 +08:00
YoVinchen 07ba3df0f4 feat(proxy): add structured log codes for i18n support
Add error code system to proxy module logs for multi-language support:

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

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

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

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

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

This reduces log noise while maintaining visibility into key user-facing decisions.
2026-01-10 16:07:53 +08:00
25 changed files with 809 additions and 880 deletions
+776 -239
View File
File diff suppressed because it is too large Load Diff
+2 -97
View File
@@ -65,30 +65,6 @@ fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
// 非 Windows 平台不做任何处理
}
/// 检测路径是否为 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)]
#[serde(rename_all = "camelCase")]
pub struct McpStatus {
@@ -395,11 +371,6 @@ pub fn set_mcp_servers_map(
};
// 构建 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();
for (id, spec) in servers.iter() {
let mut obj = if let Some(map) = spec.as_object() {
@@ -426,10 +397,8 @@ pub fn set_mcp_servers_map(
obj.remove("homepage");
obj.remove("docs");
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式WSL 路径除外)
if !is_wsl_target {
wrap_command_for_windows(&mut obj);
}
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
wrap_command_for_windows(&mut obj);
out.insert(id.clone(), Value::Object(obj));
}
@@ -576,68 +545,4 @@ mod tests {
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")));
}
}
}
-286
View File
@@ -1,13 +1,9 @@
#![allow(non_snake_case)]
use crate::app_config::AppType;
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
use crate::services::ProviderService;
use once_cell::sync::Lazy;
use regex::Regex;
use std::str::FromStr;
use tauri::AppHandle;
use tauri::State;
use tauri_plugin_opener::OpenerExt;
#[cfg(target_os = "windows")]
@@ -304,285 +300,3 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<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(())
}
+1 -1
View File
@@ -55,7 +55,7 @@ pub struct DeepLinkImportRequest {
/// Provider homepage URL
#[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
/// API endpoint/base URL (supports comma-separated multiple URLs)
/// API endpoint/base URL
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
/// API key
+2 -6
View File
@@ -101,13 +101,9 @@ fn parse_provider_deeplink(
validate_url(hp, "homepage")?;
}
}
// Validate each endpoint (supports comma-separated multiple URLs)
if let Some(ref ep) = endpoint {
for (i, url) in ep.split(',').enumerate() {
let trimmed = url.trim();
if !trimmed.is_empty() {
validate_url(trimmed, &format!("endpoint[{i}]"))?;
}
if !ep.is_empty() {
validate_url(ep, "endpoint")?;
}
}
+21 -67
View File
@@ -33,12 +33,12 @@ pub fn import_provider_from_deeplink(
}
// Step 1: Merge config file if provided (v3.8+)
let mut merged_request = parse_and_merge_config(&request)?;
let merged_request = parse_and_merge_config(&request)?;
// Extract required fields (now as Option)
let app_str = merged_request
.app
.clone()
.as_ref()
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
let api_key = merged_request.api_key.as_ref().ok_or_else(|| {
@@ -51,29 +51,14 @@ pub fn import_provider_from_deeplink(
));
}
// Get endpoint: supports comma-separated multiple URLs (first is primary)
let endpoint_str = merged_request.endpoint.as_ref().ok_or_else(|| {
let endpoint = merged_request.endpoint.as_ref().ok_or_else(|| {
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
})?;
// Parse endpoints: split by comma, first is primary
let all_endpoints: Vec<String> = endpoint_str
.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);
if endpoint.is_empty() {
return Err(AppError::InvalidInput(
"Endpoint cannot be empty".to_string(),
));
}
let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
@@ -88,11 +73,11 @@ pub fn import_provider_from_deeplink(
let name = merged_request
.name
.clone()
.as_ref()
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
// 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}")))?;
// Build provider configuration based on app type
@@ -112,21 +97,6 @@ pub fn import_provider_from_deeplink(
// Use ProviderService to add the 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 merged_request.enabled.unwrap_or(false) {
ProviderService::switch(state, app_type.clone(), &provider_id)?;
@@ -168,16 +138,6 @@ pub(crate) fn build_provider_from_request(
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
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
// Check if any usage script fields are provided
@@ -205,7 +165,6 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
// 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 {
enabled,
language: "javascript".to_string(),
@@ -215,14 +174,10 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
.usage_api_key
.clone()
.or_else(|| request.api_key.clone()),
base_url: request.usage_base_url.clone().or_else(|| {
let primary = get_primary_endpoint(request);
if primary.is_empty() {
None
} else {
Some(primary)
}
}),
base_url: request
.usage_base_url
.clone()
.or_else(|| request.endpoint.clone()),
access_token: request.usage_access_token.clone(),
user_id: request.usage_user_id.clone(),
auto_query_interval: request.usage_auto_interval,
@@ -243,7 +198,7 @@ fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
);
env.insert(
"ANTHROPIC_BASE_URL".to_string(),
json!(get_primary_endpoint(request)),
json!(request.endpoint.clone().unwrap_or_default()),
);
// Add default model if provided
@@ -316,8 +271,11 @@ fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
.unwrap_or("gpt-5-codex")
.to_string();
// Endpoint: normalize trailing slashes (use primary endpoint only)
let endpoint = get_primary_endpoint(request)
// Endpoint: normalize trailing slashes
let endpoint = request
.endpoint
.as_deref()
.unwrap_or("")
.trim()
.trim_end_matches('/')
.to_string();
@@ -351,7 +309,7 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
env.insert(
"GOOGLE_GEMINI_BASE_URL".to_string(),
json!(get_primary_endpoint(request)),
json!(request.endpoint),
);
// Add model if provided
@@ -565,11 +523,7 @@ fn merge_gemini_config(
}
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(base_url) = config
.get("GOOGLE_GEMINI_BASE_URL")
.or_else(|| config.get("GEMINI_BASE_URL"))
.and_then(|v| v.as_str())
{
if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
request.endpoint = Some(base_url.to_string());
}
}
-54
View File
@@ -404,57 +404,3 @@ fn test_parse_skill_deeplink() {
assert_eq!(request.directory.unwrap(), "skills");
assert_eq!(request.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())
);
}
-3
View File
@@ -27,7 +27,6 @@ mod usage_script;
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 commands::open_provider_terminal;
pub use commands::*;
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
pub use database::Database;
@@ -833,8 +832,6 @@ pub fn run() {
commands::get_stream_check_config,
commands::save_stream_check_config,
commands::get_tool_versions,
// Provider terminal
commands::open_provider_terminal,
// Universal Provider management
commands::get_universal_providers,
commands::get_universal_provider,
-3
View File
@@ -147,9 +147,6 @@ pub struct ProviderMeta {
/// 用量查询脚本配置
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_script: Option<UsageScript>,
/// 请求地址管理:测速后自动选择最佳端点
#[serde(rename = "endpointAutoSelect", skip_serializing_if = "Option::is_none")]
pub endpoint_auto_select: Option<bool>,
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
pub is_partner: Option<bool>,
+1 -1
View File
@@ -152,7 +152,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// Skill sync
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
log::warn!("同步 Skill 到 {:?} 失败: {}", app_type, e);
// Continue syncing other apps, don't abort
}
}
-21
View File
@@ -382,26 +382,6 @@ function App() {
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 () => {
try {
@@ -502,7 +482,6 @@ function App() {
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onOpenTerminal={activeApp === "claude" ? handleOpenTerminal : undefined}
onCreate={() => setIsAddOpen(true)}
/>
</motion.div>
+4 -19
View File
@@ -389,27 +389,12 @@ export function DeepLinkImportDialog() {
</div>
{/* API Endpoint */}
<div className="grid grid-cols-3 items-start gap-4">
<div className="font-medium text-sm text-muted-foreground pt-0.5">
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.endpoint")}
</div>
<div className="col-span-2 text-sm break-all space-y-1">
{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 className="col-span-2 text-sm break-all">
{request.endpoint}
</div>
</div>
@@ -6,7 +6,6 @@ import {
Loader2,
Play,
Plus,
Terminal,
TestTube2,
Trash2,
} from "lucide-react";
@@ -24,7 +23,6 @@ interface ProviderActionsProps {
onTest?: () => void;
onConfigureUsage: () => void;
onDelete: () => void;
onOpenTerminal?: () => void;
// 故障转移相关
isAutoFailoverEnabled?: boolean;
isInFailoverQueue?: boolean;
@@ -41,7 +39,6 @@ export function ProviderActions({
onTest,
onConfigureUsage,
onDelete,
onOpenTerminal,
// 故障转移相关
isAutoFailoverEnabled = false,
isInFailoverQueue = false,
@@ -174,21 +171,6 @@ export function ProviderActions({
<BarChart3 className="h-4 w-4" />
</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
size="icon"
variant="ghost"
@@ -33,7 +33,6 @@ interface ProviderCardProps {
onOpenWebsite: (url: string) => void;
onDuplicate: (provider: Provider) => void;
onTest?: (provider: Provider) => void;
onOpenTerminal?: (provider: Provider) => void;
isTesting?: boolean;
isProxyRunning: boolean;
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
@@ -92,7 +91,6 @@ export function ProviderCard({
onOpenWebsite,
onDuplicate,
onTest,
onOpenTerminal,
isTesting,
isProxyRunning,
isProxyTakeover = false,
@@ -341,7 +339,6 @@ export function ProviderCard({
onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)}
onOpenTerminal={onOpenTerminal ? () => onOpenTerminal(provider) : undefined}
// 故障转移相关
isAutoFailoverEnabled={isAutoFailoverEnabled}
isInFailoverQueue={isInFailoverQueue}
@@ -41,7 +41,6 @@ interface ProviderListProps {
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onOpenTerminal?: (provider: Provider) => void;
onCreate?: () => void;
isLoading?: boolean;
isProxyRunning?: boolean; // 代理服务运行状态
@@ -59,7 +58,6 @@ export function ProviderList({
onDuplicate,
onConfigureUsage,
onOpenWebsite,
onOpenTerminal,
onCreate,
isLoading = false,
isProxyRunning = false,
@@ -205,7 +203,6 @@ export function ProviderList({
onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
onTest={handleTest}
isTesting={isChecking(provider.id)}
isProxyRunning={isProxyRunning}
@@ -314,7 +311,6 @@ interface SortableProviderCardProps {
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onOpenTerminal?: (provider: Provider) => void;
onTest: (provider: Provider) => void;
isTesting: boolean;
isProxyRunning: boolean;
@@ -337,7 +333,6 @@ function SortableProviderCard({
onDuplicate,
onConfigureUsage,
onOpenWebsite,
onOpenTerminal,
onTest,
isTesting,
isProxyRunning,
@@ -376,7 +371,6 @@ function SortableProviderCard({
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
}
onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
onTest={onTest}
isTesting={isTesting}
isProxyRunning={isProxyRunning}
@@ -36,8 +36,6 @@ interface ClaudeFormFieldsProps {
isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// Model Selector
shouldShowModelSelector: boolean;
@@ -85,8 +83,6 @@ export function ClaudeFormFields({
isEndpointModalOpen,
onEndpointModalToggle,
onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
shouldShowModelSelector,
claudeModel,
reasoningModel,
@@ -174,8 +170,6 @@ export function ClaudeFormFields({
initialEndpoints={speedTestEndpoints}
visible={isEndpointModalOpen}
onClose={() => onEndpointModalToggle(false)}
autoSelect={autoSelect}
onAutoSelectChange={onAutoSelectChange}
onCustomEndpointsChange={onCustomEndpointsChange}
/>
)}
@@ -25,8 +25,6 @@ interface CodexFormFieldsProps {
isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// Model Name
shouldShowModelField?: boolean;
@@ -52,8 +50,6 @@ export function CodexFormFields({
isEndpointModalOpen,
onEndpointModalToggle,
onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
shouldShowModelField = true,
modelName = "",
onModelNameChange,
@@ -134,8 +130,6 @@ export function CodexFormFields({
initialEndpoints={speedTestEndpoints}
visible={isEndpointModalOpen}
onClose={() => onEndpointModalToggle(false)}
autoSelect={autoSelect}
onAutoSelectChange={onAutoSelectChange}
onCustomEndpointsChange={onCustomEndpointsChange}
/>
)}
@@ -30,8 +30,6 @@ interface EndpointSpeedTestProps {
initialEndpoints: EndpointCandidate[];
visible?: boolean;
onClose: () => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目)
// 编辑模式:不使用此回调,端点直接保存到后端
onCustomEndpointsChange?: (urls: string[]) => void;
@@ -87,8 +85,6 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
initialEndpoints,
visible = true,
onClose,
autoSelect,
onAutoSelectChange,
onCustomEndpointsChange,
}) => {
const { t } = useTranslation();
@@ -97,6 +93,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
);
const [customUrl, setCustomUrl] = useState("");
const [addError, setAddError] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [isTesting, setIsTesting] = useState(false);
const [lastError, setLastError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
@@ -491,9 +488,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
<input
type="checkbox"
checked={autoSelect}
onChange={(event) => {
onAutoSelectChange(event.target.checked);
}}
onChange={(event) => setAutoSelect(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"
/>
{t("endpointTest.autoSelect")}
@@ -29,8 +29,6 @@ interface GeminiFormFieldsProps {
isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange: (endpoints: string[]) => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// Model
shouldShowModelField: boolean;
@@ -57,8 +55,6 @@ export function GeminiFormFields({
isEndpointModalOpen,
onEndpointModalToggle,
onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
shouldShowModelField,
model,
onModelChange,
@@ -146,8 +142,6 @@ export function GeminiFormFields({
initialEndpoints={speedTestEndpoints}
visible={isEndpointModalOpen}
onClose={() => onEndpointModalToggle(false)}
autoSelect={autoSelect}
onAutoSelectChange={onAutoSelectChange}
onCustomEndpointsChange={onCustomEndpointsChange}
/>
)}
@@ -124,9 +124,6 @@ export function ProviderForm({
return [];
},
);
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
() => initialData?.meta?.endpointAutoSelect ?? true,
);
// 使用 category hook
const { category } = useProviderCategory({
@@ -144,7 +141,6 @@ export function ProviderForm({
if (!initialData) {
setDraftCustomEndpoints([]);
}
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
}, [appId, initialData]);
const defaultValues: ProviderFormData = useMemo(
@@ -651,13 +647,6 @@ export function ProviderForm({
}
}
const baseMeta: ProviderMeta | undefined =
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
payload.meta = {
...(baseMeta ?? {}),
endpointAutoSelect,
};
onSubmit(payload);
};
@@ -867,8 +856,6 @@ export function ProviderForm({
onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints
}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelSelector={category !== "official"}
claudeModel={claudeModel}
reasoningModel={reasoningModel}
@@ -902,8 +889,6 @@ export function ProviderForm({
onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints
}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelField={category !== "official"}
modelName={codexModelName}
onModelNameChange={handleCodexModelNameChange}
@@ -932,8 +917,6 @@ export function ProviderForm({
isEndpointModalOpen={isEndpointModalOpen}
onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={setDraftCustomEndpoints}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelField={true}
model={geminiModel}
onModelChange={handleGeminiModelChange}
-1
View File
@@ -958,7 +958,6 @@
"configDetails": "Config Details",
"configUrl": "Config File URL",
"configMergeError": "Failed to merge configuration file",
"primaryEndpoint": "Primary",
"mcp": {
"title": "Batch Import MCP Servers",
"targetApps": "Target Apps",
-1
View File
@@ -958,7 +958,6 @@
"configDetails": "設定の詳細",
"configUrl": "設定ファイル URL",
"configMergeError": "設定ファイルのマージに失敗しました",
"primaryEndpoint": "メイン",
"mcp": {
"title": "MCP サーバーを一括インポート",
"targetApps": "ターゲットアプリ",
-1
View File
@@ -958,7 +958,6 @@
"configDetails": "配置详情",
"configUrl": "配置文件 URL",
"configMergeError": "合并配置文件失败",
"primaryEndpoint": "主",
"mcp": {
"title": "批量导入 MCP Servers",
"targetApps": "目标应用",
-9
View File
@@ -65,15 +65,6 @@ export const providersApi = {
handler(payload);
});
},
/**
*
*
* 使 API
*/
async openTerminal(providerId: string, appId: AppId): Promise<boolean> {
return await invoke("open_provider_terminal", { providerId, app: appId });
},
};
// ============================================================================
-2
View File
@@ -92,8 +92,6 @@ export interface ProviderMeta {
custom_endpoints?: Record<string, CustomEndpoint>;
// 用量查询脚本配置
usage_script?: UsageScript;
// 请求地址管理:测速后自动选择最佳端点
endpointAutoSelect?: boolean;
// 是否为官方合作伙伴
isPartner?: boolean;
// 合作伙伴促销 key(用于后端识别 PackyCode 等)