// Combobox widget for genai *.model fields. // Fetches available models from the provider's backend and shows them in a dropdown. import { useState, useMemo, useEffect, useRef } from "react"; import type { WidgetProps } from "@rjsf/utils"; import { useTranslation } from "react-i18next"; import useSWR from "swr"; import axios from "axios"; import { Check, ChevronsUpDown, Plus, RefreshCw } from "lucide-react"; import { LuCheck } from "react-icons/lu"; import { cn } from "@/lib/utils"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { Button } from "@/components/ui/button"; import { Command, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import type { ConfigFormContext, JsonObject } from "@/types/configForm"; import { getSizedFieldClassName } from "../utils"; type ProbeResponse = | { success: true; models: string[] } | { success: false; message: string }; type ProbeStatus = "idle" | "probing" | "success" | "error"; const PROBE_SUCCESS_INDICATOR_MS = 3000; /** * Extract the provider config entry name from the RJSF widget id. * Widget ids look like "root_myProvider_model". */ function getProviderKey(widgetId: string): string | undefined { const prefix = "root_"; const suffix = "_model"; if (!widgetId.startsWith(prefix) || !widgetId.endsWith(suffix)) { return undefined; } return widgetId.slice(prefix.length, -suffix.length) || undefined; } export function GenAIModelWidget(props: WidgetProps) { const { id, value, disabled, readonly, onChange, options, registry } = props; const { t } = useTranslation(["views/settings"]); const [open, setOpen] = useState(false); const [searchValue, setSearchValue] = useState(""); const fieldClassName = getSizedFieldClassName(options, "sm"); const providerKey = useMemo(() => getProviderKey(id), [id]); const formContext = registry?.formContext as ConfigFormContext | undefined; // Build a fingerprint from the saved config's provider + base_url so the // SWR key changes (and models are refetched) whenever those fields are saved. const configFingerprint = useMemo(() => { if (!providerKey) return ""; const genai = ( formContext?.fullConfig as Record | undefined )?.genai; if (!genai || typeof genai !== "object" || Array.isArray(genai)) return ""; const entry = (genai as Record)[providerKey]; if (!entry || typeof entry !== "object" || Array.isArray(entry)) return ""; const e = entry as Record; return `${e.provider ?? ""}|${e.base_url ?? ""}`; }, [providerKey, formContext?.fullConfig]); const { data: allModels, mutate: mutateModels } = useSWR< Record >("genai/models", { revalidateOnFocus: false, }); // Revalidate models when the saved config fingerprint changes (e.g. after // switching provider or base_url and saving). const prevFingerprint = useRef(configFingerprint); useEffect(() => { if (configFingerprint !== prevFingerprint.current) { prevFingerprint.current = configFingerprint; mutateModels(); } }, [configFingerprint, mutateModels]); const fetchedModels = useMemo(() => { if (!allModels || !providerKey) return []; return allModels[providerKey] ?? []; }, [allModels, providerKey]); const [probeStatus, setProbeStatus] = useState("idle"); const [probeError, setProbeError] = useState(null); const [probedModels, setProbedModels] = useState(null); const probeSuccessTimerRef = useRef | null>( null, ); const probing = probeStatus === "probing"; // Reset probe results if the provider entry name changes useEffect(() => { setProbedModels(null); setProbeError(null); setProbeStatus("idle"); if (probeSuccessTimerRef.current) { clearTimeout(probeSuccessTimerRef.current); probeSuccessTimerRef.current = null; } }, [providerKey]); useEffect(() => { return () => { if (probeSuccessTimerRef.current) { clearTimeout(probeSuccessTimerRef.current); } }; }, []); const models = probedModels ?? fetchedModels; const trimmedSearch = searchValue.trim(); const matchesFetched = useMemo( () => models.some((m) => m.toLowerCase() === trimmedSearch.toLowerCase()), [models, trimmedSearch], ); const showCustomOption = trimmedSearch.length > 0 && !matchesFetched; // Read the live form values for this provider so probe sends the user's // in-flight edits, not the saved config (which may not exist yet). const formEntry = useMemo(() => { if (!providerKey) return null; const formData = formContext?.formData as JsonObject | undefined; const entry = formData?.[providerKey]; if (!entry || typeof entry !== "object" || Array.isArray(entry)) { return null; } return entry as JsonObject; }, [providerKey, formContext?.formData]); const formProvider = typeof formEntry?.provider === "string" ? formEntry.provider : null; const canProbe = Boolean(formProvider) && !probing; const probe = async () => { if (!formEntry || !formProvider) return; if (probeSuccessTimerRef.current) { clearTimeout(probeSuccessTimerRef.current); probeSuccessTimerRef.current = null; } setProbeStatus("probing"); setProbeError(null); try { const res = await axios.post("genai/probe", { provider: formProvider, api_key: typeof formEntry.api_key === "string" ? formEntry.api_key : null, base_url: typeof formEntry.base_url === "string" ? formEntry.base_url : null, provider_options: formEntry.provider_options && typeof formEntry.provider_options === "object" && !Array.isArray(formEntry.provider_options) ? (formEntry.provider_options as JsonObject) : {}, }); if (res.data.success) { setProbedModels(res.data.models); setProbeStatus("success"); probeSuccessTimerRef.current = setTimeout(() => { setProbeStatus("idle"); probeSuccessTimerRef.current = null; }, PROBE_SUCCESS_INDICATOR_MS); } else { setProbedModels([]); setProbeError(res.data.message); setProbeStatus("error"); } } catch { setProbedModels(null); setProbeError( t("configForm.genaiModel.probeFailed", { ns: "views/settings", defaultValue: "Failed to probe models", }), ); setProbeStatus("error"); } }; const commit = (next: string) => { onChange(next); setSearchValue(""); setOpen(false); }; const currentLabel = typeof value === "string" && value ? value : undefined; const refreshLabel = t("configForm.genaiModel.refresh", { ns: "views/settings", defaultValue: "Refresh models", }); return (
{ setOpen(next); if (!next) setSearchValue(""); }} > { if (e.key === "Enter" && showCustomOption) { e.preventDefault(); commit(trimmedSearch); } }} /> {showCustomOption && ( commit(trimmedSearch)} > {t("configForm.genaiModel.useCustom", { ns: "views/settings", value: trimmedSearch, defaultValue: 'Use "{{value}}"', })} )} {models.length > 0 ? ( {models.map((model) => ( commit(model)} > {model} ))} ) : !showCustomOption ? (
{t("configForm.genaiModel.noModels", { ns: "views/settings", defaultValue: "No models available", })}
) : null}
{probeStatus === "success" && ( {t("configForm.genaiModel.fetchedModels", { ns: "views/settings", defaultValue: "Successfully fetched model list", })} )} {probeStatus === "error" && probeError && ( {probeError} )}
); }