feat: Implement Speed Test Function
* feat: add unified endpoint speed test for API providers Add a comprehensive endpoint latency testing system that allows users to: - Test multiple API endpoints concurrently - Auto-select the fastest endpoint based on latency - Add/remove custom endpoints dynamically - View latency results with color-coded indicators Backend (Rust): - Implement parallel HTTP HEAD requests with configurable timeout - Handle various error scenarios (timeout, connection failure, invalid URL) - Return structured latency data with status codes Frontend (React): - Create interactive speed test UI component with auto-sort by latency - Support endpoint management (add/remove custom endpoints) - Extract and update Codex base_url from TOML configuration - Integrate with provider presets for default endpoint candidates This feature improves user experience when selecting optimal API endpoints, especially useful for users with multiple provider options or proxy setups. * refactor: convert endpoint speed test to modal dialog - Transform EndpointSpeedTest component into a modal dialog - Add "Advanced" button next to base URL input to open modal - Support ESC key and backdrop click to close modal - Apply Linear design principles: minimal styling, clean layout - Remove unused showBaseUrlInput variable - Implement same modal pattern for both Claude and Codex * fix: prevent modal cascade closing when ESC is pressed - Add state checks to prevent parent modal from closing when child modals (endpoint speed test or template wizard) are open - Update ESC key handler dependencies to track all modal states - Ensures only the topmost modal responds to ESC key * refactor: unify speed test panel UI with project design system UI improvements: - Update modal border radius from rounded-lg to rounded-xl - Unify header padding from px-6 py-4 to p-6 - Change speed test button color to blue theme (bg-blue-500) for consistency - Update footer background from bg-gray-50 to bg-gray-100 - Style "Done" button as primary action button with blue theme - Adjust footer button spacing and hover states Simplify endpoint display: - Remove endpoint labels (e.g., "Current Address", "Custom 1") - Display only URL for cleaner interface - Clean up all label-related logic: * Remove label field from EndpointCandidate interface * Remove label generation in buildInitialEntries function * Remove label handling in useEffect merge logic * Remove label generation in handleAddEndpoint * Remove label parameters from claudeSpeedTestEndpoints * Remove label parameters from codexSpeedTestEndpoints * refactor: improve endpoint list UI consistency - Show delete button for all endpoints on hover for uniform UI - Change selected state to use blue theme matching main interface: * Blue border (border-blue-500) for selected items * Light blue background (bg-blue-50/dark:bg-blue-900/20) * Blue indicator dot (bg-blue-500/dark:bg-blue-400) - Switch from compact list (space-y-px) to card-based layout (space-y-2) - Add rounded corners to each endpoint item for better visual separation * feat: persist custom endpoints to settings.json - Extend AppSettings to store custom endpoints for Claude and Codex - Add Tauri commands: get/add/remove/update custom endpoints - Update frontend API with endpoint persistence methods - Modify EndpointSpeedTest to load/save custom endpoints via API - Track endpoint last used time for future sorting/cleanup - Store endpoints per app type in settings.json instead of localStorage * - feat(types): add Provider.meta and ProviderMeta (snake_case) with custom_endpoints map - feat(provider-form): persist custom endpoints on provider create by merging EndpointSpeedTest’s custom URLs into meta.custom_endpoints on submit - feat(endpoint-speed-test): add onCustomEndpointsChange callback emitting normalized custom URLs; wire it for both Claude/Codex modals - fix(api): send alias param names (app/appType/app_type and provider_id/providerId) in Tauri invokes to avoid “missing providerId” with older backends - storage: custom endpoints are stored in ~/.cc-switch/config.json under providers[<id>].meta.custom_endpoints (not in settings.json) - behavior: edit flow remains immediate writes; create flow now writes once via addProvider, removing the providerId dependency during creation * feat: add endpoint candidates support and code formatting improvements - Add endpointCandidates field to ProviderPreset and CodexProviderPreset interfaces - Integrate preset endpoint candidates into speed test endpoint selection - Add multiple endpoint options for PackyCode providers (Claude & Codex) - Apply consistent code formatting (trailing commas, line breaks) - Improve template value type safety and readability * refactor: improve endpoint management button UX Replace ambiguous "Advanced" text with intuitive "Manage & Test" label accompanied by Zap icon, making the endpoint management panel entry point more discoverable and self-explanatory for both Claude and Codex configurations. * - merge: merge origin/main, resolve conflicts and preserve both feature sets - feat(tauri): register import/export and file dialogs; keep endpoint speed test and custom endpoints - feat(api): add updateTrayMenu and onProviderSwitched; wire import/export APIs - feat(types): extend global API declarations (import/export) - chore(presets): GLM preset supports both new and legacy model keys - chore(rust): add chrono dependency; refresh lockfile --------- Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
@@ -9,7 +9,8 @@ use crate::app_config::AppType;
|
||||
use crate::claude_plugin;
|
||||
use crate::codex_config;
|
||||
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
||||
use crate::provider::Provider;
|
||||
use crate::provider::{Provider, ProviderMeta};
|
||||
use crate::speedtest;
|
||||
use crate::store::AppState;
|
||||
|
||||
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
|
||||
@@ -725,3 +726,191 @@ pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String>
|
||||
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
|
||||
claude_plugin::is_claude_config_applied()
|
||||
}
|
||||
|
||||
/// 测试第三方/自定义供应商端点的网络延迟
|
||||
#[tauri::command]
|
||||
pub async fn test_api_endpoints(
|
||||
urls: Vec<String>,
|
||||
timeout_secs: Option<u64>,
|
||||
) -> Result<Vec<speedtest::EndpointLatency>, String> {
|
||||
let filtered: Vec<String> = urls
|
||||
.into_iter()
|
||||
.filter(|url| !url.trim().is_empty())
|
||||
.collect();
|
||||
speedtest::test_endpoints(filtered, timeout_secs).await
|
||||
}
|
||||
|
||||
/// 获取自定义端点列表
|
||||
#[tauri::command]
|
||||
pub async fn get_custom_endpoints(
|
||||
state: State<'_, crate::store::AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
provider_id: Option<String>,
|
||||
providerId: Option<String>,
|
||||
) -> Result<Vec<crate::settings::CustomEndpoint>, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
let provider_id = provider_id
|
||||
.or(providerId)
|
||||
.ok_or_else(|| "缺少 providerId".to_string())?;
|
||||
let mut cfg_guard = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = cfg_guard
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
let Some(provider) = manager.providers.get_mut(&provider_id) else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
|
||||
// 首选从 provider.meta 读取
|
||||
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
|
||||
if !meta.custom_endpoints.is_empty() {
|
||||
let mut result: Vec<_> = meta.custom_endpoints.values().cloned().collect();
|
||||
result.sort_by(|a, b| b.added_at.cmp(&a.added_at));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
/// 添加自定义端点
|
||||
#[tauri::command]
|
||||
pub async fn add_custom_endpoint(
|
||||
state: State<'_, crate::store::AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
provider_id: Option<String>,
|
||||
providerId: Option<String>,
|
||||
url: String,
|
||||
) -> Result<(), String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
let provider_id = provider_id
|
||||
.or(providerId)
|
||||
.ok_or_else(|| "缺少 providerId".to_string())?;
|
||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||
if normalized.is_empty() {
|
||||
return Err("URL 不能为空".to_string());
|
||||
}
|
||||
|
||||
let mut cfg_guard = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = cfg_guard
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
let Some(provider) = manager.providers.get_mut(&provider_id) else {
|
||||
return Err("供应商不存在或未选择".to_string());
|
||||
};
|
||||
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64;
|
||||
|
||||
let endpoint = crate::settings::CustomEndpoint {
|
||||
url: normalized.clone(),
|
||||
added_at: timestamp,
|
||||
last_used: None,
|
||||
};
|
||||
meta.custom_endpoints.insert(normalized, endpoint);
|
||||
drop(cfg_guard);
|
||||
state.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除自定义端点
|
||||
#[tauri::command]
|
||||
pub async fn remove_custom_endpoint(
|
||||
state: State<'_, crate::store::AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
provider_id: Option<String>,
|
||||
providerId: Option<String>,
|
||||
url: String,
|
||||
) -> Result<(), String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
let provider_id = provider_id
|
||||
.or(providerId)
|
||||
.ok_or_else(|| "缺少 providerId".to_string())?;
|
||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||
|
||||
let mut cfg_guard = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = cfg_guard
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
if let Some(provider) = manager.providers.get_mut(&provider_id) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.remove(&normalized);
|
||||
}
|
||||
}
|
||||
drop(cfg_guard);
|
||||
state.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新端点最后使用时间
|
||||
#[tauri::command]
|
||||
pub async fn update_endpoint_last_used(
|
||||
state: State<'_, crate::store::AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
provider_id: Option<String>,
|
||||
providerId: Option<String>,
|
||||
url: String,
|
||||
) -> Result<(), String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
let provider_id = provider_id
|
||||
.or(providerId)
|
||||
.ok_or_else(|| "缺少 providerId".to_string())?;
|
||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||
|
||||
let mut cfg_guard = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = cfg_guard
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
if let Some(provider) = manager.providers.get_mut(&provider_id) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) {
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64;
|
||||
endpoint.last_used = Some(timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(cfg_guard);
|
||||
state.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ mod migration;
|
||||
mod provider;
|
||||
mod settings;
|
||||
mod store;
|
||||
mod speedtest;
|
||||
|
||||
use store::AppState;
|
||||
use tauri::{
|
||||
@@ -420,6 +421,13 @@ pub fn run() {
|
||||
commands::read_claude_plugin_config,
|
||||
commands::apply_claude_plugin_config,
|
||||
commands::is_claude_plugin_applied,
|
||||
// ours: endpoint speed test + custom endpoint management
|
||||
commands::test_api_endpoints,
|
||||
commands::get_custom_endpoints,
|
||||
commands::add_custom_endpoint,
|
||||
commands::remove_custom_endpoint,
|
||||
commands::update_endpoint_last_used,
|
||||
// theirs: config import/export and dialogs
|
||||
import_export::export_config_to_file,
|
||||
import_export::import_config_from_file,
|
||||
import_export::save_file_dialog,
|
||||
|
||||
@@ -19,6 +19,9 @@ pub struct Provider {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: Option<i64>,
|
||||
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<ProviderMeta>,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
@@ -36,6 +39,7 @@ impl Provider {
|
||||
website_url,
|
||||
category: None,
|
||||
created_at: None,
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +60,14 @@ impl Default for ProviderManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// 供应商元数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderMeta {
|
||||
/// 自定义端点列表(按 URL 去重存储)
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
/// 获取所有供应商
|
||||
pub fn get_all_providers(&self) -> &HashMap<String, Provider> {
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
/// 自定义端点配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomEndpoint {
|
||||
pub url: String,
|
||||
pub added_at: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_used: Option<i64>,
|
||||
}
|
||||
|
||||
/// 应用设置结构,允许覆盖默认配置目录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -17,6 +28,12 @@ pub struct AppSettings {
|
||||
pub codex_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
/// Claude 自定义端点列表
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
|
||||
/// Codex 自定义端点列表
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub custom_endpoints_codex: HashMap<String, CustomEndpoint>,
|
||||
}
|
||||
|
||||
fn default_show_in_tray() -> bool {
|
||||
@@ -35,6 +52,8 @@ impl Default for AppSettings {
|
||||
claude_config_dir: None,
|
||||
codex_config_dir: None,
|
||||
language: None,
|
||||
custom_endpoints_claude: HashMap::new(),
|
||||
custom_endpoints_codex: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
102
src-tauri/src/speedtest.rs
Normal file
102
src-tauri/src/speedtest.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use futures::future::join_all;
|
||||
use reqwest::{Client, Url};
|
||||
use serde::Serialize;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 8;
|
||||
const MAX_TIMEOUT_SECS: u64 = 30;
|
||||
const MIN_TIMEOUT_SECS: u64 = 2;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct EndpointLatency {
|
||||
pub url: String,
|
||||
pub latency: Option<u128>,
|
||||
pub status: Option<u16>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
fn build_client(timeout_secs: u64) -> Result<Client, String> {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.user_agent("cc-switch-speedtest/1.0")
|
||||
.build()
|
||||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))
|
||||
}
|
||||
|
||||
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
|
||||
let secs = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
secs.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)
|
||||
}
|
||||
|
||||
pub async fn test_endpoints(
|
||||
urls: Vec<String>,
|
||||
timeout_secs: Option<u64>,
|
||||
) -> Result<Vec<EndpointLatency>, String> {
|
||||
if urls.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let timeout = sanitize_timeout(timeout_secs);
|
||||
let client = build_client(timeout)?;
|
||||
|
||||
let tasks = urls.into_iter().map(|raw_url| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
let trimmed = raw_url.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return EndpointLatency {
|
||||
url: raw_url,
|
||||
latency: None,
|
||||
status: None,
|
||||
error: Some("URL 不能为空".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let parsed_url = match Url::parse(&trimmed) {
|
||||
Ok(url) => url,
|
||||
Err(err) => {
|
||||
return EndpointLatency {
|
||||
url: trimmed,
|
||||
latency: None,
|
||||
status: None,
|
||||
error: Some(format!("URL 无效: {err}")),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let start = Instant::now();
|
||||
match client.get(parsed_url).send().await {
|
||||
Ok(resp) => {
|
||||
let latency = start.elapsed().as_millis();
|
||||
EndpointLatency {
|
||||
url: trimmed,
|
||||
latency: Some(latency),
|
||||
status: Some(resp.status().as_u16()),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let status = err.status().map(|s| s.as_u16());
|
||||
let error_message = if err.is_timeout() {
|
||||
"请求超时".to_string()
|
||||
} else if err.is_connect() {
|
||||
"连接失败".to_string()
|
||||
} else {
|
||||
err.to_string()
|
||||
};
|
||||
|
||||
EndpointLatency {
|
||||
url: trimmed,
|
||||
latency: None,
|
||||
status,
|
||||
error: Some(error_message),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let results = join_all(tasks).await;
|
||||
Ok(results)
|
||||
}
|
||||
Reference in New Issue
Block a user