feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user