import { useEffect, useRef, useState } from 'react'; import { parseSecretValue } from '../../api'; interface ModelSelectProps { /** Currently saved model name. Always shown even if discovery fails. */ value: string; onChange: (model: string) => void; /** LLM endpoint to probe `/models` against. */ endpoint: string | undefined; /** * Raw `apiKey` string from the draft config. Used to attach a Bearer * token to the discovery request when it's a literal secret. Masked * / env_ref values cannot be used for direct discovery in Phase 1 * (the actual literal is not exposed to the browser), and we fall * back to manual input in that case. */ apiKeyRaw: string | null | undefined; } /** * Endpoint + apiKey -aware model dropdown with a manual-input fallback. * * Behaviour: * - Probes `/models` once whenever endpoint / apiKey * identity changes. * - Success → renders a searchable dropdown of returned ids; the * currently saved `value` is always included even if discovery * dropped it (so a typo doesn't silently overwrite the choice). * - Failure (network error, non-2xx, malformed body) → renders a * plain text input and shows an inline amber warning suggesting * manual entry. * - apiKey is `unchanged` / `env_ref` / `cleared` → also falls back * to manual input. Probing with the masked sentinel would 401 and * leak nothing useful. * * The component is deliberately self-contained: the parent passes the * current draft endpoint+apiKey and the new model name flows back via * `onChange`. No global state, no caching across remounts — discovery * latency is short enough (< 1s typically) that re-probing on every * mount is fine, and avoids stale dropdowns if the endpoint changed. */ export function ModelSelect({ value, onChange, endpoint, apiKeyRaw }: ModelSelectProps) { const [models, setModels] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Track the in-flight probe so a fast endpoint edit cancels the // previous one instead of racing the latest write. const probeIdRef = useRef(0); useEffect(() => { if (!endpoint || endpoint.trim() === '') { setModels(null); setError(null); return; } const parsed = parseSecretValue(apiKeyRaw); // Phase 1: only `literal` keys can be used for direct discovery // from the browser. For `env_ref` / `unchanged` the literal is // server-side only — manual fallback. For `cleared` we attempt // discovery without an Authorization header (works for Ollama). const bearer = parsed.type === 'literal' ? parsed.value : parsed.type === 'cleared' ? undefined : null; // null = skip discovery if (bearer === null) { setModels(null); setError('API key is masked or env-ref; please enter the model name manually.'); return; } const probeId = ++probeIdRef.current; setLoading(true); setError(null); const trimmed = endpoint.replace(/\/+$/, ''); const url = `${trimmed}/models`; const headers: Record = { Accept: 'application/json' }; if (bearer) headers.Authorization = `Bearer ${bearer}`; fetch(url, { headers }) .then(async res => { if (probeId !== probeIdRef.current) return; // stale if (!res.ok) { setModels(null); setError(`model discovery failed (HTTP ${res.status}); please enter manually.`); return; } const body = await res.json().catch(() => null) as { data?: Array<{ id?: unknown }> } | null; if (!body || !Array.isArray(body.data)) { setModels(null); setError('model discovery returned an unexpected payload; please enter manually.'); return; } const ids = body.data .map(m => (typeof m?.id === 'string' ? m.id.trim() : '')) .filter(id => id.length > 0); // Surface the discovered set even if empty — distinguishes // "endpoint reachable, no models loaded" from "endpoint down". setModels(Array.from(new Set(ids))); setError(null); }) .catch(err => { if (probeId !== probeIdRef.current) return; setModels(null); setError( `model discovery failed (${err instanceof Error ? err.message : 'network error'}); ` + 'please enter manually.', ); }) .finally(() => { if (probeId === probeIdRef.current) setLoading(false); }); }, [endpoint, apiKeyRaw]); // Discovery succeeded — render a datalist-backed combobox so the user // can either pick or override. A native datalist is the simplest way // to get "dropdown with manual fallback" without a custom popover. if (models !== null) { const options = value && !models.includes(value) ? [value, ...models] : models; return (
onChange(e.target.value)} placeholder={loading ? 'loading...' : 'choose or type a model'} className="w-full h-8 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white" /> {options.map(m => {options.length === 0 && (

endpoint reachable but no models reported.

)}
); } // Manual fallback (discovery failed or skipped). return (
onChange(e.target.value)} placeholder={loading ? 'loading...' : 'qwen3:8b'} className="w-full h-8 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white" /> {error && (

{error}

)}
); }