/** * AIModelSelector - Vacuum tube display for LLM preset selection. * * Design: "Tube Display" - Evokes vintage radio tuners and oscilloscope * selectors. The current model glows warmly in an amber display window, * suggesting analog warmth in a digital interface. * * Uses Old Gold (secondary) to clearly distinguish from search/primary * actions - this controls the active retrieval/answer preset. */ import { AlertCircle, BadgeCheck, Check, ChevronDown, Download, Loader2, ScanSearch, Sparkles, } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { AppStatusResponse } from "../../status-model"; import { apiFetch } from "../hooks/use-api"; import { cn } from "../lib/utils"; import { Badge } from "./ui/badge"; interface Preset { id: string; name: string; embed: string; rerank: string; expand?: string; gen: string; active: boolean; } interface PresetsResponse { presets: Preset[]; activePreset: string; capabilities: Capabilities; } interface Capabilities { bm25: boolean; vector: boolean; hybrid: boolean; answer: boolean; } interface SetPresetResponse { success: boolean; activePreset: string; capabilities: Capabilities; embedModelChanged?: boolean; note?: string; } interface DownloadProgress { downloadedBytes: number; totalBytes: number; percent: number; } interface DownloadStatus { active: boolean; currentType: string | null; progress: DownloadProgress | null; completed: string[]; failed: Array<{ type: string; error: string }>; startedAt: number | null; } const PRESET_EXPLANATIONS: Record = { slim: "Fastest setup. Lowest disk use.", balanced: "Better answers. Good default.", quality: "Best local answers. Highest disk use.", "slim-tuned": "Fine-tuned retrieval in a compact footprint.", }; const BUILTIN_PRESET_IDS = new Set([ "slim-tuned", "slim", "balanced", "quality", ]); // Extract readable model name from preset name const SIZE_REGEX = /~[\d.]+GB/; const MODEL_URI_SEGMENT_RE = /\/([^/#]+?)(?:\.(?:gguf|bin|safetensors))?$/i; function extractBaseName(name: string): string { const [firstPart] = name.split("("); return firstPart?.trim() ?? name.trim(); } function extractSize(name: string): string | null { const match = name.match(SIZE_REGEX); return match ? match[0] : null; } function formatPresetLabel(preset: Preset | undefined): string { if (!preset) { return "Select"; } if (preset.id === "slim-tuned") { return "Slim Tuned"; } return extractBaseName(preset.name); } function formatModelRole(uri: string | undefined): string { if (!uri) { return "Not set"; } const hashModel = uri.split("#")[1]?.trim(); if (hashModel) { return hashModel; } const matched = uri.match(MODEL_URI_SEGMENT_RE)?.[1]; return matched?.trim() || uri; } export interface AIModelSelectorProps { appStatus?: AppStatusResponse | null; onPresetChange?: (presetId: string) => void; showDetails?: boolean; showDownloadAction?: boolean; showLabel?: boolean; } function isCustomPreset(preset: Preset): boolean { return !BUILTIN_PRESET_IDS.has(preset.id); } export function AIModelSelector({ appStatus, onPresetChange, showDetails = false, showDownloadAction = true, showLabel = true, }: AIModelSelectorProps = {}) { const [presets, setPresets] = useState([]); const [activeId, setActiveId] = useState(""); const [loading, setLoading] = useState(true); const [switching, setSwitching] = useState(false); const [error, setError] = useState(null); const [modelsNeeded, setModelsNeeded] = useState(false); const [notice, setNotice] = useState(null); const [open, setOpen] = useState(false); const [menuPosition, setMenuPosition] = useState<{ left: number; top: number; width: number; } | null>(null); // Download state const [downloading, setDownloading] = useState(false); const [downloadStatus, setDownloadStatus] = useState( null ); const pollInterval = useRef | null>(null); const parentOwnsStatus = useRef(appStatus !== undefined); const containerRef = useRef(null); const menuRef = useRef(null); const triggerRef = useRef(null); // Click outside to close useEffect(() => { function handleClickOutside(e: MouseEvent) { const target = e.target as Node; if ( (containerRef.current && containerRef.current.contains(target)) || (menuRef.current && menuRef.current.contains(target)) ) { return; } setOpen(false); } document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); const updateMenuPosition = useCallback(() => { if (!triggerRef.current || typeof window === "undefined") { return; } const rect = triggerRef.current.getBoundingClientRect(); const width = Math.min(360, window.innerWidth - 32); const left = Math.max( 16, Math.min(rect.left, window.innerWidth - width - 16) ); const top = rect.bottom + 8; setMenuPosition({ left, top, width }); }, []); useEffect(() => { if (!open) { return; } updateMenuPosition(); const handlePosition = () => updateMenuPosition(); window.addEventListener("resize", handlePosition); window.addEventListener("scroll", handlePosition, true); return () => { window.removeEventListener("resize", handlePosition); window.removeEventListener("scroll", handlePosition, true); }; }, [open, updateMenuPosition]); // Check capabilities const checkCapabilities = useCallback((caps: Capabilities) => { if (!caps.answer) { setError("Answer model not loaded"); setModelsNeeded(true); } else { setError(null); setModelsNeeded(false); } }, []); // Poll download status const pollStatus = useCallback(async () => { const { data } = await apiFetch("/api/models/status"); if (data) { setDownloadStatus(data); if (!data.active && downloading) { setDownloading(false); if (pollInterval.current) { clearInterval(pollInterval.current); pollInterval.current = null; } // Refresh presets const { data: presetsData } = await apiFetch("/api/presets"); if (presetsData) { checkCapabilities(presetsData.capabilities); } const { data: statusData } = await apiFetch("/api/status"); if (statusData) { setModelsNeeded( statusData.bootstrap.models.cachedCount < statusData.bootstrap.models.totalCount ); } if (data.failed.length > 0) { setError(`Failed: ${data.failed.map((f) => f.type).join(", ")}`); } } } }, [downloading, checkCapabilities]); // Initial load useEffect(() => { void apiFetch("/api/presets").then(({ data }) => { if (data) { setPresets(data.presets); setActiveId(data.activePreset); onPresetChange?.(data.activePreset); checkCapabilities(data.capabilities); } setLoading(false); }); if (!parentOwnsStatus.current) { void apiFetch("/api/status").then(({ data }) => { if (data) { setModelsNeeded( data.bootstrap.models.cachedCount < data.bootstrap.models.totalCount ); } }); } void apiFetch("/api/models/status").then(({ data }) => { if (data?.active) { setDownloading(true); setDownloadStatus(data); } }); }, [checkCapabilities]); useEffect(() => { if (!appStatus) { return; } setModelsNeeded( appStatus.bootstrap.models.cachedCount < appStatus.bootstrap.models.totalCount ); }, [appStatus]); // Polling useEffect(() => { if (downloading && !pollInterval.current) { pollInterval.current = setInterval(pollStatus, 1000); } return () => { if (pollInterval.current) { clearInterval(pollInterval.current); pollInterval.current = null; } }; }, [downloading, pollStatus]); const activePreset = presets.find((p) => p.id === activeId); const activeExplanation = activePreset ? (PRESET_EXPLANATIONS[activePreset.id] ?? "Switch between presets without redoing setup.") : "Select a preset"; const syncFromStatus = useCallback( async (status: AppStatusResponse | null) => { if (!status) { return; } const readyModels = status.bootstrap.models.cachedCount >= status.bootstrap.models.totalCount; setModelsNeeded(!readyModels); checkCapabilities(status.capabilities); if (!readyModels) { setNotice("Switched preset. Downloading required models..."); await handleDownload(); } }, [checkCapabilities] ); const handleSelect = async (id: string) => { if (id === activeId || switching || downloading) return; setSwitching(true); setError(null); const { data, error: fetchError } = await apiFetch( "/api/presets", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ presetId: id }), } ); setSwitching(false); if (fetchError) { setError(fetchError); return; } if (data?.success) { setActiveId(data.activePreset); onPresetChange?.(data.activePreset); checkCapabilities(data.capabilities); setOpen(false); const presetName = presets.find((preset) => preset.id === id)?.name ?? id; setNotice( data.embedModelChanged ? (data.note ?? `Switched to ${presetName}. Run embeddings again so vector results catch up.`) : `Switched to ${presetName}` ); const { data: statusData } = await apiFetch("/api/status"); await syncFromStatus(statusData); } }; const handleDownload = async () => { if (downloading) return; setDownloading(true); setError(null); const { error: fetchError } = await apiFetch("/api/models/pull", { method: "POST", }); if (fetchError) { setError(fetchError); setDownloading(false); return; } void pollStatus(); }; useEffect(() => { if (!notice) { return; } const timer = window.setTimeout(() => setNotice(null), 3000); return () => window.clearTimeout(timer); }, [notice]); // Loading skeleton if (loading) { return (
{showLabel && ( Preset )}
); } if (presets.length === 0) return null; const displayName = formatPresetLabel(activePreset); return (
{/* Label */}
{showLabel && ( Preset )} {/* Tube Display Button */}
{/* Dropdown Panel */} {open && menuPosition && createPortal(
{/* Download progress */} {downloading && downloadStatus && (
{downloadStatus.currentType || "Preparing..."} {downloadStatus.progress?.percent.toFixed(0) ?? 0}%
{downloadStatus.completed.length > 0 && (

Done: {downloadStatus.completed.join(", ")}

)}
)}
{presets.map((preset) => { const isActive = preset.id === activeId; const baseName = extractBaseName(preset.name); const size = extractSize(preset.name); const explanation = PRESET_EXPLANATIONS[preset.id] ?? "Pick this if the trade-off fits your machine."; return ( ); })}
{(error || modelsNeeded) && !downloading && ( <>
{error && (

{error}

)} {modelsNeeded && ( )}
)}

Controls retrieval expansion and AI answers

, document.body )} {showDetails && activePreset && (
{activePreset.name}
{extractSize(activePreset.name) && ( {extractSize(activePreset.name)} )} {isCustomPreset(activePreset) && ( Tuned )}

{activeExplanation}

{`${presets.length} presets available`}
Retrieval profile

{activePreset.id === "slim" ? "Quickest local setup with the lightest footprint." : activePreset.id === "balanced" ? "Good general-purpose trade-off for most projects." : activePreset.id === "quality" ? "Highest local answer quality with heavier resource use." : "Custom tuned profile layered on top of the built-in options."}

Active models
{`expand: ${formatModelRole(activePreset.expand ?? activePreset.gen)}`}
{`answer: ${formatModelRole(activePreset.gen)}`}
{`rerank: ${formatModelRole(activePreset.rerank)}`}
{showDownloadAction && (error || modelsNeeded) && !downloading && (

{error ?? "This preset still needs local model files."}

)}
)} {notice &&
{notice}
}
); }