import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X, RefreshCw } from "lucide-react"; import type { ProviderConfig, ProviderAdvancedParams, OpenAIApiFormat, ProviderStyle } from "../../../../infrastructure/ai/types"; import { PROVIDER_PRESETS, resolveOpenAIApi, resolveProviderStyle } from "../../../../infrastructure/ai/types"; import { normalizeOllamaSdkBaseURL } from "../../../../infrastructure/ai/ollamaCompatBaseUrl"; import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction"; import { probeProviderConnection, validateProviderProbeInputs, type ProviderProbeHealth, } from "../../../../infrastructure/ai/providerConnectionProbe"; import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter"; import { useI18n } from "../../../../application/i18n/I18nProvider"; import { Button } from "../../../ui/button"; import { cn } from "../../../../lib/utils"; import type { BuiltinProviderIcon } from "./types"; import { BUILTIN_PROVIDER_ICONS, getFetchBridge } from "./types"; import type { ProviderFormState } from "./types"; import { ModelSelector } from "./ModelSelector"; import { mergeModelContextWindow } from "./modelMetadata"; import { ProviderIconBadge } from "./ProviderIconBadge"; const ICON_PIXEL_SIZE = 64; const ICON_WEBP_QUALITY = 0.85; const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; async function compressIconFileToDataUrl(file: File): Promise { if (file.size > MAX_UPLOAD_BYTES) { throw new Error("Image too large; please use an image under 5 MB."); } const sourceUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => reject(reader.error ?? new Error("Failed to read file")); reader.readAsDataURL(file); }); const img = await new Promise((resolve, reject) => { const el = new Image(); el.onload = () => resolve(el); el.onerror = () => reject(new Error("Failed to decode image")); el.src = sourceUrl; }); const canvas = document.createElement("canvas"); canvas.width = ICON_PIXEL_SIZE; canvas.height = ICON_PIXEL_SIZE; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Canvas 2D context unavailable"); ctx.clearRect(0, 0, ICON_PIXEL_SIZE, ICON_PIXEL_SIZE); const scale = Math.min(ICON_PIXEL_SIZE / img.width, ICON_PIXEL_SIZE / img.height); const w = img.width * scale; const h = img.height * scale; ctx.drawImage(img, (ICON_PIXEL_SIZE - w) / 2, (ICON_PIXEL_SIZE - h) / 2, w, h); return canvas.toDataURL("image/webp", ICON_WEBP_QUALITY); } const STYLE_OPTIONS: ReadonlyArray = ["anthropic", "openai", "google"]; const OPENAI_API_OPTIONS: ReadonlyArray = ["chat", "responses"]; /** Same box as the h-8 fields above. Transparent border keeps primary aligned with outline. */ const PROVIDER_ACTION_CLASS = "box-border h-8 px-3 gap-1.5 text-sm font-medium leading-none"; export const ProviderConfigForm: React.FC<{ provider: ProviderConfig; onSave: (updates: Partial) => void; onCancel: () => void; }> = ({ provider, onSave, onCancel }) => { const { t } = useI18n(); const fileInputRef = useRef(null); const [form, setForm] = useState({ name: provider.name ?? PROVIDER_PRESETS[provider.providerId]?.name ?? "", apiKey: "", baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "", defaultModel: provider.defaultModel ?? "", contextWindow: provider.contextWindow != null ? String(provider.contextWindow) : "", modelContextWindows: provider.modelContextWindows ?? {}, skipTLSVerify: provider.skipTLSVerify ?? false, advancedParams: provider.advancedParams ?? {}, style: provider.style ?? "", openaiApi: resolveOpenAIApi(provider), iconId: provider.iconId ?? "", iconDataUrl: provider.iconDataUrl ?? "", }); const [showApiKey, setShowApiKey] = useState(false); const [isDecrypting, setIsDecrypting] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); const [showIconPicker, setShowIconPicker] = useState(false); const [iconError, setIconError] = useState(null); const [contextWindowError, setContextWindowError] = useState(null); const [apiKeySourceVersion, setApiKeySourceVersion] = useState(0); const [isTesting, setIsTesting] = useState(false); const [probeResult, setProbeResult] = useState<{ health: ProviderProbeHealth; message: string; } | null>(null); const probeRequestIdRef = useRef(0); const preset = PROVIDER_PRESETS[provider.providerId]; const resolvedStyle: ProviderStyle = form.style || resolveProviderStyle({ providerId: provider.providerId }); const resolvedBaseURL = provider.providerId === "ollama" ? normalizeOllamaSdkBaseURL(form.baseURL || preset?.defaultBaseURL || "") : (form.baseURL || preset?.defaultBaseURL || ""); const modelMetadataSourceKey = useMemo(() => JSON.stringify({ providerId: provider.providerId, baseURL: form.baseURL || preset?.defaultBaseURL || "", modelsEndpoint: preset?.modelsEndpoint ?? "", apiKeySourceVersion, style: resolvedStyle, skipTLSVerify: form.skipTLSVerify, }), [ provider.providerId, form.baseURL, apiKeySourceVersion, form.skipTLSVerify, preset?.defaultBaseURL, preset?.modelsEndpoint, resolvedStyle, ]); const probeFingerprint = useMemo(() => JSON.stringify({ baseURL: form.baseURL || preset?.defaultBaseURL || "", apiKey: form.apiKey, style: resolvedStyle, skipTLSVerify: form.skipTLSVerify, modelsEndpoint: preset?.modelsEndpoint ?? "", }), [ form.apiKey, form.baseURL, form.skipTLSVerify, preset?.defaultBaseURL, preset?.modelsEndpoint, resolvedStyle, ]); const modelMetadataSourceKeyRef = useRef(null); const probeFingerprintRef = useRef(null); const previewProvider: Pick = { providerId: provider.providerId, name: form.name, iconId: form.iconId || undefined, iconDataUrl: form.iconDataUrl || undefined, }; // Decrypt and load existing API key on mount useEffect(() => { if (provider.apiKey) { setIsDecrypting(true); decryptField(provider.apiKey) .then((decrypted) => { setForm((prev) => ({ ...prev, apiKey: decrypted ?? "" })); }) .catch(() => { // If decryption fails, show raw value setForm((prev) => ({ ...prev, apiKey: provider.apiKey ?? "" })); }) .finally(() => setIsDecrypting(false)); } }, [provider.apiKey]); useEffect(() => { if (modelMetadataSourceKeyRef.current == null) { modelMetadataSourceKeyRef.current = modelMetadataSourceKey; return; } if (modelMetadataSourceKeyRef.current === modelMetadataSourceKey) return; modelMetadataSourceKeyRef.current = modelMetadataSourceKey; setForm((prev) => Object.keys(prev.modelContextWindows).length > 0 ? { ...prev, modelContextWindows: {} } : prev); }, [modelMetadataSourceKey]); useEffect(() => { if (probeFingerprintRef.current == null) { probeFingerprintRef.current = probeFingerprint; return; } if (probeFingerprintRef.current === probeFingerprint) return; probeFingerprintRef.current = probeFingerprint; probeRequestIdRef.current += 1; setProbeResult(null); setIsTesting(false); }, [probeFingerprint]); const [advancedParamRaw, setAdvancedParamRaw] = useState>({}); const handleAdvancedParam = useCallback((key: keyof ProviderAdvancedParams, raw: string) => { setAdvancedParamRaw((prev) => ({ ...prev, [key]: raw })); setForm((prev) => { const next = { ...prev.advancedParams }; if (raw.trim() === "" || raw.trim() === "-") { delete next[key]; } else { const num = Number(raw); if (!Number.isNaN(num)) { next[key] = num; } } return { ...prev, advancedParams: next }; }); }, []); const handleIconFileSelect = useCallback(async (file: File | null) => { setIconError(null); if (!file) return; if (!/^image\//.test(file.type)) { setIconError(t("ai.providers.icon.errorType")); return; } try { const dataUrl = await compressIconFileToDataUrl(file); setForm((prev) => ({ ...prev, iconDataUrl: dataUrl, iconId: "" })); } catch (err) { setIconError(err instanceof Error ? err.message : String(err)); } }, [t]); const handlePickBuiltin = useCallback((icon: BuiltinProviderIcon) => { setIconError(null); setForm((prev) => ({ ...prev, iconId: icon.id, iconDataUrl: "", name: icon.name })); }, []); const handleResetIcon = useCallback(() => { setIconError(null); setForm((prev) => ({ ...prev, iconId: "", iconDataUrl: "" })); }, []); const handleApiKeyChange = useCallback((value: string) => { setApiKeySourceVersion((version) => version + 1); setForm((prev) => ({ ...prev, apiKey: value })); }, []); const handleTestConnection = useCallback(async () => { const baseURL = resolvedBaseURL; const inputCheck = validateProviderProbeInputs({ baseURL, apiKey: form.apiKey, providerId: provider.providerId, }); if (!inputCheck.ok) { probeRequestIdRef.current += 1; setIsTesting(false); setProbeResult({ health: "error", message: t( inputCheck.reason === "missing_base_url" ? "ai.providers.test.missingBaseUrl" : "ai.providers.test.missingApiKey", ), }); return; } const requestId = ++probeRequestIdRef.current; setIsTesting(true); setProbeResult(null); try { const run = await probeProviderConnection({ bridge: getFetchBridge(), baseURL, apiKey: form.apiKey, providerId: provider.providerId, style: resolvedStyle, presetModelsEndpoint: preset?.modelsEndpoint, skipTLSVerify: form.skipTLSVerify, }); if (probeRequestIdRef.current !== requestId) return; if (!run.ok) { setProbeResult({ health: "error", message: t( run.reason === "missing_base_url" ? "ai.providers.test.missingBaseUrl" : run.reason === "missing_api_key" ? "ai.providers.test.missingApiKey" : "ai.providers.test.unavailable", ), }); return; } const classified = run.classification; const latency = String(classified.latencyMs); if (classified.health === "ok") { setProbeResult({ health: "ok", message: t("ai.providers.test.ok", { latency }), }); } else if (classified.health === "warn") { const warnKey = classified.modelCount === 0 || classified.error ? "ai.providers.test.warn" : "ai.providers.test.warnSlow"; setProbeResult({ health: "warn", message: t(warnKey, { latency }), }); } else { const detail = classified.error || (classified.statusCode ? `HTTP ${classified.statusCode}` : "error"); setProbeResult({ health: "error", message: t("ai.providers.test.error", { detail }), }); } } catch (err) { if (probeRequestIdRef.current !== requestId) return; setProbeResult({ health: "error", message: t("ai.providers.test.error", { detail: err instanceof Error ? err.message : String(err), }), }); } finally { if (probeRequestIdRef.current === requestId) setIsTesting(false); } }, [form.apiKey, form.skipTLSVerify, preset?.modelsEndpoint, provider.providerId, resolvedBaseURL, resolvedStyle, t]); const handleSave = useCallback(async () => { const cleanedParams: ProviderAdvancedParams = {}; const ap = form.advancedParams; if (ap.maxTokens != null && Number.isFinite(ap.maxTokens) && ap.maxTokens > 0) cleanedParams.maxTokens = Math.max(1, Math.round(ap.maxTokens)); if (ap.temperature != null) cleanedParams.temperature = Math.min(2, Math.max(0, ap.temperature)); if (ap.topP != null) cleanedParams.topP = Math.min(1, Math.max(0, ap.topP)); if (ap.frequencyPenalty != null) cleanedParams.frequencyPenalty = Math.min(2, Math.max(-2, ap.frequencyPenalty)); if (ap.presencePenalty != null) cleanedParams.presencePenalty = Math.min(2, Math.max(-2, ap.presencePenalty)); const trimmedName = form.name.trim(); const defaultName = PROVIDER_PRESETS[provider.providerId]?.name ?? ""; const rawContextWindow = form.contextWindow.trim(); const rawContextWindowNumber = Number(rawContextWindow); if (rawContextWindow && (!Number.isInteger(rawContextWindowNumber) || rawContextWindowNumber <= 0)) { setContextWindowError(t("ai.providers.contextWindow.error")); return; } const manualContextWindow = rawContextWindow ? sanitizeContextWindow(rawContextWindow) : undefined; if (rawContextWindow && manualContextWindow == null) { setContextWindowError(t("ai.providers.contextWindow.error")); return; } setContextWindowError(null); const updates: Partial = { name: trimmedName || defaultName, baseURL: provider.providerId === "ollama" ? resolvedBaseURL : (form.baseURL || undefined), defaultModel: form.defaultModel || undefined, contextWindow: manualContextWindow, modelContextWindows: Object.keys(form.modelContextWindows).length > 0 ? form.modelContextWindows : undefined, skipTLSVerify: form.skipTLSVerify || undefined, advancedParams: Object.keys(cleanedParams).length > 0 ? cleanedParams : undefined, style: form.style || undefined, openaiApi: resolvedStyle === "openai" && form.openaiApi === "responses" ? "responses" : undefined, iconId: form.iconId || undefined, iconDataUrl: form.iconDataUrl || undefined, }; // Encrypt API key before saving if (form.apiKey) { updates.apiKey = await encryptField(form.apiKey); } else { updates.apiKey = undefined; } onSave(updates); }, [form, onSave, provider.providerId, resolvedBaseURL, resolvedStyle, t]); return (
{/* Display: icon + name */}
setForm((prev) => ({ ...prev, name: e.target.value }))} placeholder={t('ai.providers.name.placeholder')} className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" />
{showIconPicker && (
{BUILTIN_PROVIDER_ICONS.map((icon) => { const isSelected = form.iconId === icon.id && !form.iconDataUrl; return ( ); })}
void handleIconFileSelect(e.target.files?.[0] ?? null)} /> {form.iconDataUrl && ( {t('ai.providers.icon.uploadedNote')} )}
{iconError &&

{iconError}

}
)}
{/* Provider style */}
{STYLE_OPTIONS.map((style) => { const isSelected = resolvedStyle === style; const isInherited = !form.style && isSelected; return ( ); })}

{t('ai.providers.style.help')}

{resolvedStyle === "openai" && (
{OPENAI_API_OPTIONS.map((format) => { const isSelected = form.openaiApi === format; return ( ); })}

{t('ai.providers.openaiApi.help')}

)} {/* API Key */}
handleApiKeyChange(e.target.value)} placeholder={isDecrypting ? t('ai.providers.apiKey.decrypting') : t('ai.providers.apiKey.placeholder')} disabled={isDecrypting} className="w-full h-8 rounded-md border border-input bg-background px-3 pr-9 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50" />
{/* Base URL */}
setForm((prev) => ({ ...prev, baseURL: e.target.value }))} placeholder={preset?.defaultBaseURL || "https://"} className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" /> {resolvedStyle === "anthropic" ? (

{t('ai.providers.baseUrl.anthropicHelp')}

) : null} {provider.providerId === "ollama" ? (

{t('ai.providers.baseUrl.ollamaHelp')}

) : null}
{/* Default Model */}
setForm((prev) => ({ ...prev, defaultModel: val }))} onModelMetadata={(model) => { setForm((prev) => ({ ...prev, modelContextWindows: mergeModelContextWindow(prev.modelContextWindows, model.id, model.contextWindow) ?? prev.modelContextWindows, })); }} baseURL={resolvedBaseURL} modelsEndpoint={preset?.modelsEndpoint} presetModels={preset?.defaultModels} apiKey={form.apiKey} providerId={provider.providerId} style={resolvedStyle} skipTLSVerify={form.skipTLSVerify} />
{/* Context window */}
{ setContextWindowError(null); setForm((prev) => ({ ...prev, contextWindow: e.target.value })); }} placeholder={ form.defaultModel && form.modelContextWindows[form.defaultModel] ? String(form.modelContextWindows[form.defaultModel]) : t('ai.providers.contextWindow.placeholder') } className={cn( "w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", contextWindowError && "border-destructive focus-visible:ring-destructive", )} /> {contextWindowError &&

{contextWindowError}

}

{t('ai.providers.contextWindow.help')}

{/* Skip TLS Verification */} {/* Advanced Parameters */}
{showAdvanced && (

{t('ai.providers.advancedParams.hint')}

{/* max_tokens */}
handleAdvancedParam("maxTokens", e.target.value)} placeholder={t('ai.providers.advancedParams.maxTokens.placeholder')} className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" />
{/* temperature */}
handleAdvancedParam("temperature", e.target.value)} placeholder={t('ai.providers.advancedParams.default')} className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" />
{/* top_p */}
handleAdvancedParam("topP", e.target.value)} placeholder={t('ai.providers.advancedParams.default')} className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" />
{/* frequency_penalty */}
handleAdvancedParam("frequencyPenalty", e.target.value)} placeholder={t('ai.providers.advancedParams.default')} className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" />
{/* presence_penalty */}
handleAdvancedParam("presencePenalty", e.target.value)} placeholder={t('ai.providers.advancedParams.default')} className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" />
)}
{/* Actions */}
{(isTesting || probeResult) && (

{isTesting ? t('ai.providers.test.testing') : probeResult?.message}

)}
); };