import { getErrorMessage } from "../../lib/error-message.js"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { Label } from "@/components/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/select"; import { createAiProvider, deleteAiProvider, getAiProviderModels, probeAiModels, updateAiProvider, type AiProvider, type AiProviderType, } from "@/api/ai-api"; const PROVIDER_TYPES: Array<{ value: AiProviderType; labelKey: string; needsBaseUrl: boolean; needsApiKey: boolean; defaultBaseUrl?: string; }> = [ { value: "ollama", labelKey: "ai.providerOllama", needsBaseUrl: true, needsApiKey: false, defaultBaseUrl: "http://localhost:11434", }, { value: "anthropic", labelKey: "ai.providerAnthropic", needsBaseUrl: false, needsApiKey: true, }, { value: "openai", labelKey: "ai.providerOpenai", needsBaseUrl: false, needsApiKey: true, }, { value: "gemini", labelKey: "ai.providerGemini", needsBaseUrl: false, needsApiKey: true, }, { value: "openai_compatible", labelKey: "ai.providerOpenaiCompatible", needsBaseUrl: true, needsApiKey: false, }, ]; interface AiProviderSettingsProps { providers: AiProvider[]; /** selectId names a provider that should become the active one. */ onChanged: (selectId?: number) => void; onAdded?: () => void; } function AiProviderEditForm({ provider, onSaved, onCancel, }: { provider: AiProvider; onSaved: () => void; onCancel: () => void; }) { const { t } = useTranslation(); const [label, setLabel] = useState(provider.label); const [defaultModel, setDefaultModel] = useState(provider.defaultModel ?? ""); const [models, setModels] = useState([]); const [customModel, setCustomModel] = useState(false); const [detecting, setDetecting] = useState(false); const [saving, setSaving] = useState(false); const detectModels = useCallback(async () => { setDetecting(true); try { const detected = await getAiProviderModels(provider.id); setModels(detected); setCustomModel(!!defaultModel && !detected.includes(defaultModel)); } catch { setModels([]); setCustomModel(true); } finally { setDetecting(false); } }, [provider.id, defaultModel]); useEffect(() => { void detectModels(); // The initial model value belongs to this provider. Subsequent edits must // not trigger a provider model-list request on every keystroke. // eslint-disable-next-line react-hooks/exhaustive-deps }, [provider.id]); async function handleSave() { if (!label.trim()) { toast.error(t("ai.labelRequired")); return; } setSaving(true); try { await updateAiProvider(provider.id, { label: label.trim(), defaultModel: defaultModel.trim() || null, }); toast.success(t("ai.providerUpdated")); onSaved(); } catch (error) { toast.error(getErrorMessage(error, t("ai.providerSaveFailed"))); } finally { setSaving(false); } } return (
setLabel(event.target.value)} autoFocus />
{models.length > 0 && !customModel ? ( ) : ( setDefaultModel(event.target.value)} placeholder={t("ai.defaultModelPlaceholder")} /> )}
); } export function AiProviderSettings({ providers, onChanged, onAdded, }: AiProviderSettingsProps) { const { t } = useTranslation(); const [adding, setAdding] = useState(false); const [editingId, setEditingId] = useState(null); const [saving, setSaving] = useState(false); const [providerType, setProviderType] = useState("ollama"); const [label, setLabel] = useState(""); const [baseUrl, setBaseUrl] = useState("http://localhost:11434"); const [apiKey, setApiKey] = useState(""); const [defaultModel, setDefaultModel] = useState(""); const [models, setModels] = useState([]); const [detecting, setDetecting] = useState(false); const [detectWarning, setDetectWarning] = useState(null); const [customModel, setCustomModel] = useState(false); const spec = PROVIDER_TYPES.find((entry) => entry.value === providerType)!; useEffect(() => { setBaseUrl(spec.defaultBaseUrl ?? ""); setApiKey(""); setModels([]); setDefaultModel(""); setCustomModel(false); setDetectWarning(null); }, [providerType, spec.defaultBaseUrl]); /** * Fetches the provider's own model list so nobody has to go and look model * names up. Falls back to a curated list when the endpoint is unreachable, * and a free-text field is always available for anything not listed. */ const detectModels = useCallback(async () => { setDetecting(true); setDetectWarning(null); try { const result = await probeAiModels({ providerType, baseUrl: baseUrl.trim() || null, apiKey: apiKey.trim() || null, }); setModels(result.models); if (result.source === "fallback") { setDetectWarning(t("ai.modelDetectFailed")); } // Pick the first suggestion so the field is never left empty. setDefaultModel((current) => current || result.models[0] || ""); } catch { setDetectWarning(t("ai.modelDetectFailed")); } finally { setDetecting(false); } }, [providerType, baseUrl, apiKey, t]); // Detect as soon as the provider has enough detail to be reachable. useEffect(() => { if (!adding) return; const ready = spec.needsApiKey ? apiKey.trim().length > 0 : true; if (!ready) return; const timer = setTimeout(() => void detectModels(), 400); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [adding, providerType, baseUrl, apiKey]); async function handleAdd() { if (!label.trim()) { toast.error(t("ai.labelRequired")); return; } setSaving(true); try { const created = await createAiProvider({ providerType, label: label.trim(), baseUrl: baseUrl.trim() || null, apiKey: apiKey.trim() || null, defaultModel: defaultModel.trim() || null, }); setAdding(false); setLabel(""); setApiKey(""); setDefaultModel(""); onChanged(created.id); onAdded?.(); } catch (error) { toast.error(getErrorMessage(error, t("ai.providerSaveFailed"))); } finally { setSaving(false); } } async function handleDelete(id: number) { try { await deleteAiProvider(id); onChanged(); } catch (error) { toast.error(getErrorMessage(error, t("ai.providerDeleteFailed"))); } } return (
{providers.map((provider) => editingId === provider.id ? ( { setEditingId(null); onChanged(provider.id); }} onCancel={() => setEditingId(null)} /> ) : (
{provider.label}
{provider.providerType} {provider.defaultModel ? ` · ${provider.defaultModel}` : ""} {provider.baseUrl ? ` · ${provider.baseUrl}` : ""} {provider.apiKeyPrefix ? ` · ${provider.apiKeyPrefix}…` : ""}
), )} {!adding && ( )} {adding && (
setLabel(event.target.value)} placeholder={t("ai.providerLabelPlaceholder")} />
{spec.needsBaseUrl && (
setBaseUrl(event.target.value)} placeholder="http://localhost:11434" />

{t("ai.privateEndpointHint")}

)} {(spec.needsApiKey || providerType === "openai_compatible") && (
setApiKey(event.target.value)} autoComplete="off" />
)}
{models.length > 0 && !customModel ? ( ) : ( setDefaultModel(event.target.value)} placeholder={t("ai.defaultModelPlaceholder")} /> )} {detectWarning && (

{detectWarning}

)}
)}
); }