feat: implement Pi Switch i18n UI and agent config switch
This commit is contained in:
1 parent
7d69aac08f
commit
af991a7bda
86 files changed
+4214
-235
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user