156 lines
6.1 KiB
TypeScript
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>
|
|
);
|
|
}
|