feat: implement Pi Switch i18n UI and agent config switch

This commit is contained in:
KimiSwitch Dev
2026-07-08 08:53:26 +08:00
parent 7d69aac08f
commit af991a7bda
86 changed files with 4214 additions and 235 deletions
+468 -3
View File
@@ -1,5 +1,470 @@
function App() {
return <div>KimiSwitch</div>;
import { useEffect, useMemo, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useConfig } from "./hooks/useConfig";
import { ProviderList } from "./components/ProviderList";
import { ProviderEdit } from "./components/ProviderEdit";
import { useTranslation } from "./i18n";
import type { Agent, Model, Provider } from "./types";
const AGENT_STORAGE_KEY = "pi-switch-agent";
const AGENTS: { key: Agent; label: string }[] = [
{ key: "kimi_code", label: "Kimi Code" },
{ key: "pi", label: "Pi" },
];
function getInitialAgent(): Agent {
try {
const stored = localStorage.getItem(AGENT_STORAGE_KEY) as Agent | null;
if (stored === "kimi_code" || stored === "pi") return stored;
} catch {
// ignore
}
return "pi";
}
export default App;
export default function App() {
const { t, lang, setLang } = useTranslation();
const [agent, setAgentState] = useState<Agent>(getInitialAgent);
const {
config,
dirty,
error,
loading,
refresh,
save,
updateConfig,
} = useConfig(agent);
const [view, setView] = useState<"list" | "edit">("list");
const [editingProvider, setEditingProvider] = useState<string>("");
const [loadTimeout, setLoadTimeout] = useState(false);
const setAgent = (next: Agent) => {
setAgentState(next);
try {
localStorage.setItem(AGENT_STORAGE_KEY, next);
} catch {
// ignore
}
setView("list");
setEditingProvider("");
};
useEffect(() => {
if (!loading) {
setLoadTimeout(false);
return;
}
const timer = setTimeout(() => setLoadTimeout(true), 5000);
return () => clearTimeout(timer);
}, [loading]);
// Keyboard shortcuts: Ctrl+S save, Ctrl+R reload, Ctrl+O open config dir
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (!e.ctrlKey || e.metaKey) return;
switch (e.key) {
case "s":
case "S":
e.preventDefault();
save();
break;
case "r":
case "R":
e.preventDefault();
if (dirty && !confirm(t("unsavedConfirm"))) return;
refresh();
break;
case "o":
case "O":
e.preventDefault();
invoke("open_agent_config_dir", { agent }).catch((err) =>
alert(err instanceof Error ? err.message : String(err))
);
break;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [save, refresh, dirty, t, agent]);
// Update window title to reflect unsaved changes.
useEffect(() => {
const app = getCurrentWindow();
const prefix = dirty ? "* " : "";
app.setTitle(`${prefix}${t("appTitle")}`);
}, [dirty, t]);
// Warn before closing the window when there are unsaved changes.
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (dirty) {
e.preventDefault();
e.returnValue = "";
}
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [dirty]);
useEffect(() => {
invoke("debug_log", {
message: `App mounted. agent=${agent} loading=${loading} config=${config ? Object.keys(config.providers).length + " providers" : "null"}`,
}).catch(() => {});
}, [loading, config, agent]);
const providers = useMemo(
() => (config ? Object.values(config.providers) : []),
[config]
);
const handleAddProvider = () => {
if (!config) return;
const name = `provider-${providers.length + 1}`;
const defaultType = agent === "kimi_code" ? "kimi" : "openai";
updateConfig((cfg) => ({
...cfg,
providers: {
...cfg.providers,
[name]: {
name,
provider_type: defaultType,
base_url: null,
api_key: null,
env: {},
note: null,
official_url: null,
managed: false,
enabled: true,
},
},
}));
setEditingProvider(name);
setView("edit");
};
const handleUpdateProvider = (provider: Provider) => {
updateConfig((cfg) => {
const providers = { ...cfg.providers };
const oldEntry = Object.entries(providers).find(
([, p]) => p.name === editingProvider
);
if (oldEntry && oldEntry[0] !== provider.name) {
delete providers[oldEntry[0]];
const models = { ...cfg.models };
for (const key of Object.keys(models)) {
if (models[key].provider === editingProvider) {
models[key] = { ...models[key], provider: provider.name };
}
}
providers[provider.name] = provider;
setEditingProvider(provider.name);
return { ...cfg, providers, models };
}
providers[provider.name] = provider;
return { ...cfg, providers };
});
};
const handleDeleteProvider = (name: string) => {
if (!confirm(t("deleteProviderConfirm", { name }))) return;
updateConfig((cfg) => {
const providers = { ...cfg.providers };
delete providers[name];
const models = { ...cfg.models };
for (const key of Object.keys(models)) {
if (models[key].provider === name) {
delete models[key];
}
}
return { ...cfg, providers, models };
});
if (editingProvider === name) {
setEditingProvider("");
setView("list");
}
};
const handleSetDefaultModel = (alias: string) => {
updateConfig((cfg) => ({ ...cfg, default_model: alias }));
};
const handleToggleProviderEnabled = (name: string) => {
updateConfig((cfg) => {
const provider = cfg.providers[name];
if (!provider) return cfg;
return {
...cfg,
providers: {
...cfg.providers,
[name]: { ...provider, enabled: !provider.enabled },
},
};
});
};
const handleApplyProviderJson = (provider: Provider, models: Model[]) => {
updateConfig((cfg) => {
const providers = { ...cfg.providers };
const oldName = editingProvider;
if (oldName && oldName !== provider.name) {
delete providers[oldName];
}
providers[provider.name] = provider;
const updatedModels = { ...cfg.models };
// Remove existing models for this provider.
for (const key of Object.keys(updatedModels)) {
if (updatedModels[key].provider === oldName) {
delete updatedModels[key];
}
}
// Add updated models, ensuring aliases are unique.
for (const m of models) {
let alias = m.alias;
let n = 1;
while (alias in updatedModels) {
alias = `${m.alias}-${n}`;
n++;
}
updatedModels[alias] = { ...m, alias, provider: provider.name };
}
let default_model = cfg.default_model;
if (default_model && !(default_model in updatedModels)) {
default_model = null;
}
return { ...cfg, providers, models: updatedModels, default_model };
});
if (provider.name !== editingProvider) {
setEditingProvider(provider.name);
}
};
if (loading || !config) {
return (
<div className="p-4 space-y-2 text-[#e5e5e7]">
<div>{t("loading")}</div>
{loadTimeout && (
<div className="text-sm text-orange-400">{t("loadTimeout")}</div>
)}
{error && (
<div className="text-sm text-red-400 bg-red-900/20 p-2 rounded">
<div className="font-semibold">{t("loadFailed")}</div>
<div>{error}</div>
<button
type="button"
className="mt-2 px-2 py-1 border border-red-400/30 rounded hover:bg-red-900/30"
onClick={refresh}
>
{t("retry")}
</button>
</div>
)}
</div>
);
}
const currentProvider = config.providers[editingProvider];
return (
<div className="flex flex-col h-full bg-[#0f0f11] text-[#e5e5e7]">
<header className="flex items-center justify-between gap-3 px-4 py-3 border-b border-[#2a2a2e] bg-[#16161a]">
<div className="flex items-center bg-[#1f1f23] border border-[#2a2a2e] rounded p-0.5">
{AGENTS.map(({ key, label }) => (
<button
key={key}
type="button"
onClick={() => setAgent(key)}
className={`px-3 py-1 text-sm rounded transition-colors ${
agent === key
? "bg-blue-600 text-white"
: "text-gray-400 hover:text-[#e5e5e7]"
}`}
>
{label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<select
className="bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={lang}
onChange={(e) => setLang(e.target.value as typeof lang)}
title={t("language")}
>
<option value="zh">{t("zh")}</option>
<option value="en">{t("en")}</option>
</select>
</div>
</header>
{error && (
<div role="alert" className="bg-red-900/20 text-red-400 p-2 text-sm border-b border-red-500/20">
{error}
</div>
)}
<main className="flex-1 overflow-hidden relative">
{view === "list" ? (
<ProviderList
providers={providers}
defaultModel={config.default_model}
models={config.models}
onEdit={(name) => {
setEditingProvider(name);
setView("edit");
}}
onDelete={handleDeleteProvider}
onAdd={handleAddProvider}
onToggleEnabled={handleToggleProviderEnabled}
/>
) : currentProvider ? (
<ProviderEdit
agent={agent}
provider={currentProvider}
models={Object.values(config.models).filter(
(m) => m.provider === currentProvider.name
)}
defaultModel={config.default_model}
onBack={() => setView("list")}
onChange={handleUpdateProvider}
onDelete={() => handleDeleteProvider(currentProvider.name)}
onModelChange={(model) => {
updateConfig((cfg) => {
const models = { ...cfg.models };
const existingKeys = Object.keys(models).filter(
(k) => models[k].provider === currentProvider.name
);
const oldKey = existingKeys.find((k) =>
model.alias === k ? true : models[k].alias === model.alias
);
if (oldKey && oldKey !== model.alias) {
delete models[oldKey];
}
models[model.alias] = model;
let default_model = cfg.default_model;
if (default_model === oldKey) default_model = model.alias;
return { ...cfg, models, default_model };
});
}}
onModelDelete={(alias) => {
updateConfig((cfg) => {
const models = { ...cfg.models };
delete models[alias];
return {
...cfg,
models,
default_model:
cfg.default_model === alias ? null : cfg.default_model,
};
});
}}
onModelAdd={() => {
const providerModels = Object.values(config.models).filter(
(m) => m.provider === currentProvider.name
);
const alias = `${currentProvider.name}-${providerModels.length + 1}`;
updateConfig((cfg) => ({
...cfg,
models: {
...cfg.models,
[alias]: {
alias,
provider: currentProvider.name,
model: "",
max_context_size: 128000,
display_name: null,
role: null,
supports_1m: false,
capabilities: [],
},
},
}));
}}
onBulkAdd={(models) => {
updateConfig((cfg) => {
const updated = { ...cfg.models };
for (const m of models) {
let alias = m.alias;
let n = 1;
while (alias in updated) {
alias = `${m.alias}-${n}`;
n++;
}
updated[alias] = { ...m, alias };
}
return { ...cfg, models: updated };
});
}}
onSetDefault={handleSetDefaultModel}
onApplyJson={handleApplyProviderJson}
onSave={save}
/>
) : (
<div className="p-8 text-center text-gray-400">
<div>{t("providerNotFound")}</div>
<button
type="button"
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
onClick={() => setView("list")}
>
{t("backToList")}
</button>
</div>
)}
</main>
<footer className="flex items-center justify-between px-4 py-2 border-t border-[#2a2a2e] bg-[#16161a] text-sm">
<div className="flex items-center gap-2 min-w-0">
{dirty && (
<span className="text-orange-400 truncate"> {t("unsavedChanges")}</span>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={save}
disabled={!dirty || loading}
className={`px-3 py-1.5 text-sm rounded focus:ring-2 focus:outline-none disabled:opacity-50 ${
dirty
? "bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500"
: "border border-[#2a2a2e] hover:bg-[#252529] focus:ring-blue-500"
}`}
>
{dirty ? `${t("saveConfig")}` : t("saveConfig")}
</button>
<button
type="button"
onClick={() => {
if (dirty && !confirm(t("unsavedConfirm"))) {
return;
}
refresh();
}}
disabled={loading}
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529] focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50"
>
{t("reloadConfig")}
</button>
<button
type="button"
onClick={async () => {
try {
await invoke("open_agent_config_dir", { agent });
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
}
}}
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529] focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("openConfigDir")}
</button>
</div>
</footer>
</div>
);
}
+662
View File
@@ -0,0 +1,662 @@
import { useEffect, useId, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types";
const PROVIDER_TYPES: ProviderType[] = [
"openai",
"openai_responses",
"anthropic",
"google-genai",
"vertexai",
"kimi",
];
function defaultBaseUrl(agent: Agent, type: ProviderType): string {
if (agent === "kimi_code") {
switch (type) {
case "kimi":
return "https://api.kimi.com/coding/v1";
case "openai":
case "openai_responses":
return "https://api.openai.com/v1";
case "google-genai":
return "https://generativelanguage.googleapis.com";
case "anthropic":
case "vertexai":
return "";
}
}
// Pi defaults
switch (type) {
case "kimi":
return "https://api.moonshot.ai/v1";
case "openai":
case "openai_responses":
return "https://api.openai.com/v1";
case "google-genai":
return "https://generativelanguage.googleapis.com";
case "anthropic":
case "vertexai":
return "";
}
}
interface ProviderEditProps {
agent: Agent;
provider: Provider;
models: Model[];
defaultModel: string | null;
onBack: () => void;
onChange: (provider: Provider) => void;
onDelete: () => void;
onModelChange: (model: Model) => void;
onModelDelete: (alias: string) => void;
onModelAdd: () => void;
onBulkAdd: (models: Model[]) => void;
onSetDefault: (alias: string) => void;
onApplyJson: (provider: Provider, models: Model[]) => void;
onSave: () => void;
}
export function ProviderEdit({
agent,
provider,
models,
defaultModel,
onBack,
onChange,
onDelete,
onModelChange,
onModelDelete,
onModelAdd,
onBulkAdd,
onSetDefault,
onApplyJson,
onSave,
}: ProviderEditProps) {
const { t } = useTranslation();
const nameId = useId();
const noteId = useId();
const officialUrlId = useId();
const apiKeyId = useId();
const baseUrlId = useId();
const [activeTab, setActiveTab] = useState<"basic" | "models" | "json">("basic");
const [showApiKey, setShowApiKey] = useState(false);
useEffect(() => {
const def = defaultBaseUrl(agent, provider.provider_type);
if (!def) return;
if (!provider.base_url) {
onChange({ ...provider, base_url: def });
}
}, [provider.provider_type, agent]);
return (
<div className="h-full flex flex-col bg-[#0f0f11]">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-[#2a2a2e] bg-[#16161a]">
<button
type="button"
onClick={onBack}
className="p-2 rounded-lg hover:bg-[#252529] focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("back")}
</button>
<h2 className="font-semibold text-lg">{t("editProvider")}</h2>
<div className="flex-1" />
<button
type="button"
onClick={onSave}
className="px-3 py-1.5 text-sm bg-green-600 hover:bg-green-700 text-white rounded focus:ring-2 focus:ring-green-500 focus:outline-none"
>
{t("saveConfig")}
</button>
<button
type="button"
onClick={onDelete}
className="px-3 py-1.5 text-sm border border-red-500/30 rounded hover:bg-red-900/30 text-red-400 focus:ring-2 focus:ring-red-500 focus:outline-none"
>
{t("delete")}
</button>
</div>
{/* Tabs */}
<div className="flex items-center gap-1 px-4 border-b border-[#2a2a2e] bg-[#16161a]">
{[
{ key: "basic", label: t("basicInfo") },
{ key: "models", label: t("modelMapping") },
{ key: "json", label: t("configJson") },
].map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key as typeof activeTab)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors focus:ring-2 focus:ring-blue-500 focus:outline-none ${
activeTab === tab.key
? "border-blue-500 text-blue-400"
: "border-transparent text-gray-400 hover:text-[#e5e5e7]"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
{activeTab === "basic" && (
<div className="space-y-6">
{/* Basic info */}
<section className="bg-[#16161a] border border-[#2a2a2e] rounded-xl p-5">
<h3 className="font-medium mb-4 text-[#e5e5e7]">{t("basicInfo")}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor={nameId} className="block text-sm text-gray-400 mb-1.5">
{t("providerName")}
</label>
<input
id={nameId}
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.name}
onChange={(e) => onChange({ ...provider, name: e.target.value })}
/>
</div>
<div>
<label htmlFor={noteId} className="block text-sm text-gray-400 mb-1.5">
{t("note")}
</label>
<input
id={noteId}
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.note || ""}
placeholder={t("notePlaceholder")}
onChange={(e) =>
onChange({ ...provider, note: e.target.value || null })
}
/>
</div>
<div className="col-span-1 md:col-span-2">
<label htmlFor={officialUrlId} className="block text-sm text-gray-400 mb-1.5">
{t("officialUrl")}
</label>
<input
id={officialUrlId}
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.official_url || ""}
onChange={(e) =>
onChange({ ...provider, official_url: e.target.value || null })
}
/>
</div>
<div className="col-span-1 md:col-span-2 flex items-center gap-2">
<input
id="managed"
type="checkbox"
checked={provider.managed || false}
onChange={(e) =>
onChange({ ...provider, managed: e.target.checked })
}
className="w-4 h-4 rounded border-[#2a2a2e] bg-[#1f1f23] text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="managed" className="text-sm text-gray-400">
{t("managedProvider")}
</label>
</div>
</div>
</section>
{/* API settings */}
<section className="bg-[#16161a] border border-[#2a2a2e] rounded-xl p-5">
<h3 className="font-medium mb-4 text-[#e5e5e7]">{t("apiSettings")}</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-1.5">
{t("apiFormat")}
</label>
<select
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.provider_type}
onChange={(e) =>
onChange({ ...provider, provider_type: e.target.value as ProviderType })
}
>
{PROVIDER_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
</div>
{!provider.managed && (
<div>
<label htmlFor={apiKeyId} className="block text-sm text-gray-400 mb-1.5">
{t("apiKey")}
</label>
<div className="relative">
<input
id={apiKeyId}
type={showApiKey ? "text" : "password"}
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 pr-10 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.api_key || ""}
onChange={(e) =>
onChange({ ...provider, api_key: e.target.value || null })
}
/>
<button
type="button"
onClick={() => setShowApiKey((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 text-xs px-1"
>
{showApiKey ? t("hide") : t("show")}
</button>
</div>
</div>
)}
<div>
<label htmlFor={baseUrlId} className="block text-sm text-gray-400 mb-1.5">
{t("requestUrl")}
</label>
<input
id={baseUrlId}
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.base_url || ""}
placeholder={defaultBaseUrl(agent, provider.provider_type)}
onChange={(e) =>
onChange({ ...provider, base_url: e.target.value || null })
}
/>
<div className="mt-2 text-xs text-orange-400/80 bg-orange-900/20 border border-orange-500/20 rounded-lg px-3 py-2">
{t("baseUrlHint", { type: provider.provider_type })}
</div>
</div>
</div>
</section>
</div>
)}
{activeTab === "models" && (
<ModelMapping
agent={agent}
provider={provider}
models={models}
defaultModel={defaultModel}
onModelChange={onModelChange}
onModelDelete={onModelDelete}
onModelAdd={onModelAdd}
onBulkAdd={onBulkAdd}
onSetDefault={onSetDefault}
/>
)}
{activeTab === "json" && (
<JsonPreview
provider={provider}
models={models}
onApply={onApplyJson}
/>
)}
</div>
</div>
);
}
function ModelMapping({
agent,
provider,
models,
defaultModel,
onModelChange,
onModelDelete,
onModelAdd,
onBulkAdd,
onSetDefault,
}: {
agent: Agent;
provider: Provider;
models: Model[];
defaultModel: string | null;
onModelChange: (model: Model) => void;
onModelDelete: (alias: string) => void;
onModelAdd: () => void;
onBulkAdd: (models: Model[]) => void;
onSetDefault: (alias: string) => void;
}) {
const { t } = useTranslation();
const [discovering, setDiscovering] = useState(false);
const [discovered, setDiscovered] = useState<DiscoveredModel[] | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [discoverError, setDiscoverError] = useState<string | null>(null);
const handleDiscover = async () => {
setDiscovering(true);
setDiscoverError(null);
setDiscovered(null);
setSelected(new Set());
try {
const result = await invoke<DiscoveredModel[]>("list_provider_models", {
provider,
});
setDiscovered(result);
} catch (err) {
setDiscoverError(err instanceof Error ? err.message : String(err));
} finally {
setDiscovering(false);
}
};
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleAddSelected = () => {
if (!discovered || selected.size === 0) return;
const toAdd: Model[] = [];
for (const dm of discovered) {
if (!selected.has(dm.id)) continue;
const alias = dm.id.replace(/[^a-zA-Z0-9_-]/g, "-");
toAdd.push({
alias,
provider: provider.name,
model: dm.id,
max_context_size: dm.max_context_size ?? 128000,
display_name: dm.display_name,
role: null,
supports_1m: (dm.max_context_size ?? 0) >= 1_000_000,
capabilities: [],
});
}
onBulkAdd(toAdd);
setDiscovered(null);
setSelected(new Set());
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-[#e5e5e7]">{t("modelMapping")}</h3>
<p className="text-xs text-gray-500 mt-1">{t("modelMappingDesc")}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onModelAdd}
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529] focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("oneClickSetup")}
</button>
<button
type="button"
onClick={handleDiscover}
disabled={discovering || provider.managed}
className="px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{discovering ? t("fetchingModels") : t("fetchModels")}
</button>
</div>
</div>
{discoverError && (
<div className="text-red-400 text-sm bg-red-900/20 border border-red-500/20 p-3 rounded-lg">
{discoverError}
</div>
)}
{discovered && (
<div className="bg-[#16161a] border border-[#2a2a2e] rounded-xl p-4 space-y-3">
<div className="text-sm font-medium">
{t("discoveredModels", { count: discovered.length })}
</div>
<div className="max-h-48 overflow-auto space-y-1">
{discovered.map((m) => (
<label
key={m.id}
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-[#1f1f23] p-1.5 rounded"
>
<input
type="checkbox"
checked={selected.has(m.id)}
onChange={() => toggleSelect(m.id)}
/>
<span className="font-mono text-xs text-gray-300">{m.id}</span>
{m.display_name && (
<span className="text-gray-500">({m.display_name})</span>
)}
</label>
))}
</div>
<button
type="button"
onClick={handleAddSelected}
disabled={selected.size === 0}
className="px-4 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded"
>
{t("addSelected")} {selected.size > 0 ? `(${selected.size})` : ""}
</button>
</div>
)}
<div className="bg-[#16161a] border border-[#2a2a2e] rounded-xl overflow-auto">
<table className="w-full min-w-[640px] text-sm">
<thead className="bg-[#1f1f23] text-gray-400">
<tr>
<th className="text-left px-4 py-3 font-medium">{t("modelRole")}</th>
<th className="text-left px-4 py-3 font-medium">{t("displayName")}</th>
<th className="text-left px-4 py-3 font-medium">{t("actualModel")}</th>
<th className="text-left px-4 py-3 font-medium w-28">{t("contextSize")}</th>
<th className="text-center px-4 py-3 font-medium w-28">{t("supports1M")}</th>
{agent === "kimi_code" && (
<th className="text-left px-4 py-3 font-medium">{t("capabilities")}</th>
)}
<th className="text-center px-4 py-3 font-medium w-32">{t("default")}</th>
<th className="text-center px-4 py-3 font-medium w-20">{t("operation")}</th>
</tr>
</thead>
<tbody className="divide-y divide-[#2a2a2e]">
{models.map((m) => (
<tr key={m.alias} className="hover:bg-[#1c1c20]">
<td className="px-4 py-2">
<input
className="w-full bg-transparent border border-[#2a2a2e] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={m.role || ""}
placeholder={m.alias}
onChange={(e) =>
onModelChange({
...m,
role: e.target.value || null,
})
}
/>
</td>
<td className="px-4 py-2">
<input
className="w-full bg-transparent border border-[#2a2a2e] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={m.display_name || ""}
onChange={(e) =>
onModelChange({
...m,
display_name: e.target.value || null,
})
}
/>
</td>
<td className="px-4 py-2">
<input
className="w-full bg-transparent border border-[#2a2a2e] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={m.model}
onChange={(e) =>
onModelChange({ ...m, model: e.target.value })
}
/>
</td>
<td className="px-4 py-2">
<input
type="number"
min={0}
step={1024}
className="w-full bg-transparent border border-[#2a2a2e] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={m.max_context_size}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
onModelChange({
...m,
max_context_size: isNaN(value) ? 0 : value,
});
}}
/>
</td>
<td className="px-4 py-2 text-center">
<input
type="checkbox"
checked={m.supports_1m || false}
onChange={(e) =>
onModelChange({
...m,
supports_1m: e.target.checked,
})
}
className="w-4 h-4 rounded border-[#2a2a2e] bg-[#1f1f23] text-blue-600 focus:ring-blue-500"
/>
</td>
{agent === "kimi_code" && (
<td className="px-4 py-2">
<input
className="w-full bg-transparent border border-[#2a2a2e] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={(m.capabilities || []).join(", ")}
placeholder="thinking, image_in"
onChange={(e) =>
onModelChange({
...m,
capabilities: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</td>
)}
<td className="px-4 py-2 text-center">
<button
type="button"
onClick={() => onSetDefault(m.alias)}
className={`text-xs px-2 py-1 rounded border ${
defaultModel === m.alias
? "bg-green-900/30 text-green-400 border-green-500/30"
: "border-[#2a2a2e] text-gray-400 hover:bg-[#252529]"
}`}
>
{defaultModel === m.alias ? t("isDefault") : t("setDefault")}
</button>
</td>
<td className="px-4 py-2 text-center">
<button
type="button"
onClick={() => {
if (confirm(t("deleteModelConfirm", { alias: m.alias }))) {
onModelDelete(m.alias);
}
}}
className="text-red-400 hover:text-red-300 text-sm"
>
{t("delete")}
</button>
</td>
</tr>
))}
</tbody>
</table>
{models.length === 0 && (
<div className="text-center py-8 text-gray-500 text-sm">
{t("noModelMappings")}
</div>
)}
</div>
<button
type="button"
onClick={onModelAdd}
className="px-4 py-2 border border-[#2a2a2e] rounded-lg hover:bg-[#252529] text-sm"
>
{t("addModelMapping")}
</button>
</div>
);
}
function JsonPreview({
provider,
models,
onApply,
}: {
provider: Provider;
models: Model[];
onApply: (provider: Provider, models: Model[]) => void;
}) {
const { t } = useTranslation();
const [text, setText] = useState(() =>
JSON.stringify({ provider, models }, null, 2)
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setText(JSON.stringify({ provider, models }, null, 2));
setError(null);
}, [provider, models]);
const handleApply = () => {
try {
const parsed = JSON.parse(text);
if (!parsed.provider || typeof parsed.provider !== "object") {
throw new Error("missing provider object");
}
if (!Array.isArray(parsed.models)) {
throw new Error("models must be an array");
}
onApply(parsed.provider as Provider, parsed.models as Model[]);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
return (
<div className="w-full h-full flex flex-col">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-[#e5e5e7]">{t("configJson")}</h3>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{t("readOnlyPreview")}</span>
<button
type="button"
onClick={handleApply}
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("apply")}
</button>
</div>
</div>
{error && (
<div className="mb-2 text-red-400 text-sm bg-red-900/20 border border-red-500/20 p-2 rounded">
{t("invalidJson", { message: error })}
</div>
)}
<textarea
className="flex-1 min-h-[40vh] bg-[#16161a] border border-[#2a2a2e] rounded-xl p-4 overflow-auto text-xs font-mono text-green-400 focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
/>
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
import { useTranslation } from "../i18n";
import type { Model, Provider } from "../types";
interface ProviderListProps {
providers: Provider[];
defaultModel: string | null;
models: Record<string, Model>;
onEdit: (name: string) => void;
onDelete: (name: string) => void;
onAdd: () => void;
onToggleEnabled: (name: string) => void;
}
const PROVIDER_ICONS: Record<string, string> = {
kimi: "K",
anthropic: "A",
openai: "O",
openai_responses: "R",
"google-genai": "G",
vertexai: "V",
};
function getInitial(provider: Provider): string {
return (
PROVIDER_ICONS[provider.provider_type] ||
provider.name.charAt(0).toUpperCase()
);
}
function getProviderColor(type: string): string {
switch (type) {
case "kimi":
return "from-blue-500 to-cyan-400";
case "anthropic":
return "from-orange-500 to-red-400";
case "openai":
return "from-green-500 to-emerald-400";
case "openai_responses":
return "from-teal-500 to-green-400";
case "google-genai":
return "from-purple-500 to-pink-400";
case "vertexai":
return "from-indigo-500 to-purple-400";
default:
return "from-gray-500 to-gray-400";
}
}
export function ProviderList({
providers,
defaultModel,
models,
onEdit,
onDelete,
onAdd,
onToggleEnabled,
}: ProviderListProps) {
const { t } = useTranslation();
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-[#2a2a2e]">
<h2 className="font-medium text-[#e5e5e7]">{t("providers")}</h2>
<button
type="button"
onClick={onAdd}
title={t("addProvider")}
className="w-9 h-9 flex items-center justify-center rounded-full bg-orange-500 hover:bg-orange-600 text-white text-xl shadow-lg focus:ring-2 focus:ring-orange-400 focus:outline-none transition-colors"
>
+
</button>
</div>
<div className="flex-1 overflow-auto p-4">
{providers.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<div className="text-4xl mb-3 opacity-30"></div>
<div>{t("noProviders")}</div>
<button
type="button"
onClick={onAdd}
className="mt-4 w-12 h-12 rounded-full bg-orange-500 hover:bg-orange-600 text-white text-2xl shadow-lg flex items-center justify-center mx-auto"
>
+
</button>
</div>
) : (
<div className="grid grid-cols-1 gap-3 w-full">
{providers.map((provider) => {
const providerModels = Object.values(models).filter(
(m) => m.provider === provider.name
);
const defaultModelName = defaultModel
? models[defaultModel]?.display_name || defaultModel
: null;
const enabled = provider.enabled !== false;
return (
<div
key={provider.name}
className={`group relative flex items-center gap-4 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
enabled
? "bg-[#16161a] border-[#2a2a2e] hover:border-[#3a3a42] hover:bg-[#1c1c20]"
: "bg-[#16161a]/50 border-[#2a2a2e]/50 opacity-60"
}`}
onClick={() => onEdit(provider.name)}
>
<div
className={`w-11 h-11 rounded-xl bg-gradient-to-br ${getProviderColor(
provider.provider_type
)} flex items-center justify-center text-white font-bold text-lg shadow-lg`}
>
{getInitial(provider)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-[#e5e5e7] truncate">
{provider.name}
</h3>
{!enabled && (
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-800 text-gray-400 border border-gray-700">
{t("disabled")}
</span>
)}
{provider.note && (
<span className="text-xs text-gray-500 truncate max-w-[200px]">
{provider.note}
</span>
)}
</div>
<div className="mt-1 flex items-center gap-3 text-sm text-gray-400 flex-wrap">
<span className="font-mono text-xs">
{provider.official_url || provider.base_url || t("noUrl")}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-[#252529] text-gray-400 border border-[#2a2a2e]">
{provider.provider_type}
</span>
<span className="text-xs">
{t("modelCount", { count: providerModels.length })}
</span>
</div>
</div>
<div className="flex items-center flex-wrap justify-end gap-2">
{defaultModel &&
models[defaultModel]?.provider === provider.name && (
<span className="text-xs px-2 py-1 rounded-full bg-green-900/30 text-green-400 border border-green-500/20">
{t("defaultModel", { name: defaultModelName ?? "" })}
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleEnabled(provider.name);
}}
className={`px-3 py-1.5 text-sm rounded focus:ring-2 focus:outline-none ${
enabled
? "bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500"
: "border border-[#2a2a2e] hover:bg-[#252529] text-gray-400 focus:ring-blue-500"
}`}
>
{enabled ? `${t("activated")}` : t("activate")}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onEdit(provider.name);
}}
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529] focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("edit")}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(provider.name);
}}
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-red-900/30 hover:border-red-500/30 text-red-400 focus:ring-2 focus:ring-red-500 focus:outline-none"
>
{t("delete")}
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { Agent, Config } from "../types";
interface UseConfigReturn {
config: Config | null;
dirty: boolean;
error: string | null;
loading: boolean;
refresh: () => Promise<void>;
save: () => Promise<void>;
updateConfig: (updater: (config: Config) => Config) => void;
}
export function useConfig(agent: Agent): UseConfigReturn {
const { t } = useTranslation();
const [config, setConfig] = useState<Config | null>(null);
const configRef = useRef(config);
const [dirty, setDirty] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
configRef.current = config;
}, [config]);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const loadedConfig = await invoke<Config>("load_agent_config_command", {
agent,
});
setConfig(loadedConfig);
setDirty(false);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
console.error("useConfig refresh failed:", message);
} finally {
setLoading(false);
}
}, [agent]);
useEffect(() => {
refresh();
}, [refresh]);
const updateConfig = useCallback((updater: (config: Config) => Config) => {
setConfig((prev) => (prev ? updater(prev) : prev));
setDirty(true);
}, []);
const save = useCallback(async () => {
const current = configRef.current;
if (!current) return;
setLoading(true);
setError(null);
try {
await invoke("save_agent_config_command", { agent, config: current });
await refresh();
} catch (err) {
const raw = err instanceof Error ? err.message : String(err);
setError(raw);
} finally {
setLoading(false);
}
}, [refresh, t, agent]);
return {
config,
dirty,
error,
loading,
refresh,
save,
updateConfig,
};
}
+111
View File
@@ -0,0 +1,111 @@
import type { TranslationKey } from "./zh";
export const enTranslations: Record<TranslationKey, string> = {
// App / global
appTitle: "Pi Switch",
loading: "Loading...",
loadTimeout: "Loading is taking a while. Try restarting the app if it stays stuck.",
loadFailed: "Failed to load:",
retry: "Retry",
saveConfig: "Save Config",
reloadConfig: "Reload Config",
openConfigDir: "Open Config Dir",
unsavedConfirm: "You have unsaved changes. Are you sure you want to reload?",
providerNotFound: "Provider not found",
backToList: "Back to list",
syncToOmp: "Sync to OMP",
syncedToOmp: "Synced to OMP: ",
ompOverwriteConfirm: "OMP already has provider \"{existing}\". Replace with \"{name}\"?",
deleteProviderConfirm: "Delete provider {name}?",
// Provider list
providers: "Providers",
addProvider: "Add Provider",
noProviders: "No providers yet",
addFirstProvider: "Add first provider",
noUrl: "No URL",
modelCount: "{count} models",
ompActive: "OMP Active",
defaultModel: "Default: {name}",
edit: "Edit",
delete: "Delete",
// Provider edit
editProvider: "Edit Provider",
back: "Back",
basicInfo: "Basic Info",
modelMapping: "Model Mapping",
configJson: "Config JSON",
providerName: "Provider Name",
note: "Note",
notePlaceholder: "e.g. company account",
officialUrl: "Official URL",
managedProvider: "Managed provider (no credentials needed)",
apiSettings: "API Settings",
apiFormat: "API Format",
authField: "Auth Field",
apiKey: "API Key",
show: "Show",
hide: "Hide",
requestUrl: "Request URL",
fullUrl: "Full URL",
baseUrlHint: "Enter a {type} API-compatible endpoint without a trailing slash.",
envPairs: "Env pairs",
addEnv: "+ Add",
modelMappingDesc: "Display name only affects the /model menu; 1M declares context capability for Kimi Code.",
oneClickSetup: "One-click setup",
fetchModels: "Fetch models",
fetchingModels: "Fetching...",
discoveredModels: "Discovered {count} models",
addSelected: "Add selected",
modelRole: "Model role",
displayName: "Display name",
actualModel: "Actual model",
contextSize: "Context size",
supports1M: "Supports 1M",
default: "Default",
operation: "Operation",
isDefault: "Default",
setDefault: "Set default",
deleteModelConfirm: "Delete model {alias}?",
noModelMappings: "No model mappings",
addModelMapping: "+ Add model mapping",
readOnlyPreview: "Editable JSON",
apply: "Apply",
invalidJson: "Invalid JSON: {message}",
// Agent / target
target: "Target",
targetKimi: "Kimi Code",
targetOmp: "OMP",
targetQwen: "Qwen Code",
agent: "Agent",
agentKimiCode: "Kimi Code",
agentPi: "Pi",
activate: "Activate",
activated: "Activated",
disabled: "Disabled",
unsavedChanges: "Unsaved changes",
qwenActivateNotImplemented: "Qwen Code activation will be implemented in a follow-up.",
importConfig: "Import current config",
importConfigConfirm: "Importing will overwrite unsaved changes. Continue?",
importConfigFailed: "Import failed: {message}",
capabilities: "Capabilities",
// Languages
language: "Language",
zh: "中文",
en: "English",
// Validation errors
emptyProviderName: "Provider name is empty",
duplicateProviderName: "Duplicate provider name: {0}",
missingCredentials: "Provider '{0}' is missing credentials (api_key or env key)",
ambiguousCredentials: "Provider '{0}' has both direct and env credentials; only one source is allowed",
missingVertexProject: "Vertex provider '{0}' is missing GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_LOCATION",
emptyModelAlias: "Model alias is empty",
duplicateModelAlias: "Duplicate model alias: {0}",
unknownModelProvider: "Model '{0}' references unknown provider '{1}'",
emptyModelId: "Model '{0}' has an empty model ID",
invalidMaxContextSize: "Model '{0}' has an invalid max_context_size",
};
+82
View File
@@ -0,0 +1,82 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { enTranslations } from "./en";
import { zhTranslations, type TranslationKey } from "./zh";
export type Language = "zh" | "en";
const translations = {
zh: zhTranslations,
en: enTranslations,
};
function interpolate(template: string, params?: Record<string, string | number>): string {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`));
}
function interpolatePositional(template: string, args: string[]): string {
return template.replace(/\{(\d+)\}/g, (_, index) => args[Number(index)] ?? `{${index}}`);
}
interface I18nContextValue {
lang: Language;
setLang: (lang: Language) => void;
t: (key: TranslationKey, params?: Record<string, string | number>) => string;
tv: (code: string, args: string[]) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
const STORAGE_KEY = "pi-switch-lang";
function getInitialLang(): Language {
try {
const stored = localStorage.getItem(STORAGE_KEY) as Language | null;
if (stored === "zh" || stored === "en") return stored;
} catch {
// ignore
}
const browser = navigator.language.toLowerCase();
return browser.startsWith("zh") ? "zh" : "en";
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Language>(getInitialLang);
const setLang = (next: Language) => {
setLangState(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
// ignore
}
};
useEffect(() => {
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
}, [lang]);
const t = (key: TranslationKey, params?: Record<string, string | number>) => {
const template = translations[lang][key];
return interpolate(template ?? key, params);
};
const tv = (code: string, args: string[]) => {
const template = translations[lang][code as TranslationKey];
return interpolatePositional(template ?? code, args);
};
return (
<I18nContext.Provider value={{ lang, setLang, t, tv }}>
{children}
</I18nContext.Provider>
);
}
export function useTranslation() {
const ctx = useContext(I18nContext);
if (!ctx) {
throw new Error("useTranslation must be used within I18nProvider");
}
return ctx;
}
+111
View File
@@ -0,0 +1,111 @@
export const zhTranslations = {
// App / global
appTitle: "Pi Switch",
loading: "加载中...",
loadTimeout: "加载时间较长,如果一直卡住,请尝试重新启动应用。",
loadFailed: "加载失败:",
retry: "重试",
saveConfig: "保存配置",
reloadConfig: "读取配置",
openConfigDir: "打开配置目录",
unsavedConfirm: "有未保存修改,确定要重新读取配置吗?",
providerNotFound: "供应商不存在",
backToList: "返回列表",
syncToOmp: "同步到 OMP",
syncedToOmp: "已同步到 OMP",
ompOverwriteConfirm: "OMP 中已存在供应商「{existing}」,是否替换为「{name}」?",
deleteProviderConfirm: "确定删除供应商 {name}",
// Provider list
providers: "供应商",
addProvider: "添加供应商",
noProviders: "暂无供应商",
addFirstProvider: "添加第一个供应商",
noUrl: "无 URL",
modelCount: "{count} 个模型",
ompActive: "OMP 活跃",
defaultModel: "默认: {name}",
edit: "编辑",
delete: "删除",
// Provider edit
editProvider: "编辑供应商",
back: "返回",
basicInfo: "基本信息",
modelMapping: "模型映射",
configJson: "配置 JSON",
providerName: "供应商名称",
note: "备注",
notePlaceholder: "例如:公司专用账号",
officialUrl: "官网链接",
managedProvider: "托管供应商(无需凭证)",
apiSettings: "API 设置",
apiFormat: "API 格式",
authField: "认证字段",
apiKey: "API Key",
show: "显示",
hide: "隐藏",
requestUrl: "请求地址",
fullUrl: "完整 URL",
baseUrlHint: "填写兼容 {type} API 的服务端点地址,不要以斜杠结尾",
envPairs: "Env 键值对",
addEnv: "+ 添加",
modelMappingDesc: "显示名称只影响 /model 菜单;1M 只是给 Kimi Code 的上下文能力声明。",
oneClickSetup: "一键设置",
fetchModels: "获取模型列表",
fetchingModels: "获取中...",
discoveredModels: "发现 {count} 个模型",
addSelected: "添加选中的",
modelRole: "模型角色",
displayName: "显示名称",
actualModel: "实际请求模型",
contextSize: "上下文长度",
supports1M: "声明支持 1M",
default: "默认",
operation: "操作",
isDefault: "已默认",
setDefault: "设为默认",
deleteModelConfirm: "确定删除模型 {alias}",
noModelMappings: "暂无模型映射",
addModelMapping: "+ 添加模型映射",
readOnlyPreview: "可编辑 JSON",
apply: "应用",
invalidJson: "JSON 格式错误:{message}",
// Agent / target
target: "目标",
targetKimi: "Kimi Code",
targetOmp: "OMP",
targetQwen: "Qwen Code",
agent: "智能体",
agentKimiCode: "Kimi Code",
agentPi: "Pi",
activate: "启用",
activated: "已启用",
disabled: "已停用",
unsavedChanges: "未保存修改",
qwenActivateNotImplemented: "Qwen Code 启用功能将在后续实现。",
importConfig: "导入当前配置",
importConfigConfirm: "导入会覆盖当前未保存的修改,是否继续?",
importConfigFailed: "导入失败:{message}",
capabilities: "能力标签",
// Languages
language: "语言",
zh: "中文",
en: "English",
// Validation errors
emptyProviderName: "供应商名称为空",
duplicateProviderName: "重复的供应商名称:{0}",
missingCredentials: "供应商「{0}」缺少凭证(api_key 或环境变量键)",
ambiguousCredentials: "供应商「{0}」同时设置了直接凭证和 Env 凭证,只允许一种",
missingVertexProject: "Vertex 供应商「{0}」缺少 GOOGLE_CLOUD_PROJECT 或 GOOGLE_CLOUD_LOCATION",
emptyModelAlias: "模型别名为空",
duplicateModelAlias: "重复的模型别名:{0}",
unknownModelProvider: "模型「{0}」引用了未知供应商「{1}」",
emptyModelId: "模型「{0}」的模型 ID 为空",
invalidMaxContextSize: "模型「{0}」的 max_context_size 无效",
} as const;
export type TranslationKey = keyof typeof zhTranslations;
+33
View File
@@ -7,3 +7,36 @@ html, body, #root {
margin: 0;
overflow: hidden;
}
:root {
color-scheme: dark;
}
body {
background-color: #0f0f11;
color: #e5e5e7;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* Custom scrollbar for dark theme */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1a1a1e;
}
::-webkit-scrollbar-thumb {
background: #3a3a42;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #4a4a52;
}
input, select, textarea {
color-scheme: dark;
}
+44 -1
View File
@@ -1,10 +1,53 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { invoke } from "@tauri-apps/api/core";
import App from "./App";
import { I18nProvider } from "./i18n";
import "./index.css";
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ error: Error | null }
> {
constructor(props: { children: React.ReactNode }) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
const payload = `${error.toString()}\n${info.componentStack || ""}`;
console.error("React render error:", payload);
try {
invoke("debug_log", { message: payload }).catch(() => {});
} catch {
// ignore
}
}
render() {
if (this.state.error) {
return (
<div style={{ padding: 16, color: "#ff6b6b", whiteSpace: "pre-wrap" }}>
<h2>Render error</h2>
<pre>{this.state.error.toString()}</pre>
<pre>{(this.state.error as Error).stack}</pre>
</div>
);
}
return this.props.children;
}
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<I18nProvider>
<ErrorBoundary>
<App />
</ErrorBoundary>
</I18nProvider>
</React.StrictMode>
);
+57
View File
@@ -0,0 +1,57 @@
export type Agent = "kimi_code" | "pi";
export type ProviderType =
| "kimi"
| "anthropic"
| "openai"
| "openai_responses"
| "google-genai"
| "vertexai";
export interface Provider {
name: string;
provider_type: ProviderType;
base_url: string | null;
api_key: string | null;
env: Record<string, string>;
/** Optional note / remark for the provider. */
note?: string | null;
/** Optional official website URL. */
official_url?: string | null;
/** Managed/OAuth providers skip credential validation. */
managed?: boolean;
/** Whether the provider is enabled/activated. */
enabled?: boolean;
/** Extra agent-specific provider fields preserved across edits. */
raw_other?: unknown;
}
export interface Model {
alias: string;
provider: string;
model: string;
max_context_size: number;
display_name: string | null;
/** Optional model role, e.g. Sonnet/Opus/Fable/Haiku for Claude-style mapping. */
role?: string | null;
/** Whether the model declares extended context / thinking support. */
supports_1m?: boolean;
/** Agent-specific capability flags (e.g. Kimi Code `capabilities`). */
capabilities?: string[];
/** Extra agent-specific model fields preserved across edits. */
raw_other?: unknown;
}
export interface Config {
default_model: string | null;
providers: Record<string, Provider>;
models: Record<string, Model>;
raw_other?: unknown;
}
/** A model discovered from a provider's API endpoint. */
export interface DiscoveredModel {
id: string;
display_name: string | null;
max_context_size: number | null;
}