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)
This commit is contained in:
KimiSwitch Dev committed 2026-07-28 21:04:47 +08:00
1 parent 6b172914be
commit b1d137f072
12 files changed
+232 -12

No files matched your search

+12 -2
View File
@@ -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",
+2 -1
View File
@@ -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"
},
+1 -1
View File
@@ -1958,7 +1958,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -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"
+49
View File
@@ -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> {
if provider.managed {
return Some("managed".to_string());
+1
View File
@@ -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,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.4.1",
"version": "0.5.0",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
+34
View File
@@ -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}
+115 -4
View File
@@ -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<string, Model>;
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<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 (
<div className="h-full flex flex-col">
<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
: null;
const isActive = provider.active === true;
const ts = testState[provider.name];
return (
<div
@@ -139,6 +194,27 @@ export function ProviderList({
{t("defaultModel", { name: defaultModelName ?? "" })}
</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
type="button"
onClick={(e) => {
@@ -162,15 +238,48 @@ export function ProviderList({
ⓘ
</span>
)}
{/* icon button group */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
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
type="button"
@@ -178,9 +287,11 @@ export function ProviderList({
e.stopPropagation();
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>
</div>
</div>
+2 -2
View File
@@ -22,12 +22,12 @@ function Card({
children: ReactNode;
}) {
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">
<h3 className="text-sm font-medium text-content-primary">{title}</h3>
{subtitle && <span className="text-xs text-content-muted">{subtitle}</span>}
</div>
<div className="p-4">{children}</div>
<div className="flex-1 p-4">{children}</div>
</div>
);
}
+7
View File
@@ -31,6 +31,13 @@ export const enTranslations: Record<TranslationKey, string> = {
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",
+7
View File
@@ -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: "编辑供应商",