1107 lines
40 KiB
TypeScript
1107 lines
40 KiB
TypeScript
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 (
|
||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
|
||
<div
|
||
className="h-full rounded-full bg-primary transition-[width] duration-300"
|
||
style={{ width: `${Math.max(2, Math.min(100, percent ?? 2))}%` }}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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<LocalModelsStatus | null>(null)
|
||
const [hardware, setHardware] = useState<LocalHardware | null>(null)
|
||
const [catalog, setCatalog] = useState<LocalCatalogModel[] | null>(null)
|
||
const [deleting, setDeleting] = useState<null | string>(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<string>())
|
||
|
||
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 <SettingsSkeleton sections={[{ rows: 2 }, { rows: 4 }]} />
|
||
}
|
||
|
||
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 (
|
||
<SettingsContent>
|
||
<div className="flex min-h-[60dvh] items-center justify-center">
|
||
<div className="w-full max-w-md text-center">
|
||
<div className="mx-auto mb-5 flex size-14 items-center justify-center rounded-2xl bg-primary/10">
|
||
{qJob ? (
|
||
<Loader2 className="size-7 animate-spin text-primary" />
|
||
) : (
|
||
<Cpu className="size-7 text-primary" />
|
||
)}
|
||
</div>
|
||
|
||
<h2 className="text-lg font-semibold text-foreground">
|
||
{qJob ? qJob.target : (heroModel?.display_name ?? '')}
|
||
</h2>
|
||
|
||
{qJob ? (
|
||
<>
|
||
<p className="mt-2 min-h-10 text-[0.8rem] leading-5 text-muted-foreground">{liveDetail}</p>
|
||
|
||
<div className="mt-5">
|
||
<ProgressBar percent={qJob.percent} />
|
||
</div>
|
||
|
||
{/* Stage rail: engine -> model -> finish. */}
|
||
<div className="mt-5 flex items-center justify-center gap-5">
|
||
{stages.map((label, i) => (
|
||
<span
|
||
className={cn(
|
||
'inline-flex items-center gap-1.5 text-[0.72rem]',
|
||
i < stageIndex && 'text-(--ui-text-tertiary)',
|
||
i === stageIndex && 'font-medium text-foreground',
|
||
i > stageIndex && 'text-(--ui-text-tertiary) opacity-60'
|
||
)}
|
||
key={label}
|
||
>
|
||
{i < stageIndex ? (
|
||
<CheckCircle2 className="size-3.5 text-primary" />
|
||
) : i === stageIndex ? (
|
||
<Loader2 className="size-3.5 animate-spin" />
|
||
) : (
|
||
<span className="size-1.5 rounded-full bg-current" />
|
||
)}
|
||
{label}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</>
|
||
) : heroModel ? (
|
||
<>
|
||
<p className="mt-2 text-[0.8rem] leading-5 text-muted-foreground">
|
||
{heroModel.downloaded
|
||
? copy.quickstartDetailReady(heroModel.display_name)
|
||
: copy.quickstartDetail(heroModel.display_name, heroModel.size_label)}
|
||
</p>
|
||
|
||
<div className="mt-6 flex items-center justify-center gap-3">
|
||
<Button onClick={() => setConfigure(true)} size="sm" variant="outline">
|
||
{copy.quickstartConfigure}
|
||
</Button>
|
||
<Button onClick={() => void handleQuickstart()} size="default">
|
||
<Zap />
|
||
{copy.quickstartAction}
|
||
</Button>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
|
||
{lastError?.kind === 'quickstart' && !qJob && (
|
||
<p className="mt-4 text-[0.75rem] text-destructive">{lastError.error}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</SettingsContent>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<SettingsContent>
|
||
{/* ── Runtime ── */}
|
||
<SettingsSection
|
||
aside={
|
||
status.runtime_installed ? (
|
||
<Pill tone="primary">
|
||
{status.server_running ? copy.serverRunning : copy.runtimeReady(status.runtime_backend ?? '')}
|
||
</Pill>
|
||
) : undefined
|
||
}
|
||
icon={Zap}
|
||
meta={status.tag}
|
||
title={copy.runtimeTitle}
|
||
>
|
||
{status.runtime_installed ? (
|
||
<ListRow
|
||
action={
|
||
status.server_running ? (
|
||
<Button
|
||
className={cn(serverBusy && '[&_svg]:animate-spin')}
|
||
disabled={serverBusy}
|
||
onClick={() => void handleServer('stop')}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
{serverBusy ? <Loader2 /> : <StopFilled />}
|
||
{copy.stopServer}
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
className={cn(serverBusy && '[&_svg]:animate-spin')}
|
||
disabled={serverBusy}
|
||
onClick={() => void handleServer('start')}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
{serverBusy ? <Loader2 /> : <Zap />}
|
||
{copy.startServer}
|
||
</Button>
|
||
)
|
||
}
|
||
description={
|
||
status.server_running
|
||
? copy.runtimeRunningDetail
|
||
: copy.runtimeInstalledDetail(status.tag, status.runtime_backend ?? 'cpu')
|
||
}
|
||
title={copy.runtimeInstalled}
|
||
/>
|
||
) : rJob ? (
|
||
<ListRow
|
||
below={<ProgressBar percent={rJob.percent} />}
|
||
description={rJob.detail || copy.installing}
|
||
title={
|
||
<span className="inline-flex items-center gap-2">
|
||
<Loader2 className="size-3.5 animate-spin" />
|
||
{copy.installing}
|
||
</span>
|
||
}
|
||
/>
|
||
) : (
|
||
<ListRow
|
||
action={
|
||
<Button onClick={() => void handleInstallRuntime()} size="sm">
|
||
<Download />
|
||
{copy.installAction}
|
||
</Button>
|
||
}
|
||
description={copy.installDetail}
|
||
title={copy.installTitle}
|
||
/>
|
||
)}
|
||
|
||
{status.update_available && !rJob && (
|
||
<ListRow
|
||
action={
|
||
<Button onClick={() => void handleInstallRuntime()} size="sm">
|
||
<Download />
|
||
{copy.updateAction}
|
||
</Button>
|
||
}
|
||
description={copy.updateDetail(status.configured_tag, status.tag)}
|
||
title={copy.updateTitle}
|
||
/>
|
||
)}
|
||
|
||
{rJob && status.runtime_installed && (
|
||
<ListRow
|
||
below={<ProgressBar percent={rJob.percent} />}
|
||
description={rJob.detail || copy.updating}
|
||
title={
|
||
<span className="inline-flex items-center gap-2">
|
||
<Loader2 className="size-3.5 animate-spin" />
|
||
{copy.updating}
|
||
</span>
|
||
}
|
||
/>
|
||
)}
|
||
|
||
{updateApplied && (
|
||
<ListRow
|
||
description={copy.upToDateDetail(status.tag, status.runtime_backend ?? 'cpu')}
|
||
title={
|
||
<span className="inline-flex items-center gap-2">
|
||
<CheckCircle2 className="size-4 text-emerald-600 dark:text-emerald-400" />
|
||
{copy.upToDateTitle}
|
||
</span>
|
||
}
|
||
/>
|
||
)}
|
||
|
||
{lastError?.kind === 'runtime-install' && <p className="text-[0.75rem] text-destructive">{lastError.error}</p>}
|
||
</SettingsSection>
|
||
|
||
{/* ── This machine ── */}
|
||
<SettingsSection icon={Monitor} title={copy.hardwareTitle}>
|
||
{hardware ? (
|
||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 py-1 text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
||
{hardware.gpu_name && (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<Zap className="size-3.5" />
|
||
{hardware.gpu_name}
|
||
</span>
|
||
)}
|
||
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<Cpu className="size-3.5" />
|
||
{copy.vram(gbLabel(hardware.vram_total_bytes))}
|
||
</span>
|
||
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<Package className="size-3.5" />
|
||
{copy.ram(gbLabel(hardware.ram_total_bytes))}
|
||
</span>
|
||
|
||
{hardware.uma && <Pill>{copy.unifiedMemory}</Pill>}
|
||
</div>
|
||
) : (
|
||
<p className="py-1 text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
||
{copy.hardwareLoading}
|
||
</p>
|
||
)}
|
||
</SettingsSection>
|
||
|
||
{/* ── Models ── */}
|
||
<SettingsSection icon={Download} meta={`${catalog.length}`} title={copy.modelsTitle}>
|
||
<div className="grid gap-1">
|
||
{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 (
|
||
<ListRow
|
||
action={
|
||
model.downloaded ? (
|
||
<div className="flex items-center justify-end gap-2">
|
||
{isLoaded && livePlacement && (
|
||
<Tip label={livePlacement.spilled ? copy.placementSpilledTip : copy.placementResidentTip}>
|
||
<Pill tone={livePlacement.spilled ? 'warn' : 'success'}>
|
||
<Cpu className="mr-1 size-3" />
|
||
{livePlacement.granted_window_label ?? livePlacement.window_label ?? ''}
|
||
{' · '}
|
||
{livePlacement.spilled ? copy.placementSpilled : copy.placementResident}
|
||
</Pill>
|
||
</Tip>
|
||
)}
|
||
{isLoaded && !livePlacement && <Pill>{copy.loadedPill}</Pill>}
|
||
|
||
{isLoadingNow && (
|
||
<Pill>
|
||
<Loader2 className="mr-1 size-3 animate-spin" />
|
||
{copy.loadingPill}
|
||
</Pill>
|
||
)}
|
||
|
||
{isActive ? (
|
||
<Tip label={copy.activeDetail}>
|
||
<Pill tone="primary">
|
||
<Check className="mr-1 size-3" />
|
||
{copy.activePill}
|
||
</Pill>
|
||
</Tip>
|
||
) : (
|
||
<Button
|
||
className={cn(aJob && '[&_svg]:animate-spin')}
|
||
disabled={anyActivateRunning}
|
||
onClick={() => void handleActivate(activateTarget ?? null, model.display_name)}
|
||
size="sm"
|
||
>
|
||
{aJob ? <Loader2 /> : <Check />}
|
||
{aJob ? copy.activating : copy.useAction}
|
||
</Button>
|
||
)}
|
||
|
||
{isLoaded && (
|
||
<Tip label={copy.ejectTip}>
|
||
<Button
|
||
onClick={() => void handleEject(activateTarget ?? model.id)}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<Eject />
|
||
</Button>
|
||
</Tip>
|
||
)}
|
||
|
||
<Tip label={copy.deleteAction}>
|
||
<Button
|
||
className={cn(deleting === model.id && '[&_svg]:animate-spin')}
|
||
onClick={() => void handleDelete(model.downloaded_model_id ?? model.id, model.id)}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
{deleting === model.id ? <Loader2 /> : <Trash2 />}
|
||
</Button>
|
||
</Tip>
|
||
</div>
|
||
) : dJob ? undefined : (
|
||
<Button
|
||
disabled={!model.fits || anyDownloadRunning || !status.runtime_installed}
|
||
onClick={() => void handleDownload(model)}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
<Download />
|
||
{copy.downloadAction(model.size_label)}
|
||
</Button>
|
||
)
|
||
}
|
||
below={
|
||
dJob ? (
|
||
<div className="mt-2 grid gap-1">
|
||
<ProgressBar percent={dJob.percent} />
|
||
|
||
<p className="text-[0.68rem] text-muted-foreground">
|
||
{!dJob.done_bytes && dJob.detail
|
||
? dJob.detail
|
||
: copy.downloadProgress(gbLabel(dJob.done_bytes), gbLabel(dJob.total_bytes))}
|
||
</p>
|
||
</div>
|
||
) : undefined
|
||
}
|
||
description={
|
||
<>
|
||
{model.description}
|
||
|
||
<span className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||
{/* 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 ? (
|
||
<Tip label={model.fit_detail ?? model.fit_summary}>
|
||
<Pill tone="destructive">
|
||
<Cpu className="mr-1 size-3" />
|
||
{copy.pillTooBig}
|
||
</Pill>
|
||
</Tip>
|
||
) : model.spilled ? (
|
||
<Tip label={model.quant_reason ?? model.fit_summary}>
|
||
<Pill tone="warn">
|
||
<Cpu className="mr-1 size-3" />
|
||
{copy.pillUsesRam}
|
||
</Pill>
|
||
</Tip>
|
||
) : (
|
||
<Tip label={model.quant_reason ?? model.fit_summary}>
|
||
<Pill tone="success">
|
||
<Cpu className="mr-1 size-3" />
|
||
{copy.pillFitsGpu}
|
||
</Pill>
|
||
</Tip>
|
||
)}
|
||
|
||
{/* 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 ? (
|
||
<Tip label={copy.pillFullContextTip}>
|
||
<Pill tone={model.spilled ? 'muted' : 'success'}>
|
||
{copy.pillFullContext(model.native_context_label)}
|
||
</Pill>
|
||
</Tip>
|
||
) : (
|
||
<Tip label={copy.pillGrowsTip}>
|
||
<Pill>{copy.pillUpTo(model.native_context_label)}</Pill>
|
||
</Tip>
|
||
))}
|
||
|
||
{!model.fits && <Pill>{copy.pillUpTo(model.native_context_label)}</Pill>}
|
||
|
||
{model.vision && <Pill>{copy.pillVision}</Pill>}
|
||
</span>
|
||
|
||
{isActive && !isLoaded && !isLoadingNow && status.server_running && (
|
||
<span className="mt-0.5 block text-(--ui-text-tertiary)">{copy.activeNotLoaded}</span>
|
||
)}
|
||
</>
|
||
}
|
||
key={model.id}
|
||
title={
|
||
<span className="inline-flex items-center gap-2">
|
||
{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.
|
||
<Tip label={copy.recommendedReason[model.recommended_reason]}>
|
||
<Pill tone="primary">{copy.recommended}</Pill>
|
||
</Tip>
|
||
) : (
|
||
<Pill tone="primary">{copy.recommended}</Pill>
|
||
))}
|
||
</span>
|
||
}
|
||
/>
|
||
)
|
||
})}
|
||
|
||
{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 (
|
||
<ListRow
|
||
action={
|
||
<div className="flex items-center justify-end gap-2">
|
||
{isLoaded && livePlacement && (
|
||
<Tip label={livePlacement.spilled ? copy.placementSpilledTip : copy.placementResidentTip}>
|
||
<Pill tone={livePlacement.spilled ? 'warn' : 'success'}>
|
||
<Cpu className="mr-1 size-3" />
|
||
{livePlacement.granted_window_label ?? livePlacement.window_label ?? ''}
|
||
{' · '}
|
||
{livePlacement.spilled ? copy.placementSpilled : copy.placementResident}
|
||
</Pill>
|
||
</Tip>
|
||
)}
|
||
{isLoaded && !livePlacement && <Pill>{copy.loadedPill}</Pill>}
|
||
|
||
{isLoadingNow && (
|
||
<Pill>
|
||
<Loader2 className="mr-1 size-3 animate-spin" />
|
||
{copy.loadingPill}
|
||
</Pill>
|
||
)}
|
||
|
||
{isActive ? (
|
||
<Pill tone="primary">
|
||
<CheckCircle2 className="mr-1 size-3" />
|
||
{copy.activePill}
|
||
</Pill>
|
||
) : (
|
||
<Button
|
||
className={cn(aJob && '[&_svg]:animate-spin')}
|
||
disabled={anyActivateRunning}
|
||
onClick={() => void handleActivate(m.id, m.id)}
|
||
size="sm"
|
||
>
|
||
{aJob ? <Loader2 /> : <Check />}
|
||
{copy.useAction}
|
||
</Button>
|
||
)}
|
||
|
||
{isLoaded && (
|
||
<Tip label={copy.ejectTip}>
|
||
<Button onClick={() => void handleEject(m.id)} size="icon" variant="ghost">
|
||
<Eject />
|
||
</Button>
|
||
</Tip>
|
||
)}
|
||
|
||
<Tip label={copy.deleteAction}>
|
||
<Button
|
||
className={cn(deleting === m.id && '[&_svg]:animate-spin')}
|
||
onClick={() => void handleDelete(m.id, m.id)}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
{deleting === m.id ? <Loader2 /> : <Trash2 />}
|
||
</Button>
|
||
</Tip>
|
||
</div>
|
||
}
|
||
description={<span>{copy.addedByYou}</span>}
|
||
key={m.id}
|
||
title={
|
||
<span className="inline-flex items-center gap-2">
|
||
<span className="truncate font-mono text-[0.8rem]">{m.id}</span>
|
||
|
||
<span className="text-[0.68rem] font-normal text-muted-foreground">{m.size_label}</span>
|
||
</span>
|
||
}
|
||
/>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{lastError?.kind === 'model-download' && <p className="text-[0.75rem] text-destructive">{lastError.error}</p>}
|
||
</SettingsSection>
|
||
|
||
<BrowseSection onChanged={refresh} />
|
||
</SettingsContent>
|
||
)
|
||
}
|
||
|
||
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<HFSearchHit[]>([])
|
||
const [searching, setSearching] = useState(false)
|
||
const [openRepo, setOpenRepo] = useState<null | string>(null)
|
||
const [files, setFiles] = useState<HFFileGroup[]>([])
|
||
const [listing, setListing] = useState(false)
|
||
const [error, setError] = useState<null | string>(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 (
|
||
<SettingsSection
|
||
aside={
|
||
<Button onClick={sideload} size="sm" variant="outline">
|
||
<FolderOpen className="mr-1 size-3.5" />
|
||
{copy.sideloadButton}
|
||
</Button>
|
||
}
|
||
icon={Search}
|
||
title={copy.browseTitle}
|
||
>
|
||
<p className="text-[0.75rem] text-muted-foreground">{copy.browseHint}</p>
|
||
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||
<input
|
||
className="w-full rounded-md border border-(--ui-border) bg-transparent py-1.5 pl-8 pr-3 text-[0.8rem] outline-none placeholder:text-muted-foreground focus:border-primary"
|
||
onChange={e => setQuery(e.target.value)}
|
||
placeholder={copy.browsePlaceholder}
|
||
value={query}
|
||
/>
|
||
</div>
|
||
|
||
{searching && (
|
||
<p className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
|
||
<Loader2 className="size-3 animate-spin" />
|
||
{copy.browseSearching}
|
||
</p>
|
||
)}
|
||
|
||
{error && <p className="text-[0.75rem] text-destructive">{error}</p>}
|
||
|
||
<div className="grid gap-1">
|
||
{hits.map(hit => (
|
||
<div key={hit.repo}>
|
||
<ListRow
|
||
action={
|
||
<Button onClick={() => openFiles(hit.repo)} size="sm" variant="ghost">
|
||
{openRepo === hit.repo ? copy.browseRefresh : copy.browseShowFiles}
|
||
</Button>
|
||
}
|
||
description={
|
||
<span>
|
||
{Intl.NumberFormat().format(hit.downloads)} {copy.browseDownloads}
|
||
{' · '}
|
||
{Intl.NumberFormat().format(hit.likes)} {copy.browseLikes}
|
||
{hit.gated ? ` · ${copy.browseGated}` : ''}
|
||
</span>
|
||
}
|
||
title={<span className="font-mono text-[0.8rem]">{hit.repo}</span>}
|
||
/>
|
||
|
||
{openRepo === hit.repo && (
|
||
<div className="ml-4 grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-1.5 border-l border-(--ui-border) py-1 pl-3">
|
||
{listing && (
|
||
<p className="col-span-full flex items-center gap-2 py-1 text-[0.75rem] text-muted-foreground">
|
||
<Loader2 className="size-3 animate-spin" />
|
||
{copy.browseListing}
|
||
</p>
|
||
)}
|
||
|
||
{!listing && files.length === 0 && (
|
||
<p className="col-span-full py-1 text-[0.75rem] text-muted-foreground">{copy.browseNoGguf}</p>
|
||
)}
|
||
|
||
{files.map(group => {
|
||
const dJob = runningDownloadFor(jobs, browsedModelId(group))
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'flex flex-col gap-1 rounded-md border border-(--ui-border) px-2.5 py-1.5',
|
||
group.fit === 'too-big' && 'opacity-45'
|
||
)}
|
||
key={group.label}
|
||
>
|
||
<span className="flex w-full items-center justify-between gap-2">
|
||
<span className="truncate font-mono text-[0.75rem]">
|
||
{group.label}
|
||
{group.paths.length > 1 ? ` ×${group.paths.length}` : ''}
|
||
</span>
|
||
|
||
<Button
|
||
aria-label={copy.browseDownloadAria.replace('{name}', group.label)}
|
||
className="h-6 shrink-0 px-2"
|
||
disabled={group.fit === 'too-big' || Boolean(dJob)}
|
||
onClick={() => startBrowsedDownload(hit.repo, group)}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
{dJob ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
|
||
</Button>
|
||
</span>
|
||
|
||
{dJob ? (
|
||
<>
|
||
<ProgressBar percent={dJob.percent} />
|
||
|
||
<span className="text-[0.68rem] text-muted-foreground">
|
||
{!dJob.done_bytes && dJob.detail
|
||
? dJob.detail
|
||
: copy.downloadProgress(gbLabel(dJob.done_bytes), gbLabel(dJob.total_bytes))}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<span className="flex items-center justify-between gap-2">
|
||
<Pill tone={fitTone(group.fit)}>
|
||
<Cpu className="mr-1 size-3" />
|
||
{group.fit === 'fits-gpu'
|
||
? copy.pillFitsGpu
|
||
: group.fit === 'needs-ram'
|
||
? copy.pillUsesRam
|
||
: group.fit === 'too-big'
|
||
? copy.pillTooBig
|
||
: copy.browseFitUnknown}
|
||
</Pill>
|
||
|
||
<span className="shrink-0 text-[0.7rem] text-muted-foreground">
|
||
{gbLabel(group.total_bytes)}
|
||
</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</SettingsSection>
|
||
)
|
||
}
|