From b1d137f0723300aaa73805b1a2e9ab3ad60910bf Mon Sep 17 00:00:00 2001 From: KimiSwitch Dev Date: Tue, 28 Jul 2026 21:04:47 +0800 Subject: [PATCH] feat: v0.5.0 - provider icon actions, duplicate & connectivity test, dashboard chart fixes - ProviderList: icon buttons (edit/copy/test/delete) via lucide-react, speed-test badge before enable/switch button - Duplicate provider: deep-copy provider + models as xxx-copy - test_connectivity command (cc-switch semantics: GET base_url, any HTTP response = reachable), latency badge green/orange/red, auto-dismiss 6s - Dashboard: daily bars anchored to panel bottom, Card flex height fix (heatmap stays content-height) --- package-lock.json | 14 ++- package.json | 3 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands.rs | 49 +++++++++ src-tauri/src/lib.rs | 1 + src-tauri/tauri.conf.json | 2 +- src/App.tsx | 34 ++++++ src/components/ProviderList.tsx | 119 ++++++++++++++++++++- src/components/dashboard/DashboardPage.tsx | 4 +- src/i18n/en.ts | 7 ++ src/i18n/zh.ts | 7 ++ 12 files changed, 232 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index b945b1c..97c95cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "kimiswitch", - "version": "0.3.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kimiswitch", - "version": "0.3.0", + "version": "0.4.1", "dependencies": { "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-opener": "^2.5.0", "@tauri-apps/plugin-shell": "^2.0.0", + "lucide-react": "^1.27.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -2173,6 +2174,15 @@ "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": { "version": "1.4.1", "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", diff --git a/package.json b/package.json index 441304d..dea6af7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kimiswitch", "private": true, - "version": "0.4.1", + "version": "0.5.0", "type": "module", "scripts": { "dev": "vite", @@ -17,6 +17,7 @@ "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-opener": "^2.5.0", "@tauri-apps/plugin-shell": "^2.0.0", + "lucide-react": "^1.27.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2ea0d7e..6b1e490 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1958,7 +1958,7 @@ dependencies = [ [[package]] name = "kimiswitch" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2f9d6d3..b582312 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kimiswitch" -version = "0.4.1" +version = "0.5.0" description = "Kimi Switch - model config manager" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0279e72..e5d9643 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -211,6 +211,55 @@ pub async fn list_provider_models(provider: Provider) -> Result, + pub error: Option, +} + +#[tauri::command] +pub async fn test_connectivity(provider: Provider) -> Result { + 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 { if provider.managed { return Some("managed".to_string()); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ce33692..f82463f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -99,6 +99,7 @@ pub fn run() { commands::open_agent_config_dir, commands::get_app_version, commands::list_provider_models, + commands::test_connectivity, commands::debug_log, commands::get_app_setting, commands::set_app_setting, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6947e42..e978a4a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "Kimi Switch", - "version": "0.4.1", + "version": "0.5.0", "identifier": "com.kimiswitch.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 2dbc390..f27cc01 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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) => { updateConfig((cfg) => { const model = cfg.models[alias]; @@ -458,6 +491,7 @@ export default function App() { setView("edit"); }} onDelete={handleDeleteProvider} + onDuplicate={handleDuplicateProvider} onAdd={handleAddProvider} onSwitchProvider={handleSwitchProvider} agent={agent} diff --git a/src/components/ProviderList.tsx b/src/components/ProviderList.tsx index 8042960..10e2343 100644 --- a/src/components/ProviderList.tsx +++ b/src/components/ProviderList.tsx @@ -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 { ProviderIcon } from "./ProviderIcon"; 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 { providers: Provider[]; defaultModel: string | null; models: Record; onEdit: (name: string) => void; onDelete: (name: string) => void; + onDuplicate: (name: string) => void; onAdd: () => void; onSwitchProvider: (name: string) => void; 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({ providers, defaultModel, models, onEdit, onDelete, + onDuplicate, onAdd, onSwitchProvider, agent, }: ProviderListProps) { const { t } = useTranslation(); + const [testState, setTestState] = useState>({}); + + const handleTest = async (provider: Provider) => { + setTestState((s) => ({ ...s, [provider.name]: { status: "testing" } })); + try { + const result = await invoke("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 (
@@ -85,6 +139,7 @@ export function ProviderList({ ? models[defaultModel]?.model || models[defaultModel]?.display_name || defaultModel : null; const isActive = provider.active === true; + const ts = testState[provider.name]; return (
)} + {/* inline connectivity test result */} + {ts && ts.status !== "testing" && ( + 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` : "✕"} + + )} + +
diff --git a/src/components/dashboard/DashboardPage.tsx b/src/components/dashboard/DashboardPage.tsx index 0072e74..b288d34 100644 --- a/src/components/dashboard/DashboardPage.tsx +++ b/src/components/dashboard/DashboardPage.tsx @@ -22,12 +22,12 @@ function Card({ children: ReactNode; }) { return ( -
+

{title}

{subtitle && {subtitle}}
-
{children}
+
{children}
); } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index e962c7b..c508e89 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -31,6 +31,13 @@ export const enTranslations: Record = { defaultModel: "Default: {name}", edit: "Edit", 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 editProvider: "Edit Provider", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index afa290c..5014a42 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -29,6 +29,13 @@ export const zhTranslations = { defaultModel: "默认: {name}", edit: "编辑", delete: "删除", + copyProvider: "复制供应商", + testConnectivity: "测试连通性", + testing: "测试中...", + connectivityOk: "{name} 连通正常({latency}ms)", + connectivitySlow: "{name} 连通较慢({latency}ms)", + connectivityFail: "{name} 无法连通:{error}", + copiedProvider: "已复制为 {name}", // Provider edit editProvider: "编辑供应商",