Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1d137f072 | ||
|
|
6b172914be |
No files matched your search
Generated
+12
-2
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "kimiswitch",
|
"name": "kimiswitch",
|
||||||
"version": "0.3.0",
|
"version": "0.4.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "kimiswitch",
|
"name": "kimiswitch",
|
||||||
"version": "0.3.0",
|
"version": "0.4.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||||
|
"lucide-react": "^1.27.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
@@ -2173,6 +2174,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "1.27.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz",
|
||||||
|
"integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "kimiswitch",
|
"name": "kimiswitch",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||||
|
"lucide-react": "^1.27.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+1
-1
@@ -1958,7 +1958,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
description = "Kimi Switch - model config manager"
|
description = "Kimi Switch - model config manager"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
+177
-63
@@ -211,6 +211,55 @@ pub async fn list_provider_models(provider: Provider) -> Result<Vec<DiscoveredMo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test reachability of a provider's base URL (cc-switch semantics):
|
||||||
|
/// any HTTP response counts as reachable; only network-layer errors fail.
|
||||||
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ConnectivityResult {
|
||||||
|
pub ok: bool,
|
||||||
|
pub latency_ms: u64,
|
||||||
|
pub status_code: Option<u16>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn test_connectivity(provider: Provider) -> Result<ConnectivityResult, String> {
|
||||||
|
let base = resolve_base_url(&provider);
|
||||||
|
if base.trim().is_empty() {
|
||||||
|
return Ok(ConnectivityResult {
|
||||||
|
ok: false,
|
||||||
|
latency_ms: 0,
|
||||||
|
status_code: None,
|
||||||
|
error: Some("no base URL configured".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(8))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
match client.get(base.as_str()).send().await {
|
||||||
|
Ok(resp) => Ok(ConnectivityResult {
|
||||||
|
ok: true,
|
||||||
|
latency_ms: start.elapsed().as_millis() as u64,
|
||||||
|
status_code: Some(resp.status().as_u16()),
|
||||||
|
error: None,
|
||||||
|
}),
|
||||||
|
Err(e) => Ok(ConnectivityResult {
|
||||||
|
ok: false,
|
||||||
|
latency_ms: start.elapsed().as_millis() as u64,
|
||||||
|
status_code: None,
|
||||||
|
error: Some(if e.is_connect() {
|
||||||
|
"connection refused / DNS failed".to_string()
|
||||||
|
} else if e.is_timeout() {
|
||||||
|
"request timed out".to_string()
|
||||||
|
} else {
|
||||||
|
e.to_string()
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_api_key(provider: &Provider) -> Option<String> {
|
fn resolve_api_key(provider: &Provider) -> Option<String> {
|
||||||
if provider.managed {
|
if provider.managed {
|
||||||
return Some("managed".to_string());
|
return Some("managed".to_string());
|
||||||
@@ -248,21 +297,9 @@ fn expected_api_key_key(provider_type: &ProviderType) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── OpenAI-compatible /models endpoint ──────────────────────────────
|
// ── OpenAI-compatible /models endpoint (with pagination) ─────────────
|
||||||
|
|
||||||
async fn fetch_openai_models(base: &str, api_key: &str) -> Result<Vec<DiscoveredModel>, String> {
|
async fn fetch_openai_models(base: &str, api_key: &str) -> Result<Vec<DiscoveredModel>, String> {
|
||||||
let url = format!("{}/models", base.trim_end_matches('/'));
|
|
||||||
let resp = reqwest::Client::new()
|
|
||||||
.get(&url)
|
|
||||||
.bearer_auth(api_key)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!("{} returned HTTP {}", url, resp.status()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct OaiModel {
|
struct OaiModel {
|
||||||
id: String,
|
id: String,
|
||||||
@@ -270,16 +307,57 @@ async fn fetch_openai_models(base: &str, api_key: &str) -> Result<Vec<Discovered
|
|||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct OaiList {
|
struct OaiList {
|
||||||
data: Vec<OaiModel>,
|
data: Vec<OaiModel>,
|
||||||
|
#[serde(default)]
|
||||||
|
has_more: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
last_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
next_page_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: OaiList = resp
|
let client = reqwest::Client::new();
|
||||||
.json()
|
let root = base.trim_end_matches('/').to_string();
|
||||||
.await
|
let mut url = format!("{}/models", root);
|
||||||
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
|
let mut all: Vec<OaiModel> = Vec::new();
|
||||||
|
const MAX_PAGES: usize = 50;
|
||||||
|
|
||||||
Ok(body
|
for _ in 0..MAX_PAGES {
|
||||||
.data
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(api_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("{} returned HTTP {}", url, resp.status()));
|
||||||
|
}
|
||||||
|
let body: OaiList = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
|
||||||
|
all.extend(body.data);
|
||||||
|
|
||||||
|
// OpenAI cursor pagination: has_more + last_id → ?after=<last_id>
|
||||||
|
if body.has_more.unwrap_or(false) {
|
||||||
|
if let Some(last_id) = body.last_id.clone() {
|
||||||
|
url = format!("{}/models?after={}", root, last_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Token-based pagination: next_page_token → ?page_token=<token>
|
||||||
|
if let Some(token) = body.next_page_token.clone() {
|
||||||
|
if !token.is_empty() {
|
||||||
|
url = format!("{}/models?page_token={}", root, token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
Ok(all
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
.filter(|m| seen.insert(m.id.clone()))
|
||||||
.map(|m| DiscoveredModel {
|
.map(|m| DiscoveredModel {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
display_name: None,
|
display_name: None,
|
||||||
@@ -288,22 +366,9 @@ async fn fetch_openai_models(base: &str, api_key: &str) -> Result<Vec<Discovered
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Anthropic /v1/models endpoint ───────────────────────────────────
|
// ── Anthropic /v1/models endpoint (with pagination) ──────────────────
|
||||||
|
|
||||||
async fn fetch_anthropic_models(base: &str, api_key: &str) -> Result<Vec<DiscoveredModel>, String> {
|
async fn fetch_anthropic_models(base: &str, api_key: &str) -> Result<Vec<DiscoveredModel>, String> {
|
||||||
let url = format!("{}/v1/models", base.trim_end_matches('/'));
|
|
||||||
let resp = reqwest::Client::new()
|
|
||||||
.get(&url)
|
|
||||||
.header("x-api-key", api_key)
|
|
||||||
.header("anthropic-version", "2023-06-01")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!("{} returned HTTP {}", url, resp.status()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct AntModel {
|
struct AntModel {
|
||||||
id: String,
|
id: String,
|
||||||
@@ -312,16 +377,46 @@ async fn fetch_anthropic_models(base: &str, api_key: &str) -> Result<Vec<Discove
|
|||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct AntList {
|
struct AntList {
|
||||||
data: Vec<AntModel>,
|
data: Vec<AntModel>,
|
||||||
|
#[serde(default)]
|
||||||
|
has_more: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
last_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: AntList = resp
|
let client = reqwest::Client::new();
|
||||||
.json()
|
let root = base.trim_end_matches('/').to_string();
|
||||||
.await
|
let mut url = format!("{}/v1/models?limit=1000", root);
|
||||||
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
|
let mut all: Vec<AntModel> = Vec::new();
|
||||||
|
const MAX_PAGES: usize = 20;
|
||||||
|
|
||||||
Ok(body
|
for _ in 0..MAX_PAGES {
|
||||||
.data
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.header("x-api-key", api_key)
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("{} returned HTTP {}", url, resp.status()));
|
||||||
|
}
|
||||||
|
let body: AntList = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
|
||||||
|
let more = body.has_more.unwrap_or(false);
|
||||||
|
let cursor = body.last_id.clone();
|
||||||
|
all.extend(body.data);
|
||||||
|
if !(more && cursor.is_some()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
url = format!("{}/v1/models?limit=1000&after_id={}", root, cursor.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
Ok(all
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
.filter(|m| seen.insert(m.id.clone()))
|
||||||
.map(|m| DiscoveredModel {
|
.map(|m| DiscoveredModel {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
display_name: m.display_name,
|
display_name: m.display_name,
|
||||||
@@ -330,27 +425,12 @@ async fn fetch_anthropic_models(base: &str, api_key: &str) -> Result<Vec<Discove
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Google GenAI /v1beta/models endpoint ────────────────────────────
|
// ── Google GenAI /v1beta/models endpoint (with pagination) ───────────
|
||||||
|
|
||||||
async fn fetch_google_genai_models(
|
async fn fetch_google_genai_models(
|
||||||
base: &str,
|
base: &str,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
) -> Result<Vec<DiscoveredModel>, String> {
|
) -> Result<Vec<DiscoveredModel>, String> {
|
||||||
let url = format!(
|
|
||||||
"{}/v1beta/models?key={}",
|
|
||||||
base.trim_end_matches('/'),
|
|
||||||
api_key
|
|
||||||
);
|
|
||||||
let resp = reqwest::Client::new()
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!("{} returned HTTP {}", url, resp.status()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct GglModel {
|
struct GglModel {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -362,18 +442,52 @@ async fn fetch_google_genai_models(
|
|||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct GglList {
|
struct GglList {
|
||||||
models: Vec<GglModel>,
|
models: Vec<GglModel>,
|
||||||
|
#[serde(rename = "nextPageToken", default)]
|
||||||
|
next_page_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: GglList = resp
|
let client = reqwest::Client::new();
|
||||||
.json()
|
let root = base.trim_end_matches('/').to_string();
|
||||||
.await
|
let mut url = format!("{}/v1beta/models?key={}&pageSize=1000", root, api_key);
|
||||||
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
|
let mut all: Vec<GglModel> = Vec::new();
|
||||||
|
const MAX_PAGES: usize = 50;
|
||||||
|
|
||||||
Ok(body
|
for _ in 0..MAX_PAGES {
|
||||||
.models
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("{} returned HTTP {}", url, resp.status()));
|
||||||
|
}
|
||||||
|
let body: GglList = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
|
||||||
|
let cursor = body.next_page_token.clone();
|
||||||
|
all.extend(body.models);
|
||||||
|
match cursor {
|
||||||
|
Some(t) if !t.is_empty() => {
|
||||||
|
url = format!(
|
||||||
|
"{}/v1beta/models?key={}&pageSize=1000&pageToken={}",
|
||||||
|
root, api_key, t
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
Ok(all
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
.filter(|m| seen.insert(m.name.clone()))
|
||||||
.map(|m| {
|
.map(|m| {
|
||||||
let id = m.name.strip_prefix("models/").unwrap_or(&m.name).to_string();
|
let id = m
|
||||||
|
.name
|
||||||
|
.strip_prefix("models/")
|
||||||
|
.unwrap_or(&m.name)
|
||||||
|
.to_string();
|
||||||
DiscoveredModel {
|
DiscoveredModel {
|
||||||
id,
|
id,
|
||||||
display_name: m.display_name,
|
display_name: m.display_name,
|
||||||
@@ -553,4 +667,4 @@ pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String>
|
|||||||
app.opener()
|
app.opener()
|
||||||
.open_path(&path, None::<&str>)
|
.open_path(&path, None::<&str>)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@@ -99,6 +99,7 @@ pub fn run() {
|
|||||||
commands::open_agent_config_dir,
|
commands::open_agent_config_dir,
|
||||||
commands::get_app_version,
|
commands::get_app_version,
|
||||||
commands::list_provider_models,
|
commands::list_provider_models,
|
||||||
|
commands::test_connectivity,
|
||||||
commands::debug_log,
|
commands::debug_log,
|
||||||
commands::get_app_setting,
|
commands::get_app_setting,
|
||||||
commands::set_app_setting,
|
commands::set_app_setting,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"productName": "Kimi Switch",
|
"productName": "Kimi Switch",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"identifier": "com.kimiswitch.app",
|
"identifier": "com.kimiswitch.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+34
@@ -205,6 +205,39 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDuplicateProvider = async (name: string) => {
|
||||||
|
updateConfig((cfg) => {
|
||||||
|
const src = cfg.providers[name];
|
||||||
|
if (!src) return cfg;
|
||||||
|
let newName = `${name}-copy`;
|
||||||
|
let n = 2;
|
||||||
|
while (cfg.providers[newName]) {
|
||||||
|
newName = `${name}-copy-${n}`;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
// Clone the provider (deactivated, clear its remembered default model
|
||||||
|
// since the model aliases will be re-keyed to the new name below).
|
||||||
|
const copied = setProviderDefaultModel(
|
||||||
|
{ ...src, name: newName, active: false },
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const providers = { ...cfg.providers, [newName]: copied };
|
||||||
|
// Re-key this provider's models to the new provider name so the copy
|
||||||
|
// has its own independent model set.
|
||||||
|
const models = { ...cfg.models };
|
||||||
|
for (const [alias, m] of Object.entries(cfg.models)) {
|
||||||
|
if (m.provider === name) {
|
||||||
|
const newAlias = newName + alias.slice(name.length);
|
||||||
|
models[newAlias] = { ...m, alias: newAlias, provider: newName };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...cfg, providers, models };
|
||||||
|
});
|
||||||
|
await save();
|
||||||
|
setSwitchMessage(t("copiedProvider", { name: `${name}-copy` }));
|
||||||
|
setTimeout(() => setSwitchMessage(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSetDefaultModel = (alias: string) => {
|
const handleSetDefaultModel = (alias: string) => {
|
||||||
updateConfig((cfg) => {
|
updateConfig((cfg) => {
|
||||||
const model = cfg.models[alias];
|
const model = cfg.models[alias];
|
||||||
@@ -458,6 +491,7 @@ export default function App() {
|
|||||||
setView("edit");
|
setView("edit");
|
||||||
}}
|
}}
|
||||||
onDelete={handleDeleteProvider}
|
onDelete={handleDeleteProvider}
|
||||||
|
onDuplicate={handleDuplicateProvider}
|
||||||
onAdd={handleAddProvider}
|
onAdd={handleAddProvider}
|
||||||
onSwitchProvider={handleSwitchProvider}
|
onSwitchProvider={handleSwitchProvider}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
|
|||||||
@@ -1,29 +1,83 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { Pencil, Copy, Activity, Loader2, Trash2 } from "lucide-react";
|
||||||
import { useTranslation } from "../i18n";
|
import { useTranslation } from "../i18n";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
import type { Agent, Model, Provider } from "../types";
|
import type { Agent, Model, Provider } from "../types";
|
||||||
|
|
||||||
|
interface ConnectivityResult {
|
||||||
|
ok: boolean;
|
||||||
|
latencyMs: number;
|
||||||
|
statusCode?: number | null;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestState {
|
||||||
|
status: "testing" | "ok" | "fail";
|
||||||
|
latency?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProviderListProps {
|
interface ProviderListProps {
|
||||||
providers: Provider[];
|
providers: Provider[];
|
||||||
defaultModel: string | null;
|
defaultModel: string | null;
|
||||||
models: Record<string, Model>;
|
models: Record<string, Model>;
|
||||||
onEdit: (name: string) => void;
|
onEdit: (name: string) => void;
|
||||||
onDelete: (name: string) => void;
|
onDelete: (name: string) => void;
|
||||||
|
onDuplicate: (name: string) => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onSwitchProvider: (name: string) => void;
|
onSwitchProvider: (name: string) => void;
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const iconBtn =
|
||||||
|
"w-8 h-8 flex items-center justify-center rounded border border-border text-content-muted hover:text-content-primary hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none transition-colors";
|
||||||
|
|
||||||
export function ProviderList({
|
export function ProviderList({
|
||||||
providers,
|
providers,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
models,
|
models,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onDuplicate,
|
||||||
onAdd,
|
onAdd,
|
||||||
onSwitchProvider,
|
onSwitchProvider,
|
||||||
agent,
|
agent,
|
||||||
}: ProviderListProps) {
|
}: ProviderListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [testState, setTestState] = useState<Record<string, TestState>>({});
|
||||||
|
|
||||||
|
const handleTest = async (provider: Provider) => {
|
||||||
|
setTestState((s) => ({ ...s, [provider.name]: { status: "testing" } }));
|
||||||
|
try {
|
||||||
|
const result = await invoke<ConnectivityResult>("test_connectivity", { provider });
|
||||||
|
setTestState((s) => ({
|
||||||
|
...s,
|
||||||
|
[provider.name]: {
|
||||||
|
status: result.ok ? "ok" : "fail",
|
||||||
|
latency: result.latencyMs,
|
||||||
|
error: result.error ?? undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
setTestState((s) => ({
|
||||||
|
...s,
|
||||||
|
[provider.name]: {
|
||||||
|
status: "fail",
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// auto-clear the inline result after a few seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setTestState((s) => {
|
||||||
|
const next = { ...s };
|
||||||
|
delete next[provider.name];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, 6000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
@@ -85,6 +139,7 @@ export function ProviderList({
|
|||||||
? models[defaultModel]?.model || models[defaultModel]?.display_name || defaultModel
|
? models[defaultModel]?.model || models[defaultModel]?.display_name || defaultModel
|
||||||
: null;
|
: null;
|
||||||
const isActive = provider.active === true;
|
const isActive = provider.active === true;
|
||||||
|
const ts = testState[provider.name];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -139,6 +194,27 @@ export function ProviderList({
|
|||||||
{t("defaultModel", { name: defaultModelName ?? "" })}
|
{t("defaultModel", { name: defaultModelName ?? "" })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* inline connectivity test result */}
|
||||||
|
{ts && ts.status !== "testing" && (
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full border tabular-nums ${
|
||||||
|
ts.status === "ok"
|
||||||
|
? ts.latency && ts.latency > 6000
|
||||||
|
? "bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-500/30"
|
||||||
|
: "bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30"
|
||||||
|
: "bg-red-100 dark:bg-red-900/30 text-red-500 dark:text-red-400 border-red-300 dark:border-red-500/30"
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
ts.status === "fail"
|
||||||
|
? ts.error || t("connectivityFail", { name: provider.name, error: "" })
|
||||||
|
: ts.latency && ts.latency > 6000
|
||||||
|
? t("connectivitySlow", { name: provider.name, latency: ts.latency ?? 0 })
|
||||||
|
: t("connectivityOk", { name: provider.name, latency: ts.latency ?? 0 })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ts.status === "ok" ? `${ts.latency}ms` : "✕"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -162,15 +238,48 @@ export function ProviderList({
|
|||||||
ⓘ
|
ⓘ
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* icon button group */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onEdit(provider.name);
|
onEdit(provider.name);
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
title={t("edit")}
|
||||||
|
className={iconBtn}
|
||||||
|
aria-label={t("edit")}
|
||||||
>
|
>
|
||||||
{t("edit")}
|
<Pencil className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDuplicate(provider.name);
|
||||||
|
}}
|
||||||
|
title={t("copyProvider")}
|
||||||
|
className={iconBtn}
|
||||||
|
aria-label={t("copyProvider")}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleTest(provider);
|
||||||
|
}}
|
||||||
|
disabled={ts?.status === "testing"}
|
||||||
|
title={t("testConnectivity")}
|
||||||
|
className={`${iconBtn} disabled:opacity-50`}
|
||||||
|
aria-label={t("testConnectivity")}
|
||||||
|
>
|
||||||
|
{ts?.status === "testing" ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Activity className="w-4 h-4" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -178,9 +287,11 @@ export function ProviderList({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete(provider.name);
|
onDelete(provider.name);
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-red-900/30 hover:border-red-500/30 text-red-400 focus:ring-2 focus:ring-red-500 focus:outline-none"
|
title={t("delete")}
|
||||||
|
className={`${iconBtn} hover:text-red-400 hover:border-red-500/30 hover:bg-red-900/20`}
|
||||||
|
aria-label={t("delete")}
|
||||||
>
|
>
|
||||||
{t("delete")}
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ function Card({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-panel">
|
<div className="flex flex-col rounded-xl border border-border bg-panel">
|
||||||
<div className="border-b border-border px-4 py-2.5 flex items-baseline justify-between">
|
<div className="border-b border-border px-4 py-2.5 flex items-baseline justify-between">
|
||||||
<h3 className="text-sm font-medium text-content-primary">{title}</h3>
|
<h3 className="text-sm font-medium text-content-primary">{title}</h3>
|
||||||
{subtitle && <span className="text-xs text-content-muted">{subtitle}</span>}
|
{subtitle && <span className="text-xs text-content-muted">{subtitle}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">{children}</div>
|
<div className="flex-1 p-4">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
defaultModel: "Default: {name}",
|
defaultModel: "Default: {name}",
|
||||||
edit: "Edit",
|
edit: "Edit",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
|
copyProvider: "Duplicate provider",
|
||||||
|
testConnectivity: "Test connectivity",
|
||||||
|
testing: "Testing...",
|
||||||
|
connectivityOk: "{name} reachable ({latency}ms)",
|
||||||
|
connectivitySlow: "{name} reachable but slow ({latency}ms)",
|
||||||
|
connectivityFail: "{name} unreachable: {error}",
|
||||||
|
copiedProvider: "Duplicated as {name}",
|
||||||
|
|
||||||
// Provider edit
|
// Provider edit
|
||||||
editProvider: "Edit Provider",
|
editProvider: "Edit Provider",
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ export const zhTranslations = {
|
|||||||
defaultModel: "默认: {name}",
|
defaultModel: "默认: {name}",
|
||||||
edit: "编辑",
|
edit: "编辑",
|
||||||
delete: "删除",
|
delete: "删除",
|
||||||
|
copyProvider: "复制供应商",
|
||||||
|
testConnectivity: "测试连通性",
|
||||||
|
testing: "测试中...",
|
||||||
|
connectivityOk: "{name} 连通正常({latency}ms)",
|
||||||
|
connectivitySlow: "{name} 连通较慢({latency}ms)",
|
||||||
|
connectivityFail: "{name} 无法连通:{error}",
|
||||||
|
copiedProvider: "已复制为 {name}",
|
||||||
|
|
||||||
// Provider edit
|
// Provider edit
|
||||||
editProvider: "编辑供应商",
|
editProvider: "编辑供应商",
|
||||||
|
|||||||
Reference in new issue
Block a user