Compare commits

..
2 Commits
Author SHA1 Message Date
KimiSwitch Dev b1d137f072 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)
2026-07-28 21:04:47 +08:00
KimiSwitch Dev 6b172914be fix: v0.4.1 - paginate model discovery for OpenAI / Anthropic / Google
- fetch_openai_models: loop on has_more + last_id (?after=) and fall back to next_page_token (?page_token=)

- fetch_anthropic_models: loop on has_more + last_id (?after_id=&limit=1000)

- fetch_google_genai_models: loop on nextPageToken (?pageToken=&pageSize=1000)

- Dedup by id across pages, MAX_PAGES safety cap per provider

- Bump version to 0.4.1
2026-07-28 16:24:36 +08:00
12 changed files with 360 additions and 75 deletions

No files matched your search

+12 -2
View File
@@ -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
View File
@@ -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"
}, },
+1 -1
View File
@@ -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 -1
View File
@@ -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
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> { 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())
} }
+1
View File
@@ -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 -1
View File
@@ -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
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) => { 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}
+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 { 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>
+2 -2
View File
@@ -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>
); );
} }
+7
View File
@@ -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",
+7
View File
@@ -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: "编辑供应商",