mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
3745 lines
100 KiB
TypeScript
3745 lines
100 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback, useRef } from "react";
|
||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||
import { useI18n } from "@/hooks/useI18n";
|
||
import { VisionConfigContent } from "./VisionConfig";
|
||
import type {
|
||
ModelCatalogPreset,
|
||
ModelCatalogRecommendation,
|
||
} from "@/lib/model-catalog";
|
||
import type { DiscoveredModel } from "@/lib/model-discovery";
|
||
// Color icons (have their own fill colors — no background needed)
|
||
import AnthropicIcon from "@lobehub/icons/es/Anthropic/components/Mono";
|
||
import OpenAIIcon from "@lobehub/icons/es/OpenAI/components/Mono";
|
||
import GoogleColorIcon from "@lobehub/icons/es/Google/components/Color";
|
||
import DeepSeekColorIcon from "@lobehub/icons/es/DeepSeek/components/Color";
|
||
import GroqIcon from "@lobehub/icons/es/Groq/components/Mono";
|
||
import MistralColorIcon from "@lobehub/icons/es/Mistral/components/Color";
|
||
import MoonshotIcon from "@lobehub/icons/es/Moonshot/components/Mono";
|
||
import MinimaxColorIcon from "@lobehub/icons/es/Minimax/components/Color";
|
||
import FireworksColorIcon from "@lobehub/icons/es/Fireworks/components/Color";
|
||
import HuggingFaceColorIcon from "@lobehub/icons/es/HuggingFace/components/Color";
|
||
import CerebrasColorIcon from "@lobehub/icons/es/Cerebras/components/Color";
|
||
import OpenRouterIcon from "@lobehub/icons/es/OpenRouter/components/Mono";
|
||
import XAIIcon from "@lobehub/icons/es/XAI/components/Mono";
|
||
import CloudflareColorIcon from "@lobehub/icons/es/Cloudflare/components/Color";
|
||
import VercelIcon from "@lobehub/icons/es/Vercel/components/Mono";
|
||
import GithubCopilotIcon from "@lobehub/icons/es/GithubCopilot/components/Mono";
|
||
import AwsColorIcon from "@lobehub/icons/es/Aws/components/Color";
|
||
import AzureColorIcon from "@lobehub/icons/es/Azure/components/Color";
|
||
import KimiColorIcon from "@lobehub/icons/es/Kimi/components/Color";
|
||
import QwenColorIcon from "@lobehub/icons/es/Qwen/components/Color";
|
||
import ZhipuColorIcon from "@lobehub/icons/es/Zhipu/components/Color";
|
||
import CohereColorIcon from "@lobehub/icons/es/Cohere/components/Color";
|
||
import PerplexityColorIcon from "@lobehub/icons/es/Perplexity/components/Color";
|
||
import TogetherColorIcon from "@lobehub/icons/es/Together/components/Color";
|
||
import GrokIcon from "@lobehub/icons/es/Grok/components/Mono";
|
||
import AntGroupColorIcon from "@lobehub/icons/es/AntGroup/components/Color";
|
||
import NvidiaColorIcon from "@lobehub/icons/es/Nvidia/components/Color";
|
||
import OpenCodeIcon from "@lobehub/icons/es/OpenCode/components/Mono";
|
||
import XiaomiMiMoIcon from "@lobehub/icons/es/XiaomiMiMo/components/Mono";
|
||
import ZAIIcon from "@lobehub/icons/es/ZAI/components/Mono";
|
||
|
||
type IconComponent = React.ComponentType<{
|
||
size?: number | string;
|
||
style?: React.CSSProperties;
|
||
}>;
|
||
|
||
// hasColor=true → Color icon (self-colored SVG, no wrapper)
|
||
// hasColor=false → Mono icon (rendered with currentColor, inherits theme text color)
|
||
const PROVIDER_ICONS: Record<
|
||
string,
|
||
{ Icon: IconComponent; hasColor: boolean }
|
||
> = {
|
||
anthropic: { Icon: AnthropicIcon, hasColor: false },
|
||
openai: { Icon: OpenAIIcon, hasColor: false },
|
||
"openai-codex": { Icon: OpenAIIcon, hasColor: false },
|
||
google: { Icon: GoogleColorIcon, hasColor: true },
|
||
"google-vertex": { Icon: GoogleColorIcon, hasColor: true },
|
||
"ant-ling": { Icon: AntGroupColorIcon, hasColor: true },
|
||
deepseek: { Icon: DeepSeekColorIcon, hasColor: true },
|
||
groq: { Icon: GroqIcon, hasColor: false },
|
||
mistral: { Icon: MistralColorIcon, hasColor: true },
|
||
moonshotai: { Icon: MoonshotIcon, hasColor: false },
|
||
"moonshotai-cn": { Icon: MoonshotIcon, hasColor: false },
|
||
moonshot: { Icon: MoonshotIcon, hasColor: false },
|
||
minimax: { Icon: MinimaxColorIcon, hasColor: true },
|
||
"minimax-cn": { Icon: MinimaxColorIcon, hasColor: true },
|
||
fireworks: { Icon: FireworksColorIcon, hasColor: true },
|
||
huggingface: { Icon: HuggingFaceColorIcon, hasColor: true },
|
||
cerebras: { Icon: CerebrasColorIcon, hasColor: true },
|
||
openrouter: { Icon: OpenRouterIcon, hasColor: false },
|
||
xai: { Icon: XAIIcon, hasColor: false },
|
||
"cloudflare-ai-gateway": { Icon: CloudflareColorIcon, hasColor: true },
|
||
"cloudflare-workers-ai": { Icon: CloudflareColorIcon, hasColor: true },
|
||
"vercel-ai-gateway": { Icon: VercelIcon, hasColor: false },
|
||
"github-copilot": { Icon: GithubCopilotIcon, hasColor: false },
|
||
"amazon-bedrock": { Icon: AwsColorIcon, hasColor: true },
|
||
"azure-openai-responses": { Icon: AzureColorIcon, hasColor: true },
|
||
"kimi-coding": { Icon: KimiColorIcon, hasColor: true },
|
||
nvidia: { Icon: NvidiaColorIcon, hasColor: true },
|
||
opencode: { Icon: OpenCodeIcon, hasColor: false },
|
||
"opencode-go": { Icon: OpenCodeIcon, hasColor: false },
|
||
qwen: { Icon: QwenColorIcon, hasColor: true },
|
||
xiaomi: { Icon: XiaomiMiMoIcon, hasColor: false },
|
||
"xiaomi-token-plan-ams": { Icon: XiaomiMiMoIcon, hasColor: false },
|
||
"xiaomi-token-plan-cn": { Icon: XiaomiMiMoIcon, hasColor: false },
|
||
"xiaomi-token-plan-sgp": { Icon: XiaomiMiMoIcon, hasColor: false },
|
||
zai: { Icon: ZAIIcon, hasColor: false },
|
||
"zai-coding-cn": { Icon: ZAIIcon, hasColor: false },
|
||
zhipu: { Icon: ZhipuColorIcon, hasColor: true },
|
||
cohere: { Icon: CohereColorIcon, hasColor: true },
|
||
perplexity: { Icon: PerplexityColorIcon, hasColor: true },
|
||
together: { Icon: TogetherColorIcon, hasColor: true },
|
||
grok: { Icon: GrokIcon, hasColor: false },
|
||
};
|
||
|
||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||
|
||
interface OAuthProvider {
|
||
id: string;
|
||
name: string;
|
||
usesCallbackServer: boolean;
|
||
loggedIn: boolean;
|
||
}
|
||
|
||
interface ApiKeyProvider {
|
||
id: string;
|
||
displayName: string;
|
||
configured: boolean;
|
||
source?: string;
|
||
modelCount: number;
|
||
}
|
||
|
||
type OAuthLoginState =
|
||
| { phase: "idle" }
|
||
| { phase: "connecting" }
|
||
| { phase: "auth"; url: string; instructions: string | null; token: string }
|
||
| {
|
||
phase: "device_code";
|
||
userCode: string;
|
||
verificationUri: string;
|
||
intervalSeconds: number | null;
|
||
expiresInSeconds: number | null;
|
||
}
|
||
| {
|
||
phase: "prompt";
|
||
message: string;
|
||
placeholder: string | null;
|
||
token: string;
|
||
}
|
||
| {
|
||
phase: "select";
|
||
message: string;
|
||
options: { id: string; label: string }[];
|
||
token: string;
|
||
}
|
||
| { phase: "progress"; message: string }
|
||
| { phase: "success" }
|
||
| { phase: "error"; message: string };
|
||
|
||
interface ModelEntry {
|
||
id: string;
|
||
name?: string;
|
||
api?: string;
|
||
reasoning?: boolean;
|
||
thinkingLevelMap?: Record<string, string | null>;
|
||
input?: string[];
|
||
contextWindow?: number;
|
||
maxTokens?: number;
|
||
cost?: {
|
||
input?: number;
|
||
output?: number;
|
||
cacheRead?: number;
|
||
cacheWrite?: number;
|
||
};
|
||
compat?: Record<string, unknown>;
|
||
}
|
||
|
||
interface ProviderEntry {
|
||
baseUrl?: string;
|
||
api?: string;
|
||
apiKey?: string;
|
||
headers?: Record<string, string>;
|
||
compat?: Record<string, unknown>;
|
||
models?: ModelEntry[];
|
||
modelOverrides?: Record<string, unknown>;
|
||
}
|
||
|
||
interface ModelsJson {
|
||
providers?: Record<string, ProviderEntry>;
|
||
}
|
||
|
||
type ModelTestState =
|
||
| { phase: "idle" }
|
||
| { phase: "testing" }
|
||
| {
|
||
phase: "success";
|
||
latencyMs?: number;
|
||
status?: number;
|
||
responseText?: string;
|
||
}
|
||
| { phase: "error"; message: string; latencyMs?: number; status?: number };
|
||
|
||
type ModelDiscoveryState =
|
||
| { phase: "idle" }
|
||
| { phase: "loading" }
|
||
| { phase: "success"; models: DiscoveredModel[]; endpoint: string }
|
||
| { phase: "error"; message: string };
|
||
|
||
type ModelCatalogState =
|
||
| { phase: "idle" }
|
||
| { phase: "loading" }
|
||
| {
|
||
phase: "success";
|
||
recommendation: ModelCatalogRecommendation;
|
||
appliedCount: number;
|
||
}
|
||
| { phase: "error"; message: string };
|
||
|
||
type Selection =
|
||
| { type: "provider"; name: string }
|
||
| { type: "model"; providerName: string; index: number }
|
||
| { type: "oauth"; providerId: string }
|
||
| { type: "apikey"; providerId: string };
|
||
|
||
const API_OPTIONS = [
|
||
"openai-completions",
|
||
"openai-responses",
|
||
"anthropic-messages",
|
||
"google-generative-ai",
|
||
] as const;
|
||
|
||
// ── Form field helpers ────────────────────────────────────────────────────────
|
||
|
||
function Field({
|
||
label,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||
<label
|
||
style={{ fontSize: 11, color: "var(--text-muted)", fontWeight: 500 }}
|
||
>
|
||
{label}
|
||
</label>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const inputStyle = {
|
||
padding: "6px 9px",
|
||
background: "var(--bg-panel)",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 5,
|
||
color: "var(--text)",
|
||
fontSize: 12,
|
||
outline: "none",
|
||
width: "100%",
|
||
boxSizing: "border-box" as const,
|
||
};
|
||
|
||
function TextInput({
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
mono,
|
||
}: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
placeholder?: string;
|
||
mono?: boolean;
|
||
}) {
|
||
return (
|
||
<input
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={placeholder}
|
||
style={{
|
||
...inputStyle,
|
||
fontFamily: mono ? "var(--font-mono)" : "inherit",
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function SecretTextInput({
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
mono,
|
||
onKeyDown,
|
||
autoComplete = "off",
|
||
spellCheck = false,
|
||
style,
|
||
}: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
placeholder?: string;
|
||
mono?: boolean;
|
||
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
|
||
autoComplete?: string;
|
||
spellCheck?: boolean;
|
||
style?: React.CSSProperties;
|
||
}) {
|
||
const [visible, setVisible] = useState(false);
|
||
const { t } = useI18n();
|
||
|
||
useEffect(() => {
|
||
if (!value) setVisible(false);
|
||
}, [value]);
|
||
|
||
return (
|
||
<div style={{ position: "relative", width: "100%", ...style }}>
|
||
<input
|
||
type={visible ? "text" : "password"}
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
onKeyDown={onKeyDown}
|
||
placeholder={placeholder}
|
||
style={{
|
||
...inputStyle,
|
||
paddingRight: 34,
|
||
fontFamily: mono ? "var(--font-mono)" : "inherit",
|
||
}}
|
||
autoComplete={autoComplete}
|
||
spellCheck={spellCheck}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setVisible((v) => !v)}
|
||
aria-label={visible ? t("i18n.hideDetails") : t("i18n.showDetails")}
|
||
title={visible ? t("i18n.hideDetails") : t("i18n.showDetails")}
|
||
style={{
|
||
position: "absolute",
|
||
right: 5,
|
||
top: "50%",
|
||
transform: "translateY(-50%)",
|
||
width: 24,
|
||
height: 24,
|
||
padding: 0,
|
||
border: "none",
|
||
background: "transparent",
|
||
color: "var(--text-dim)",
|
||
cursor: "pointer",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
}}
|
||
>
|
||
{visible ? (
|
||
<svg
|
||
width="15"
|
||
height="15"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<path d="M17.94 17.94A10.94 10.94 0 0 1 12 20C7 20 2.73 16.89 1 12a18.45 18.45 0 0 1 5.06-6.94" />
|
||
<path d="M9.9 4.24A10.94 10.94 0 0 1 12 4c5 0 9.27 3.11 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
||
<path d="M14.12 14.12A3 3 0 0 1 9.88 9.88" />
|
||
<path d="M1 1l22 22" />
|
||
</svg>
|
||
) : (
|
||
<svg
|
||
width="15"
|
||
height="15"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12Z" />
|
||
<circle cx="12" cy="12" r="3" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NumInput({
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
}: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
placeholder?: string;
|
||
}) {
|
||
return (
|
||
<input
|
||
type="number"
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={placeholder}
|
||
style={inputStyle}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function Select({
|
||
value,
|
||
onChange,
|
||
options,
|
||
required,
|
||
}: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
options: readonly string[];
|
||
required?: boolean;
|
||
}) {
|
||
const { t } = useI18n();
|
||
return (
|
||
<select
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
style={{
|
||
...inputStyle,
|
||
color: value ? "var(--text)" : "var(--text-dim)",
|
||
}}
|
||
>
|
||
{!required && <option value="">— {t("i18n.default")} / none —</option>}
|
||
{options.map((o) => (
|
||
<option key={o} value={o}>
|
||
{o}
|
||
</option>
|
||
))}
|
||
</select>
|
||
);
|
||
}
|
||
|
||
function Check({
|
||
label,
|
||
checked,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
checked: boolean;
|
||
onChange: (v: boolean) => void;
|
||
}) {
|
||
return (
|
||
<label
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
cursor: "pointer",
|
||
fontSize: 12,
|
||
color: "var(--text-muted)",
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={checked}
|
||
onChange={(e) => onChange(e.target.checked)}
|
||
style={{
|
||
width: 13,
|
||
height: 13,
|
||
accentColor: "var(--accent)",
|
||
cursor: "pointer",
|
||
}}
|
||
/>
|
||
{label}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<div
|
||
style={{
|
||
fontSize: 11,
|
||
fontWeight: 600,
|
||
color: "var(--text-dim)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.06em",
|
||
marginBottom: 2,
|
||
}}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Provider detail ───────────────────────────────────────────────────────────
|
||
|
||
function ProviderDetail({
|
||
name,
|
||
provider,
|
||
onChange,
|
||
onRename,
|
||
onDelete,
|
||
onAddModels,
|
||
}: {
|
||
name: string;
|
||
provider: ProviderEntry;
|
||
onChange: (p: ProviderEntry) => void;
|
||
onRename: (n: string) => void;
|
||
onDelete: () => void;
|
||
onAddModels: (models: DiscoveredModel[]) => void;
|
||
}) {
|
||
const { t } = useI18n();
|
||
const [editingName, setEditingName] = useState(name);
|
||
const [discoveryState, setDiscoveryState] = useState<ModelDiscoveryState>({
|
||
phase: "idle",
|
||
});
|
||
const [discoveryQuery, setDiscoveryQuery] = useState("");
|
||
const [selectedModelIds, setSelectedModelIds] = useState<string[]>([]);
|
||
const discoveryRequestIdRef = useRef(0);
|
||
const selectShownRef = useRef<HTMLInputElement>(null);
|
||
useEffect(() => setEditingName(name), [name]);
|
||
const set = <K extends keyof ProviderEntry>(k: K, v: ProviderEntry[K]) =>
|
||
onChange({ ...provider, [k]: v });
|
||
|
||
useEffect(() => {
|
||
if (!provider.api) onChange({ ...provider, api: "openai-completions" });
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [provider.api]);
|
||
|
||
useEffect(() => {
|
||
discoveryRequestIdRef.current += 1;
|
||
setDiscoveryState({ phase: "idle" });
|
||
setDiscoveryQuery("");
|
||
setSelectedModelIds([]);
|
||
}, [name, provider.baseUrl, provider.api, provider.apiKey]);
|
||
|
||
const handleDiscoverModels = useCallback(async () => {
|
||
if (!provider.baseUrl?.trim() || discoveryState.phase === "loading") return;
|
||
const requestId = ++discoveryRequestIdRef.current;
|
||
setDiscoveryState({ phase: "loading" });
|
||
setSelectedModelIds([]);
|
||
try {
|
||
const res = await fetch("/api/models-config/discover", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
providerName: name,
|
||
provider: { ...provider, models: undefined },
|
||
}),
|
||
});
|
||
const data = (await res.json()) as {
|
||
models?: DiscoveredModel[];
|
||
endpoint?: string;
|
||
error?: string;
|
||
};
|
||
if (requestId !== discoveryRequestIdRef.current) return;
|
||
if (!res.ok || data.error || !data.models) {
|
||
setDiscoveryState({
|
||
phase: "error",
|
||
message: data.error ?? `HTTP ${res.status}`,
|
||
});
|
||
return;
|
||
}
|
||
setDiscoveryState({
|
||
phase: "success",
|
||
models: data.models,
|
||
endpoint: data.endpoint ?? provider.baseUrl,
|
||
});
|
||
} catch (error) {
|
||
if (requestId !== discoveryRequestIdRef.current) return;
|
||
setDiscoveryState({
|
||
phase: "error",
|
||
message: error instanceof Error ? error.message : String(error),
|
||
});
|
||
}
|
||
}, [discoveryState.phase, name, provider]);
|
||
|
||
const existingModelIds = new Set(
|
||
(provider.models ?? []).map((model) => model.id),
|
||
);
|
||
const discoveredModels =
|
||
discoveryState.phase === "success" ? discoveryState.models : [];
|
||
const normalizedDiscoveryQuery = discoveryQuery.trim().toLocaleLowerCase();
|
||
const filteredDiscoveredModels = discoveredModels.filter(
|
||
(model) =>
|
||
!normalizedDiscoveryQuery ||
|
||
model.id.toLocaleLowerCase().includes(normalizedDiscoveryQuery) ||
|
||
model.name?.toLocaleLowerCase().includes(normalizedDiscoveryQuery),
|
||
);
|
||
const shownDiscoveredModels = filteredDiscoveredModels.slice(0, 300);
|
||
const selectableShownIds = shownDiscoveredModels
|
||
.filter((model) => !existingModelIds.has(model.id))
|
||
.map((model) => model.id);
|
||
const selectedCount = selectedModelIds.filter(
|
||
(id) => !existingModelIds.has(id),
|
||
).length;
|
||
const allShownSelected =
|
||
selectableShownIds.length > 0 &&
|
||
selectableShownIds.every((id) => selectedModelIds.includes(id));
|
||
const someShownSelected =
|
||
!allShownSelected &&
|
||
selectableShownIds.some((id) => selectedModelIds.includes(id));
|
||
|
||
useEffect(() => {
|
||
if (selectShownRef.current)
|
||
selectShownRef.current.indeterminate = someShownSelected;
|
||
}, [someShownSelected]);
|
||
|
||
const toggleDiscoveredModel = (id: string) => {
|
||
setSelectedModelIds((current) =>
|
||
current.includes(id)
|
||
? current.filter((entry) => entry !== id)
|
||
: [...current, id],
|
||
);
|
||
};
|
||
|
||
const toggleShownModels = () => {
|
||
const shownIds = new Set(selectableShownIds);
|
||
setSelectedModelIds((current) =>
|
||
allShownSelected
|
||
? current.filter((id) => !shownIds.has(id))
|
||
: Array.from(new Set([...current, ...selectableShownIds])),
|
||
);
|
||
};
|
||
|
||
const addSelectedModels = () => {
|
||
if (discoveryState.phase !== "success") return;
|
||
const selected = new Set(selectedModelIds);
|
||
const additions = discoveryState.models.filter(
|
||
(model) => selected.has(model.id) && !existingModelIds.has(model.id),
|
||
);
|
||
if (additions.length === 0) return;
|
||
onAddModels(additions);
|
||
setSelectedModelIds([]);
|
||
};
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
}}
|
||
>
|
||
<SectionTitle>{t("i18n.provider")}</SectionTitle>
|
||
<button
|
||
onClick={onDelete}
|
||
style={{
|
||
padding: "3px 8px",
|
||
background: "none",
|
||
border: "1px solid rgba(239,68,68,0.3)",
|
||
borderRadius: 4,
|
||
color: "#ef4444",
|
||
cursor: "pointer",
|
||
fontSize: 11,
|
||
}}
|
||
>
|
||
{t("i18n.delete")}
|
||
</button>
|
||
</div>
|
||
|
||
<Field label={t("i18n.providerName")}>
|
||
<TextInput
|
||
value={editingName}
|
||
onChange={setEditingName}
|
||
placeholder="provider-name"
|
||
mono
|
||
/>
|
||
{editingName !== name && editingName.trim() && (
|
||
<button
|
||
onClick={() => onRename(editingName.trim())}
|
||
style={{
|
||
marginTop: 4,
|
||
padding: "3px 10px",
|
||
background: "var(--accent)",
|
||
border: "none",
|
||
borderRadius: 4,
|
||
color: "#fff",
|
||
cursor: "pointer",
|
||
fontSize: 11,
|
||
alignSelf: "flex-start",
|
||
}}
|
||
>
|
||
{t("i18n.rename")}
|
||
</button>
|
||
)}
|
||
</Field>
|
||
|
||
<Field label="Base URL">
|
||
<TextInput
|
||
value={provider.baseUrl ?? ""}
|
||
onChange={(v) => set("baseUrl", v || undefined)}
|
||
placeholder="https://api.example.com/v1"
|
||
mono
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="API Key">
|
||
<SecretTextInput
|
||
value={provider.apiKey ?? ""}
|
||
onChange={(v) => set("apiKey", v || undefined)}
|
||
placeholder="ENV_VAR_NAME, !shell-command, or literal key"
|
||
mono
|
||
/>
|
||
<span style={{ fontSize: 10, color: "var(--text-dim)", marginTop: 2 }}>
|
||
Prefix with <code style={{ fontFamily: "var(--font-mono)" }}>!</code>{" "}
|
||
to run a shell command, or use an env var name
|
||
</span>
|
||
</Field>
|
||
|
||
<Field label="API">
|
||
<Select
|
||
value={provider.api ?? "openai-completions"}
|
||
onChange={(v) => set("api", v)}
|
||
options={API_OPTIONS}
|
||
required
|
||
/>
|
||
</Field>
|
||
|
||
<div
|
||
style={{
|
||
borderTop: "1px solid var(--border)",
|
||
paddingTop: 14,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 10,
|
||
}}
|
||
>
|
||
{discoveryState.phase !== "success" && (
|
||
<button
|
||
onClick={handleDiscoverModels}
|
||
disabled={
|
||
!provider.baseUrl?.trim() || discoveryState.phase === "loading"
|
||
}
|
||
style={{
|
||
alignSelf: "flex-start",
|
||
height: 30,
|
||
padding: "0 12px",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 5,
|
||
background: "var(--bg-panel)",
|
||
color:
|
||
!provider.baseUrl?.trim() || discoveryState.phase === "loading"
|
||
? "var(--text-dim)"
|
||
: "var(--text-muted)",
|
||
cursor:
|
||
!provider.baseUrl?.trim() || discoveryState.phase === "loading"
|
||
? "not-allowed"
|
||
: "pointer",
|
||
fontSize: 11,
|
||
}}
|
||
>
|
||
{discoveryState.phase === "loading"
|
||
? t("models.discoveryFetching")
|
||
: t("models.discoveryFetch")}
|
||
</button>
|
||
)}
|
||
|
||
{discoveryState.phase === "error" && (
|
||
<div
|
||
style={{
|
||
padding: "7px 9px",
|
||
border: "1px solid rgba(239,68,68,0.3)",
|
||
borderRadius: 5,
|
||
color: "#ef4444",
|
||
fontSize: 11,
|
||
lineHeight: 1.4,
|
||
}}
|
||
>
|
||
{discoveryState.message}
|
||
</div>
|
||
)}
|
||
|
||
{discoveryState.phase === "success" && (
|
||
<>
|
||
<input
|
||
value={discoveryQuery}
|
||
onChange={(event) => setDiscoveryQuery(event.target.value)}
|
||
placeholder={t("models.discoveryFilterPlaceholder", {
|
||
count: discoveryState.models.length,
|
||
})}
|
||
aria-label={t("models.discoveryFilter")}
|
||
style={{ ...inputStyle, width: "100%", minWidth: 0 }}
|
||
/>
|
||
|
||
<div
|
||
style={{
|
||
maxHeight: 220,
|
||
overflowY: "auto",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 6,
|
||
background: "var(--bg-panel)",
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
minHeight: 32,
|
||
padding: "5px 9px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
position: "sticky",
|
||
top: 0,
|
||
zIndex: 1,
|
||
borderBottom: "1px solid var(--border)",
|
||
background: "var(--bg)",
|
||
cursor: selectableShownIds.length ? "pointer" : "default",
|
||
color: "var(--text-muted)",
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
<input
|
||
ref={selectShownRef}
|
||
type="checkbox"
|
||
checked={allShownSelected}
|
||
disabled={selectableShownIds.length === 0}
|
||
onChange={toggleShownModels}
|
||
style={{
|
||
width: 13,
|
||
height: 13,
|
||
accentColor: "var(--accent)",
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
{t("models.discoverySelectShown")}
|
||
</label>
|
||
{shownDiscoveredModels.length === 0 ? (
|
||
<div
|
||
style={{
|
||
padding: 12,
|
||
color: "var(--text-dim)",
|
||
fontSize: 11,
|
||
}}
|
||
>
|
||
{t("models.discoveryNoMatches")}
|
||
</div>
|
||
) : (
|
||
shownDiscoveredModels.map((model, index) => {
|
||
const alreadyAdded = existingModelIds.has(model.id);
|
||
const checked = selectedModelIds.includes(model.id);
|
||
return (
|
||
<label
|
||
key={model.id}
|
||
style={{
|
||
minHeight: 36,
|
||
padding: "6px 9px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
borderTop:
|
||
index === 0 ? "none" : "1px solid var(--border)",
|
||
cursor: alreadyAdded ? "default" : "pointer",
|
||
opacity: alreadyAdded ? 0.65 : 1,
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={checked || alreadyAdded}
|
||
disabled={alreadyAdded}
|
||
onChange={() => toggleDiscoveredModel(model.id)}
|
||
style={{
|
||
width: 13,
|
||
height: 13,
|
||
accentColor: "var(--accent)",
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
<span style={{ minWidth: 0, flex: 1 }}>
|
||
<span
|
||
style={{
|
||
display: "block",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
color: "var(--text)",
|
||
fontSize: 11,
|
||
}}
|
||
>
|
||
{model.name ?? model.id}
|
||
</span>
|
||
{model.name && (
|
||
<code
|
||
style={{
|
||
display: "block",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
color: "var(--text-dim)",
|
||
fontSize: 10,
|
||
fontFamily: "var(--font-mono)",
|
||
}}
|
||
>
|
||
{model.id}
|
||
</code>
|
||
)}
|
||
</span>
|
||
{alreadyAdded && (
|
||
<span
|
||
style={{ color: "var(--text-dim)", fontSize: 10 }}
|
||
>
|
||
{t("models.discoveryAdded")}
|
||
</span>
|
||
)}
|
||
</label>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
gap: 10,
|
||
}}
|
||
>
|
||
<span
|
||
title={discoveryState.endpoint}
|
||
style={{
|
||
minWidth: 0,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
color: "var(--text-dim)",
|
||
fontSize: 10,
|
||
}}
|
||
>
|
||
{filteredDiscoveredModels.length > shownDiscoveredModels.length
|
||
? t("models.discoveryShowing", {
|
||
shown: shownDiscoveredModels.length,
|
||
total: filteredDiscoveredModels.length,
|
||
})
|
||
: t("models.discoveryFetched", {
|
||
count: discoveryState.models.length,
|
||
})}
|
||
</span>
|
||
<button
|
||
onClick={addSelectedModels}
|
||
disabled={selectedCount === 0}
|
||
style={{
|
||
height: 28,
|
||
padding: "0 11px",
|
||
border: "none",
|
||
borderRadius: 5,
|
||
background: selectedCount
|
||
? "var(--accent)"
|
||
: "var(--bg-panel)",
|
||
color: selectedCount ? "#fff" : "var(--text-dim)",
|
||
cursor: selectedCount ? "pointer" : "not-allowed",
|
||
fontSize: 11,
|
||
fontWeight: 600,
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{selectedCount
|
||
? t("models.discoveryAddSelectedCount", {
|
||
count: selectedCount,
|
||
})
|
||
: t("models.discoveryAddSelected")}
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── ThinkingLevelMap editor ───────────────────────────────────────────────────
|
||
|
||
const THINKING_LEVELS = [
|
||
"off",
|
||
"minimal",
|
||
"low",
|
||
"medium",
|
||
"high",
|
||
"xhigh",
|
||
"max",
|
||
] as const;
|
||
type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
||
|
||
const LEVEL_COLORS: Record<ThinkingLevel, string> = {
|
||
off: "var(--text-dim)",
|
||
minimal: "#6b7280",
|
||
low: "#60a5fa",
|
||
medium: "#a78bfa",
|
||
high: "#f472b6",
|
||
xhigh: "#fb923c",
|
||
max: "#ef4444",
|
||
};
|
||
|
||
function ThinkingLevelMapEditor({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: Record<string, string | null> | undefined;
|
||
onChange: (v: Record<string, string | null> | undefined) => void;
|
||
}) {
|
||
const map = value ?? {};
|
||
|
||
const setLevel = (level: ThinkingLevel, entry: string | null | "omit") => {
|
||
const next = { ...map };
|
||
if (entry === "omit") {
|
||
delete next[level];
|
||
} else {
|
||
next[level] = entry;
|
||
}
|
||
onChange(Object.keys(next).length ? next : undefined);
|
||
};
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||
{THINKING_LEVELS.map((level) => {
|
||
const raw = map[level];
|
||
const state: "omit" | "null" | "string" = !(level in map)
|
||
? "omit"
|
||
: raw === null
|
||
? "null"
|
||
: "string";
|
||
const strVal = typeof raw === "string" ? raw : "";
|
||
const color = LEVEL_COLORS[level];
|
||
|
||
const btnBase: React.CSSProperties = {
|
||
padding: "4px 10px",
|
||
fontSize: 10,
|
||
border: "none",
|
||
cursor: "pointer",
|
||
fontWeight: 400,
|
||
transition: "background 0.1s, color 0.1s",
|
||
whiteSpace: "nowrap",
|
||
background: "var(--bg-panel)",
|
||
color: "var(--text-dim)",
|
||
};
|
||
const btnActive: React.CSSProperties = {
|
||
background: "var(--accent)",
|
||
color: "#fff",
|
||
fontWeight: 600,
|
||
};
|
||
const btnActiveDisabled: React.CSSProperties = {
|
||
background: "#ef4444",
|
||
color: "#fff",
|
||
fontWeight: 600,
|
||
};
|
||
|
||
return (
|
||
<div
|
||
key={level}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
padding: "5px 4px",
|
||
borderRadius: 6,
|
||
background: "transparent",
|
||
border: "1px solid transparent",
|
||
}}
|
||
>
|
||
{/* Level badge */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 5,
|
||
width: 68,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
width: 6,
|
||
height: 6,
|
||
borderRadius: "50%",
|
||
background: color,
|
||
flexShrink: 0,
|
||
opacity: state === "null" ? 0.3 : 1,
|
||
}}
|
||
/>
|
||
<span
|
||
style={{
|
||
fontSize: 11,
|
||
fontFamily: "var(--font-mono)",
|
||
color:
|
||
state === "null" ? "var(--text-dim)" : "var(--text-muted)",
|
||
textDecoration: state === "null" ? "line-through" : "none",
|
||
}}
|
||
>
|
||
{level}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Default + Disabled buttons */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
borderRadius: 5,
|
||
border: "1px solid var(--border)",
|
||
overflow: "hidden",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<button
|
||
onClick={() => setLevel(level, "omit")}
|
||
style={{ ...btnBase, ...(state === "omit" ? btnActive : {}) }}
|
||
>
|
||
Default
|
||
</button>
|
||
<button
|
||
onClick={() => setLevel(level, null)}
|
||
style={{
|
||
...btnBase,
|
||
borderLeft: "1px solid var(--border)",
|
||
...(state === "null" ? btnActiveDisabled : {}),
|
||
}}
|
||
>
|
||
Disabled
|
||
</button>
|
||
</div>
|
||
|
||
{/* Custom button + input fused */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
borderRadius: 5,
|
||
border: `1px solid ${state === "string" ? "var(--accent)" : "var(--border)"}`,
|
||
overflow: "hidden",
|
||
transition: "border-color 0.1s",
|
||
}}
|
||
>
|
||
<button
|
||
onClick={() => setLevel(level, strVal || level)}
|
||
style={{
|
||
...btnBase,
|
||
...(state === "string" ? btnActive : {}),
|
||
borderRight: "1px solid var(--border)",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
Custom
|
||
</button>
|
||
<input
|
||
value={strVal}
|
||
onChange={(e) => setLevel(level, e.target.value)}
|
||
onFocus={() => {
|
||
if (state !== "string") setLevel(level, strVal || level);
|
||
}}
|
||
placeholder={level}
|
||
maxLength={10}
|
||
style={{
|
||
width: "12ch",
|
||
background:
|
||
state === "string" ? "var(--bg)" : "var(--bg-panel)",
|
||
border: "none",
|
||
outline: "none",
|
||
color: state === "string" ? "var(--text)" : "var(--text-dim)",
|
||
fontFamily: "var(--font-mono)",
|
||
fontSize: 11,
|
||
padding: "4px 7px",
|
||
transition: "background 0.1s, color 0.1s",
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Model detail ──────────────────────────────────────────────────────────────
|
||
|
||
const DEEPSEEK_COMPAT = {
|
||
thinkingFormat: "deepseek",
|
||
requiresReasoningContentOnAssistantMessages: true,
|
||
} as const;
|
||
|
||
function hasDeepseekCompat(model: ModelEntry): boolean {
|
||
return model.compat?.thinkingFormat === "deepseek";
|
||
}
|
||
|
||
function setDeepseekCompat(model: ModelEntry, enabled: boolean): ModelEntry {
|
||
if (enabled) {
|
||
return {
|
||
...model,
|
||
compat: { ...(model.compat ?? {}), ...DEEPSEEK_COMPAT },
|
||
};
|
||
}
|
||
if (!model.compat) return model;
|
||
const rest = { ...model.compat };
|
||
delete rest.thinkingFormat;
|
||
delete rest.requiresReasoningContentOnAssistantMessages;
|
||
return { ...model, compat: Object.keys(rest).length ? rest : undefined };
|
||
}
|
||
|
||
function fillEmptyModelFields(
|
||
model: ModelEntry,
|
||
preset: ModelCatalogPreset,
|
||
): { model: ModelEntry; appliedCount: number } {
|
||
const next = { ...model };
|
||
let appliedCount = 0;
|
||
if (!model.name?.trim() && preset.name) {
|
||
next.name = preset.name;
|
||
appliedCount += 1;
|
||
}
|
||
if (model.reasoning === undefined && preset.reasoning === true) {
|
||
next.reasoning = true;
|
||
appliedCount += 1;
|
||
}
|
||
if (!model.input?.length && preset.input?.length) {
|
||
next.input = [...preset.input];
|
||
appliedCount += 1;
|
||
}
|
||
if (model.contextWindow === undefined && preset.contextWindow !== undefined) {
|
||
next.contextWindow = preset.contextWindow;
|
||
appliedCount += 1;
|
||
}
|
||
if (model.maxTokens === undefined && preset.maxTokens !== undefined) {
|
||
next.maxTokens = preset.maxTokens;
|
||
appliedCount += 1;
|
||
}
|
||
|
||
if (preset.cost) {
|
||
const cost = { ...(model.cost ?? {}) };
|
||
let costChanged = false;
|
||
for (const key of ["input", "output", "cacheRead", "cacheWrite"] as const) {
|
||
if (cost[key] === undefined && preset.cost[key] !== undefined) {
|
||
cost[key] = preset.cost[key];
|
||
costChanged = true;
|
||
appliedCount += 1;
|
||
}
|
||
}
|
||
if (costChanged) next.cost = cost;
|
||
}
|
||
return { model: next, appliedCount };
|
||
}
|
||
|
||
function ModelDetail({
|
||
providerName,
|
||
provider,
|
||
model,
|
||
onChange,
|
||
onDelete,
|
||
}: {
|
||
providerName: string;
|
||
provider: ProviderEntry;
|
||
model: ModelEntry;
|
||
onChange: (m: ModelEntry) => void;
|
||
onDelete: () => void;
|
||
}) {
|
||
const [testState, setTestState] = useState<ModelTestState>({ phase: "idle" });
|
||
const { t } = useI18n();
|
||
const [catalogState, setCatalogState] = useState<ModelCatalogState>({
|
||
phase: "idle",
|
||
});
|
||
const catalogRequestIdRef = useRef(0);
|
||
const catalogUndoRef = useRef<ModelEntry | null>(null);
|
||
const set = <K extends keyof ModelEntry>(k: K, v: ModelEntry[K]) =>
|
||
onChange({ ...model, [k]: v });
|
||
const costVal = (k: keyof NonNullable<ModelEntry["cost"]>) =>
|
||
model.cost?.[k] !== undefined ? String(model.cost[k]) : "";
|
||
const setCost = (k: keyof NonNullable<ModelEntry["cost"]>, v: string) => {
|
||
const n = parseFloat(v);
|
||
onChange({
|
||
...model,
|
||
cost: { ...(model.cost ?? {}), [k]: isNaN(n) ? undefined : n },
|
||
});
|
||
};
|
||
const testSummary = (() => {
|
||
if (testState.phase === "idle") return null;
|
||
if (testState.phase === "testing") return t("i18n.testingModel");
|
||
const meta = [
|
||
testState.latencyMs !== undefined ? `${testState.latencyMs}ms` : null,
|
||
testState.status !== undefined ? `HTTP ${testState.status}` : null,
|
||
].filter(Boolean);
|
||
if (testState.phase === "success") {
|
||
return [t("i18n.connected"), ...meta, testState.responseText || null]
|
||
.filter(Boolean)
|
||
.join(" · ");
|
||
}
|
||
return [t("i18n.failed"), ...meta, testState.message]
|
||
.filter(Boolean)
|
||
.join(" · ");
|
||
})();
|
||
|
||
useEffect(() => {
|
||
setTestState({ phase: "idle" });
|
||
}, [
|
||
providerName,
|
||
provider.baseUrl,
|
||
provider.api,
|
||
provider.apiKey,
|
||
model.id,
|
||
model.api,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
catalogRequestIdRef.current += 1;
|
||
setCatalogState({ phase: "idle" });
|
||
catalogUndoRef.current = null;
|
||
}, [providerName, provider.baseUrl, model.id]);
|
||
|
||
const handleTest = useCallback(async () => {
|
||
if (!model.id.trim() || testState.phase === "testing") return;
|
||
setTestState({ phase: "testing" });
|
||
try {
|
||
const res = await fetch("/api/models-config/test", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ providerName, provider, model }),
|
||
});
|
||
const d = (await res.json()) as {
|
||
ok?: boolean;
|
||
error?: string;
|
||
latencyMs?: number;
|
||
status?: number;
|
||
responseText?: string;
|
||
};
|
||
if (!res.ok || !d.ok) {
|
||
setTestState({
|
||
phase: "error",
|
||
message: d.error ?? `HTTP ${res.status}`,
|
||
latencyMs: d.latencyMs,
|
||
status: d.status,
|
||
});
|
||
return;
|
||
}
|
||
setTestState({
|
||
phase: "success",
|
||
latencyMs: d.latencyMs,
|
||
status: d.status,
|
||
responseText: d.responseText,
|
||
});
|
||
} catch (e) {
|
||
setTestState({
|
||
phase: "error",
|
||
message: e instanceof Error ? e.message : String(e),
|
||
});
|
||
}
|
||
}, [model, provider, providerName, testState.phase]);
|
||
|
||
const handleCatalogFill = useCallback(async () => {
|
||
const query = model.id.trim();
|
||
if (!query || catalogState.phase === "loading") return;
|
||
const requestId = ++catalogRequestIdRef.current;
|
||
setCatalogState({ phase: "loading" });
|
||
try {
|
||
const params = new URLSearchParams({
|
||
q: query,
|
||
provider: providerName,
|
||
limit: "50",
|
||
});
|
||
if (provider.baseUrl?.trim())
|
||
params.set("baseUrl", provider.baseUrl.trim());
|
||
const res = await fetch(`/api/models-config/catalog?${params}`);
|
||
const data = (await res.json()) as {
|
||
recommendation?: ModelCatalogRecommendation;
|
||
error?: string;
|
||
};
|
||
if (requestId !== catalogRequestIdRef.current) return;
|
||
if (!res.ok || data.error || !data.recommendation) {
|
||
setCatalogState({
|
||
phase: "error",
|
||
message: data.error ?? `HTTP ${res.status}`,
|
||
});
|
||
return;
|
||
}
|
||
const filled = fillEmptyModelFields(model, data.recommendation.preset);
|
||
if (filled.appliedCount > 0) {
|
||
catalogUndoRef.current = model;
|
||
onChange(filled.model);
|
||
}
|
||
setCatalogState({
|
||
phase: "success",
|
||
recommendation: data.recommendation,
|
||
appliedCount: filled.appliedCount,
|
||
});
|
||
} catch (error) {
|
||
if (requestId !== catalogRequestIdRef.current) return;
|
||
setCatalogState({
|
||
phase: "error",
|
||
message: error instanceof Error ? error.message : String(error),
|
||
});
|
||
}
|
||
}, [catalogState.phase, model, onChange, provider.baseUrl, providerName]);
|
||
|
||
const undoCatalogFill = () => {
|
||
const previous = catalogUndoRef.current;
|
||
if (!previous) return;
|
||
catalogUndoRef.current = null;
|
||
onChange(previous);
|
||
setCatalogState({ phase: "idle" });
|
||
};
|
||
|
||
const catalogResultSummary = (() => {
|
||
if (catalogState.phase !== "success") return null;
|
||
const { recommendation, appliedCount } = catalogState;
|
||
const applied =
|
||
appliedCount > 0
|
||
? t("models.catalogFilled", { count: appliedCount })
|
||
: t("models.catalogNoEmptyFields");
|
||
if (recommendation.price.status === "unreliable") {
|
||
const price =
|
||
recommendation.price.reason === "no-exact-match"
|
||
? t("models.catalogNoExactMatch")
|
||
: t("models.catalogPriceUnreliable");
|
||
return `${applied} · ${price}`;
|
||
}
|
||
const price =
|
||
recommendation.price.method === "provider"
|
||
? t("models.catalogPriceProvider", {
|
||
provider:
|
||
recommendation.price.providerName ??
|
||
recommendation.price.providerId ??
|
||
providerName,
|
||
})
|
||
: recommendation.price.method === "base-url"
|
||
? t("models.catalogPriceBaseUrl", {
|
||
provider:
|
||
recommendation.price.providerName ??
|
||
recommendation.price.providerId ??
|
||
providerName,
|
||
})
|
||
: t("models.catalogPriceConsensus", {
|
||
support: recommendation.price.support,
|
||
total: recommendation.price.total,
|
||
});
|
||
return `${applied} · ${price}`;
|
||
})();
|
||
const catalogStatusText =
|
||
catalogState.phase === "error"
|
||
? catalogState.message
|
||
: catalogResultSummary;
|
||
const catalogStatusColor =
|
||
catalogState.phase === "error"
|
||
? "#ef4444"
|
||
: catalogState.phase === "success" &&
|
||
catalogState.recommendation.price.status === "unreliable"
|
||
? "#d97706"
|
||
: "var(--text-dim)";
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
}}
|
||
>
|
||
<SectionTitle>{t("i18n.model")}</SectionTitle>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||
{testSummary && (
|
||
<span
|
||
title={testSummary}
|
||
style={{
|
||
maxWidth: 260,
|
||
height: 24,
|
||
padding: "0 8px",
|
||
border: `1px solid ${testState.phase === "error" ? "#fecaca" : testState.phase === "success" ? "#bbf7d0" : "var(--border)"}`,
|
||
borderRadius: 4,
|
||
background:
|
||
testState.phase === "error"
|
||
? "#fee2e2"
|
||
: testState.phase === "success"
|
||
? "#dcfce7"
|
||
: "#e5e7eb",
|
||
color: "#111827",
|
||
fontSize: 11,
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
whiteSpace: "nowrap",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
boxSizing: "border-box",
|
||
}}
|
||
>
|
||
{testSummary}
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={handleTest}
|
||
disabled={!model.id.trim() || testState.phase === "testing"}
|
||
title={t("i18n.testConnection")}
|
||
style={{
|
||
height: 24,
|
||
padding: "0 8px",
|
||
background: testState.phase === "success" ? "#16a34a" : "none",
|
||
border: `1px solid ${testState.phase === "success" ? "#16a34a" : "var(--border)"}`,
|
||
borderRadius: 4,
|
||
color:
|
||
testState.phase === "success"
|
||
? "#fff"
|
||
: !model.id.trim() || testState.phase === "testing"
|
||
? "var(--text-dim)"
|
||
: "var(--text-muted)",
|
||
cursor:
|
||
!model.id.trim() || testState.phase === "testing"
|
||
? "not-allowed"
|
||
: "pointer",
|
||
fontSize: 11,
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
boxSizing: "border-box",
|
||
gap: 5,
|
||
}}
|
||
>
|
||
{testState.phase === "success" && (
|
||
<svg
|
||
width="11"
|
||
height="11"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="3"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<polyline points="20 6 9 17 4 12" />
|
||
</svg>
|
||
)}
|
||
{testState.phase === "testing"
|
||
? t("i18n.checking")
|
||
: testState.phase === "success"
|
||
? t("common.ok")
|
||
: t("i18n.test")}
|
||
</button>
|
||
<button
|
||
onClick={onDelete}
|
||
style={{
|
||
height: 24,
|
||
padding: "0 8px",
|
||
background: "none",
|
||
border: "1px solid rgba(239,68,68,0.3)",
|
||
borderRadius: 4,
|
||
color: "#ef4444",
|
||
cursor: "pointer",
|
||
fontSize: 11,
|
||
boxSizing: "border-box",
|
||
}}
|
||
>
|
||
{t("i18n.remove")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||
<Field label="ID *">
|
||
<TextInput
|
||
value={model.id}
|
||
onChange={(v) => set("id", v)}
|
||
placeholder="model-id"
|
||
mono
|
||
/>
|
||
</Field>
|
||
<Field label="Name">
|
||
<TextInput
|
||
value={model.name ?? ""}
|
||
onChange={(v) => set("name", v || undefined)}
|
||
placeholder="Display name"
|
||
/>
|
||
</Field>
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
padding: "10px 0",
|
||
borderTop: "1px solid var(--border)",
|
||
borderBottom: "1px solid var(--border)",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
flexWrap: "wrap",
|
||
}}
|
||
>
|
||
<button
|
||
onClick={() => void handleCatalogFill()}
|
||
disabled={!model.id.trim() || catalogState.phase === "loading"}
|
||
style={{
|
||
height: 28,
|
||
padding: "0 10px",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 5,
|
||
background: "var(--bg-panel)",
|
||
color:
|
||
!model.id.trim() || catalogState.phase === "loading"
|
||
? "var(--text-dim)"
|
||
: "var(--text-muted)",
|
||
cursor:
|
||
!model.id.trim() || catalogState.phase === "loading"
|
||
? "not-allowed"
|
||
: "pointer",
|
||
fontSize: 11,
|
||
}}
|
||
>
|
||
{catalogState.phase === "loading"
|
||
? t("models.catalogFilling")
|
||
: t("models.catalogFill")}
|
||
</button>
|
||
<a
|
||
href="https://github.com/anomalyco/models.dev"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
style={{
|
||
marginLeft: "auto",
|
||
color: "var(--text-dim)",
|
||
fontSize: 10,
|
||
textDecoration: "none",
|
||
}}
|
||
>
|
||
{t("models.catalogSource")}
|
||
</a>
|
||
</div>
|
||
|
||
<div
|
||
aria-live="polite"
|
||
style={{
|
||
marginTop: 6,
|
||
height: 20,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
gap: 8,
|
||
color: catalogStatusColor,
|
||
fontSize: 10,
|
||
}}
|
||
>
|
||
<span
|
||
title={catalogStatusText ?? undefined}
|
||
style={{
|
||
minWidth: 0,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{catalogStatusText}
|
||
</span>
|
||
{catalogUndoRef.current && (
|
||
<button
|
||
onClick={undoCatalogFill}
|
||
style={{
|
||
flexShrink: 0,
|
||
padding: "0 2px",
|
||
border: "none",
|
||
background: "none",
|
||
color: "var(--accent)",
|
||
cursor: "pointer",
|
||
fontSize: 10,
|
||
}}
|
||
>
|
||
{t("models.catalogUndo")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<Field label="API override">
|
||
<Select
|
||
value={model.api ?? ""}
|
||
onChange={(v) => set("api", v || undefined)}
|
||
options={API_OPTIONS}
|
||
/>
|
||
</Field>
|
||
|
||
<div style={{ display: "flex", gap: 20, flexWrap: "wrap" }}>
|
||
<Check
|
||
label="Reasoning / thinking"
|
||
checked={model.reasoning ?? false}
|
||
onChange={(v) => set("reasoning", v || undefined)}
|
||
/>
|
||
<Check
|
||
label="Image input"
|
||
checked={model.input?.includes("image") ?? false}
|
||
onChange={(v) => set("input", v ? ["text", "image"] : undefined)}
|
||
/>
|
||
</div>
|
||
|
||
{model.reasoning && (
|
||
<>
|
||
<Check
|
||
label="DeepSeek thinking compat"
|
||
checked={hasDeepseekCompat(model)}
|
||
onChange={(v) => onChange(setDeepseekCompat(model, v))}
|
||
/>
|
||
<div>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
marginBottom: 8,
|
||
}}
|
||
>
|
||
<SectionTitle>Thinking level map</SectionTitle>
|
||
{model.thinkingLevelMap && (
|
||
<button
|
||
onClick={() => set("thinkingLevelMap", undefined)}
|
||
style={{
|
||
fontSize: 10,
|
||
padding: "2px 7px",
|
||
background: "none",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 4,
|
||
color: "var(--text-dim)",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
clear all
|
||
</button>
|
||
)}
|
||
</div>
|
||
<ThinkingLevelMapEditor
|
||
value={model.thinkingLevelMap}
|
||
onChange={(v) => set("thinkingLevelMap", v)}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||
<Field label="Context window (tokens)">
|
||
<NumInput
|
||
value={
|
||
model.contextWindow !== undefined
|
||
? String(model.contextWindow)
|
||
: ""
|
||
}
|
||
onChange={(v) => set("contextWindow", v ? parseInt(v) : undefined)}
|
||
placeholder="128000"
|
||
/>
|
||
</Field>
|
||
<Field label="Max output tokens">
|
||
<NumInput
|
||
value={model.maxTokens !== undefined ? String(model.maxTokens) : ""}
|
||
onChange={(v) => set("maxTokens", v ? parseInt(v) : undefined)}
|
||
placeholder="16384"
|
||
/>
|
||
</Field>
|
||
</div>
|
||
|
||
<div>
|
||
<SectionTitle>Cost (per million tokens)</SectionTitle>
|
||
<div
|
||
style={{
|
||
marginTop: 8,
|
||
display: "grid",
|
||
gridTemplateColumns: "1fr 1fr 1fr 1fr",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
{(["input", "output", "cacheRead", "cacheWrite"] as const).map(
|
||
(k) => (
|
||
<Field key={k} label={k}>
|
||
<NumInput
|
||
value={costVal(k)}
|
||
onChange={(v) => setCost(k, v)}
|
||
placeholder="0"
|
||
/>
|
||
</Field>
|
||
),
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── OAuth detail ──────────────────────────────────────────────────────────────
|
||
|
||
function OAuthDetail({
|
||
provider,
|
||
onRefresh,
|
||
}: {
|
||
provider: OAuthProvider;
|
||
onRefresh: () => void;
|
||
}) {
|
||
const [loginState, setLoginState] = useState<OAuthLoginState>({
|
||
phase: "idle",
|
||
});
|
||
const { t } = useI18n();
|
||
const [inputValue, setInputValue] = useState("");
|
||
const eventSourceRef = useRef<EventSource | null>(null);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (loginState.phase === "auth" || loginState.phase === "prompt") {
|
||
setTimeout(() => inputRef.current?.focus(), 50);
|
||
}
|
||
}, [loginState.phase]);
|
||
|
||
// Reset state when provider changes
|
||
useEffect(() => {
|
||
setLoginState({ phase: "idle" });
|
||
setInputValue("");
|
||
eventSourceRef.current?.close();
|
||
eventSourceRef.current = null;
|
||
}, [provider.id]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
eventSourceRef.current?.close();
|
||
};
|
||
}, []);
|
||
|
||
const handleLogin = useCallback(() => {
|
||
eventSourceRef.current?.close();
|
||
setLoginState({ phase: "connecting" });
|
||
setInputValue("");
|
||
|
||
const es = new EventSource(
|
||
`/api/auth/login/${encodeURIComponent(provider.id)}`,
|
||
);
|
||
eventSourceRef.current = es;
|
||
|
||
es.onmessage = (e) => {
|
||
const data = JSON.parse(e.data) as {
|
||
type: string;
|
||
url?: string;
|
||
instructions?: string | null;
|
||
token?: string;
|
||
message?: string;
|
||
placeholder?: string | null;
|
||
userCode?: string;
|
||
verificationUri?: string;
|
||
intervalSeconds?: number | null;
|
||
expiresInSeconds?: number | null;
|
||
options?: { id: string; label: string }[];
|
||
};
|
||
if (data.type === "auth") {
|
||
setLoginState({
|
||
phase: "auth",
|
||
url: data.url!,
|
||
instructions: data.instructions ?? null,
|
||
token: data.token!,
|
||
});
|
||
window.open(data.url!, "_blank", "noopener,noreferrer");
|
||
} else if (data.type === "device_code") {
|
||
setLoginState({
|
||
phase: "device_code",
|
||
userCode: data.userCode!,
|
||
verificationUri: data.verificationUri!,
|
||
intervalSeconds: data.intervalSeconds ?? null,
|
||
expiresInSeconds: data.expiresInSeconds ?? null,
|
||
});
|
||
window.open(data.verificationUri!, "_blank", "noopener,noreferrer");
|
||
} else if (data.type === "prompt_request") {
|
||
setLoginState({
|
||
phase: "prompt",
|
||
message: data.message!,
|
||
placeholder: data.placeholder ?? null,
|
||
token: data.token!,
|
||
});
|
||
} else if (data.type === "select_request") {
|
||
setLoginState({
|
||
phase: "select",
|
||
message: data.message!,
|
||
options: data.options ?? [],
|
||
token: data.token!,
|
||
});
|
||
} else if (data.type === "progress") {
|
||
setLoginState({ phase: "progress", message: data.message! });
|
||
} else if (data.type === "success") {
|
||
es.close();
|
||
setLoginState({ phase: "success" });
|
||
onRefresh();
|
||
} else if (data.type === "error") {
|
||
es.close();
|
||
setLoginState({ phase: "error", message: data.message! });
|
||
} else if (data.type === "cancelled") {
|
||
es.close();
|
||
setLoginState({ phase: "idle" });
|
||
}
|
||
};
|
||
es.onerror = () => {
|
||
es.close();
|
||
setLoginState((prev) =>
|
||
prev.phase === "success"
|
||
? prev
|
||
: { phase: "error", message: "Connection lost" },
|
||
);
|
||
};
|
||
}, [provider.id, onRefresh]);
|
||
|
||
const handleLogout = useCallback(async () => {
|
||
await fetch(`/api/auth/logout/${encodeURIComponent(provider.id)}`, {
|
||
method: "POST",
|
||
});
|
||
setLoginState({ phase: "idle" });
|
||
onRefresh();
|
||
}, [provider.id, onRefresh]);
|
||
|
||
const submitCode = useCallback(
|
||
async (token: string, code: string) => {
|
||
if (!code.trim()) return;
|
||
setLoginState({ phase: "progress", message: "Verifying…" });
|
||
try {
|
||
const res = await fetch(
|
||
`/api/auth/login/${encodeURIComponent(provider.id)}`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ token, code: code.trim() }),
|
||
},
|
||
);
|
||
if (!res.ok) {
|
||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||
setLoginState({
|
||
phase: "error",
|
||
message: d.error ?? `Server error ${res.status}`,
|
||
});
|
||
return;
|
||
}
|
||
setInputValue("");
|
||
// Success path: SSE stream will emit "success" and update state
|
||
} catch (e) {
|
||
setLoginState({
|
||
phase: "error",
|
||
message: e instanceof Error ? e.message : "Network error",
|
||
});
|
||
}
|
||
},
|
||
[provider.id],
|
||
);
|
||
|
||
const submitSelection = useCallback(
|
||
async (token: string, value: string) => {
|
||
setLoginState({ phase: "progress", message: "Continuing…" });
|
||
try {
|
||
const res = await fetch(
|
||
`/api/auth/login/${encodeURIComponent(provider.id)}`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ token, code: value }),
|
||
},
|
||
);
|
||
if (!res.ok) {
|
||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||
setLoginState({
|
||
phase: "error",
|
||
message: d.error ?? `Server error ${res.status}`,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
setLoginState({
|
||
phase: "error",
|
||
message: e instanceof Error ? e.message : "Network error",
|
||
});
|
||
}
|
||
},
|
||
[provider.id],
|
||
);
|
||
|
||
const isWorking =
|
||
loginState.phase === "connecting" ||
|
||
loginState.phase === "progress" ||
|
||
loginState.phase === "auth" ||
|
||
loginState.phase === "device_code" ||
|
||
loginState.phase === "prompt" ||
|
||
loginState.phase === "select";
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
}}
|
||
>
|
||
<SectionTitle>{t("i18n.subscription")}</SectionTitle>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||
<span
|
||
style={{
|
||
width: 7,
|
||
height: 7,
|
||
borderRadius: "50%",
|
||
background: provider.loggedIn ? "#4ade80" : "var(--border)",
|
||
display: "inline-block",
|
||
}}
|
||
/>
|
||
<span
|
||
style={{
|
||
fontSize: 11,
|
||
color: provider.loggedIn ? "#4ade80" : "var(--text-dim)",
|
||
}}
|
||
>
|
||
{provider.loggedIn ? t("i18n.connected") : t("i18n.notConnected")}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Status */}
|
||
<div style={{ minHeight: 48 }}>
|
||
{loginState.phase === "idle" && (
|
||
<p
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 12,
|
||
color: "var(--text-muted)",
|
||
lineHeight: 1.5,
|
||
}}
|
||
>
|
||
{provider.loggedIn
|
||
? "Already connected. You can re-login or disconnect."
|
||
: `Connect your ${provider.name} account.`}
|
||
</p>
|
||
)}
|
||
{loginState.phase === "connecting" && (
|
||
<p style={{ margin: 0, fontSize: 12, color: "var(--text-muted)" }}>
|
||
{t("i18n.openingBrowser")}
|
||
</p>
|
||
)}
|
||
{loginState.phase === "select" && (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||
<p
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 12,
|
||
color: "var(--text-muted)",
|
||
lineHeight: 1.5,
|
||
}}
|
||
>
|
||
{loginState.message}
|
||
</p>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||
{loginState.options.map((option) => (
|
||
<button
|
||
key={option.id}
|
||
onClick={() => submitSelection(loginState.token, option.id)}
|
||
style={{
|
||
padding: "6px 9px",
|
||
background: "var(--bg)",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 5,
|
||
color: "var(--text)",
|
||
cursor: "pointer",
|
||
fontSize: 12,
|
||
textAlign: "left",
|
||
}}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{(loginState.phase === "auth" || loginState.phase === "prompt") && (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||
<p
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 12,
|
||
color: "var(--text-muted)",
|
||
lineHeight: 1.5,
|
||
}}
|
||
>
|
||
{loginState.phase === "auth"
|
||
? "Complete sign-in in the browser, then copy the redirect URL from the address bar and paste it below."
|
||
: loginState.message}
|
||
</p>
|
||
{loginState.phase === "auth" && (
|
||
<p
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 11,
|
||
color: "var(--text-dim)",
|
||
lineHeight: 1.5,
|
||
}}
|
||
>
|
||
If the browser window did not open,{" "}
|
||
<a
|
||
href={loginState.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ color: "var(--accent)", wordBreak: "break-all" }}
|
||
>
|
||
click here to open the login page
|
||
</a>
|
||
.
|
||
</p>
|
||
)}
|
||
<div style={{ display: "flex", gap: 6 }}>
|
||
<input
|
||
ref={inputRef}
|
||
value={inputValue}
|
||
onChange={(e) => setInputValue(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter")
|
||
submitCode(loginState.token, inputValue);
|
||
}}
|
||
placeholder={
|
||
loginState.phase === "auth"
|
||
? "http://localhost:1455/auth/callback?code=…"
|
||
: (loginState.placeholder ?? "Enter value…")
|
||
}
|
||
style={{
|
||
flex: 1,
|
||
padding: "6px 9px",
|
||
background: "var(--bg)",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 5,
|
||
color: "var(--text)",
|
||
fontSize: 12,
|
||
outline: "none",
|
||
fontFamily: "var(--font-mono)",
|
||
boxSizing: "border-box",
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={() => submitCode(loginState.token, inputValue)}
|
||
disabled={!inputValue.trim()}
|
||
style={{
|
||
padding: "6px 12px",
|
||
background: inputValue.trim()
|
||
? "var(--accent)"
|
||
: "var(--bg-panel)",
|
||
border: "none",
|
||
borderRadius: 5,
|
||
color: inputValue.trim() ? "#fff" : "var(--text-dim)",
|
||
cursor: inputValue.trim() ? "pointer" : "not-allowed",
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
{t("i18n.submit")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{loginState.phase === "device_code" && (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||
<p
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 12,
|
||
color: "var(--text-muted)",
|
||
lineHeight: 1.5,
|
||
}}
|
||
>
|
||
Open the verification page and enter this code:
|
||
</p>
|
||
<div
|
||
style={{
|
||
padding: "8px 10px",
|
||
background: "var(--bg)",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 5,
|
||
color: "var(--text)",
|
||
fontSize: 16,
|
||
fontWeight: 700,
|
||
fontFamily: "var(--font-mono)",
|
||
letterSpacing: 0,
|
||
}}
|
||
>
|
||
{loginState.userCode}
|
||
</div>
|
||
<p
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 11,
|
||
color: "var(--text-dim)",
|
||
lineHeight: 1.5,
|
||
}}
|
||
>
|
||
<a
|
||
href={loginState.verificationUri}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ color: "var(--accent)", wordBreak: "break-all" }}
|
||
>
|
||
{loginState.verificationUri}
|
||
</a>
|
||
{loginState.expiresInSeconds
|
||
? ` Expires in ${Math.ceil(loginState.expiresInSeconds / 60)} minutes.`
|
||
: ""}
|
||
</p>
|
||
</div>
|
||
)}
|
||
{loginState.phase === "progress" && (
|
||
<p style={{ margin: 0, fontSize: 12, color: "var(--text-muted)" }}>
|
||
{loginState.message}
|
||
</p>
|
||
)}
|
||
{loginState.phase === "success" && (
|
||
<p style={{ margin: 0, fontSize: 12, color: "#4ade80" }}>
|
||
{t("i18n.connectedSuccessfully")}
|
||
</p>
|
||
)}
|
||
{loginState.phase === "error" && (
|
||
<p style={{ margin: 0, fontSize: 12, color: "#f87171" }}>
|
||
{loginState.message}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div style={{ display: "flex", gap: 8 }}>
|
||
{isWorking ? (
|
||
<button
|
||
onClick={() => {
|
||
eventSourceRef.current?.close();
|
||
setLoginState({ phase: "idle" });
|
||
}}
|
||
style={{
|
||
padding: "5px 12px",
|
||
background: "none",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 5,
|
||
color: "var(--text-muted)",
|
||
cursor: "pointer",
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{t("i18n.cancel")}
|
||
</button>
|
||
) : (
|
||
<>
|
||
<button
|
||
onClick={handleLogin}
|
||
style={{
|
||
padding: "5px 14px",
|
||
background: "var(--accent)",
|
||
border: "none",
|
||
borderRadius: 5,
|
||
color: "#fff",
|
||
cursor: "pointer",
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{provider.loggedIn ? t("i18n.relogin") : t("i18n.login")}
|
||
</button>
|
||
{provider.loggedIn && (
|
||
<button
|
||
onClick={handleLogout}
|
||
style={{
|
||
padding: "5px 12px",
|
||
background: "none",
|
||
border: "1px solid rgba(239,68,68,0.3)",
|
||
borderRadius: 5,
|
||
color: "#ef4444",
|
||
cursor: "pointer",
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{t("i18n.disconnect")}
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── API Key detail ────────────────────────────────────────────────────────────
|
||
|
||
function ApiKeyDetail({
|
||
provider,
|
||
onRefresh,
|
||
}: {
|
||
provider: ApiKeyProvider;
|
||
onRefresh: () => void;
|
||
}) {
|
||
const [apiKey, setApiKey] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
const [removing, setRemoving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [savedOk, setSavedOk] = useState(false);
|
||
const { t } = useI18n();
|
||
|
||
// Reset state when provider changes
|
||
useEffect(() => {
|
||
setApiKey("");
|
||
setError(null);
|
||
setSavedOk(false);
|
||
}, [provider.id]);
|
||
|
||
const handleSave = useCallback(async () => {
|
||
if (!apiKey.trim()) return;
|
||
setSaving(true);
|
||
setError(null);
|
||
setSavedOk(false);
|
||
try {
|
||
const res = await fetch(
|
||
`/api/auth/api-key/${encodeURIComponent(provider.id)}`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ apiKey: apiKey.trim() }),
|
||
},
|
||
);
|
||
const d = (await res.json()) as { success?: boolean; error?: string };
|
||
if (!res.ok || d.error) {
|
||
setError(d.error ?? `HTTP ${res.status}`);
|
||
} else {
|
||
setApiKey("");
|
||
setSavedOk(true);
|
||
setTimeout(() => setSavedOk(false), 2000);
|
||
onRefresh();
|
||
}
|
||
} catch (e) {
|
||
setError(String(e));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}, [apiKey, provider.id, onRefresh]);
|
||
|
||
const handleRemove = useCallback(async () => {
|
||
setRemoving(true);
|
||
setError(null);
|
||
try {
|
||
const res = await fetch(
|
||
`/api/auth/api-key/${encodeURIComponent(provider.id)}`,
|
||
{ method: "DELETE" },
|
||
);
|
||
const d = (await res.json()) as { success?: boolean; error?: string };
|
||
if (!res.ok || d.error) setError(d.error ?? `HTTP ${res.status}`);
|
||
else onRefresh();
|
||
} catch (e) {
|
||
setError(String(e));
|
||
} finally {
|
||
setRemoving(false);
|
||
}
|
||
}, [provider.id, onRefresh]);
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
}}
|
||
>
|
||
<SectionTitle>API Key</SectionTitle>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||
<span
|
||
style={{
|
||
width: 7,
|
||
height: 7,
|
||
borderRadius: "50%",
|
||
background: provider.configured ? "#4ade80" : "var(--border)",
|
||
display: "inline-block",
|
||
}}
|
||
/>
|
||
<span
|
||
style={{
|
||
fontSize: 11,
|
||
color: provider.configured ? "#4ade80" : "var(--text-dim)",
|
||
}}
|
||
>
|
||
{provider.configured
|
||
? t("i18n.configured")
|
||
: t("i18n.notConfigured")}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<p
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 12,
|
||
color: "var(--text-muted)",
|
||
lineHeight: 1.5,
|
||
}}
|
||
>
|
||
{provider.configured
|
||
? `API key is stored. Enter a new key below to replace it, or disconnect to remove it.`
|
||
: `Enter your ${provider.displayName} API key to enable ${provider.modelCount} model${provider.modelCount !== 1 ? "s" : ""}.`}
|
||
</p>
|
||
|
||
<Field label="API Key">
|
||
<div style={{ display: "flex", gap: 6 }}>
|
||
<SecretTextInput
|
||
value={apiKey}
|
||
onChange={setApiKey}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && apiKey.trim()) handleSave();
|
||
}}
|
||
placeholder={
|
||
provider.configured ? "Enter new key to replace…" : "sk-…"
|
||
}
|
||
style={{ flex: 1 }}
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
mono
|
||
/>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={saving || !apiKey.trim() || savedOk}
|
||
style={{
|
||
padding: "6px 12px",
|
||
background: savedOk
|
||
? "#16a34a"
|
||
: apiKey.trim()
|
||
? "var(--accent)"
|
||
: "var(--bg-panel)",
|
||
border: "none",
|
||
borderRadius: 5,
|
||
color: apiKey.trim() || savedOk ? "#fff" : "var(--text-dim)",
|
||
cursor:
|
||
saving || !apiKey.trim() || savedOk ? "not-allowed" : "pointer",
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
flexShrink: 0,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 5,
|
||
}}
|
||
>
|
||
{savedOk && (
|
||
<svg
|
||
width="12"
|
||
height="12"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="3"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<polyline points="20 6 9 17 4 12" />
|
||
</svg>
|
||
)}
|
||
{savedOk
|
||
? t("i18n.saved")
|
||
: saving
|
||
? t("i18n.saving")
|
||
: t("i18n.save")}
|
||
</button>
|
||
</div>
|
||
</Field>
|
||
|
||
{error && (
|
||
<p style={{ margin: 0, fontSize: 12, color: "#f87171" }}>{error}</p>
|
||
)}
|
||
|
||
{provider.configured && (
|
||
<button
|
||
onClick={handleRemove}
|
||
disabled={removing}
|
||
style={{
|
||
alignSelf: "flex-start",
|
||
padding: "5px 12px",
|
||
background: "none",
|
||
border: "1px solid rgba(239,68,68,0.3)",
|
||
borderRadius: 5,
|
||
color: "#ef4444",
|
||
cursor: removing ? "not-allowed" : "pointer",
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{removing ? t("i18n.removing") : t("i18n.disconnect")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Provider icon ─────────────────────────────────────────────────────────────
|
||
|
||
function ProviderIcon({ id, size }: { id: string; size: number }) {
|
||
const pi = PROVIDER_ICONS[id];
|
||
if (!pi) {
|
||
const label =
|
||
id
|
||
.split(/[-_]/)
|
||
.filter(Boolean)
|
||
.slice(0, 2)
|
||
.map((part) => part[0])
|
||
.join("")
|
||
.toUpperCase() || "?";
|
||
return (
|
||
<span
|
||
aria-hidden="true"
|
||
style={{
|
||
width: size,
|
||
height: size,
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 4,
|
||
color: "var(--text-dim)",
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
flexShrink: 0,
|
||
fontSize: Math.max(8, Math.floor(size * 0.42)),
|
||
fontWeight: 700,
|
||
lineHeight: 1,
|
||
}}
|
||
>
|
||
{label}
|
||
</span>
|
||
);
|
||
}
|
||
// Color icons: self-colored SVG, no wrapper needed
|
||
if (pi.hasColor) return <pi.Icon size={size} />;
|
||
// Mono icons: use currentColor so they adapt to light/dark theme
|
||
return <pi.Icon size={size} style={{ color: "var(--text-muted)" }} />;
|
||
}
|
||
|
||
// ── Add provider picker ───────────────────────────────────────────────────────
|
||
|
||
interface AddProviderPickerProps {
|
||
oauthProviders: OAuthProvider[];
|
||
apiKeyProviders: ApiKeyProvider[];
|
||
onSelectOAuth: (id: string) => void;
|
||
onSelectApiKey: (id: string) => void;
|
||
onAddCustom: () => void;
|
||
onClose: () => void;
|
||
}
|
||
|
||
function AddProviderPicker({
|
||
oauthProviders,
|
||
apiKeyProviders,
|
||
onSelectOAuth,
|
||
onSelectApiKey,
|
||
onAddCustom,
|
||
onClose,
|
||
}: AddProviderPickerProps) {
|
||
const [search, setSearch] = useState("");
|
||
const { t } = useI18n();
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
setTimeout(() => inputRef.current?.focus(), 30);
|
||
}, []);
|
||
|
||
const q = search.trim().toLowerCase();
|
||
|
||
const availableOAuth = oauthProviders.filter(
|
||
(p) => !p.loggedIn && (!q || p.name.toLowerCase().includes(q)),
|
||
);
|
||
const availableApiKey = apiKeyProviders.filter(
|
||
(p) =>
|
||
!p.configured &&
|
||
(!q ||
|
||
p.displayName.toLowerCase().includes(q) ||
|
||
p.id.toLowerCase().includes(q)),
|
||
);
|
||
const showCustom =
|
||
!q ||
|
||
"custom".includes(q) ||
|
||
"openai-compatible".includes(q) ||
|
||
"anthropic-compatible".includes(q);
|
||
|
||
const totalCount =
|
||
availableOAuth.length + availableApiKey.length + (showCustom ? 1 : 0);
|
||
|
||
const cardStyle: React.CSSProperties = {
|
||
display: "flex",
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
padding: "10px 12px",
|
||
background: "var(--bg-panel)",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 7,
|
||
boxSizing: "border-box",
|
||
cursor: "pointer",
|
||
minWidth: 0,
|
||
textAlign: "left",
|
||
transition: "border-color 0.12s, background 0.12s",
|
||
width: "100%",
|
||
};
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
position: "fixed",
|
||
inset: 0,
|
||
zIndex: 1100,
|
||
background: "rgba(0,0,0,0.4)",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
}}
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) onClose();
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: 820,
|
||
maxWidth: "calc(100vw - 32px)",
|
||
maxHeight: "min(72vh, calc(100vh - 32px))",
|
||
background: "var(--bg)",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 10,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
boxShadow: "0 8px 32px rgba(0,0,0,0.22)",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{/* Search */}
|
||
<div
|
||
style={{
|
||
padding: "10px 14px",
|
||
borderBottom: "1px solid var(--border)",
|
||
flexShrink: 0,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<svg
|
||
width="13"
|
||
height="13"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
style={{ color: "var(--text-dim)", flexShrink: 0 }}
|
||
>
|
||
<circle cx="11" cy="11" r="8" />
|
||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||
</svg>
|
||
<input
|
||
ref={inputRef}
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Escape") onClose();
|
||
}}
|
||
placeholder={t("i18n.searchProviders")}
|
||
style={{
|
||
flex: 1,
|
||
background: "none",
|
||
border: "none",
|
||
outline: "none",
|
||
color: "var(--text)",
|
||
fontSize: 13,
|
||
boxSizing: "border-box",
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Card grid */}
|
||
<div style={{ flex: 1, overflowY: "auto", padding: 14 }}>
|
||
{totalCount === 0 ? (
|
||
<div
|
||
style={{
|
||
padding: "20px 0",
|
||
fontSize: 12,
|
||
color: "var(--text-dim)",
|
||
textAlign: "center",
|
||
}}
|
||
>
|
||
{t("i18n.noProviders")}
|
||
</div>
|
||
) : (
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns:
|
||
"repeat(auto-fit, minmax(min(240px, 100%), 1fr))",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
{showCustom && (
|
||
<div
|
||
style={{
|
||
gridColumn: "1 / -1",
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
color: "var(--text-dim)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.07em",
|
||
}}
|
||
>
|
||
{t("i18n.custom")}
|
||
</div>
|
||
)}
|
||
{showCustom && (
|
||
<button
|
||
onClick={() => {
|
||
onAddCustom();
|
||
onClose();
|
||
}}
|
||
style={cardStyle}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.borderColor = "var(--accent)";
|
||
e.currentTarget.style.background = "var(--bg-hover)";
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.borderColor = "var(--border)";
|
||
e.currentTarget.style.background = "var(--bg-panel)";
|
||
}}
|
||
>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: "var(--text)",
|
||
lineHeight: 1.3,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
OpenAI / Anthropic compatible
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontSize: 10,
|
||
color: "var(--text-dim)",
|
||
marginTop: 2,
|
||
}}
|
||
>
|
||
{t("i18n.customEndpoint")}
|
||
</div>
|
||
</div>
|
||
<span
|
||
style={{
|
||
width: 26,
|
||
height: 26,
|
||
borderRadius: 5,
|
||
background: "var(--bg-hover)",
|
||
border: "1px dashed var(--border)",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<svg
|
||
width="13"
|
||
height="13"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
style={{ color: "var(--text-dim)" }}
|
||
>
|
||
<line x1="12" y1="5" x2="12" y2="19" />
|
||
<line x1="5" y1="12" x2="19" y2="12" />
|
||
</svg>
|
||
</span>
|
||
</button>
|
||
)}
|
||
|
||
{availableOAuth.length > 0 && (
|
||
<div
|
||
style={{
|
||
gridColumn: "1 / -1",
|
||
paddingTop: showCustom ? 6 : 0,
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
color: "var(--text-dim)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.07em",
|
||
}}
|
||
>
|
||
{t("i18n.subscriptions")}
|
||
</div>
|
||
)}
|
||
{availableOAuth.map((p) => (
|
||
<button
|
||
key={p.id}
|
||
onClick={() => {
|
||
onSelectOAuth(p.id);
|
||
onClose();
|
||
}}
|
||
style={cardStyle}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.borderColor = "var(--accent)";
|
||
e.currentTarget.style.background = "var(--bg-hover)";
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.borderColor = "var(--border)";
|
||
e.currentTarget.style.background = "var(--bg-panel)";
|
||
}}
|
||
>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: "var(--text)",
|
||
lineHeight: 1.3,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{p.name}
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontSize: 10,
|
||
color: "var(--text-dim)",
|
||
marginTop: 2,
|
||
}}
|
||
>
|
||
OAuth
|
||
</div>
|
||
</div>
|
||
<ProviderIcon id={p.id} size={28} />
|
||
</button>
|
||
))}
|
||
|
||
{availableApiKey.length > 0 && (
|
||
<div
|
||
style={{
|
||
gridColumn: "1 / -1",
|
||
paddingTop: availableOAuth.length > 0 ? 6 : 0,
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
color: "var(--text-dim)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.07em",
|
||
}}
|
||
>
|
||
API Key
|
||
</div>
|
||
)}
|
||
{availableApiKey.map((p) => (
|
||
<button
|
||
key={p.id}
|
||
onClick={() => {
|
||
onSelectApiKey(p.id);
|
||
onClose();
|
||
}}
|
||
style={cardStyle}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.borderColor = "var(--accent)";
|
||
e.currentTarget.style.background = "var(--bg-hover)";
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.borderColor = "var(--border)";
|
||
e.currentTarget.style.background = "var(--bg-panel)";
|
||
}}
|
||
>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: "var(--text)",
|
||
lineHeight: 1.3,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{p.displayName}
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontSize: 10,
|
||
color: "var(--text-dim)",
|
||
marginTop: 2,
|
||
}}
|
||
>
|
||
{p.modelCount} models
|
||
</div>
|
||
</div>
|
||
<ProviderIcon id={p.id} size={28} />
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Main component ────────────────────────────────────────────────────────────
|
||
|
||
export function ModelsConfig({ onClose }: { onClose: () => void }) {
|
||
const isMobile = useIsMobile();
|
||
const { t } = useI18n();
|
||
const [config, setConfig] = useState<ModelsJson>({ providers: {} });
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [saveError, setSaveError] = useState<string | null>(null);
|
||
const [savedOk, setSavedOk] = useState(false);
|
||
const [selection, setSelection] = useState<Selection | null>(null);
|
||
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([]);
|
||
const [apiKeyProviders, setApiKeyProviders] = useState<ApiKeyProvider[]>([]);
|
||
const [pickerOpen, setPickerOpen] = useState(false);
|
||
const [tab, setTab] = useState<"models" | "vision">("models");
|
||
|
||
const loadOAuthProviders = useCallback(() => {
|
||
fetch("/api/auth/providers")
|
||
.then((r) => r.json())
|
||
.then((d: { providers: OAuthProvider[] }) =>
|
||
setOauthProviders(d.providers),
|
||
)
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
const loadApiKeyProviders = useCallback(() => {
|
||
fetch("/api/auth/all-providers")
|
||
.then((r) => r.json())
|
||
.then((d: { providers: ApiKeyProvider[] }) =>
|
||
setApiKeyProviders(d.providers),
|
||
)
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetch("/api/models-config")
|
||
.then((r) => r.json())
|
||
.then((d: ModelsJson) => {
|
||
const normalized = d.providers ? d : { ...d, providers: {} };
|
||
setConfig(normalized);
|
||
const keys = Object.keys(normalized.providers ?? {});
|
||
if (keys.length > 0) setSelection({ type: "provider", name: keys[0] });
|
||
})
|
||
.catch(() => setConfig({ providers: {} }))
|
||
.finally(() => setLoading(false));
|
||
loadOAuthProviders();
|
||
loadApiKeyProviders();
|
||
}, [loadOAuthProviders, loadApiKeyProviders]);
|
||
|
||
const addCustomProvider = useCallback(() => {
|
||
let finalName = "new-provider";
|
||
let n = 1;
|
||
while (config.providers?.[finalName]) finalName = `new-provider-${n++}`;
|
||
setConfig((prev) => ({
|
||
...prev,
|
||
providers: {
|
||
...(prev.providers ?? {}),
|
||
[finalName]: { api: "openai-completions" },
|
||
},
|
||
}));
|
||
setSelection({ type: "provider", name: finalName });
|
||
}, [config.providers]);
|
||
|
||
const updateProvider = useCallback((name: string, p: ProviderEntry) => {
|
||
setConfig((prev) => ({
|
||
...prev,
|
||
providers: { ...(prev.providers ?? {}), [name]: p },
|
||
}));
|
||
}, []);
|
||
|
||
const renameProvider = useCallback((oldName: string, newName: string) => {
|
||
setConfig((prev) => {
|
||
const entries = Object.entries(prev.providers ?? {});
|
||
const idx = entries.findIndex(([k]) => k === oldName);
|
||
if (idx === -1) return prev;
|
||
entries[idx] = [newName, entries[idx][1]];
|
||
return { ...prev, providers: Object.fromEntries(entries) };
|
||
});
|
||
setSelection((prev) => {
|
||
if (!prev) return prev;
|
||
if (prev.type === "provider" && prev.name === oldName)
|
||
return { type: "provider", name: newName };
|
||
if (prev.type === "model" && prev.providerName === oldName)
|
||
return { ...prev, providerName: newName };
|
||
return prev;
|
||
});
|
||
}, []);
|
||
|
||
const deleteProvider = useCallback((name: string) => {
|
||
setConfig((prev) => {
|
||
const providers = { ...(prev.providers ?? {}) };
|
||
delete providers[name];
|
||
return { ...prev, providers };
|
||
});
|
||
setConfig((prev) => {
|
||
const remaining = Object.keys(prev.providers ?? {});
|
||
setSelection(
|
||
remaining.length > 0 ? { type: "provider", name: remaining[0] } : null,
|
||
);
|
||
return prev;
|
||
});
|
||
}, []);
|
||
|
||
const addModel = useCallback((providerName: string) => {
|
||
setConfig((prev) => {
|
||
const provider = prev.providers?.[providerName] ?? {};
|
||
const models = [...(provider.models ?? []), { id: "" }];
|
||
return {
|
||
...prev,
|
||
providers: {
|
||
...(prev.providers ?? {}),
|
||
[providerName]: { ...provider, models },
|
||
},
|
||
};
|
||
});
|
||
setConfig((prev) => {
|
||
const idx = (prev.providers?.[providerName]?.models?.length ?? 1) - 1;
|
||
setSelection({ type: "model", providerName, index: idx });
|
||
return prev;
|
||
});
|
||
}, []);
|
||
|
||
const addDiscoveredModels = useCallback(
|
||
(providerName: string, discovered: DiscoveredModel[]) => {
|
||
setConfig((prev) => {
|
||
const provider = prev.providers?.[providerName] ?? {};
|
||
const models = [...(provider.models ?? [])];
|
||
const existingIds = new Set(models.map((model) => model.id));
|
||
for (const discoveredModel of discovered) {
|
||
if (existingIds.has(discoveredModel.id)) continue;
|
||
existingIds.add(discoveredModel.id);
|
||
models.push({ id: discoveredModel.id, name: discoveredModel.name });
|
||
}
|
||
return {
|
||
...prev,
|
||
providers: {
|
||
...(prev.providers ?? {}),
|
||
[providerName]: { ...provider, models },
|
||
},
|
||
};
|
||
});
|
||
},
|
||
[],
|
||
);
|
||
|
||
const updateModel = useCallback(
|
||
(providerName: string, index: number, m: ModelEntry) => {
|
||
setConfig((prev) => {
|
||
const provider = prev.providers?.[providerName] ?? {};
|
||
const models = [...(provider.models ?? [])];
|
||
models[index] = m;
|
||
return {
|
||
...prev,
|
||
providers: {
|
||
...(prev.providers ?? {}),
|
||
[providerName]: { ...provider, models },
|
||
},
|
||
};
|
||
});
|
||
},
|
||
[],
|
||
);
|
||
|
||
const removeModel = useCallback((providerName: string, index: number) => {
|
||
setConfig((prev) => {
|
||
const provider = prev.providers?.[providerName] ?? {};
|
||
const models = [...(provider.models ?? [])];
|
||
models.splice(index, 1);
|
||
return {
|
||
...prev,
|
||
providers: {
|
||
...(prev.providers ?? {}),
|
||
[providerName]: {
|
||
...provider,
|
||
models: models.length ? models : undefined,
|
||
},
|
||
},
|
||
};
|
||
});
|
||
setSelection({ type: "provider", name: providerName });
|
||
}, []);
|
||
|
||
const handleSave = useCallback(async () => {
|
||
setSaving(true);
|
||
setSaveError(null);
|
||
setSavedOk(false);
|
||
try {
|
||
const res = await fetch("/api/models-config", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(config),
|
||
});
|
||
const d = (await res.json()) as { success?: boolean; error?: string };
|
||
if (!res.ok || d.error) setSaveError(d.error ?? `HTTP ${res.status}`);
|
||
else {
|
||
setSavedOk(true);
|
||
setTimeout(() => setSavedOk(false), 2000);
|
||
}
|
||
} catch (e) {
|
||
setSaveError(String(e));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}, [config]);
|
||
|
||
const providers = Object.entries(config.providers ?? {});
|
||
const activeOAuth = oauthProviders.filter((p) => p.loggedIn);
|
||
const activeApiKey = apiKeyProviders.filter((p) => p.configured);
|
||
|
||
// Resolve current detail
|
||
const detailContent = (() => {
|
||
if (!selection) return null;
|
||
if (selection.type === "oauth") {
|
||
const p = oauthProviders.find((p) => p.id === selection.providerId);
|
||
if (!p) return null;
|
||
return (
|
||
<OAuthDetail key={p.id} provider={p} onRefresh={loadOAuthProviders} />
|
||
);
|
||
}
|
||
if (selection.type === "apikey") {
|
||
const p = apiKeyProviders.find((p) => p.id === selection.providerId);
|
||
if (!p) return null;
|
||
return (
|
||
<ApiKeyDetail key={p.id} provider={p} onRefresh={loadApiKeyProviders} />
|
||
);
|
||
}
|
||
if (selection.type === "provider") {
|
||
const provider = config.providers?.[selection.name];
|
||
if (!provider) return null;
|
||
return (
|
||
<ProviderDetail
|
||
key={selection.name}
|
||
name={selection.name}
|
||
provider={provider}
|
||
onChange={(p) => updateProvider(selection.name, p)}
|
||
onRename={(n) => renameProvider(selection.name, n)}
|
||
onDelete={() => deleteProvider(selection.name)}
|
||
onAddModels={(models) => addDiscoveredModels(selection.name, models)}
|
||
/>
|
||
);
|
||
}
|
||
const provider = config.providers?.[selection.providerName];
|
||
const model = provider?.models?.[selection.index];
|
||
if (!model) return null;
|
||
return (
|
||
<ModelDetail
|
||
key={`${selection.providerName}-${selection.index}`}
|
||
providerName={selection.providerName}
|
||
provider={provider}
|
||
model={model}
|
||
onChange={(m) =>
|
||
updateModel(selection.providerName, selection.index, m)
|
||
}
|
||
onDelete={() => removeModel(selection.providerName, selection.index)}
|
||
/>
|
||
);
|
||
})();
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
style={{
|
||
position: "fixed",
|
||
inset: 0,
|
||
zIndex: 1000,
|
||
background: "rgba(0,0,0,0.35)",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
}}
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) onClose();
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: isMobile ? "calc(100vw - 16px)" : 860,
|
||
maxWidth: "calc(100vw - 16px)",
|
||
height: isMobile ? "calc(100dvh - 16px)" : "78vh",
|
||
maxHeight: "calc(100dvh - 16px)",
|
||
background: "var(--bg)",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 10,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{/* Header */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
padding: "12px 18px",
|
||
borderBottom: "1px solid var(--border)",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
|
||
<span
|
||
style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}
|
||
>
|
||
{t("common.models")}
|
||
</span>
|
||
<code
|
||
style={{
|
||
fontSize: 11,
|
||
color: "var(--text-muted)",
|
||
fontFamily: "var(--font-mono)",
|
||
}}
|
||
>
|
||
data/agent/models.json
|
||
</code>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
style={{
|
||
background: "none",
|
||
border: "none",
|
||
color: "var(--text-muted)",
|
||
cursor: "pointer",
|
||
fontSize: 20,
|
||
lineHeight: 1,
|
||
padding: "2px 6px",
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
gap: 4,
|
||
padding: "6px 18px 0",
|
||
borderBottom: "1px solid var(--border)",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<button
|
||
onClick={() => setTab("models")}
|
||
style={{
|
||
padding: "6px 12px",
|
||
borderRadius: "6px 6px 0 0",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
fontSize: 13,
|
||
fontWeight: tab === "models" ? 600 : 400,
|
||
color: tab === "models" ? "var(--text)" : "var(--text-muted)",
|
||
background: tab === "models" ? "var(--bg-panel)" : "none",
|
||
borderBottom:
|
||
tab === "models"
|
||
? "2px solid var(--accent)"
|
||
: "2px solid transparent",
|
||
}}
|
||
>
|
||
{t("common.models")}
|
||
</button>
|
||
<button
|
||
onClick={() => setTab("vision")}
|
||
style={{
|
||
padding: "6px 12px",
|
||
borderRadius: "6px 6px 0 0",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
fontSize: 13,
|
||
fontWeight: tab === "vision" ? 600 : 400,
|
||
color: tab === "vision" ? "var(--text)" : "var(--text-muted)",
|
||
background: tab === "vision" ? "var(--bg-panel)" : "none",
|
||
borderBottom:
|
||
tab === "vision"
|
||
? "2px solid var(--accent)"
|
||
: "2px solid transparent",
|
||
}}
|
||
>
|
||
{t("vision.title")}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
display: "flex",
|
||
flexDirection: isMobile ? "column" : "row",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{tab === "vision" ? (
|
||
<VisionConfigContent />
|
||
) : (
|
||
<>
|
||
{/* Left: tree */}
|
||
<div
|
||
style={{
|
||
width: isMobile ? "100%" : 210,
|
||
maxHeight: isMobile ? "40vh" : undefined,
|
||
borderRight: isMobile ? "none" : "1px solid var(--border)",
|
||
borderBottom: isMobile ? "1px solid var(--border)" : "none",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
flexShrink: 0,
|
||
background: "var(--bg-panel)",
|
||
}}
|
||
>
|
||
<div
|
||
style={{ flex: 1, overflowY: "auto", padding: "8px 6px" }}
|
||
>
|
||
{/* Active OAuth subscriptions */}
|
||
{activeOAuth.map((p) => {
|
||
const isSelected =
|
||
selection?.type === "oauth" &&
|
||
selection.providerId === p.id;
|
||
return (
|
||
<div
|
||
key={p.id}
|
||
onClick={() =>
|
||
setSelection({ type: "oauth", providerId: p.id })
|
||
}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 7,
|
||
padding: "5px 8px",
|
||
borderRadius: 5,
|
||
cursor: "pointer",
|
||
background: isSelected
|
||
? "var(--bg-selected)"
|
||
: "none",
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!isSelected)
|
||
e.currentTarget.style.background =
|
||
"var(--bg-hover)";
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!isSelected)
|
||
e.currentTarget.style.background = "none";
|
||
}}
|
||
>
|
||
<ProviderIcon id={p.id} size={16} />
|
||
<span
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--text)",
|
||
flex: 1,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{p.name}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Active API key providers */}
|
||
{activeApiKey.map((p) => {
|
||
const isSelected =
|
||
selection?.type === "apikey" &&
|
||
selection.providerId === p.id;
|
||
return (
|
||
<div
|
||
key={p.id}
|
||
onClick={() =>
|
||
setSelection({ type: "apikey", providerId: p.id })
|
||
}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 7,
|
||
padding: "5px 8px",
|
||
borderRadius: 5,
|
||
cursor: "pointer",
|
||
background: isSelected
|
||
? "var(--bg-selected)"
|
||
: "none",
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!isSelected)
|
||
e.currentTarget.style.background =
|
||
"var(--bg-hover)";
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!isSelected)
|
||
e.currentTarget.style.background = "none";
|
||
}}
|
||
>
|
||
<ProviderIcon id={p.id} size={16} />
|
||
<span
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--text)",
|
||
flex: 1,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{p.displayName}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Divider before custom providers, only when there are active managed providers */}
|
||
{(activeOAuth.length > 0 || activeApiKey.length > 0) &&
|
||
providers.length > 0 && (
|
||
<div
|
||
style={{
|
||
margin: "4px 8px",
|
||
borderTop: "1px solid var(--border)",
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Custom providers */}
|
||
{loading ? (
|
||
<div
|
||
style={{
|
||
padding: "10px 8px",
|
||
fontSize: 12,
|
||
color: "var(--text-muted)",
|
||
}}
|
||
>
|
||
{t("i18n.loading")}
|
||
</div>
|
||
) : (
|
||
providers.map(([pName, pData]) => {
|
||
const isProviderSelected =
|
||
selection?.type === "provider" &&
|
||
selection.name === pName;
|
||
const models = pData.models ?? [];
|
||
return (
|
||
<div key={pName} style={{ marginBottom: 2 }}>
|
||
{/* Provider row */}
|
||
<div
|
||
onClick={() =>
|
||
setSelection({ type: "provider", name: pName })
|
||
}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
padding: "7px 8px",
|
||
borderRadius: 5,
|
||
cursor: "pointer",
|
||
background: isProviderSelected
|
||
? "var(--bg-selected)"
|
||
: "none",
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!isProviderSelected)
|
||
e.currentTarget.style.background =
|
||
"var(--bg-hover)";
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!isProviderSelected)
|
||
e.currentTarget.style.background = "none";
|
||
}}
|
||
>
|
||
<svg
|
||
width="11"
|
||
height="11"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
style={{
|
||
color: "var(--text-dim)",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<rect
|
||
x="4"
|
||
y="4"
|
||
width="16"
|
||
height="16"
|
||
rx="2"
|
||
/>
|
||
<rect x="9" y="9" width="6" height="6" />
|
||
<line x1="9" y1="1" x2="9" y2="4" />
|
||
<line x1="15" y1="1" x2="15" y2="4" />
|
||
<line x1="9" y1="20" x2="9" y2="23" />
|
||
<line x1="15" y1="20" x2="15" y2="23" />
|
||
<line x1="20" y1="9" x2="23" y2="9" />
|
||
<line x1="20" y1="14" x2="23" y2="14" />
|
||
<line x1="1" y1="9" x2="4" y2="9" />
|
||
<line x1="1" y1="14" x2="4" y2="14" />
|
||
</svg>
|
||
<span
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: isProviderSelected ? 600 : 400,
|
||
color: "var(--text)",
|
||
fontFamily: "var(--font-mono)",
|
||
flex: 1,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{pName}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Model rows */}
|
||
{models.map((m, i) => {
|
||
const isModelSelected =
|
||
selection?.type === "model" &&
|
||
selection.providerName === pName &&
|
||
selection.index === i;
|
||
return (
|
||
<div
|
||
key={i}
|
||
onClick={() =>
|
||
setSelection({
|
||
type: "model",
|
||
providerName: pName,
|
||
index: i,
|
||
})
|
||
}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
padding: "5px 8px 5px 26px",
|
||
borderRadius: 5,
|
||
cursor: "pointer",
|
||
background: isModelSelected
|
||
? "var(--bg-selected)"
|
||
: "none",
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!isModelSelected)
|
||
e.currentTarget.style.background =
|
||
"var(--bg-hover)";
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!isModelSelected)
|
||
e.currentTarget.style.background = "none";
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: 11,
|
||
fontFamily: "var(--font-mono)",
|
||
color: m.id
|
||
? "var(--text-muted)"
|
||
: "var(--text-dim)",
|
||
flex: 1,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{m.id || t("i18n.newModel")}
|
||
</span>
|
||
{m.reasoning && (
|
||
<span
|
||
style={{
|
||
fontSize: 9,
|
||
padding: "1px 4px",
|
||
background: "rgba(99,102,241,0.12)",
|
||
color: "rgba(99,102,241,0.8)",
|
||
borderRadius: 3,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
T
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Add model button */}
|
||
<div
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
addModel(pName);
|
||
}}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
padding: "4px 8px 4px 26px",
|
||
borderRadius: 5,
|
||
cursor: "pointer",
|
||
color: "var(--text-dim)",
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.color = "var(--accent)";
|
||
e.currentTarget.style.background =
|
||
"var(--bg-hover)";
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.color = "var(--text-dim)";
|
||
e.currentTarget.style.background = "none";
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 11 }}>
|
||
+ {t("i18n.model")}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
|
||
{/* Add provider */}
|
||
<div
|
||
style={{
|
||
borderTop: "1px solid var(--border)",
|
||
padding: "8px 6px",
|
||
}}
|
||
>
|
||
<button
|
||
onClick={() => setPickerOpen(true)}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
gap: 5,
|
||
width: "100%",
|
||
padding: "6px 0",
|
||
background: "none",
|
||
border: "1px dashed var(--border)",
|
||
borderRadius: 5,
|
||
color: "var(--text-muted)",
|
||
cursor: "pointer",
|
||
fontSize: 12,
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.borderColor = "var(--accent)";
|
||
e.currentTarget.style.color = "var(--accent)";
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.borderColor = "var(--border)";
|
||
e.currentTarget.style.color = "var(--text-muted)";
|
||
}}
|
||
>
|
||
+ {t("i18n.addProvider")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right: detail */}
|
||
<div style={{ flex: 1, overflowY: "auto", padding: 20 }}>
|
||
{loading
|
||
? null
|
||
: (detailContent ?? (
|
||
<div
|
||
style={{
|
||
height: "100%",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
color: "var(--text-dim)",
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
{t("i18n.selectProviderModel")}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "flex-end",
|
||
gap: 10,
|
||
padding: "10px 18px",
|
||
borderTop: "1px solid var(--border)",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
{saveError && (
|
||
<span style={{ fontSize: 12, color: "#f87171", flex: 1 }}>
|
||
{saveError}
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={onClose}
|
||
style={{
|
||
padding: "6px 14px",
|
||
background: "none",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 6,
|
||
color: "var(--text-muted)",
|
||
cursor: "pointer",
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
{t("i18n.cancel")}
|
||
</button>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={saving || savedOk}
|
||
style={{
|
||
position: "relative",
|
||
padding: "6px 16px",
|
||
minWidth: 92,
|
||
background: savedOk
|
||
? "#16a34a"
|
||
: saving
|
||
? "var(--bg-panel)"
|
||
: "var(--accent)",
|
||
border: "none",
|
||
borderRadius: 6,
|
||
color: savedOk ? "#fff" : saving ? "var(--text-muted)" : "#fff",
|
||
cursor: saving || savedOk ? "default" : "pointer",
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
gap: 6,
|
||
transition: "background-color 0.2s ease, color 0.2s ease",
|
||
animation: savedOk ? "saved-pop 0.45s ease" : undefined,
|
||
}}
|
||
>
|
||
{savedOk && (
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="3"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
style={{
|
||
strokeDasharray: 18,
|
||
animation: "saved-check-draw 0.35s ease forwards",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<polyline points="20 6 9 17 4 12" />
|
||
</svg>
|
||
)}
|
||
<span>
|
||
{savedOk
|
||
? t("i18n.saved")
|
||
: saving
|
||
? t("i18n.saving")
|
||
: t("i18n.save")}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{pickerOpen && (
|
||
<AddProviderPicker
|
||
oauthProviders={oauthProviders}
|
||
apiKeyProviders={apiKeyProviders}
|
||
onSelectOAuth={(id) =>
|
||
setSelection({ type: "oauth", providerId: id })
|
||
}
|
||
onSelectApiKey={(id) =>
|
||
setSelection({ type: "apikey", providerId: id })
|
||
}
|
||
onAddCustom={addCustomProvider}
|
||
onClose={() => setPickerOpen(false)}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|