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.
This commit is contained in:
Jason
2025-10-04 18:04:40 +08:00
parent e0908701b4
commit 4fc76200e8
10 changed files with 1014 additions and 135 deletions

286
src-tauri/Cargo.lock generated
View File

@@ -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",
@@ -566,9 +566,11 @@ name = "cc-switch"
version = "3.4.0"
dependencies = [
"dirs 5.0.1",
"futures",
"log",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"reqwest",
"serde",
"serde_json",
"tauri",
@@ -579,6 +581,7 @@ dependencies = [
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tokio",
"toml 0.8.2",
]
@@ -825,12 +828,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]]
@@ -906,7 +909,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -1093,7 +1096,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]]
@@ -1165,9 +1168,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"
@@ -1237,6 +1240,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"
@@ -1244,6 +1262,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -1311,6 +1330,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",
@@ -1480,9 +1500,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"
@@ -1797,7 +1817,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.0",
"windows-core 0.62.1",
]
[[package]]
@@ -2063,9 +2083,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",
@@ -2148,9 +2168,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"
@@ -2197,11 +2217,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",
]
@@ -2259,9 +2278,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"
@@ -2322,7 +2341,7 @@ dependencies = [
"once_cell",
"png",
"serde",
"thiserror 2.0.16",
"thiserror 2.0.17",
"windows-sys 0.60.2",
]
@@ -2718,9 +2737,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",
]
@@ -2770,7 +2789,7 @@ dependencies = [
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.16",
"thiserror 2.0.17",
]
[[package]]
@@ -2806,9 +2825,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",
@@ -2816,15 +2835,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]]
@@ -3039,7 +3058,7 @@ dependencies = [
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -3192,7 +3211,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.16",
"thiserror 2.0.17",
"tokio",
"tracing",
"web-time",
@@ -3213,7 +3232,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.16",
"thiserror 2.0.17",
"tinyvec",
"tracing",
"web-time",
@@ -3235,9 +3254,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",
]
@@ -3398,23 +3417,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",
@@ -3423,9 +3442,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",
@@ -3435,9 +3454,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",
@@ -3615,7 +3634,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -3644,9 +3663,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",
@@ -3773,9 +3792,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",
@@ -3795,18 +3814,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",
@@ -3880,9 +3899,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",
@@ -3891,8 +3910,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",
@@ -3900,9 +3918,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",
@@ -4292,7 +4310,7 @@ dependencies = [
"tauri-runtime",
"tauri-runtime-wry",
"tauri-utils",
"thiserror 2.0.16",
"thiserror 2.0.17",
"tokio",
"tray-icon",
"url",
@@ -4345,7 +4363,7 @@ dependencies = [
"sha2",
"syn 2.0.106",
"tauri-utils",
"thiserror 2.0.16",
"thiserror 2.0.17",
"time",
"url",
"uuid",
@@ -4397,7 +4415,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.16",
"thiserror 2.0.17",
"url",
]
@@ -4418,7 +4436,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.16",
"thiserror 2.0.17",
"toml 0.9.7",
"url",
]
@@ -4441,7 +4459,7 @@ dependencies = [
"swift-rs",
"tauri",
"tauri-plugin",
"thiserror 2.0.16",
"thiserror 2.0.17",
"time",
]
@@ -4461,7 +4479,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.16",
"thiserror 2.0.17",
"url",
"windows",
"zbus",
@@ -4486,7 +4504,7 @@ dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.16",
"thiserror 2.0.17",
"tracing",
"windows-sys 0.60.2",
"zbus",
@@ -4516,7 +4534,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.16",
"thiserror 2.0.17",
"time",
"tokio",
"url",
@@ -4542,7 +4560,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
"thiserror 2.0.16",
"thiserror 2.0.17",
"url",
"webkit2gtk",
"webview2-com",
@@ -4606,7 +4624,7 @@ dependencies = [
"serde_json",
"serde_with",
"swift-rs",
"thiserror 2.0.16",
"thiserror 2.0.17",
"toml 0.9.7",
"url",
"urlpattern",
@@ -4626,15 +4644,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]]
@@ -4659,11 +4677,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]]
@@ -4679,9 +4697,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",
@@ -4761,15 +4779,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",
@@ -4978,7 +5008,7 @@ dependencies = [
"once_cell",
"png",
"serde",
"thiserror 2.0.16",
"thiserror 2.0.17",
"windows-sys 0.59.0",
]
@@ -4996,9 +5026,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"
@@ -5213,9 +5243,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",
@@ -5226,9 +5256,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",
@@ -5240,9 +5270,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",
@@ -5253,9 +5283,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",
@@ -5263,9 +5293,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",
@@ -5276,9 +5306,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",
]
@@ -5358,9 +5388,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",
@@ -5460,7 +5490,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",
]
@@ -5487,7 +5517,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]]
@@ -5548,9 +5578,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",
@@ -5572,9 +5602,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",
@@ -5583,9 +5613,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",
@@ -5692,14 +5722,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",
]
@@ -5752,11 +5782,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",
@@ -5778,9 +5808,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",
]
@@ -6039,7 +6069,7 @@ dependencies = [
"sha2",
"soup3",
"tao-macros",
"thiserror 2.0.16",
"thiserror 2.0.17",
"url",
"webkit2gtk",
"webkit2gtk-sys",
@@ -6218,9 +6248,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"

View File

@@ -29,6 +29,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"

View File

@@ -10,6 +10,7 @@ use crate::claude_plugin;
use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::provider::Provider;
use crate::speedtest;
use crate::store::AppState;
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
@@ -725,3 +726,16 @@ 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
}

View File

@@ -7,6 +7,7 @@ mod migration;
mod provider;
mod settings;
mod store;
mod speedtest;
use store::AppState;
use tauri::{
@@ -419,6 +420,7 @@ pub fn run() {
commands::read_claude_plugin_config,
commands::apply_claude_plugin_config,
commands::is_claude_plugin_applied,
commands::test_api_endpoints,
update_tray_menu,
]);

102
src-tauri/src/speedtest.rs Normal file
View 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)
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Provider, ProviderCategory } from "../types";
import { AppType } from "../lib/tauri-api";
import {
@@ -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";
@@ -26,6 +28,9 @@ import CodexConfigEditor from "./ProviderForm/CodexConfigEditor";
import KimiModelSelector from "./ProviderForm/KimiModelSelector";
import { X, AlertCircle, Save } from "lucide-react";
import { isLinux } from "../lib/platform";
import EndpointSpeedTest, {
EndpointCandidate,
} from "./ProviderForm/EndpointSpeedTest";
// 分类仅用于控制少量交互(如官方禁用 API Key不显示介绍组件
type TemplateValueMap = Record<string, TemplateValueConfig>;
@@ -211,6 +216,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const [codexAuth, setCodexAuthState] = useState("");
const [codexConfig, setCodexConfigState] = useState("");
const [codexApiKey, setCodexApiKey] = useState("");
const [codexBaseUrl, setCodexBaseUrl] = useState("");
const [isCodexTemplateModalOpen, setIsCodexTemplateModalOpen] =
useState(false);
// -1 表示自定义null 表示未选择,>= 0 表示预设索引
@@ -223,8 +229,10 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
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 +246,10 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
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 +304,8 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
});
const [codexCommonConfigError, setCodexCommonConfigError] = useState("");
const isUpdatingFromCodexCommonConfig = useRef(false);
const isUpdatingBaseUrlRef = useRef(false);
const isUpdatingCodexBaseUrlRef = useRef(false);
// -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedPreset, setSelectedPreset] = useState<number | null>(
@@ -436,6 +450,49 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}
}, [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;
@@ -721,7 +778,6 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 清空 API Key 输入框,让用户重新输入
setApiKey("");
setBaseUrl(""); // 清空基础 URL
// 同步通用配置状态
const hasCommon = hasCommonConfigSnippet(configString, commonConfigSnippet);
@@ -734,6 +790,11 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
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 +806,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
} else {
setClaudeModel("");
setClaudeSmallFastModel("");
setBaseUrl("");
}
}
};
@@ -791,6 +853,10 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
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 +894,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setCodexAuth(JSON.stringify(customAuth, null, 2));
setCodexConfig(customConfig);
setCodexApiKey("");
setCodexBaseUrl("https://your-api-endpoint.com/v1");
setCategory("custom");
};
@@ -851,21 +918,42 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 处理基础 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 +1059,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setUseCodexCommonConfig(hasCommon);
}
setCodexConfig(value);
if (!isUpdatingCodexBaseUrlRef.current) {
const extracted = extractCodexBaseUrl(value) || "";
if (extracted !== codexBaseUrl) {
setCodexBaseUrl(extracted);
}
}
};
// 根据当前配置决定是否展示 API Key 输入框
@@ -979,6 +1073,10 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
selectedPreset !== null ||
(!showPresets && hasApiKeyField(formData.settingsConfig));
const normalizedCategory = category ?? initialData?.category;
const shouldShowSpeedTest =
normalizedCategory === "third_party" || normalizedCategory === "custom";
const selectedTemplatePreset =
!isCodex &&
selectedPreset !== null &&
@@ -1020,7 +1118,87 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const shouldShowKimiSelector = isKimiPreset || isEditingKimi;
// 判断是否显示基础 URL 输入框(仅自定义模式显示)
const showBaseUrlInput = selectedPreset === -1 && !isCodex;
const showBaseUrlInput =
!isCodex && shouldShowSpeedTest;
const claudeSpeedTestEndpoints = useMemo<EndpointCandidate[]>(() => {
if (isCodex) return [];
const map = new Map<string, EndpointCandidate>();
const add = (url?: string, label?: string) => {
if (!url) return;
const sanitized = url.trim().replace(/\/+$/, "");
if (!sanitized || map.has(sanitized)) return;
map.set(sanitized, { url: sanitized, label });
};
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, envUrl === baseUrl ? "当前地址" : "历史地址");
}
}
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, presetEnv === baseUrl ? "当前地址" : "预设地址");
}
}
return Array.from(map.values());
}, [isCodex, baseUrl, initialData, selectedPreset]);
const codexSpeedTestEndpoints = useMemo<EndpointCandidate[]>(() => {
if (!isCodex) return [];
const map = new Map<string, EndpointCandidate>();
const add = (url?: string, label?: string) => {
if (!url) return;
const sanitized = url.trim().replace(/\/+$/, "");
if (!sanitized || map.has(sanitized)) return;
map.set(sanitized, { url: sanitized, label });
};
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, existing === codexBaseUrl ? "当前地址" : "历史地址");
}
if (
selectedCodexPreset !== null &&
selectedCodexPreset >= 0 &&
selectedCodexPreset < codexProviderPresets.length
) {
const presetConfig = codexProviderPresets[selectedCodexPreset]?.config;
const presetBase = extractCodexBaseUrl(presetConfig);
if (presetBase) {
add(presetBase, presetBase === codexBaseUrl ? "当前地址" : "预设地址");
}
}
return Array.from(map.values());
}, [
isCodex,
codexBaseUrl,
initialData,
selectedCodexPreset,
]);
// 判断是否显示"获取 API Key"链接(国产官方、聚合站和第三方显示)
const shouldShowApiKeyLink =
@@ -1392,6 +1570,15 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
</div>
)}
{!isCodex && shouldShowSpeedTest && (
<EndpointSpeedTest
appType={appType}
value={baseUrl}
onChange={handleBaseUrlChange}
initialEndpoints={claudeSpeedTestEndpoints}
/>
)}
{/* 基础 URL 输入框 - 仅在自定义模式下显示 */}
{!isCodex && showBaseUrlInput && (
<div className="space-y-2">
@@ -1462,6 +1649,15 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
</div>
)}
{isCodex && shouldShowSpeedTest && (
<EndpointSpeedTest
appType={appType}
value={codexBaseUrl}
onChange={handleCodexBaseUrlChange}
initialEndpoints={codexSpeedTestEndpoints}
/>
)}
{/* Claude 或 Codex 的配置部分 */}
{isCodex ? (
<CodexConfigEditor

View File

@@ -0,0 +1,478 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Zap, Loader2, Plus, Trash2, AlertCircle, Check } from "lucide-react";
import type { AppType } from "../../lib/tauri-api";
export interface EndpointCandidate {
id?: string;
url: string;
label?: string;
isCustom?: boolean;
}
interface EndpointSpeedTestProps {
appType: AppType;
value: string;
onChange: (url: string) => void;
initialEndpoints: EndpointCandidate[];
visible?: boolean;
}
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<string, EndpointEntry>();
const addCandidate = (candidate: EndpointCandidate) => {
const sanitized = candidate.url ? normalizeEndpointUrl(candidate.url) : "";
if (!sanitized) return;
if (map.has(sanitized)) {
const existing = map.get(sanitized)!;
if (candidate.label && candidate.label !== existing.label) {
map.set(sanitized, { ...existing, label: candidate.label });
}
return;
}
const index = map.size;
const label =
candidate.label ??
(candidate.isCustom
? `自定义 ${index + 1}`
: index === 0
? "默认地址"
: `候选 ${index + 1}`);
map.set(sanitized, {
id: candidate.id ?? randomId(),
url: sanitized,
label,
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, label: "当前地址", isCustom: true });
}
return Array.from(map.values());
};
const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
appType,
value,
onChange,
initialEndpoints,
visible = true,
}) => {
const [entries, setEntries] = useState<EndpointEntry[]>(() =>
buildInitialEntries(initialEndpoints, value),
);
const [customUrl, setCustomUrl] = useState("");
const [addError, setAddError] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [isTesting, setIsTesting] = useState(false);
const [lastError, setLastError] = useState<string | null>(null);
const normalizedSelected = normalizeEndpointUrl(value);
const hasEndpoints = entries.length > 0;
useEffect(() => {
setEntries((prev) => {
const map = new Map<string, EndpointEntry>();
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) {
if (candidate.label && candidate.label !== existing.label) {
map.set(sanitized, { ...existing, label: candidate.label });
changed = true;
}
return;
}
const index = map.size;
const label =
candidate.label ??
(candidate.isCustom
? `自定义 ${index + 1}`
: index === 0
? "默认地址"
: `候选 ${index + 1}`);
map.set(sanitized, {
id: candidate.id ?? randomId(),
url: sanitized,
label,
isCustom: candidate.isCustom ?? false,
latency: null,
status: undefined,
error: null,
});
changed = true;
};
initialEndpoints.forEach(mergeCandidate);
if (normalizedSelected) {
const existing = map.get(normalizedSelected);
if (existing) {
if (existing.label !== "当前地址") {
map.set(normalizedSelected, {
...existing,
label: existing.isCustom ? existing.label : "当前地址",
});
changed = true;
}
} else {
mergeCandidate({ url: normalizedSelected, label: "当前地址", isCustom: true });
}
}
if (!changed) {
return prev;
}
return Array.from(map.values());
});
}, [initialEndpoints, normalizedSelected]);
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(() => {
const candidate = customUrl.trim();
setAddError(null);
if (!candidate) {
setAddError("请输入有效的 URL");
return;
}
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
setAddError("URL 格式不正确,请确认包含 http(s) 前缀");
return;
}
if (!parsed.protocol.startsWith("http")) {
setAddError("仅支持 HTTP/HTTPS 地址");
return;
}
const sanitized = normalizeEndpointUrl(parsed.toString());
setEntries((prev) => {
if (prev.some((entry) => entry.url === sanitized)) {
setAddError("该地址已存在");
return prev;
}
const customCount = prev.filter((entry) => entry.isCustom).length;
return [
...prev,
{
id: randomId(),
url: sanitized,
label: `自定义 ${customCount + 1}`,
isCustom: true,
latency: null,
status: undefined,
error: null,
},
];
});
if (!normalizedSelected) {
onChange(sanitized);
}
setCustomUrl("");
}, [customUrl, normalizedSelected, onChange]);
const handleRemoveEndpoint = useCallback(
(entry: EndpointEntry) => {
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],
);
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(
(url: string) => {
if (!url || url === normalizedSelected) return;
onChange(url);
},
[normalizedSelected, onChange],
);
if (!visible) {
return null;
}
return (
<div className="mt-4 space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-200">
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
</p>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={autoSelect}
onChange={(event) => setAutoSelect(event.target.checked)}
className="rounded border-gray-300 text-blue-500 focus:ring-blue-400"
/>
</label>
<button
type="button"
onClick={runSpeedTest}
disabled={isTesting || entries.length === 0}
className="flex items-center gap-2 rounded-md bg-blue-500 px-3 py-1.5 text-sm text-white transition hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-60"
>
{isTesting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Zap className="h-4 w-4" />
</>
)}
</button>
</div>
</div>
{hasEndpoints ? (
<div className="space-y-2">
{sortedEntries.map((entry) => {
const isSelected = normalizedSelected === entry.url;
const latency = entry.latency;
const statusBadge =
latency !== null
? latency <= 100
? "text-green-600 dark:text-green-400"
: latency <= 300
? "text-amber-600 dark:text-amber-400"
: "text-red-600 dark:text-red-400"
: "text-gray-500 dark:text-gray-400";
return (
<div
key={entry.id}
className={`flex items-start justify-between gap-2 rounded-lg border px-3 py-2 text-sm transition ${
isSelected
? "border-green-400 bg-green-50 dark:border-green-500 dark:bg-green-900/30"
: "border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
}`}
>
<label className="flex flex-1 cursor-pointer items-start gap-2">
<input
type="radio"
name="endpoint-speedtest"
checked={isSelected}
onChange={() => handleSelect(entry.url)}
className="mt-1 h-4 w-4 border-gray-300 text-green-500 focus:ring-green-400"
/>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-800 dark:text-gray-100">
{entry.label || "候选节点"}
</span>
{isSelected && (
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<Check className="h-3 w-3" />
</span>
)}
</div>
<span className="break-all text-xs text-gray-500 dark:text-gray-400">
{entry.url}
</span>
</div>
</label>
<div className="flex items-center gap-3">
<div className="text-xs font-mono">
{latency !== null ? (
<span className={statusBadge}>{latency} ms</span>
) : isTesting ? (
<span className="text-gray-400"></span>
) : entry.error ? (
<span className="flex items-center gap-1 text-red-500">
<AlertCircle className="h-3 w-3" />
</span>
) : (
<span className="text-gray-400"></span>
)}
</div>
{entry.isCustom && (
<button
type="button"
onClick={() => handleRemoveEndpoint(entry)}
className="rounded-md p-1 text-red-500 transition hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/30"
title="删除该地址"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed border-gray-300 bg-white p-4 text-center text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
</div>
)}
<div>
<div className="flex gap-2">
<input
type="url"
value={customUrl}
placeholder="https://example.com/claude"
onChange={(event) => setCustomUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddEndpoint();
}
}}
className="flex-1 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400/20 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
/>
<button
type="button"
onClick={handleAddEndpoint}
className="flex items-center gap-1 rounded-md bg-green-500 px-3 py-1.5 text-sm text-white transition hover:bg-green-600"
>
<Plus className="h-4 w-4" />
</button>
</div>
{addError && (
<p className="mt-1 text-xs text-red-500">{addError}</p>
)}
</div>
{lastError && (
<p className="text-xs text-red-500">
<AlertCircle className="mr-1 inline h-3 w-3 align-middle" />
{lastError}
</p>
)}
</div>
);
};
export default EndpointSpeedTest;

View File

@@ -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 = {
// 获取所有供应商
@@ -312,6 +319,22 @@ export const tauriAPI = {
throw new Error(`检测 Claude 插件配置失败: ${String(error)}`);
}
},
// 第三方/自定义供应商:批量测试端点延迟
testApiEndpoints: async (
urls: string[],
options?: { timeoutSecs?: number },
): Promise<EndpointLatencyResult[]> => {
try {
return await invoke<EndpointLatencyResult[]>("test_api_endpoints", {
urls,
timeout_secs: options?.timeoutSecs,
});
} catch (error) {
console.error("测速调用失败:", error);
throw error;
}
},
};
// 创建全局 API 对象,兼容现有代码

View File

@@ -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`;
};

9
src/vite-env.d.ts vendored
View File

@@ -49,6 +49,15 @@ declare global {
official: boolean;
}) => Promise<boolean>;
isClaudePluginApplied: () => Promise<boolean>;
testApiEndpoints: (
urls: string[],
options?: { timeoutSecs?: number },
) => Promise<Array<{
url: string;
latency: number | null;
status?: number;
error?: string;
}>>;
};
platform: {
isMac: boolean;