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)}
)}
)
})}
)}
))}
)
}