Spaces:
Sleeping
Sleeping
| /** "Connect an LLM" panel — four endpoints, one model picker. | |
| * | |
| * Decomposed UX: | |
| * - **Endpoint**: flat select over { Ollama, HF Router, OpenAI, Custom }. | |
| * - **Model**: render adapts per endpoint. | |
| * * Ollama → hard select over locally-installed tags | |
| * (server tells us which ones via /interactive/models). | |
| * * HF / OpenAI / Custom → free-form text input with a | |
| * curated `<datalist>` of suggestions. Users can type | |
| * anything — the suggestions are shortcut clicks, not | |
| * a whitelist. | |
| * - **Custom base URL**: only shown when endpoint = Custom. | |
| * - **API key**: always shown, optional, persisted per base URL. */ | |
| import { useEffect, useState } from "react"; | |
| import { PhysixInferStatus } from "@/components/PhysixInferStatus"; | |
| import { cn } from "@/lib/cn"; | |
| import type { LlmModelInfo } from "@/lib/interactiveClient"; | |
| import { | |
| type Endpoint, | |
| type EndpointId, | |
| type LlmConnection, | |
| ENDPOINTS, | |
| connectionForEndpoint, | |
| findEndpoint, | |
| loadApiKey, | |
| saveApiKey, | |
| } from "@/lib/llmPresets"; | |
| interface LlmConnectionPanelProps { | |
| /** Side label ("A", "B", …). */ | |
| title: string; | |
| /** Sub-label that gives the slot semantic meaning. */ | |
| subtitle?: string; | |
| value: LlmConnection; | |
| onChange: (next: LlmConnection) => void; | |
| /** Disable all fields while an episode is running. */ | |
| disabled?: boolean; | |
| /** Subtle accent colour for the title strip. */ | |
| accent?: "primary" | "blue" | "muted"; | |
| /** Locally-installed Ollama tags from /interactive/models. Only used | |
| * when the user picks the Ollama endpoint; pass [] otherwise. */ | |
| installedOllamaModels?: LlmModelInfo[]; | |
| /** True while the catalogue is loading. */ | |
| installedOllamaLoading?: boolean; | |
| /** Optional error string from the lister (Ollama unreachable, etc.). */ | |
| installedOllamaError?: string | null; | |
| /** Refetch handler so the user can retry without reloading the page. */ | |
| onRefreshOllama?: () => void; | |
| } | |
| export function LlmConnectionPanel({ | |
| title, | |
| subtitle, | |
| value, | |
| onChange, | |
| disabled, | |
| accent = "muted", | |
| installedOllamaModels = [], | |
| installedOllamaLoading = false, | |
| installedOllamaError = null, | |
| onRefreshOllama, | |
| }: LlmConnectionPanelProps): JSX.Element { | |
| const endpoint = findEndpoint(value.endpointId); | |
| const isCustom = endpoint.id === "custom"; | |
| const [revealKey, setRevealKey] = useState(false); | |
| // Hydrate the API key from per-URL storage whenever the base URL the | |
| // panel is pointing at changes (endpoint switch, custom URL edit). | |
| useEffect(() => { | |
| if (!value.baseUrl) return; | |
| const stored = loadApiKey(value.baseUrl); | |
| if (stored && stored !== value.apiKey) { | |
| onChange({ ...value, apiKey: stored }); | |
| } | |
| // Run only when the base URL changes — keying on `value` would loop. | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [value.baseUrl]); | |
| function setEndpoint(id: EndpointId): void { | |
| const next = findEndpoint(id); | |
| onChange({ | |
| ...connectionForEndpoint(next), | |
| apiKey: loadApiKey(next.baseUrl), | |
| }); | |
| } | |
| function setBaseUrl(url: string): void { | |
| onChange({ ...value, baseUrl: url, apiKey: loadApiKey(url) }); | |
| } | |
| function setModel(model: string): void { | |
| onChange({ ...value, model }); | |
| } | |
| function setApiKey(key: string): void { | |
| saveApiKey(value.baseUrl, key); | |
| onChange({ ...value, apiKey: key }); | |
| } | |
| return ( | |
| <section className="panel flex flex-col gap-3"> | |
| <header className="flex items-baseline justify-between gap-3"> | |
| <div> | |
| <p className={cn("heading-eyebrow", accentClass(accent))}> | |
| Connect an LLM · {title} | |
| </p> | |
| {subtitle ? ( | |
| <p className="mt-1 text-xs text-textMuted">{subtitle}</p> | |
| ) : ( | |
| <p className="mt-1 text-xs text-textMuted"> | |
| Point at any OpenAI-compatible{" "} | |
| <code className="font-mono text-textPrimary"> | |
| /v1/chat/completions | |
| </code>{" "} | |
| endpoint. | |
| </p> | |
| )} | |
| </div> | |
| <EndpointBadge endpoint={endpoint} /> | |
| </header> | |
| <div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> | |
| <Field label="Endpoint"> | |
| <select | |
| value={endpoint.id} | |
| onChange={(event) => setEndpoint(event.target.value as EndpointId)} | |
| disabled={disabled} | |
| className="w-full rounded-lg border border-border bg-surfaceMuted px-3 py-2 text-sm text-textPrimary outline-none transition focus:border-textMuted disabled:opacity-50" | |
| > | |
| {ENDPOINTS.map((ep) => ( | |
| <option key={ep.id} value={ep.id}> | |
| {ep.label} | |
| </option> | |
| ))} | |
| </select> | |
| </Field> | |
| <Field label="Model"> | |
| <ModelInput | |
| endpoint={endpoint} | |
| value={value.model} | |
| onChange={setModel} | |
| disabled={disabled} | |
| installedOllamaModels={installedOllamaModels} | |
| installedOllamaLoading={installedOllamaLoading} | |
| installedOllamaError={installedOllamaError} | |
| /> | |
| </Field> | |
| </div> | |
| {isCustom ? ( | |
| <Field label="Custom base URL"> | |
| <input | |
| type="url" | |
| value={value.baseUrl} | |
| onChange={(event) => setBaseUrl(event.target.value)} | |
| disabled={disabled} | |
| placeholder="https://your-endpoint.example/v1" | |
| className="w-full rounded-lg border border-border bg-surfaceMuted px-3 py-2 font-mono text-xs text-textPrimary outline-none transition focus:border-textMuted disabled:opacity-50" | |
| /> | |
| </Field> | |
| ) : null} | |
| <Field | |
| label={ | |
| <span className="flex items-baseline justify-between gap-2"> | |
| <span>API key {endpoint.needsKey ? "" : "(optional)"}</span> | |
| <button | |
| type="button" | |
| onClick={() => setRevealKey((v) => !v)} | |
| className="text-[10px] uppercase tracking-wider text-textMuted underline hover:text-textPrimary" | |
| > | |
| {revealKey ? "hide" : "show"} | |
| </button> | |
| </span> | |
| } | |
| > | |
| <input | |
| type={revealKey ? "text" : "password"} | |
| value={value.apiKey} | |
| onChange={(event) => setApiKey(event.target.value)} | |
| disabled={disabled} | |
| placeholder={ | |
| endpoint.needsKey | |
| ? "Bearer <key>" | |
| : "(server will use env var if set)" | |
| } | |
| className="w-full rounded-lg border border-border bg-surfaceMuted px-3 py-2 font-mono text-xs text-textPrimary outline-none transition focus:border-textMuted disabled:opacity-50" | |
| /> | |
| </Field> | |
| <p className="text-[11px] leading-relaxed text-textMuted"> | |
| {endpoint.hint} | |
| </p> | |
| {/* | |
| Live status banner for the GPU Space. Only renders for the | |
| physix endpoint — every other endpoint either has no sleep | |
| cycle (HF Router, OpenAI, Custom) or is local-only (Ollama), | |
| so there's nothing to surface. | |
| */} | |
| {endpoint.id === "physix" ? <PhysixInferStatus /> : null} | |
| {endpoint.id === "ollama" && installedOllamaError ? ( | |
| <OllamaTroubleshooter | |
| message={installedOllamaError} | |
| onRetry={onRefreshOllama} | |
| /> | |
| ) : null} | |
| </section> | |
| ); | |
| } | |
| // --------------------------------------------------------------------- | |
| // Model picker — Ollama dropdown vs. free-form input with datalist. | |
| // --------------------------------------------------------------------- | |
| interface ModelInputProps { | |
| endpoint: Endpoint; | |
| value: string; | |
| onChange: (next: string) => void; | |
| disabled?: boolean | undefined; | |
| installedOllamaModels: LlmModelInfo[]; | |
| installedOllamaLoading: boolean; | |
| installedOllamaError: string | null; | |
| } | |
| function ModelInput({ | |
| endpoint, | |
| value, | |
| onChange, | |
| disabled, | |
| installedOllamaModels, | |
| installedOllamaLoading, | |
| installedOllamaError, | |
| }: ModelInputProps): JSX.Element { | |
| if (endpoint.modelInputMode === "ollama-installed") { | |
| return ( | |
| <OllamaInstalledSelect | |
| value={value} | |
| onChange={onChange} | |
| disabled={disabled} | |
| models={installedOllamaModels} | |
| loading={installedOllamaLoading} | |
| error={installedOllamaError} | |
| fallbackSuggestions={endpoint.modelSuggestions.map((s) => s.id)} | |
| /> | |
| ); | |
| } | |
| // Free-form input + curated datalist. The user can type anything; the | |
| // datalist is just a click-to-fill convenience. | |
| const datalistId = `model-suggestions-${endpoint.id}`; | |
| return ( | |
| <> | |
| <input | |
| type="text" | |
| value={value} | |
| onChange={(event) => onChange(event.target.value)} | |
| disabled={disabled} | |
| placeholder={endpoint.modelSuggestions[0]?.id ?? "model-id"} | |
| list={datalistId} | |
| className="w-full rounded-lg border border-border bg-surfaceMuted px-3 py-2 font-mono text-xs text-textPrimary outline-none transition focus:border-textMuted disabled:opacity-50" | |
| /> | |
| <datalist id={datalistId}> | |
| {endpoint.modelSuggestions.map((suggestion) => ( | |
| <option key={suggestion.id} value={suggestion.id}> | |
| {suggestion.tag ?? ""} | |
| </option> | |
| ))} | |
| </datalist> | |
| </> | |
| ); | |
| } | |
| interface OllamaInstalledSelectProps { | |
| value: string; | |
| onChange: (next: string) => void; | |
| disabled?: boolean | undefined; | |
| models: LlmModelInfo[]; | |
| loading: boolean; | |
| error: string | null; | |
| fallbackSuggestions: string[]; | |
| } | |
| function OllamaInstalledSelect({ | |
| value, | |
| onChange, | |
| disabled, | |
| models, | |
| loading, | |
| error, | |
| fallbackSuggestions, | |
| }: OllamaInstalledSelectProps): JSX.Element { | |
| // Three states to handle: | |
| // 1. Daemon up + ≥1 tags pulled — render a real <select>. | |
| // 2. Daemon up + nothing pulled — render an empty disabled select | |
| // with a hint (the panel hint already covers the install | |
| // command). | |
| // 3. Daemon unreachable — fall back to a typed input so the user | |
| // can still set the model and hit Run after starting the daemon. | |
| if (loading) { | |
| return ( | |
| <select | |
| disabled | |
| className="w-full rounded-lg border border-border bg-surfaceMuted px-3 py-2 font-mono text-xs text-textMuted outline-none disabled:opacity-50" | |
| > | |
| <option>Loading installed models…</option> | |
| </select> | |
| ); | |
| } | |
| if (error) { | |
| // Fallback: typed input with the canonical Qwen tag suggestions. | |
| const datalistId = "ollama-typed-fallback"; | |
| return ( | |
| <> | |
| <input | |
| type="text" | |
| value={value} | |
| onChange={(event) => onChange(event.target.value)} | |
| disabled={disabled} | |
| placeholder={fallbackSuggestions[0] ?? "qwen2.5:3b-instruct"} | |
| list={datalistId} | |
| className="w-full rounded-lg border border-accentAmber/40 bg-surfaceMuted px-3 py-2 font-mono text-xs text-textPrimary outline-none transition focus:border-accentAmber disabled:opacity-50" | |
| /> | |
| <datalist id={datalistId}> | |
| {fallbackSuggestions.map((id) => ( | |
| <option key={id} value={id} /> | |
| ))} | |
| </datalist> | |
| </> | |
| ); | |
| } | |
| if (models.length === 0) { | |
| return ( | |
| <select | |
| disabled | |
| className="w-full rounded-lg border border-border bg-surfaceMuted px-3 py-2 font-mono text-xs text-textMuted outline-none disabled:opacity-50" | |
| > | |
| <option>No models pulled — see hint below</option> | |
| </select> | |
| ); | |
| } | |
| // Make sure the currently-selected value is renderable: if the user | |
| // had a tag selected and then uninstalled it, pull it into the list | |
| // as a stale entry rather than silently swapping selection. | |
| const selectableNames = models.map((m) => m.name); | |
| const includesValue = !value || selectableNames.includes(value); | |
| return ( | |
| <select | |
| value={value} | |
| onChange={(event) => onChange(event.target.value)} | |
| disabled={disabled} | |
| className="w-full rounded-lg border border-border bg-surfaceMuted px-3 py-2 font-mono text-xs text-textPrimary outline-none transition focus:border-textMuted disabled:opacity-50" | |
| > | |
| {!includesValue ? ( | |
| <option value={value}>{value} (not installed)</option> | |
| ) : null} | |
| {!value ? <option value="">Pick a tag…</option> : null} | |
| {models.map((m) => ( | |
| <option key={m.name} value={m.name}> | |
| {m.name} | |
| {m.parameter_size ? ` · ${m.parameter_size}` : ""} | |
| </option> | |
| ))} | |
| </select> | |
| ); | |
| } | |
| // --------------------------------------------------------------------- | |
| // Misc small subcomponents | |
| // --------------------------------------------------------------------- | |
| function OllamaTroubleshooter({ | |
| message, | |
| onRetry, | |
| }: { | |
| message: string; | |
| onRetry?: (() => void) | undefined; | |
| }): JSX.Element { | |
| return ( | |
| <div className="rounded-md border border-accentAmber/40 bg-accentAmber/5 px-3 py-2 text-[11px] text-accentAmber"> | |
| <div className="flex items-start justify-between gap-3"> | |
| <span>{message}</span> | |
| {onRetry ? ( | |
| <button | |
| type="button" | |
| onClick={onRetry} | |
| className="text-[10px] text-textMuted underline hover:text-textPrimary" | |
| > | |
| retry | |
| </button> | |
| ) : null} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function Field({ | |
| label, | |
| children, | |
| }: { | |
| label: React.ReactNode; | |
| children: React.ReactNode; | |
| }): JSX.Element { | |
| return ( | |
| <label className="flex flex-col gap-1 text-xs text-textMuted"> | |
| <span className="heading-eyebrow">{label}</span> | |
| {children} | |
| </label> | |
| ); | |
| } | |
| function EndpointBadge({ endpoint }: { endpoint: Endpoint }): JSX.Element { | |
| const COPY: Record<EndpointId, { label: string; tone: string }> = { | |
| physix: { | |
| label: "PhysiX-3B", | |
| tone: "border-accentGreen/40 text-accentGreen", | |
| }, | |
| ollama: { label: "local", tone: "border-textMuted/40 text-textMuted" }, | |
| hf: { label: "HF router", tone: "border-accentBlue/40 text-accentBlue" }, | |
| openai: { label: "OpenAI", tone: "border-primary/40 text-primary" }, | |
| custom: { label: "custom", tone: "border-accentAmber/40 text-accentAmber" }, | |
| }; | |
| const entry = COPY[endpoint.id]; | |
| return ( | |
| <span | |
| className={cn( | |
| "rounded-full border bg-surface px-2 py-0.5 text-[10px] uppercase tracking-wider", | |
| entry.tone, | |
| )} | |
| > | |
| {entry.label} | |
| </span> | |
| ); | |
| } | |
| function accentClass(accent: "primary" | "blue" | "muted"): string { | |
| if (accent === "primary") return "text-primary"; | |
| if (accent === "blue") return "text-accentBlue"; | |
| return "text-textMuted"; | |
| } | |