feat: implement Pi Switch i18n UI and agent config switch
This commit is contained in:
+468
-3
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user