From ca488cf076403e7fd535e135c62cab86fcd1f9ef Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Tue, 7 Oct 2025 19:14:32 +0800 Subject: [PATCH] feat: Implement Speed Test Function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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[].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 --- src-tauri/Cargo.lock | 286 +++++---- src-tauri/Cargo.toml | 3 + src-tauri/src/commands.rs | 191 +++++- src-tauri/src/lib.rs | 8 + src-tauri/src/provider.rs | 12 + src-tauri/src/settings.rs | 19 + src-tauri/src/speedtest.rs | 102 +++ src/components/ProviderForm.tsx | 476 +++++++++++--- .../ProviderForm/EndpointSpeedTest.tsx | 602 ++++++++++++++++++ src/config/codexProviderPresets.ts | 8 + src/config/providerPresets.ts | 13 + src/lib/tauri-api.ts | 205 ++++-- src/types.ts | 19 + src/utils/providerConfigUtils.ts | 22 + src/vite-env.d.ts | 31 +- 15 files changed, 1710 insertions(+), 287 deletions(-) create mode 100644 src-tauri/src/speedtest.rs create mode 100644 src/components/ProviderForm/EndpointSpeedTest.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8260ee2..823e265 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "addr2line" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] @@ -173,7 +173,7 @@ dependencies = [ "polling", "rustix", "slab", - "windows-sys 0.61.0", + "windows-sys 0.61.1", ] [[package]] @@ -231,7 +231,7 @@ dependencies = [ "rustix", "signal-hook-registry", "slab", - "windows-sys 0.61.0", + "windows-sys 0.61.1", ] [[package]] @@ -288,9 +288,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "backtrace" -version = "0.3.75" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", @@ -298,7 +298,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-link 0.2.0", ] [[package]] @@ -465,9 +465,9 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.23.2" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" [[package]] name = "byteorder" @@ -511,9 +511,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1de8bc0aa9e9385ceb3bf0c152e3a9b9544f6c4a912c8ae504e80c1f0368603" +checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" dependencies = [ "serde_core", ] @@ -538,7 +538,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -553,9 +553,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.38" +version = "1.2.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" +checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb" dependencies = [ "find-msvc-tools", "shlex", @@ -567,9 +567,11 @@ version = "3.4.0" dependencies = [ "chrono", "dirs 5.0.1", + "futures", "log", "objc2 0.5.2", "objc2-app-kit 0.2.2", + "reqwest", "serde", "serde_json", "tauri", @@ -580,6 +582,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tokio", "toml 0.8.2", ] @@ -828,12 +831,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -909,7 +912,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.0", + "windows-sys 0.61.1", ] [[package]] @@ -1096,7 +1099,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.0", + "windows-sys 0.61.1", ] [[package]] @@ -1168,9 +1171,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" +checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3" [[package]] name = "flate2" @@ -1240,6 +1243,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1247,6 +1265,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1314,6 +1333,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1483,9 +1503,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.31.1" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "gio" @@ -1800,7 +1820,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.0", + "windows-core 0.62.1", ] [[package]] @@ -2066,9 +2086,9 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "js-sys" -version = "0.3.80" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" dependencies = [ "once_cell", "wasm-bindgen", @@ -2151,9 +2171,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.175" +version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" [[package]] name = "libloading" @@ -2200,11 +2220,10 @@ checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] @@ -2262,9 +2281,9 @@ checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" [[package]] name = "memchr" -version = "2.7.5" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memoffset" @@ -2325,7 +2344,7 @@ dependencies = [ "once_cell", "png", "serde", - "thiserror 2.0.16", + "thiserror 2.0.17", "windows-sys 0.60.2", ] @@ -2721,9 +2740,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.7" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] @@ -2773,7 +2792,7 @@ dependencies = [ "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -2809,9 +2828,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -2819,15 +2838,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link 0.2.0", ] [[package]] @@ -3042,7 +3061,7 @@ dependencies = [ "hermit-abi", "pin-project-lite", "rustix", - "windows-sys 0.61.0", + "windows-sys 0.61.1", ] [[package]] @@ -3195,7 +3214,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tracing", "web-time", @@ -3216,7 +3235,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.16", + "thiserror 2.0.17", "tinyvec", "tracing", "web-time", @@ -3238,9 +3257,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.40" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" dependencies = [ "proc-macro2", ] @@ -3401,23 +3420,23 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] name = "ref-cast" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", @@ -3426,9 +3445,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.2" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" dependencies = [ "aho-corasick", "memchr", @@ -3438,9 +3457,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" dependencies = [ "aho-corasick", "memchr", @@ -3618,7 +3637,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.0", + "windows-sys 0.61.1", ] [[package]] @@ -3647,9 +3666,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.6" +version = "0.103.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" +checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" dependencies = [ "ring", "rustls-pki-types", @@ -3776,9 +3795,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -3798,18 +3817,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -3883,9 +3902,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.14.1" +version = "3.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" +checksum = "6093cd8c01b25262b84927e0f7151692158fab02d961e04c979d3903eba7ecc5" dependencies = [ "base64 0.22.1", "chrono", @@ -3894,8 +3913,7 @@ dependencies = [ "indexmap 2.11.4", "schemars 0.9.0", "schemars 1.0.4", - "serde", - "serde_derive", + "serde_core", "serde_json", "serde_with_macros", "time", @@ -3903,9 +3921,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.14.1" +version = "3.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e" +checksum = "a7e6c180db0816026a61afa1cff5344fb7ebded7e4d3062772179f2501481c27" dependencies = [ "darling", "proc-macro2", @@ -4295,7 +4313,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tray-icon", "url", @@ -4348,7 +4366,7 @@ dependencies = [ "sha2", "syn 2.0.106", "tauri-utils", - "thiserror 2.0.16", + "thiserror 2.0.17", "time", "url", "uuid", @@ -4400,7 +4418,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.16", + "thiserror 2.0.17", "url", ] @@ -4421,7 +4439,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.16", + "thiserror 2.0.17", "toml 0.9.7", "url", ] @@ -4444,7 +4462,7 @@ dependencies = [ "swift-rs", "tauri", "tauri-plugin", - "thiserror 2.0.16", + "thiserror 2.0.17", "time", ] @@ -4464,7 +4482,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.16", + "thiserror 2.0.17", "url", "windows", "zbus", @@ -4489,7 +4507,7 @@ dependencies = [ "serde", "serde_json", "tauri", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", "windows-sys 0.60.2", "zbus", @@ -4519,7 +4537,7 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.16", + "thiserror 2.0.17", "time", "tokio", "url", @@ -4545,7 +4563,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.16", + "thiserror 2.0.17", "url", "webkit2gtk", "webview2-com", @@ -4609,7 +4627,7 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.16", + "thiserror 2.0.17", "toml 0.9.7", "url", "urlpattern", @@ -4629,15 +4647,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.22.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.61.0", + "windows-sys 0.61.1", ] [[package]] @@ -4662,11 +4680,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.16", + "thiserror-impl 2.0.17", ] [[package]] @@ -4682,9 +4700,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", @@ -4764,15 +4782,27 @@ dependencies = [ "signal-hook-registry", "slab", "socket2", + "tokio-macros", "tracing", "windows-sys 0.59.0", ] [[package]] -name = "tokio-rustls" -version = "0.26.3" +name = "tokio-macros" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -4981,7 +5011,7 @@ dependencies = [ "once_cell", "png", "serde", - "thiserror 2.0.16", + "thiserror 2.0.17", "windows-sys 0.59.0", ] @@ -4999,9 +5029,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "uds_windows" @@ -5216,9 +5246,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" dependencies = [ "cfg-if", "once_cell", @@ -5229,9 +5259,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" dependencies = [ "bumpalo", "log", @@ -5243,9 +5273,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.53" +version = "0.4.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" dependencies = [ "cfg-if", "js-sys", @@ -5256,9 +5286,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5266,9 +5296,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", @@ -5279,9 +5309,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" dependencies = [ "unicode-ident", ] @@ -5361,9 +5391,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.80" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" dependencies = [ "js-sys", "wasm-bindgen", @@ -5463,7 +5493,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" dependencies = [ - "thiserror 2.0.16", + "thiserror 2.0.17", "windows", "windows-core 0.61.2", ] @@ -5490,7 +5520,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.61.1", ] [[package]] @@ -5551,9 +5581,9 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.62.0" +version = "0.62.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" +checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" dependencies = [ "windows-implement", "windows-interface", @@ -5575,9 +5605,9 @@ dependencies = [ [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0" dependencies = [ "proc-macro2", "quote", @@ -5586,9 +5616,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5" dependencies = [ "proc-macro2", "quote", @@ -5695,14 +5725,14 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.3", + "windows-targets 0.53.4", ] [[package]] name = "windows-sys" -version = "0.61.0" +version = "0.61.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" dependencies = [ "windows-link 0.2.0", ] @@ -5755,11 +5785,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.3" +version = "0.53.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" dependencies = [ - "windows-link 0.1.3", + "windows-link 0.2.0", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -5781,9 +5811,9 @@ dependencies = [ [[package]] name = "windows-version" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e061eb0a22b4a1d778ad70f7575ec7845490abb35b08fa320df7895882cacb" +checksum = "700dad7c058606087f6fdc1f88da5841e06da40334413c6cd4367b25ef26d24e" dependencies = [ "windows-link 0.2.0", ] @@ -6042,7 +6072,7 @@ dependencies = [ "sha2", "soup3", "tao-macros", - "thiserror 2.0.16", + "thiserror 2.0.17", "url", "webkit2gtk", "webkit2gtk-sys", @@ -6221,9 +6251,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8c81d3f..91a8620 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,6 +30,9 @@ tauri-plugin-updater = "2" tauri-plugin-dialog = "2" dirs = "5.0" toml = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +futures = "0.3" [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 02d72ed..04a9aa4 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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 pub async fn is_claude_plugin_applied() -> Result { claude_plugin::is_claude_config_applied() } + +/// 测试第三方/自定义供应商端点的网络延迟 +#[tauri::command] +pub async fn test_api_endpoints( + urls: Vec, + timeout_secs: Option, +) -> Result, String> { + let filtered: Vec = 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, + app: Option, + appType: Option, + provider_id: Option, + providerId: Option, +) -> 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 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, + app: Option, + appType: Option, + provider_id: Option, + providerId: Option, + 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, + app: Option, + appType: Option, + provider_id: Option, + providerId: Option, + 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, + app: Option, + appType: Option, + provider_id: Option, + providerId: Option, + 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(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 39c20f7..2454fa8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 20a3ff3..6bb9acd 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -19,6 +19,9 @@ pub struct Provider { #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "createdAt")] pub created_at: Option, + /// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json) + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, } 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, +} + impl ProviderManager { /// 获取所有供应商 pub fn get_all_providers(&self) -> &HashMap { diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index defc88f..7dec859 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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, +} + /// 应用设置结构,允许覆盖默认配置目录 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -17,6 +28,12 @@ pub struct AppSettings { pub codex_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, + /// Claude 自定义端点列表 + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub custom_endpoints_claude: HashMap, + /// Codex 自定义端点列表 + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub custom_endpoints_codex: HashMap, } 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(), } } } diff --git a/src-tauri/src/speedtest.rs b/src-tauri/src/speedtest.rs new file mode 100644 index 0000000..e5c5b74 --- /dev/null +++ b/src-tauri/src/speedtest.rs @@ -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, + pub status: Option, + pub error: Option, +} + +fn build_client(timeout_secs: u64) -> Result { + 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 { + let secs = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS); + secs.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS) +} + +pub async fn test_endpoints( + urls: Vec, + timeout_secs: Option, +) -> Result, 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) +} diff --git a/src/components/ProviderForm.tsx b/src/components/ProviderForm.tsx index 2cde773..47fcdda 100644 --- a/src/components/ProviderForm.tsx +++ b/src/components/ProviderForm.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useRef } from "react"; -import { Provider, ProviderCategory } from "../types"; +import React, { useState, useEffect, useRef, useMemo } from "react"; +import { Provider, ProviderCategory, CustomEndpoint } from "../types"; import { AppType } from "../lib/tauri-api"; import { updateCommonConfigSnippet, @@ -11,6 +11,8 @@ import { hasTomlCommonConfigSnippet, validateJsonConfig, applyTemplateValues, + extractCodexBaseUrl, + setCodexBaseUrl as setCodexBaseUrlInConfig, } from "../utils/providerConfigUtils"; import { providerPresets } from "../config/providerPresets"; import type { TemplateValueConfig } from "../config/providerPresets"; @@ -24,8 +26,11 @@ import ApiKeyInput from "./ProviderForm/ApiKeyInput"; import ClaudeConfigEditor from "./ProviderForm/ClaudeConfigEditor"; import CodexConfigEditor from "./ProviderForm/CodexConfigEditor"; import KimiModelSelector from "./ProviderForm/KimiModelSelector"; -import { X, AlertCircle, Save } from "lucide-react"; +import { X, AlertCircle, Save, Zap } from "lucide-react"; import { isLinux } from "../lib/platform"; +import EndpointSpeedTest, { + EndpointCandidate, +} from "./ProviderForm/EndpointSpeedTest"; // 分类仅用于控制少量交互(如官方禁用 API Key),不显示介绍组件 type TemplateValueMap = Record; @@ -36,11 +41,11 @@ const collectTemplatePaths = ( source: unknown, templateKeys: string[], currentPath: TemplatePath = [], - acc: TemplatePath[] = [], + acc: TemplatePath[] = [] ): TemplatePath[] => { if (typeof source === "string") { const hasPlaceholder = templateKeys.some((key) => - source.includes(`\${${key}}`), + source.includes(`\${${key}}`) ); if (hasPlaceholder) { acc.push([...currentPath]); @@ -50,14 +55,14 @@ const collectTemplatePaths = ( if (Array.isArray(source)) { source.forEach((item, index) => - collectTemplatePaths(item, templateKeys, [...currentPath, index], acc), + collectTemplatePaths(item, templateKeys, [...currentPath, index], acc) ); return acc; } if (source && typeof source === "object") { Object.entries(source).forEach(([key, value]) => - collectTemplatePaths(value, templateKeys, [...currentPath, key], acc), + collectTemplatePaths(value, templateKeys, [...currentPath, key], acc) ); } @@ -76,7 +81,7 @@ const getValueAtPath = (source: any, path: TemplatePath) => { const setValueAtPath = ( target: any, path: TemplatePath, - value: unknown, + value: unknown ): any => { if (path.length === 0) { return value; @@ -114,7 +119,7 @@ const setValueAtPath = ( const applyTemplateValuesToConfigString = ( presetConfig: any, currentConfigString: string, - values: TemplateValueMap, + values: TemplateValueMap ) => { const replacedConfig = applyTemplateValues(presetConfig, values); const templateKeys = Object.keys(values); @@ -204,15 +209,25 @@ const ProviderForm: React.FC = ({ const [claudeSmallFastModel, setClaudeSmallFastModel] = useState(""); const [baseUrl, setBaseUrl] = useState(""); // 新增:基础 URL 状态 // 模板变量状态 - const [templateValues, setTemplateValues] = - useState>({}); + const [templateValues, setTemplateValues] = useState< + Record + >({}); // Codex 特有的状态 const [codexAuth, setCodexAuthState] = useState(""); const [codexConfig, setCodexConfigState] = useState(""); const [codexApiKey, setCodexApiKey] = useState(""); + const [codexBaseUrl, setCodexBaseUrl] = useState(""); const [isCodexTemplateModalOpen, setIsCodexTemplateModalOpen] = useState(false); + // 新建供应商:收集端点测速弹窗中的“自定义端点”,提交时一次性落盘到 meta.custom_endpoints + const [draftCustomEndpoints, setDraftCustomEndpoints] = useState( + [] + ); + // 端点测速弹窗状态 + const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false); + const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] = + useState(false); // -1 表示自定义,null 表示未选择,>= 0 表示预设索引 const [selectedCodexPreset, setSelectedCodexPreset] = useState( showPresets && isCodex ? -1 : null @@ -223,8 +238,12 @@ const ProviderForm: React.FC = ({ setCodexAuthError(validateCodexAuth(value)); }; - const setCodexConfig = (value: string) => { - setCodexConfigState(value); + const setCodexConfig = (value: string | ((prev: string) => string)) => { + setCodexConfigState((prev) => + typeof value === "function" + ? (value as (input: string) => string)(prev) + : value + ); }; const setCodexCommonConfigSnippet = (value: string) => { @@ -238,6 +257,10 @@ const ProviderForm: React.FC = ({ if (typeof config === "object" && config !== null) { setCodexAuth(JSON.stringify(config.auth || {}, null, 2)); setCodexConfig(config.config || ""); + const initialBaseUrl = extractCodexBaseUrl(config.config); + if (initialBaseUrl) { + setCodexBaseUrl(initialBaseUrl); + } try { const auth = config.auth || {}; if (auth && typeof auth.OPENAI_API_KEY === "string") { @@ -292,6 +315,8 @@ const ProviderForm: React.FC = ({ }); const [codexCommonConfigError, setCodexCommonConfigError] = useState(""); const isUpdatingFromCodexCommonConfig = useRef(false); + const isUpdatingBaseUrlRef = useRef(false); + const isUpdatingCodexBaseUrlRef = useRef(false); // -1 表示自定义,null 表示未选择,>= 0 表示预设索引 const [selectedPreset, setSelectedPreset] = useState( @@ -436,6 +461,43 @@ const ProviderForm: React.FC = ({ } }, [showPresets, isCodex, selectedPreset, selectedCodexPreset]); + // 与 JSON 配置保持基础 URL 同步(Claude 第三方/自定义) + useEffect(() => { + if (isCodex) return; + const currentCategory = category ?? initialData?.category; + if (currentCategory !== "third_party" && currentCategory !== "custom") { + return; + } + if (isUpdatingBaseUrlRef.current) { + return; + } + try { + const config = JSON.parse(formData.settingsConfig || "{}"); + const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL; + if (typeof envUrl === "string" && envUrl && envUrl !== baseUrl) { + setBaseUrl(envUrl.trim()); + } + } catch { + // ignore JSON parse errors + } + }, [isCodex, category, initialData, formData.settingsConfig, baseUrl]); + + // 与 TOML 配置保持基础 URL 同步(Codex 第三方/自定义) + useEffect(() => { + if (!isCodex) return; + const currentCategory = category ?? initialData?.category; + if (currentCategory !== "third_party" && currentCategory !== "custom") { + return; + } + if (isUpdatingCodexBaseUrlRef.current) { + return; + } + const extracted = extractCodexBaseUrl(codexConfig) || ""; + if (extracted !== codexBaseUrl) { + setCodexBaseUrl(extracted); + } + }, [isCodex, category, initialData, codexConfig, codexBaseUrl]); + // 同步本地存储的通用配置片段 useEffect(() => { if (typeof window === "undefined") return; @@ -543,13 +605,31 @@ const ProviderForm: React.FC = ({ } } - onSubmit({ + // 构造基础提交数据 + const basePayload: Omit = { name: formData.name, websiteUrl: formData.websiteUrl, settingsConfig, // 仅在用户选择了预设或手动选择“自定义”时持久化分类 ...(category ? { category } : {}), - }); + }; + + // 若为“新建供应商”,且已在弹窗中添加了自定义端点,则随提交一并落盘 + if (!initialData && draftCustomEndpoints.length > 0) { + const now = Date.now(); + const customMap: Record = {}; + for (const raw of draftCustomEndpoints) { + const url = raw.trim().replace(/\/+$/, ""); + if (!url) continue; + if (!customMap[url]) { + customMap[url] = { url, addedAt: now }; + } + } + onSubmit({ ...basePayload, meta: { custom_endpoints: customMap } }); + return; + } + + onSubmit(basePayload); }; const handleChange = ( @@ -692,7 +772,7 @@ const ProviderForm: React.FC = ({ ...config, editorValue: config.editorValue ? config.editorValue - : config.defaultValue ?? "", + : (config.defaultValue ?? ""), }, ]) ); @@ -721,7 +801,6 @@ const ProviderForm: React.FC = ({ // 清空 API Key 输入框,让用户重新输入 setApiKey(""); - setBaseUrl(""); // 清空基础 URL // 同步通用配置状态 const hasCommon = hasCommonConfigSnippet(configString, commonConfigSnippet); @@ -734,6 +813,11 @@ const ProviderForm: React.FC = ({ if (config.env) { setClaudeModel(config.env.ANTHROPIC_MODEL || ""); setClaudeSmallFastModel(config.env.ANTHROPIC_SMALL_FAST_MODEL || ""); + const presetBaseUrl = + typeof config.env.ANTHROPIC_BASE_URL === "string" + ? config.env.ANTHROPIC_BASE_URL + : ""; + setBaseUrl(presetBaseUrl); // 如果是 Kimi 预设,同步 Kimi 模型选择 if (preset.name?.includes("Kimi")) { @@ -745,6 +829,7 @@ const ProviderForm: React.FC = ({ } else { setClaudeModel(""); setClaudeSmallFastModel(""); + setBaseUrl(""); } } }; @@ -791,6 +876,10 @@ const ProviderForm: React.FC = ({ const authString = JSON.stringify(preset.auth || {}, null, 2); setCodexAuth(authString); setCodexConfig(preset.config || ""); + const presetBaseUrl = extractCodexBaseUrl(preset.config); + if (presetBaseUrl) { + setCodexBaseUrl(presetBaseUrl); + } setFormData((prev) => ({ ...prev, @@ -828,6 +917,7 @@ const ProviderForm: React.FC = ({ setCodexAuth(JSON.stringify(customAuth, null, 2)); setCodexConfig(customConfig); setCodexApiKey(""); + setCodexBaseUrl("https://your-api-endpoint.com/v1"); setCategory("custom"); }; @@ -851,21 +941,42 @@ const ProviderForm: React.FC = ({ // 处理基础 URL 变化 const handleBaseUrlChange = (url: string) => { - setBaseUrl(url); + const sanitized = url.trim().replace(/\/+$/, ""); + setBaseUrl(sanitized); + isUpdatingBaseUrlRef.current = true; try { const config = JSON.parse(formData.settingsConfig || "{}"); if (!config.env) { config.env = {}; } - config.env.ANTHROPIC_BASE_URL = url.trim(); + config.env.ANTHROPIC_BASE_URL = sanitized; updateSettingsConfigValue(JSON.stringify(config, null, 2)); } catch { // ignore + } finally { + setTimeout(() => { + isUpdatingBaseUrlRef.current = false; + }, 0); } }; + const handleCodexBaseUrlChange = (url: string) => { + const sanitized = url.trim().replace(/\/+$/, ""); + setCodexBaseUrl(sanitized); + + if (!sanitized) { + return; + } + + isUpdatingCodexBaseUrlRef.current = true; + setCodexConfig((prev) => setCodexBaseUrlInConfig(prev, sanitized)); + setTimeout(() => { + isUpdatingCodexBaseUrlRef.current = false; + }, 0); + }; + // Codex: 处理 API Key 输入并写回 auth.json const handleCodexApiKeyChange = (key: string) => { setCodexApiKey(key); @@ -971,6 +1082,12 @@ const ProviderForm: React.FC = ({ setUseCodexCommonConfig(hasCommon); } setCodexConfig(value); + if (!isUpdatingCodexBaseUrlRef.current) { + const extracted = extractCodexBaseUrl(value) || ""; + if (extracted !== codexBaseUrl) { + setCodexBaseUrl(extracted); + } + } }; // 根据当前配置决定是否展示 API Key 输入框 @@ -979,6 +1096,10 @@ const ProviderForm: React.FC = ({ selectedPreset !== null || (!showPresets && hasApiKeyField(formData.settingsConfig)); + const normalizedCategory = category ?? initialData?.category; + const shouldShowSpeedTest = + normalizedCategory === "third_party" || normalizedCategory === "custom"; + const selectedTemplatePreset = !isCodex && selectedPreset !== null && @@ -989,9 +1110,9 @@ const ProviderForm: React.FC = ({ const templateValueEntries: Array<[string, TemplateValueConfig]> = selectedTemplatePreset?.templateValues - ? (Object.entries( - selectedTemplatePreset.templateValues - ) as Array<[string, TemplateValueConfig]>) + ? (Object.entries(selectedTemplatePreset.templateValues) as Array< + [string, TemplateValueConfig] + >) : []; // 判断当前选中的预设是否是官方 @@ -1019,8 +1140,88 @@ const ProviderForm: React.FC = ({ // 综合判断是否应该显示 Kimi 模型选择器 const shouldShowKimiSelector = isKimiPreset || isEditingKimi; - // 判断是否显示基础 URL 输入框(仅自定义模式显示) - const showBaseUrlInput = selectedPreset === -1 && !isCodex; + const claudeSpeedTestEndpoints = useMemo(() => { + if (isCodex) return []; + const map = new Map(); + const add = (url?: string) => { + if (!url) return; + const sanitized = url.trim().replace(/\/+$/, ""); + if (!sanitized || map.has(sanitized)) return; + map.set(sanitized, { url: sanitized }); + }; + + if (baseUrl) { + add(baseUrl); + } + + if (initialData && typeof initialData.settingsConfig === "object") { + const envUrl = (initialData.settingsConfig as any)?.env + ?.ANTHROPIC_BASE_URL; + if (typeof envUrl === "string") { + add(envUrl); + } + } + + if ( + selectedPreset !== null && + selectedPreset >= 0 && + selectedPreset < providerPresets.length + ) { + const preset = providerPresets[selectedPreset]; + const presetEnv = (preset.settingsConfig as any)?.env?.ANTHROPIC_BASE_URL; + if (typeof presetEnv === "string") { + add(presetEnv); + } + // 合并预设内置的请求地址候选 + if (Array.isArray((preset as any).endpointCandidates)) { + ((preset as any).endpointCandidates as string[]).forEach((u) => add(u)); + } + } + + return Array.from(map.values()); + }, [isCodex, baseUrl, initialData, selectedPreset]); + + const codexSpeedTestEndpoints = useMemo(() => { + if (!isCodex) return []; + const map = new Map(); + const add = (url?: string) => { + if (!url) return; + const sanitized = url.trim().replace(/\/+$/, ""); + if (!sanitized || map.has(sanitized)) return; + map.set(sanitized, { url: sanitized }); + }; + + if (codexBaseUrl) { + add(codexBaseUrl); + } + + const initialCodexConfig = + initialData && typeof initialData.settingsConfig?.config === "string" + ? (initialData.settingsConfig as any).config + : ""; + const existing = extractCodexBaseUrl(initialCodexConfig); + if (existing) { + add(existing); + } + + if ( + selectedCodexPreset !== null && + selectedCodexPreset >= 0 && + selectedCodexPreset < codexProviderPresets.length + ) { + const preset = codexProviderPresets[selectedCodexPreset]; + const presetBase = extractCodexBaseUrl(preset?.config || ""); + if (presetBase) { + add(presetBase); + } + // 合并预设内置的请求地址候选 + if (Array.isArray((preset as any)?.endpointCandidates)) { + ((preset as any).endpointCandidates as string[]).forEach((u) => add(u)); + } + } + + return Array.from(map.values()); + }, [isCodex, codexBaseUrl, initialData, selectedCodexPreset]); // 判断是否显示"获取 API Key"链接(国产官方、聚合站和第三方显示) const shouldShowApiKeyLink = @@ -1168,13 +1369,26 @@ const ProviderForm: React.FC = ({ useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { + // 若有子弹窗(端点测速/模板向导)处于打开状态,则交由子弹窗自身处理,避免级联关闭 + if ( + isEndpointModalOpen || + isCodexEndpointModalOpen || + isCodexTemplateModalOpen + ) { + return; + } e.preventDefault(); onClose(); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [onClose]); + }, [ + onClose, + isEndpointModalOpen, + isCodexEndpointModalOpen, + isCodexTemplateModalOpen, + ]); return (
= ({
)} - {!isCodex && selectedTemplatePreset && templateValueEntries.length > 0 && ( -
-

- 参数配置 - {selectedTemplatePreset.name.trim()} * -

-
- {templateValueEntries.map(([key, config]) => ( -
- - { - const newValue = e.target.value; - setTemplateValues((prev) => { - const prevEntry = prev[key]; - const nextEntry: TemplateValueConfig = { - ...config, - ...(prevEntry ?? {}), - editorValue: newValue, - }; - const nextValues: TemplateValueMap = { - ...prev, - [key]: nextEntry, - }; + {!isCodex && + selectedTemplatePreset && + templateValueEntries.length > 0 && ( +
+

+ 参数配置 - {selectedTemplatePreset.name.trim()} * +

+
+ {templateValueEntries.map(([key, config]) => ( +
+ + { + const newValue = e.target.value; + setTemplateValues((prev) => { + const prevEntry = prev[key]; + const nextEntry: TemplateValueConfig = { + ...config, + ...(prevEntry ?? {}), + editorValue: newValue, + }; + const nextValues: TemplateValueMap = { + ...prev, + [key]: nextEntry, + }; - if (selectedTemplatePreset) { - try { - const configString = applyTemplateValuesToConfigString( - selectedTemplatePreset.settingsConfig, - formData.settingsConfig, - nextValues - ); - setFormData((prevForm) => ({ - ...prevForm, - settingsConfig: configString, - })); - setSettingsConfigError( - validateSettingsConfig(configString) - ); - } catch (err) { - console.error("更新模板值失败:", err); + if (selectedTemplatePreset) { + try { + const configString = + applyTemplateValuesToConfigString( + selectedTemplatePreset.settingsConfig, + formData.settingsConfig, + nextValues + ); + setFormData((prevForm) => ({ + ...prevForm, + settingsConfig: configString, + })); + setSettingsConfigError( + validateSettingsConfig(configString) + ); + } catch (err) { + console.error("更新模板值失败:", err); + } } - } - return nextValues; - }); - }} - aria-label={config.label} - autoComplete="off" - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - /> -
- ))} + return nextValues; + }); + }} + aria-label={config.label} + autoComplete="off" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ ))} +
-
- )} + )} - {/* 基础 URL 输入框 - 仅在自定义模式下显示 */} - {!isCodex && showBaseUrlInput && ( + {!isCodex && shouldShowSpeedTest && (
- +
+ + +
= ({
)} + {/* 端点测速弹窗 - Claude */} + {!isCodex && shouldShowSpeedTest && isEndpointModalOpen && ( + setIsEndpointModalOpen(false)} + onCustomEndpointsChange={setDraftCustomEndpoints} + /> + )} + {!isCodex && shouldShowKimiSelector && ( = ({
)} + {isCodex && shouldShowSpeedTest && ( +
+
+ + +
+ handleCodexBaseUrlChange(e.target.value)} + placeholder="https://your-api-endpoint.com/v1" + autoComplete="off" + className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" + /> +
+ )} + + {/* 端点测速弹窗 - Codex */} + {isCodex && shouldShowSpeedTest && isCodexEndpointModalOpen && ( + setIsCodexEndpointModalOpen(false)} + onCustomEndpointsChange={setDraftCustomEndpoints} + /> + )} + {/* Claude 或 Codex 的配置部分 */} {isCodex ? ( void; + initialEndpoints: EndpointCandidate[]; + visible?: boolean; + onClose: () => void; + // 当自定义端点列表变化时回传(仅包含 isCustom 的条目) + onCustomEndpointsChange?: (urls: string[]) => void; +} + +interface EndpointEntry extends EndpointCandidate { + id: string; + latency: number | null; + status?: number; + error?: string | null; +} + +const randomId = () => `ep_${Math.random().toString(36).slice(2, 9)}`; + +const normalizeEndpointUrl = (url: string): string => + url.trim().replace(/\/+$/, ""); + +const buildInitialEntries = ( + candidates: EndpointCandidate[], + selected: string, +): EndpointEntry[] => { + const map = new Map(); + const addCandidate = (candidate: EndpointCandidate) => { + const sanitized = candidate.url ? normalizeEndpointUrl(candidate.url) : ""; + if (!sanitized) return; + if (map.has(sanitized)) return; + + map.set(sanitized, { + id: candidate.id ?? randomId(), + url: sanitized, + isCustom: candidate.isCustom ?? false, + latency: null, + status: undefined, + error: null, + }); + }; + + candidates.forEach(addCandidate); + + const selectedUrl = normalizeEndpointUrl(selected); + if (selectedUrl && !map.has(selectedUrl)) { + addCandidate({ url: selectedUrl, isCustom: true }); + } + + return Array.from(map.values()); +}; + +const EndpointSpeedTest: React.FC = ({ + appType, + providerId, + value, + onChange, + initialEndpoints, + visible = true, + onClose, + onCustomEndpointsChange, +}) => { + const [entries, setEntries] = useState(() => + buildInitialEntries(initialEndpoints, value), + ); + const [customUrl, setCustomUrl] = useState(""); + const [addError, setAddError] = useState(null); + const [autoSelect, setAutoSelect] = useState(true); + const [isTesting, setIsTesting] = useState(false); + const [lastError, setLastError] = useState(null); + + const normalizedSelected = normalizeEndpointUrl(value); + + const hasEndpoints = entries.length > 0; + + // 加载保存的自定义端点(按正在编辑的供应商) + useEffect(() => { + const loadCustomEndpoints = async () => { + try { + if (!providerId) return; + const customEndpoints = await window.api.getCustomEndpoints( + appType, + providerId, + ); + const candidates: EndpointCandidate[] = customEndpoints.map((ep) => ({ + url: ep.url, + isCustom: true, + })); + + setEntries((prev) => { + const map = new Map(); + + // 先添加现有端点 + prev.forEach((entry) => { + map.set(entry.url, entry); + }); + + // 合并自定义端点 + candidates.forEach((candidate) => { + const sanitized = normalizeEndpointUrl(candidate.url); + if (sanitized && !map.has(sanitized)) { + map.set(sanitized, { + id: randomId(), + url: sanitized, + isCustom: true, + latency: null, + status: undefined, + error: null, + }); + } + }); + + return Array.from(map.values()); + }); + } catch (error) { + console.error("加载自定义端点失败:", error); + } + }; + + if (visible) { + loadCustomEndpoints(); + } + }, [appType, visible, providerId]); + + useEffect(() => { + setEntries((prev) => { + const map = new Map(); + prev.forEach((entry) => { + map.set(entry.url, entry); + }); + + let changed = false; + + const mergeCandidate = (candidate: EndpointCandidate) => { + const sanitized = candidate.url + ? normalizeEndpointUrl(candidate.url) + : ""; + if (!sanitized) return; + const existing = map.get(sanitized); + if (existing) return; + + map.set(sanitized, { + id: candidate.id ?? randomId(), + url: sanitized, + isCustom: candidate.isCustom ?? false, + latency: null, + status: undefined, + error: null, + }); + changed = true; + }; + + initialEndpoints.forEach(mergeCandidate); + + if (normalizedSelected && !map.has(normalizedSelected)) { + mergeCandidate({ url: normalizedSelected, isCustom: true }); + } + + if (!changed) { + return prev; + } + + return Array.from(map.values()); + }); + }, [initialEndpoints, normalizedSelected]); + + // 将自定义端点变化透传给父组件(仅限 isCustom) + useEffect(() => { + if (!onCustomEndpointsChange) return; + try { + const customUrls = Array.from( + new Set( + entries + .filter((e) => e.isCustom) + .map((e) => (e.url ? normalizeEndpointUrl(e.url) : "")) + .filter(Boolean), + ), + ); + onCustomEndpointsChange(customUrls); + } catch (err) { + // ignore + } + // 仅在 entries 变化时同步 + }, [entries, onCustomEndpointsChange]); + + const sortedEntries = useMemo(() => { + return entries.slice().sort((a, b) => { + const aLatency = a.latency ?? Number.POSITIVE_INFINITY; + const bLatency = b.latency ?? Number.POSITIVE_INFINITY; + if (aLatency === bLatency) { + return a.url.localeCompare(b.url); + } + return aLatency - bLatency; + }); + }, [entries]); + + const handleAddEndpoint = useCallback( + async () => { + const candidate = customUrl.trim(); + let errorMsg: string | null = null; + + if (!candidate) { + errorMsg = "请输入有效的 URL"; + } + + let parsed: URL | null = null; + if (!errorMsg) { + try { + parsed = new URL(candidate); + } catch { + errorMsg = "URL 格式不正确"; + } + } + + if (!errorMsg && parsed && !parsed.protocol.startsWith("http")) { + errorMsg = "仅支持 HTTP/HTTPS"; + } + + let sanitized = ""; + if (!errorMsg && parsed) { + sanitized = normalizeEndpointUrl(parsed.toString()); + // 使用当前 entries 做去重校验,避免依赖可能过期的 addError + const isDuplicate = entries.some((entry) => entry.url === sanitized); + if (isDuplicate) { + errorMsg = "该地址已存在"; + } + } + + if (errorMsg) { + setAddError(errorMsg); + return; + } + + setAddError(null); + + // 保存到后端 + try { + if (providerId) { + await window.api.addCustomEndpoint(appType, providerId, sanitized); + } + + // 更新本地状态 + setEntries((prev) => { + if (prev.some((e) => e.url === sanitized)) return prev; + return [ + ...prev, + { + id: randomId(), + url: sanitized, + isCustom: true, + latency: null, + status: undefined, + error: null, + }, + ]; + }); + + if (!normalizedSelected) { + onChange(sanitized); + } + + setCustomUrl(""); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + setAddError(message || "保存失败,请重试"); + console.error("添加自定义端点失败:", error); + } + }, + [customUrl, entries, normalizedSelected, onChange, appType, providerId], + ); + + const handleRemoveEndpoint = useCallback( + async (entry: EndpointEntry) => { + // 如果是自定义端点,尝试从后端删除(无 providerId 则仅本地删除) + if (entry.isCustom && providerId) { + try { + await window.api.removeCustomEndpoint(appType, providerId, entry.url); + } catch (error) { + console.error("删除自定义端点失败:", error); + return; + } + } + + // 更新本地状态 + setEntries((prev) => { + const next = prev.filter((item) => item.id !== entry.id); + if (entry.url === normalizedSelected) { + const fallback = next[0]; + onChange(fallback ? fallback.url : ""); + } + return next; + }); + }, + [normalizedSelected, onChange, appType, providerId], + ); + + const runSpeedTest = useCallback(async () => { + const urls = entries.map((entry) => entry.url); + if (urls.length === 0) { + setLastError("请先添加端点"); + return; + } + + if (typeof window === "undefined" || !window.api?.testApiEndpoints) { + setLastError("测速功能不可用"); + return; + } + + setIsTesting(true); + setLastError(null); + + try { + const results = await window.api.testApiEndpoints(urls, { + timeoutSecs: appType === "codex" ? 12 : 8, + }); + const resultMap = new Map( + results.map((item) => [normalizeEndpointUrl(item.url), item]), + ); + + setEntries((prev) => + prev.map((entry) => { + const match = resultMap.get(entry.url); + if (!match) { + return { + ...entry, + latency: null, + status: undefined, + error: "未返回结果", + }; + } + return { + ...entry, + latency: + typeof match.latency === "number" ? Math.round(match.latency) : null, + status: match.status, + error: match.error ?? null, + }; + }), + ); + + if (autoSelect) { + const successful = results + .filter((item) => typeof item.latency === "number" && item.latency !== null) + .sort((a, b) => (a.latency! || 0) - (b.latency! || 0)); + const best = successful[0]; + if (best && best.url && best.url !== normalizedSelected) { + onChange(best.url); + } + } + } catch (error) { + const message = + error instanceof Error ? error.message : `测速失败: ${String(error)}`; + setLastError(message); + } finally { + setIsTesting(false); + } + }, [entries, autoSelect, appType, normalizedSelected, onChange]); + + const handleSelect = useCallback( + async (url: string) => { + if (!url || url === normalizedSelected) return; + + // 更新最后使用时间(对自定义端点) + const entry = entries.find((e) => e.url === url); + if (entry?.isCustom && providerId) { + await window.api.updateEndpointLastUsed(appType, providerId, url); + } + + onChange(url); + }, + [normalizedSelected, onChange, appType, entries, providerId], + ); + + // 支持按下 ESC 关闭弹窗 + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + if (!visible) { + return null; + } + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > + {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+

+ 请求地址管理 +

+ +
+ + {/* Content */} +
+ {/* 测速控制栏 */} +
+
+ {entries.length} 个端点 +
+
+ + +
+
+ + {/* 添加输入 */} +
+
+ setCustomUrl(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleAddEndpoint(); + } + }} + className="flex-1 rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 transition focus:border-gray-400 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500 dark:focus:border-gray-600" + /> + +
+ {addError && ( +
+ + {addError} +
+ )} +
+ + {/* 端点列表 */} + {hasEndpoints ? ( +
+ {sortedEntries.map((entry) => { + const isSelected = normalizedSelected === entry.url; + const latency = entry.latency; + + return ( +
handleSelect(entry.url)} + className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${ + isSelected + ? "border-blue-500 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20" + : "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600 dark:hover:bg-gray-850" + }`} + > +
+ {/* 选择指示器 */} +
+ + {/* 内容 */} +
+
+ {entry.url} +
+
+
+ + {/* 右侧信息 */} +
+ {latency !== null ? ( +
+
+ {latency}ms +
+
+ ) : isTesting ? ( + + ) : entry.error ? ( +
失败
+ ) : ( +
+ )} + + +
+
+ ); + })} +
+ ) : ( +
+ 暂无端点 +
+ )} + + {/* 错误提示 */} + {lastError && ( +
+ + {lastError} +
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ); +}; + +export default EndpointSpeedTest; diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index b9ae042..fb633eb 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -13,6 +13,8 @@ export interface CodexProviderPreset { isOfficial?: boolean; // 标识是否为官方预设 category?: ProviderCategory; // 新增:分类 isCustomTemplate?: boolean; // 标识是否为自定义模板 + // 新增:请求地址候选列表(用于地址管理/测速) + endpointCandidates?: string[]; } /** @@ -71,5 +73,11 @@ export const codexProviderPresets: CodexProviderPreset[] = [ "https://codex-api.packycode.com/v1", "gpt-5-codex" ), + // Codex 请求地址候选(用于地址管理/测速) + endpointCandidates: [ + "https://codex-api.packycode.com/v1", + "https://codex-api-hk-cn2.packycode.com/v1", + "https://codex-api-hk-cdn.packycode.com/v1", + ], }, ]; diff --git a/src/config/providerPresets.ts b/src/config/providerPresets.ts index caf198a..99d4091 100644 --- a/src/config/providerPresets.ts +++ b/src/config/providerPresets.ts @@ -20,6 +20,8 @@ export interface ProviderPreset { category?: ProviderCategory; // 新增:分类 // 新增:模板变量定义,用于动态替换配置中的值 templateValues?: Record; // editorValue 存储编辑器中的实时输入值 + // 新增:请求地址候选列表(用于地址管理/测速) + endpointCandidates?: string[]; } export const providerPresets: ProviderPreset[] = [ @@ -52,6 +54,9 @@ export const providerPresets: ProviderPreset[] = [ env: { ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic", ANTHROPIC_AUTH_TOKEN: "", + // 兼容旧键名,保持前端读取一致 + ANTHROPIC_MODEL: "GLM-4.6", + ANTHROPIC_SMALL_FAST_MODEL: "glm-4.5-air", ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.5-air", ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.6", ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.6", @@ -109,6 +114,14 @@ export const providerPresets: ProviderPreset[] = [ ANTHROPIC_AUTH_TOKEN: "", }, }, + // 请求地址候选(用于地址管理/测速) + endpointCandidates: [ + "https://api.packycode.com", + "https://api-hk-cn2.packycode.com", + "https://api-hk-g.packycode.com", + "https://api-us-cn2.packycode.com", + "https://api-cf-pro.packycode.com", + ], category: "third_party", }, { diff --git a/src/lib/tauri-api.ts b/src/lib/tauri-api.ts index 7304ddb..b8ef4ed 100644 --- a/src/lib/tauri-api.ts +++ b/src/lib/tauri-api.ts @@ -1,6 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, UnlistenFn } from "@tauri-apps/api/event"; -import { Provider, Settings } from "../types"; +import { Provider, Settings, CustomEndpoint } from "../types"; // 应用类型 export type AppType = "claude" | "codex"; @@ -18,6 +18,13 @@ interface ImportResult { message?: string; } +export interface EndpointLatencyResult { + url: string; + latency: number | null; + status?: number; + error?: string; +} + // Tauri API 封装,提供统一的全局 API 接口 export const tauriAPI = { // 获取所有供应商 @@ -132,40 +139,22 @@ export const tauriAPI = { } }, - // 获取 Claude Code 配置状态 - getClaudeConfigStatus: async (): Promise => { - try { - return await invoke("get_claude_config_status"); - } catch (error) { - console.error("获取配置状态失败:", error); - return { - exists: false, - path: "", - error: String(error), - }; - } - }, - - // 获取应用配置状态(通用) - getConfigStatus: async (app?: AppType): Promise => { - try { - return await invoke("get_config_status", { app_type: app, app }); - } catch (error) { - console.error("获取配置状态失败:", error); - return { - exists: false, - path: "", - error: String(error), - }; - } - }, - - // 打开配置文件夹 + // 打开配置目录(按应用类型) openConfigFolder: async (app?: AppType): Promise => { try { await invoke("open_config_folder", { app_type: app, app }); } catch (error) { - console.error("打开配置文件夹失败:", error); + console.error("打开配置目录失败:", error); + } + }, + + // 选择配置目录(可选默认路径) + selectConfigDirectory: async (defaultPath?: string): Promise => { + try { + return await invoke("pick_directory", { defaultPath }); + } catch (error) { + console.error("选择配置目录失败:", error); + return null; } }, @@ -181,47 +170,20 @@ export const tauriAPI = { // 更新托盘菜单 updateTrayMenu: async (): Promise => { try { - return await invoke("update_tray_menu"); + return await invoke("update_tray_menu"); } catch (error) { console.error("更新托盘菜单失败:", error); return false; } }, - // 监听供应商切换事件 - onProviderSwitched: async ( - callback: (data: { appType: string; providerId: string }) => void, - ): Promise => { - return await listen("provider-switched", (event) => { - callback(event.payload as { appType: string; providerId: string }); - }); - }, - - // 选择配置目录 - selectConfigDirectory: async ( - defaultPath?: string, - ): Promise => { - try { - const sanitized = - defaultPath && defaultPath.trim() !== "" - ? defaultPath - : undefined; - return await invoke("pick_directory", { - defaultPath: sanitized, - }); - } catch (error) { - console.error("选择配置目录失败:", error); - return null; - } - }, - - // 获取设置 + // 获取应用设置 getSettings: async (): Promise => { try { return await invoke("get_settings"); } catch (error) { console.error("获取设置失败:", error); - return { showInTray: true, minimizeToTrayOnClose: true }; + throw error; } }, @@ -313,6 +275,112 @@ export const tauriAPI = { } }, + // ours: 第三方/自定义供应商——测速与端点管理 + // 第三方/自定义供应商:批量测试端点延迟 + testApiEndpoints: async ( + urls: string[], + options?: { timeoutSecs?: number }, + ): Promise => { + try { + return await invoke("test_api_endpoints", { + urls, + timeout_secs: options?.timeoutSecs, + }); + } catch (error) { + console.error("测速调用失败:", error); + throw error; + } + }, + + // 获取自定义端点列表 + getCustomEndpoints: async ( + appType: AppType, + providerId: string, + ): Promise => { + try { + return await invoke("get_custom_endpoints", { + // 兼容不同后端参数命名 + app_type: appType, + app: appType, + appType: appType, + provider_id: providerId, + providerId: providerId, + }); + } catch (error) { + console.error("获取自定义端点列表失败:", error); + return []; + } + }, + + // 添加自定义端点 + addCustomEndpoint: async ( + appType: AppType, + providerId: string, + url: string, + ): Promise => { + try { + await invoke("add_custom_endpoint", { + app_type: appType, + app: appType, + appType: appType, + provider_id: providerId, + providerId: providerId, + url, + }); + } catch (error) { + console.error("添加自定义端点失败:", error); + // 尽量抛出可读信息 + if (error instanceof Error) { + throw error; + } else { + throw new Error(String(error)); + } + } + }, + + // 删除自定义端点 + removeCustomEndpoint: async ( + appType: AppType, + providerId: string, + url: string, + ): Promise => { + try { + await invoke("remove_custom_endpoint", { + app_type: appType, + app: appType, + appType: appType, + provider_id: providerId, + providerId: providerId, + url, + }); + } catch (error) { + console.error("删除自定义端点失败:", error); + throw error; + } + }, + + // 更新端点最后使用时间 + updateEndpointLastUsed: async ( + appType: AppType, + providerId: string, + url: string, + ): Promise => { + try { + await invoke("update_endpoint_last_used", { + app_type: appType, + app: appType, + appType: appType, + provider_id: providerId, + providerId: providerId, + url, + }); + } catch (error) { + console.error("更新端点最后使用时间失败:", error); + // 不抛出错误,因为这不是关键操作 + } + }, + + // theirs: 导入导出与文件对话框 // 导出配置到文件 exportConfigToFile: async (filePath: string): Promise<{ success: boolean; @@ -360,6 +428,21 @@ export const tauriAPI = { return null; } }, + + // 监听供应商切换事件 + onProviderSwitched: async ( + callback: (data: { appType: string; providerId: string }) => void, + ): Promise => { + const unlisten = await listen("provider-switched", (event) => { + try { + // 事件 payload 形如 { appType: string, providerId: string } + callback(event.payload as any); + } catch (e) { + console.error("处理 provider-switched 事件失败: ", e); + } + }); + return unlisten; + }, }; // 创建全局 API 对象,兼容现有代码 diff --git a/src/types.ts b/src/types.ts index ccb14ee..3a4e0f1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,8 @@ export interface Provider { // 新增:供应商分类(用于差异化提示/能力开关) category?: ProviderCategory; createdAt?: number; // 添加时间戳(毫秒) + // 可选:供应商元数据(仅存于 ~/.cc-switch/config.json,不写入 live 配置) + meta?: ProviderMeta; } export interface AppConfig { @@ -20,6 +22,19 @@ export interface AppConfig { current: string; } +// 自定义端点配置 +export interface CustomEndpoint { + url: string; + addedAt: number; + lastUsed?: number; +} + +// 供应商元数据(字段名与后端一致,保持 snake_case) +export interface ProviderMeta { + // 自定义端点:以 URL 为键,值为端点信息 + custom_endpoints?: Record; +} + // 应用设置类型(用于 SettingsModal 与 Tauri API) export interface Settings { // 是否在系统托盘(macOS 菜单栏)显示图标 @@ -32,4 +47,8 @@ export interface Settings { codexConfigDir?: string; // 首选语言(可选,默认中文) language?: "en" | "zh"; + // Claude 自定义端点列表 + customEndpointsClaude?: Record; + // Codex 自定义端点列表 + customEndpointsCodex?: Record; } diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 04a8ce7..e62c5c6 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -365,3 +365,25 @@ export const getCodexBaseUrl = ( return undefined; } }; + +// 在 Codex 的 TOML 配置文本中写入或更新 base_url 字段 +export const setCodexBaseUrl = ( + configText: string, + baseUrl: string, +): string => { + const trimmed = baseUrl.trim(); + if (!trimmed) { + return configText; + } + + const normalizedUrl = trimmed.replace(/\s+/g, "").replace(/\/+$/, ""); + const replacementLine = `base_url = "${normalizedUrl}"`; + const pattern = /base_url\s*=\s*(["'])([^"']+)\1/; + + if (pattern.test(configText)) { + return configText.replace(pattern, replacementLine); + } + + const prefix = configText && !configText.endsWith("\n") ? `${configText}\n` : configText; + return `${prefix}${replacementLine}\n`; +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 4d6968b..44a0148 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,6 +1,6 @@ /// -import { Provider, Settings } from "./types"; +import { Provider, Settings, CustomEndpoint } from "./types"; import { AppType } from "./lib/tauri-api"; import type { UnlistenFn } from "@tauri-apps/api/event"; @@ -61,6 +61,35 @@ declare global { official: boolean; }) => Promise; isClaudePluginApplied: () => Promise; + testApiEndpoints: ( + urls: string[], + options?: { timeoutSecs?: number }, + ) => Promise>; + // 自定义端点管理 + getCustomEndpoints: ( + appType: AppType, + providerId: string + ) => Promise; + addCustomEndpoint: ( + appType: AppType, + providerId: string, + url: string + ) => Promise; + removeCustomEndpoint: ( + appType: AppType, + providerId: string, + url: string + ) => Promise; + updateEndpointLastUsed: ( + appType: AppType, + providerId: string, + url: string + ) => Promise; }; platform: { isMac: boolean;