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

+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: "编辑供应商",