Files
maestro/ui/src/components/settings/ModelSelect.tsx
T

156 lines
6.1 KiB
TypeScript

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 `<endpoint>/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 `<endpoint>/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<string[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<string, string> = { 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 (
<div>
<input
list="llm-workers-model-options"
value={value}
onChange={e => 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"
/>
<datalist id="llm-workers-model-options">
{options.map(m => <option key={m} value={m} />)}
</datalist>
{options.length === 0 && (
<p className="text-2xs text-slate-500 mt-1">
endpoint reachable but no models reported.
</p>
)}
</div>
);
}
// Manual fallback (discovery failed or skipped).
return (
<div>
<input
type="text"
value={value}
onChange={e => 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 && (
<p className="text-2xs text-amber-700 bg-amber-50 border border-amber-100 px-2 py-1 rounded mt-1">
{error}
</p>
)}
</div>
);
}