import { useStore } from '@nanostores/react' import { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router' import { NEW_CHAT_ROUTE } from '@/app/routes' import { Button } from '@/components/ui/button' import { Tip } from '@/components/ui/tooltip' import { activateLocalModel, deleteLocalModel, downloadBrowsedModel, downloadLocalModel, ejectLocalModel, getLocalCatalog, getLocalHardware, getLocalModelsStatus, type HFFileGroup, type HFSearchHit, installLocalRuntime, listHFRepoFiles, quickstartLocalModels, searchHFModels, setLocalServer, sideloadLocalModel } from '@/hermes' import { useI18n } from '@/i18n' import { Check, CheckCircle2, Cpu, Download, Eject, FolderOpen, Loader2, Monitor, Package, Search, StopFilled, Trash2, Zap } from '@/lib/icons' import { cn } from '@/lib/utils' import { $localRuntimeJobs, runningDownloadFor, runningRuntimeInstall, watchLocalRuntimeJobs } from '@/store/local-runtime-jobs' import { notify, notifyError } from '@/store/notifications' import type { LocalCatalogModel, LocalHardware, LocalModelsStatus } from '@/types/hermes' import { ListRow, Pill, SettingsContent, SettingsSection, SettingsSkeleton } from './primitives' function ProgressBar({ percent }: { percent: number | undefined }) { return (
) } function gbLabel(bytes: number | null | undefined): string { if (!bytes) { return '—' } return `${(bytes / (1 << 30)).toFixed(1)} GB` } // Catalog display order: what runs well leads. Resident (all on GPU) // first, then spilled (works, slower), then doesn't-fit; catalog order // (recommended first) holds within each band. function fitRank(model: LocalCatalogModel): number { if (model.fits && !model.spilled) { return 0 } if (model.fits) { return 1 } return 2 } export function LocalModelsSettings() { const { t } = useI18n() const copy = t.settings.localModels const [status, setStatus] = useState(null) const [hardware, setHardware] = useState(null) const [catalog, setCatalog] = useState(null) const [deleting, setDeleting] = useState(null) const [serverBusy, setServerBusy] = useState(false) // Quickstart escape hatch: true once the user asks for the full pane // (model list, HF browser) instead of the one-button setup card. const [configure, setConfigure] = useState(false) // Jobs live in the app-level store (they must survive this pane // unmounting); the pane just renders the slice it cares about. const jobs = useStore($localRuntimeJobs) const refresh = useCallback(() => { void getLocalModelsStatus() .then(setStatus) .catch(() => setStatus(null)) void getLocalCatalog() .then(data => setCatalog(data.models)) .catch(() => setCatalog([])) }, []) // Snappy first paint: status + catalog immediately; hardware (may shell out // to nvidia-smi) backfills and pops in-place. The job watcher also kicks // here so reopening the pane rediscovers work started before. useEffect(() => { refresh() watchLocalRuntimeJobs() void getLocalHardware() .then(setHardware) .catch(() => setHardware(null)) }, [refresh]) // The pane is LIVE while visible: residency changes without user action // (boot warm finishing, idle sweep unloading, another surface ejecting), // and a stale snapshot here reads as a broken feature — 'VRAM full but // the pane says Not in memory'. The status route is built cheap for // polling; setTimeout chain, never overlapping. useEffect(() => { let cancelled = false let timer: number | undefined const tick = async () => { try { const next = await getLocalModelsStatus() if (!cancelled) { setStatus(next) } } catch { // Backend briefly unreachable — keep the last snapshot. } if (!cancelled) { timer = window.setTimeout(() => void tick(), 4_000) } } timer = window.setTimeout(() => void tick(), 4_000) return () => { cancelled = true if (timer !== undefined) { window.clearTimeout(timer) } } }, []) // A job finishing (download done, install done) changes what status/catalog // should show — refresh whenever the running set shrinks. const runningCount = jobs.filter(j => j.status === 'running').length useEffect(() => { refresh() }, [refresh, runningCount]) async function handleInstallRuntime() { try { await installLocalRuntime() watchLocalRuntimeJobs() } catch (err) { notifyError(err, copy.installFailed) } } async function handleQuickstart() { try { await quickstartLocalModels() watchLocalRuntimeJobs() } catch (err) { notifyError(err, copy.quickstartFailed) } } async function handleDownload(model: LocalCatalogModel) { try { const res = await downloadLocalModel(model.id) if (res.already_downloaded || !res.job_id) { refresh() return } watchLocalRuntimeJobs() } catch (err) { notifyError(err, copy.downloadFailed(model.display_name)) } } async function handleActivate(target: null | string, displayName: string) { if (!target) { return } try { await activateLocalModel(target) watchLocalRuntimeJobs() } catch (err) { notifyError(err, copy.activateFailed(displayName)) } } async function handleEject(modelId: string) { try { await ejectLocalModel(modelId) notify({ durationMs: 3_000, kind: 'success', message: copy.ejected, title: copy.title }) refresh() } catch (err) { notifyError(err, copy.ejectFailed) } } async function handleServer(action: 'start' | 'stop') { setServerBusy(true) try { await setLocalServer(action) notify({ durationMs: 3_500, kind: 'success', message: action === 'stop' ? copy.serverStopped : copy.serverStarted, title: copy.title }) refresh() } catch (err) { notifyError(err, action === 'stop' ? copy.serverStopFailed : copy.serverStartFailed) } finally { setServerBusy(false) } } async function handleDelete(target: string, rowId: string) { if (!window.confirm(copy.deleteConfirm(target))) { return } setDeleting(rowId) try { await deleteLocalModel(target) notify({ durationMs: 2_500, kind: 'success', message: copy.deleted(target), title: copy.title }) refresh() } catch (err) { notifyError(err, copy.deleteFailed) } finally { setDeleting(null) } } // Setup flows end at the action, not the settings pane: when quickstart // finishes while the user is still HERE watching it, land them on a new // chat with the model ready to try. Unmount cancels the intent — a user // who navigated away mid-download keeps their place (no focus theft). // (Lives above the loading return: hooks run unconditionally.) const navigate = useNavigate() const seenQuickstarts = useRef(new Set()) const runningQuickstart = jobs.find(j => j.kind === 'quickstart' && j.status === 'running') useEffect(() => { // Event detection, not value mirroring: the ref only remembers which // job ids THIS mount saw running, so a 'done' already in the list on // mount (stale history) never triggers a navigation. const seen = seenQuickstarts.current for (const j of jobs) { if (j.kind !== 'quickstart') { continue } if (j.status === 'running') { seen.add(j.job_id) } else if (j.status === 'done' && seen.has(j.job_id)) { seen.delete(j.job_id) navigate(NEW_CHAT_ROUTE) } } }, [jobs, navigate]) if (!status || catalog === null) { return } const rJob = runningRuntimeInstall(jobs) const lastError = jobs.find(j => j.status === 'error') const sortedCatalog = [...catalog].sort((a, b) => fitRank(a) - fitRank(b)) // ── Quickstart: the dummy-proof front door ── // Until something is servable (runtime + at least one model), the pane // leads with a hero that does everything in one click; the full pane // stays one 'Configure…' click away. A running quickstart pins this // view so its progress has a home even after a remount. const qJob = runningQuickstart ?? null const needsSetup = !status.runtime_installed || status.models.length === 0 const heroModel = catalog.find(c => c.recommended && c.fits) ?? catalog.find(c => c.fits) ?? null if (qJob || (needsSetup && !configure && heroModel)) { // Stage rail derived from the job phase: engine -> model -> finish. const phase = qJob?.phase ?? '' const stageIndex = ['starting-server', 'setting-default'].includes(phase) ? 2 : phase === 'downloading' ? 1 : 0 const stages = [copy.quickstartStageEngine, copy.quickstartStageModel, copy.quickstartStageFinish] // The model-download leg blanks job.detail on purpose (pane rows // render their own byte counter) — compose one here instead of // falling back to runtime copy that would misname the stage. const liveDetail = qJob && (qJob.detail || (qJob.total_bytes ? copy.downloadProgress(gbLabel(qJob.done_bytes), gbLabel(qJob.total_bytes)) : copy.installing)) return (
{qJob ? ( ) : ( )}

{qJob ? qJob.target : (heroModel?.display_name ?? '')}

{qJob ? ( <>

{liveDetail}

{/* Stage rail: engine -> model -> finish. */}
{stages.map((label, i) => ( stageIndex && 'text-(--ui-text-tertiary) opacity-60' )} key={label} > {i < stageIndex ? ( ) : i === stageIndex ? ( ) : ( )} {label} ))}
) : heroModel ? ( <>

{heroModel.downloaded ? copy.quickstartDetailReady(heroModel.display_name) : copy.quickstartDetail(heroModel.display_name, heroModel.size_label)}

) : null} {lastError?.kind === 'quickstart' && !qJob && (

{lastError.error}

)}
) } // Up to date = the authority (status) says the configured tag is what's // serving. Shown whenever true — not only right after an update. const updateApplied = status.runtime_installed && !status.update_available && status.tag === status.configured_tag return ( {/* ── Runtime ── */} {status.server_running ? copy.serverRunning : copy.runtimeReady(status.runtime_backend ?? '')} ) : undefined } icon={Zap} meta={status.tag} title={copy.runtimeTitle} > {status.runtime_installed ? ( void handleServer('stop')} size="sm" variant="outline" > {serverBusy ? : } {copy.stopServer} ) : ( ) } description={ status.server_running ? copy.runtimeRunningDetail : copy.runtimeInstalledDetail(status.tag, status.runtime_backend ?? 'cpu') } title={copy.runtimeInstalled} /> ) : rJob ? ( } description={rJob.detail || copy.installing} title={ {copy.installing} } /> ) : ( void handleInstallRuntime()} size="sm"> {copy.installAction} } description={copy.installDetail} title={copy.installTitle} /> )} {status.update_available && !rJob && ( void handleInstallRuntime()} size="sm"> {copy.updateAction} } description={copy.updateDetail(status.configured_tag, status.tag)} title={copy.updateTitle} /> )} {rJob && status.runtime_installed && ( } description={rJob.detail || copy.updating} title={ {copy.updating} } /> )} {updateApplied && ( {copy.upToDateTitle} } /> )} {lastError?.kind === 'runtime-install' &&

{lastError.error}

}
{/* ── This machine ── */} {hardware ? (
{hardware.gpu_name && ( {hardware.gpu_name} )} {copy.vram(gbLabel(hardware.vram_total_bytes))} {copy.ram(gbLabel(hardware.ram_total_bytes))} {hardware.uma && {copy.unifiedMemory}}
) : (

{copy.hardwareLoading}

)}
{/* ── Models ── */}
{sortedCatalog.map(model => { const dJob = runningDownloadFor(jobs, model.id) const anyDownloadRunning = jobs.some(j => j.kind === 'model-download' && j.status === 'running') const activateTarget = model.downloaded_model_id ?? model.model_id const isActive = Boolean(activateTarget && status.active_model_id === activateTarget) const residency = activateTarget ? status.loaded_models[activateTarget] : undefined const isLoaded = residency === 'loaded' || residency === 'ready' const isLoadingNow = residency === 'loading' const livePlacement = activateTarget ? status.placement?.[activateTarget] : undefined const aJob = jobs.find( j => j.kind === 'model-activate' && j.status === 'running' && j.model_id === activateTarget ) const anyActivateRunning = jobs.some(j => j.kind === 'model-activate' && j.status === 'running') return ( {isLoaded && livePlacement && ( {livePlacement.granted_window_label ?? livePlacement.window_label ?? ''} {' · '} {livePlacement.spilled ? copy.placementSpilled : copy.placementResident} )} {isLoaded && !livePlacement && {copy.loadedPill}} {isLoadingNow && ( {copy.loadingPill} )} {isActive ? ( {copy.activePill} ) : ( )} {isLoaded && ( )}
) : dJob ? undefined : ( ) } below={ dJob ? (

{!dJob.done_bytes && dJob.detail ? dJob.detail : copy.downloadProgress(gbLabel(dJob.done_bytes), gbLabel(dJob.total_bytes))}

) : undefined } description={ <> {model.description} {/* Memory: the traffic light. Green = runs fully on the GPU; amber = spills to system RAM (works, slower); red = doesn't fit this machine at all. Detail prose lives in the tooltip. */} {!model.fits ? ( {copy.pillTooBig} ) : model.spilled ? ( {copy.pillUsesRam} ) : ( {copy.pillFitsGpu} )} {/* Context: one pill. Green 'Full X context' only when the model earned its complete window resident on the GPU — a big context served from system RAM is slow, and a green badge there would sell exactly the wrong model, so a spilled full window goes gray. Anything starting below its native window gets one quiet 'Up to' pill instead of a start/grow pair. */} {model.fits && model.start_window_label && (model.start_window && model.start_window >= model.native_context ? ( {copy.pillFullContext(model.native_context_label)} ) : ( {copy.pillUpTo(model.native_context_label)} ))} {!model.fits && {copy.pillUpTo(model.native_context_label)}} {model.vision && {copy.pillVision}} {isActive && !isLoaded && !isLoadingNow && status.server_running && ( {copy.activeNotLoaded} )} } key={model.id} title={ {model.display_name} {model.recommended && (model.recommended_reason ? ( // The why, straight from the resolver: the tooltip is // the branch that picked this model, so the shown // rationale can never drift from the actual decision. {copy.recommended} ) : ( {copy.recommended} ))} } /> ) })} {status.models .filter(m => !catalog.some(c => c.downloaded_model_id === m.id || c.model_id === m.id)) .map(m => { const isActive = status.active_model_id === m.id const residency = status.loaded_models[m.id] const isLoaded = residency === 'loaded' || residency === 'ready' const isLoadingNow = residency === 'loading' const livePlacement = status.placement?.[m.id] const aJob = jobs.find(j => j.kind === 'model-activate' && j.status === 'running' && j.model_id === m.id) const anyActivateRunning = jobs.some(j => j.kind === 'model-activate' && j.status === 'running') return ( {isLoaded && livePlacement && ( {livePlacement.granted_window_label ?? livePlacement.window_label ?? ''} {' · '} {livePlacement.spilled ? copy.placementSpilled : copy.placementResident} )} {isLoaded && !livePlacement && {copy.loadedPill}} {isLoadingNow && ( {copy.loadingPill} )} {isActive ? ( {copy.activePill} ) : ( )} {isLoaded && ( )}
} description={{copy.addedByYou}} key={m.id} title={ {m.id} {m.size_label} } /> ) })} {lastError?.kind === 'model-download' &&

{lastError.error}

} ) } function fitTone(fit: HFFileGroup['fit']): 'destructive' | 'muted' | 'success' | 'warn' { if (fit === 'fits-gpu') { return 'success' } if (fit === 'needs-ram') { return 'warn' } if (fit === 'too-big') { return 'destructive' } return 'muted' } function browsedModelId(group: HFFileGroup): string { // Mirrors the backend's derivation: first file's name, split-part // suffix stripped — the id the download job carries. const first = group.paths[0].split('/').pop() ?? group.paths[0] return first.replace(/-\d{5}-of-\d{5}\.gguf$/i, '').replace(/\.gguf$/i, '') } function BrowseSection({ onChanged }: { onChanged: () => void }) { const { t } = useI18n() const copy = t.settings.localModels const jobs = useStore($localRuntimeJobs) const [query, setQuery] = useState('') const [hits, setHits] = useState([]) const [searching, setSearching] = useState(false) const [openRepo, setOpenRepo] = useState(null) const [files, setFiles] = useState([]) const [listing, setListing] = useState(false) const [error, setError] = useState(null) // Guard against the past: a stale search result must never overwrite a // newer query's hits (the desktop guide's out-of-order rule). const searchSeq = useRef(0) useEffect(() => { const q = query.trim() if (q.length < 2) { setHits([]) setSearching(false) return } const seq = ++searchSeq.current setSearching(true) const handle = setTimeout(() => { searchHFModels(q) .then(r => { if (searchSeq.current === seq) { setHits(r.hits) setError(null) } }) .catch((e: Error) => { if (searchSeq.current === seq) { setError(e.message) } }) .finally(() => { if (searchSeq.current === seq) { setSearching(false) } }) }, 350) return () => clearTimeout(handle) }, [query]) const openFiles = useCallback((repo: string) => { setOpenRepo(repo) setFiles([]) setListing(true) listHFRepoFiles(repo) .then(r => setFiles(r.files)) .catch((e: Error) => setError(e.message)) .finally(() => setListing(false)) }, []) const startBrowsedDownload = useCallback( (repo: string, group: HFFileGroup) => { downloadBrowsedModel(repo, group.paths) .then(r => { if (r.already_downloaded) { notify({ durationMs: 3_000, kind: 'info', message: copy.browseAlreadyDownloaded, title: copy.browseTitle }) return } // Same feedback loop as catalog downloads: the job store polls // and the tile renders live progress from it. watchLocalRuntimeJobs() notify({ durationMs: 3_000, kind: 'info', message: copy.browseDownloadStarted.replace('{name}', r.model_id), title: copy.browseTitle }) onChanged() }) .catch((e: Error) => notifyError(e, copy.browseTitle)) }, [copy.browseAlreadyDownloaded, copy.browseDownloadStarted, copy.browseTitle, onChanged] ) const sideload = useCallback(() => { window.hermesDesktop .selectPaths({ filters: [{ extensions: ['gguf'], name: 'GGUF models' }], title: copy.sideloadTitle }) .then(paths => { if (!paths.length) { return } return sideloadLocalModel(paths[0]).then(r => { notify({ durationMs: 3_000, kind: 'success', message: r.already_present ? copy.sideloadAlreadyPresent : copy.sideloadDone.replace('{name}', r.model_id), title: copy.browseTitle }) onChanged() }) }) .catch((e: Error) => notifyError(e, copy.browseTitle)) }, [copy.browseTitle, copy.sideloadAlreadyPresent, copy.sideloadDone, copy.sideloadTitle, onChanged]) return ( {copy.sideloadButton} } icon={Search} title={copy.browseTitle} >

{copy.browseHint}

setQuery(e.target.value)} placeholder={copy.browsePlaceholder} value={query} />
{searching && (

{copy.browseSearching}

)} {error &&

{error}

}
{hits.map(hit => (
openFiles(hit.repo)} size="sm" variant="ghost"> {openRepo === hit.repo ? copy.browseRefresh : copy.browseShowFiles} } description={ {Intl.NumberFormat().format(hit.downloads)} {copy.browseDownloads} {' · '} {Intl.NumberFormat().format(hit.likes)} {copy.browseLikes} {hit.gated ? ` · ${copy.browseGated}` : ''} } title={{hit.repo}} /> {openRepo === hit.repo && (
{listing && (

{copy.browseListing}

)} {!listing && files.length === 0 && (

{copy.browseNoGguf}

)} {files.map(group => { const dJob = runningDownloadFor(jobs, browsedModelId(group)) return (
{group.label} {group.paths.length > 1 ? ` ×${group.paths.length}` : ''} {dJob ? ( <> {!dJob.done_bytes && dJob.detail ? dJob.detail : copy.downloadProgress(gbLabel(dJob.done_bytes), gbLabel(dJob.total_bytes))} ) : ( {group.fit === 'fits-gpu' ? copy.pillFitsGpu : group.fit === 'needs-ram' ? copy.pillUsesRam : group.fit === 'too-big' ? copy.pillTooBig : copy.browseFitUnknown} {gbLabel(group.total_bytes)} )}
) })}
)}
))}
) }