import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Check, ChevronDown, RefreshCw } from "lucide-react"; import type { AIProviderId, ProviderStyle } from "../../../../infrastructure/ai/types"; import { resolveProviderStyle } from "../../../../infrastructure/ai/types"; import { buildModelDiscoveryHeaders, resolveModelsDiscoveryEndpoint } from "../../../../infrastructure/ai/modelDiscoveryHeaders"; import { buildProviderProbeUrl } from "../../../../infrastructure/ai/providerConnectionProbe"; import { useI18n } from "../../../../application/i18n/I18nProvider"; import { Button } from "../../../ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip"; import { cn } from "../../../../lib/utils"; import type { FetchedModel } from "./types"; import { getFetchBridge } from "./types"; import { parseFetchedModels } from "./modelMetadata"; export function buildModelSuggestions({ presetModels, fetchedModels, hasFetched, value, }: { presetModels?: readonly string[]; fetchedModels: FetchedModel[]; hasFetched: boolean; value: string; }): FetchedModel[] { const byId = new Map(); for (const modelId of presetModels ?? []) { const id = modelId.trim(); if (id) byId.set(id, { id }); } if (hasFetched) { for (const model of fetchedModels) { byId.set(model.id, model); } } const allSuggestions = Array.from(byId.values()); if (!value.trim()) return allSuggestions; const q = value.toLowerCase(); return allSuggestions.filter((m) => m.id.toLowerCase().includes(q) || (m.name && m.name.toLowerCase().includes(q)), ); } export function getModelSuggestionsPresentation({ suggestionsLength, isLoading, error, hasFetched, hasPresetModels, }: { suggestionsLength: number; isLoading: boolean; error: string | null; hasFetched: boolean; hasPresetModels: boolean; }): { showSuggestions: boolean; emptyState: "loading" | "error" | "noMatches" | "loadPrompt" | null; footerState: "loading" | "error" | null; } { if (suggestionsLength > 0) { return { showSuggestions: true, emptyState: null, footerState: isLoading ? "loading" : error ? "error" : null, }; } if (isLoading) { return { showSuggestions: false, emptyState: "loading", footerState: null }; } if (error) { return { showSuggestions: false, emptyState: "error", footerState: null }; } return { showSuggestions: false, emptyState: hasFetched || hasPresetModels ? "noMatches" : "loadPrompt", footerState: null, }; } export function getModelSuggestionClassName(isSelected: boolean): string { return cn( "w-full text-left px-3 py-1.5 text-xs hover:bg-accent hover:text-accent-foreground transition-colors flex items-center justify-between gap-2", isSelected && "bg-accent text-accent-foreground", ); } export const ModelSelector: React.FC<{ value: string; onChange: (value: string) => void; baseURL: string; modelsEndpoint?: string; presetModels?: readonly string[]; placeholder?: string; apiKey?: string; providerId?: AIProviderId; /** Optional protocol-family override; falls back to `providerId` via {@link resolveProviderStyle}. */ style?: ProviderStyle; skipTLSVerify?: boolean; onModelMetadata?: (model: FetchedModel) => void; }> = ({ value, onChange, baseURL, modelsEndpoint, presetModels, placeholder, apiKey, providerId, style, skipTLSVerify, onModelMetadata }) => { const { t } = useI18n(); const [models, setModels] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [isOpen, setIsOpen] = useState(false); const [hasFetched, setHasFetched] = useState(false); // Resolve the wire-protocol family: prefer an explicit style override (set in // the form), then fall back to the providerId-derived default. const resolvedStyle: ProviderStyle = style ?? (providerId ? resolveProviderStyle({ providerId }) : "openai"); // Endpoint follows the resolved style so a providerId+style mismatch (e.g. // Anthropic providerId switched to OpenAI style) still hits the right path. const effectiveModelsEndpoint = resolveModelsDiscoveryEndpoint(resolvedStyle, modelsEndpoint); // Ollama runs locally without auth; all other providers need an API key to list models const needsApiKey = providerId !== "ollama"; const canFetch = !!effectiveModelsEndpoint && (!needsApiKey || !!apiKey); const hasPresetModels = (presetModels?.length ?? 0) > 0; const canSuggest = canFetch || hasPresetModels; const discoveryKey = JSON.stringify({ baseURL, effectiveModelsEndpoint, apiKey, resolvedStyle, skipTLSVerify, }); const discoveryKeyRef = useRef(discoveryKey); useEffect(() => { discoveryKeyRef.current = discoveryKey; setModels([]); setHasFetched(false); setError(null); setIsLoading(false); }, [discoveryKey]); const fetchModels = useCallback(async () => { if (!effectiveModelsEndpoint) return; const bridge = getFetchBridge(); if (!bridge?.aiFetch) return; const requestKey = discoveryKey; setIsLoading(true); setError(null); try { // Temporarily allow the provider's host in the backend fetch allowlist // so model listing works for URLs not yet synced from the main window. if (bridge.aiAllowlistAddHost && baseURL) { await bridge.aiAllowlistAddHost(baseURL); } const url = buildProviderProbeUrl(baseURL, effectiveModelsEndpoint); const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey); const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify); if (!result.ok) { if (discoveryKeyRef.current !== requestKey) return; setError(`Failed to fetch models (${result.error || "unknown error"})`); return; } const parsed = JSON.parse(result.data); const list = parseFetchedModels(parsed); list.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id)); if (discoveryKeyRef.current !== requestKey) return; setModels(list); setHasFetched(true); } catch (err) { if (discoveryKeyRef.current !== requestKey) return; setError(err instanceof Error ? err.message : "Failed to parse response"); } finally { if (discoveryKeyRef.current === requestKey) setIsLoading(false); } }, [baseURL, effectiveModelsEndpoint, apiKey, resolvedStyle, skipTLSVerify, discoveryKey]); // Auto-fetch when dropdown first opens useEffect(() => { if (isOpen && canFetch && !hasFetched && !isLoading) { void fetchModels(); } }, [isOpen, canFetch, hasFetched, isLoading, fetchModels]); // Filter preset and discovered models by current input value (inline autocomplete). const suggestions = useMemo(() => { return buildModelSuggestions({ presetModels, fetchedModels: models, hasFetched, value, }); }, [models, presetModels, value, hasFetched]); const showSuggestions = isOpen && canSuggest; const presentation = getModelSuggestionsPresentation({ suggestionsLength: suggestions.length, isLoading, error, hasFetched, hasPresetModels, }); return (
{ onChange(e.target.value); if (canSuggest && !isOpen) setIsOpen(true); }} onFocus={() => { if (canSuggest) setIsOpen(true); }} onBlur={() => { setIsOpen(false); }} placeholder={placeholder ?? (canSuggest ? t('ai.providers.searchModel') : t('ai.providers.defaultModel.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", canSuggest && "pr-8", )} /> {canSuggest && ( )}
{canFetch && ( {t('ai.providers.refreshModels')} )}
{/* Suggestions dropdown */} {showSuggestions && (
{!presentation.showSuggestions ? (
{presentation.emptyState === "loading" ? ( <> {t('ai.providers.loadingModels')} ) : presentation.emptyState === "error" ? ( {error} ) : presentation.emptyState === "noMatches" ? ( t('ai.providers.noMatchingModels') ) : ( t('ai.providers.clickToLoadModels') )}
) : ( suggestions.slice(0, 100).map((m) => ( )) )} {presentation.footerState && (
{presentation.footerState === "loading" ? ( <> {t('ai.providers.loadingModels')} ) : ( error )}
)} {suggestions.length > 100 && (
{t('ai.providers.showingModels').replace('{count}', String(suggestions.length))}
)}
)}
); };