refactor(desktop): split the local models pane into scoped sections

ScopedLocalModelsSettings was one ~760-line component that threaded
`owner` into every child. The pane now provides its owner through
LocalModelsOwnerProvider (still keyed by owner, so each connection/profile
remounts), and renders sibling components that read it from context: the
quickstart hero, runtime, hardware, models (catalog and sideloaded rows)
and browse sections. Request actions live in local-models-actions.ts and
keep the current-owner toast fence.

Two intentional differences: the delete spinner is per row instead of one
shared id, and the runtime install/update buttons pass the pane's owner
and QueryClient explicitly (the unused handleInstallRuntime is gone).
This commit is contained in:
ethernet
2026-09-24 13:59:57 -04:00
parent fd19d439cb
commit 787c739a2a
9 changed files with 1286 additions and 1060 deletions

View File

@@ -0,0 +1,149 @@
import { type QueryClient, useQueryClient } from '@tanstack/react-query'
import {
activateLocalModel,
deleteLocalModel,
downloadLocalModel,
ejectLocalModel,
quickstartLocalModels,
setLocalServer
} from '@/hermes'
import { useI18n } from '@/i18n'
import type { Translations } from '@/i18n/types'
import {
isCurrentLocalModelsOwner,
localModelsNotificationTitle,
type LocalModelsOwner,
localModelsRequestScope,
refreshLocalModels,
watchLocalRuntimeJobs
} from '@/store/local-runtime-jobs'
import { notify, notifyError } from '@/store/notifications'
import type { LocalCatalogModel, LocalRuntimeJob } from '@/types/hermes'
import { useScopedLocalModelsOwner } from './local-models-owner'
export type LocalModelsCopy = Translations['settings']['localModels']
export interface LocalModelsActionScope {
owner: LocalModelsOwner
client: QueryClient
copy: LocalModelsCopy
}
export function useLocalModelsActionScope(): LocalModelsActionScope {
const owner: LocalModelsOwner = useScopedLocalModelsOwner()
const client: QueryClient = useQueryClient()
const { t } = useI18n()
return { owner, client, copy: t.settings.localModels }
}
// Still on its way (or parked mid-way): rows/hero stay visible while
// paused — a paused download vanishing reads as progress loss.
export function isActiveStatus(status: LocalRuntimeJob['status']): boolean {
return status === 'paused' || status === 'running'
}
// The actions below never reject: a failure toasts only while its owner is
// still the one on screen, so a late error cannot land on the next connection.
export async function runQuickstart({ owner, client, copy }: LocalModelsActionScope): Promise<void> {
try {
await quickstartLocalModels(undefined, localModelsRequestScope(owner))
watchLocalRuntimeJobs(owner, client)
} catch (err) {
if (isCurrentLocalModelsOwner(owner)) {
notifyError(err, copy.quickstartFailed)
}
}
}
export async function downloadCatalogModel(
{ owner, client, copy }: LocalModelsActionScope,
model: LocalCatalogModel
): Promise<void> {
try {
const res = await downloadLocalModel(model.id, localModelsRequestScope(owner))
if (res.already_downloaded || !res.job_id) {
refreshLocalModels(owner, client)
return
}
watchLocalRuntimeJobs(owner, client)
} catch (err) {
if (isCurrentLocalModelsOwner(owner)) {
notifyError(err, copy.downloadFailed(model.display_name))
}
}
}
export async function activateModel(
{ owner, client, copy }: LocalModelsActionScope,
target: null | string,
displayName: string
): Promise<void> {
if (!target) {
return
}
try {
await activateLocalModel(target, localModelsRequestScope(owner))
watchLocalRuntimeJobs(owner, client)
} catch (err) {
if (isCurrentLocalModelsOwner(owner)) {
notifyError(err, copy.activateFailed(displayName))
}
}
}
export async function ejectModel({ owner, client, copy }: LocalModelsActionScope, modelId: string): Promise<void> {
try {
await ejectLocalModel(modelId, localModelsRequestScope(owner))
notify({ durationMs: 3_000, kind: 'success', message: copy.ejected, title: localModelsNotificationTitle(owner) })
refreshLocalModels(owner, client)
} catch (err) {
if (isCurrentLocalModelsOwner(owner)) {
notifyError(err, copy.ejectFailed)
}
}
}
export async function setServerRunning(
{ owner, client, copy }: LocalModelsActionScope,
action: 'start' | 'stop'
): Promise<void> {
try {
await setLocalServer(action, localModelsRequestScope(owner))
notify({
durationMs: 3_500,
kind: 'success',
message: action === 'stop' ? copy.serverStopped : copy.serverStarted,
title: localModelsNotificationTitle(owner)
})
refreshLocalModels(owner, client)
} catch (err) {
if (isCurrentLocalModelsOwner(owner)) {
notifyError(err, action === 'stop' ? copy.serverStopFailed : copy.serverStartFailed)
}
}
}
export async function deleteModel({ owner, client, copy }: LocalModelsActionScope, target: string): Promise<void> {
try {
await deleteLocalModel(target, localModelsRequestScope(owner))
notify({
durationMs: 2_500,
kind: 'success',
message: copy.deleted(target),
title: localModelsNotificationTitle(owner)
})
refreshLocalModels(owner, client)
} catch (err) {
if (isCurrentLocalModelsOwner(owner)) {
notifyError(err, copy.deleteFailed)
}
}
}

View File

@@ -0,0 +1,327 @@
import { type QueryClient, useQueryClient } from '@tanstack/react-query'
import { type ReactElement, useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { SearchField } from '@/components/ui/search-field'
import {
downloadBrowsedModel,
type HFFileGroup,
type HFSearchHit,
listHFRepoFiles,
searchHFModels,
sideloadLocalModel
} from '@/hermes'
import { useI18n } from '@/i18n'
import { Cpu, Download, FolderOpen, Loader2, Search } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
isCurrentLocalModelsOwner,
localModelsNotificationTitle,
type LocalModelsOwner,
localModelsRequestScope,
refreshLocalModels,
runningDownloadFor,
useLocalRuntimeJobs,
watchLocalRuntimeJobs
} from '@/store/local-runtime-jobs'
import { notify, notifyError } from '@/store/notifications'
import type { LocalRuntimeJob } from '@/types/hermes'
import { downloadStatusText, gbLabel, LocalModelDownloadActions, ProgressBar } from './local-model-download-progress'
import { useScopedLocalModelsOwner } from './local-models-owner'
import { ListRow, Pill, SettingsSection } from './primitives'
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, '')
}
export function LocalModelsBrowseSection(): ReactElement {
const owner: LocalModelsOwner = useScopedLocalModelsOwner()
const client: QueryClient = useQueryClient()
const { t } = useI18n()
const copy = t.settings.localModels
const onChanged = useCallback((): void => refreshLocalModels(owner, client), [owner, client])
const jobs: readonly LocalRuntimeJob[] = useLocalRuntimeJobs(
owner,
(value: readonly LocalRuntimeJob[]): readonly LocalRuntimeJob[] => value,
false
)
const [query, setQuery] = useState<string>('')
const [hits, setHits] = useState<HFSearchHit[]>([])
const [searching, setSearching] = useState<boolean>(false)
const [openRepo, setOpenRepo] = useState<null | string>(null)
const [files, setFiles] = useState<HFFileGroup[]>([])
const [listing, setListing] = useState<boolean>(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: ReturnType<typeof setTimeout> = setTimeout((): void => {
searchHFModels(q, 20, localModelsRequestScope(owner))
.then((r: { hits: HFSearchHit[] }): void => {
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, owner])
const openFiles = useCallback(
(repo: string): void => {
setOpenRepo(repo)
setFiles([])
setListing(true)
listHFRepoFiles(repo, localModelsRequestScope(owner))
.then((r: { files: HFFileGroup[] }): void => setFiles(r.files))
.catch((e: Error) => setError(e.message))
.finally(() => setListing(false))
},
[owner]
)
const startBrowsedDownload = useCallback(
(repo: string, group: HFFileGroup): void => {
downloadBrowsedModel(repo, group.paths, localModelsRequestScope(owner))
.then((r: Awaited<ReturnType<typeof downloadBrowsedModel>>): void => {
if (r.already_downloaded) {
notify({
durationMs: 3_000,
kind: 'info',
message: copy.browseAlreadyDownloaded,
title: localModelsNotificationTitle(owner)
})
return
}
// Same feedback loop as catalog downloads: the job store polls
// and the tile renders live progress from it.
watchLocalRuntimeJobs(owner, client)
notify({
durationMs: 3_000,
kind: 'info',
message: copy.browseDownloadStarted.replace('{name}', r.model_id),
title: localModelsNotificationTitle(owner)
})
onChanged()
})
.catch((e: Error): void => {
if (isCurrentLocalModelsOwner(owner)) {
notifyError(e, copy.browseTitle)
}
})
},
[copy.browseAlreadyDownloaded, copy.browseDownloadStarted, copy.browseTitle, onChanged, owner, client]
)
const sideload = useCallback((): void => {
window.hermesDesktop
.selectPaths({ filters: [{ extensions: ['gguf'], name: 'GGUF models' }], title: copy.sideloadTitle })
.then((paths: string[]): Promise<void> | undefined => {
if (!paths.length) {
return
}
return sideloadLocalModel(paths[0], localModelsRequestScope(owner)).then(
(r: Awaited<ReturnType<typeof sideloadLocalModel>>): void => {
notify({
durationMs: 3_000,
kind: 'success',
message: r.already_present
? copy.sideloadAlreadyPresent
: copy.sideloadDone.replace('{name}', r.model_id),
title: localModelsNotificationTitle(owner)
})
onChanged()
}
)
})
.catch((e: Error): void => {
if (isCurrentLocalModelsOwner(owner)) {
notifyError(e, copy.browseTitle)
}
})
}, [copy.browseTitle, copy.sideloadAlreadyPresent, copy.sideloadDone, copy.sideloadTitle, onChanged, owner])
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}
>
<div id="local-model-browse">
<p className="text-[0.75rem] text-muted-foreground">{copy.browseHint}</p>
<SearchField
containerClassName="w-full"
inputClassName="flex-1"
onChange={setQuery}
placeholder={copy.browsePlaceholder}
value={query}
/>
{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 paused={dJob.status === 'paused'} percent={dJob.percent} />
<span className="text-[0.68rem] text-muted-foreground">
{downloadStatusText(dJob, copy)}
</span>
<LocalModelDownloadActions job={dJob} owner={owner} />
</>
) : (
<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>
</div>
</SettingsSection>
)
}

View File

@@ -0,0 +1,48 @@
import type { ReactElement } from 'react'
import { useI18n } from '@/i18n'
import { Cpu, Monitor, Package, Zap } from '@/lib/icons'
import type { LocalHardware } from '@/types/hermes'
import { gbLabel } from './local-model-download-progress'
import { Pill, SettingsSection } from './primitives'
export interface LocalModelsHardwareSectionProps {
hardware: LocalHardware | undefined
}
export function LocalModelsHardwareSection({ hardware }: LocalModelsHardwareSectionProps): ReactElement {
const { t } = useI18n()
const copy = t.settings.localModels
return (
<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>
)
}

View File

@@ -0,0 +1,338 @@
import { type ReactElement, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { Check, CheckCircle2, Cpu, Download, Eject, Loader2, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { runningDownloadFor } from '@/store/local-runtime-jobs'
import type { LocalCatalogModel, LocalModelPlacement, LocalModelsStatus, LocalRuntimeJob } from '@/types/hermes'
import { downloadStatusText, LocalModelDownloadActions, ProgressBar } from './local-model-download-progress'
import {
activateModel,
deleteModel,
downloadCatalogModel,
ejectModel,
isActiveStatus,
type LocalModelsActionScope,
useLocalModelsActionScope
} from './local-models-actions'
import { ListRow, Pill } from './primitives'
type SideloadedModel = LocalModelsStatus['models'][number]
interface Residency {
isLoaded: boolean
isLoadingNow: boolean
livePlacement: LocalModelPlacement | undefined
}
function residencyOf(status: LocalModelsStatus, modelId: null | string | undefined): Residency {
const residency: string | undefined = modelId ? status.loaded_models[modelId] : undefined
return {
isLoaded: residency === 'loaded' || residency === 'ready',
isLoadingNow: residency === 'loading',
livePlacement: modelId ? status.placement?.[modelId] : undefined
}
}
function runningActivation(jobs: readonly LocalRuntimeJob[], modelId: null | string | undefined): boolean {
return jobs.some(
(job: LocalRuntimeJob): boolean =>
job.kind === 'model-activate' && job.status === 'running' && job.model_id === modelId
)
}
function anyActivationRunning(jobs: readonly LocalRuntimeJob[]): boolean {
return jobs.some((job: LocalRuntimeJob): boolean => job.kind === 'model-activate' && job.status === 'running')
}
interface ResidencyPillsProps {
residency: Residency
}
function ResidencyPills({ residency }: ResidencyPillsProps): ReactElement {
const { copy } = useLocalModelsActionScope()
const { isLoaded, isLoadingNow, livePlacement } = residency
return (
<>
{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>
)}
</>
)
}
interface ModelIdProps {
modelId: string
}
function EjectModelButton({ modelId }: ModelIdProps): ReactElement {
const scope: LocalModelsActionScope = useLocalModelsActionScope()
return (
<Tip label={scope.copy.ejectTip}>
<Button onClick={() => void ejectModel(scope, modelId)} size="icon" variant="ghost">
<Eject />
</Button>
</Tip>
)
}
function DeleteModelButton({ modelId }: ModelIdProps): ReactElement {
const scope: LocalModelsActionScope = useLocalModelsActionScope()
const [deleting, setDeleting] = useState<boolean>(false)
async function handleDelete(): Promise<void> {
if (!window.confirm(scope.copy.deleteConfirm(modelId))) {
return
}
setDeleting(true)
try {
await deleteModel(scope, modelId)
} finally {
setDeleting(false)
}
}
return (
<Tip label={scope.copy.deleteAction}>
<Button
className={cn(deleting && '[&_svg]:animate-spin')}
onClick={() => void handleDelete()}
size="icon"
variant="ghost"
>
{deleting ? <Loader2 /> : <Trash2 />}
</Button>
</Tip>
)
}
export interface CatalogModelRowProps {
model: LocalCatalogModel
status: LocalModelsStatus
jobs: readonly LocalRuntimeJob[]
}
export function CatalogModelRow({ model, status, jobs }: CatalogModelRowProps): ReactElement {
const scope: LocalModelsActionScope = useLocalModelsActionScope()
const { copy, owner } = scope
const dJob: LocalRuntimeJob | null = runningDownloadFor(jobs, model.id)
const anyDownloadRunning = jobs.some(j => j.kind === 'model-download' && isActiveStatus(j.status))
const activateTarget = model.downloaded_model_id ?? model.model_id
const isActive = Boolean(activateTarget && status.active_model_id === activateTarget)
const residency: Residency = residencyOf(status, activateTarget)
const { isLoaded, isLoadingNow } = residency
const activating: boolean = runningActivation(jobs, activateTarget)
return (
<ListRow
action={
model.downloaded ? (
<div className="flex items-center justify-end gap-2">
<ResidencyPills residency={residency} />
{isActive ? (
<Tip label={copy.activeDetail}>
<Pill tone="primary">
<Check className="mr-1 size-3" />
{copy.activePill}
</Pill>
</Tip>
) : (
<Button
className={cn(activating && '[&_svg]:animate-spin')}
disabled={anyActivationRunning(jobs)}
onClick={() => void activateModel(scope, activateTarget ?? null, model.display_name)}
size="sm"
>
{activating ? <Loader2 /> : <Check />}
{activating ? copy.activating : copy.useAction}
</Button>
)}
{isLoaded && <EjectModelButton modelId={activateTarget ?? model.id} />}
<DeleteModelButton modelId={model.downloaded_model_id ?? model.id} />
</div>
) : dJob ? (
<LocalModelDownloadActions job={dJob} owner={owner} />
) : (
<Button
disabled={!model.fits || anyDownloadRunning || !status.runtime_installed}
onClick={() => void downloadCatalogModel(scope, model)}
size="sm"
variant="outline"
>
<Download />
{copy.downloadAction(model.size_label)}
</Button>
)
}
below={
dJob ? (
<div className="mt-2 grid gap-1">
<ProgressBar paused={dJob.status === 'paused'} percent={dJob.percent} />
<p className="text-[0.68rem] text-muted-foreground">{downloadStatusText(dJob, copy)}</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>
)}
</>
}
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>
}
/>
)
}
export interface SideloadedModelRowProps {
model: SideloadedModel
status: LocalModelsStatus
jobs: readonly LocalRuntimeJob[]
}
export function SideloadedModelRow({ model: m, status, jobs }: SideloadedModelRowProps): ReactElement {
const scope: LocalModelsActionScope = useLocalModelsActionScope()
const { copy } = scope
const isActive = status.active_model_id === m.id
const residency: Residency = residencyOf(status, m.id)
const activating: boolean = runningActivation(jobs, m.id)
return (
<ListRow
action={
<div className="flex items-center justify-end gap-2">
<ResidencyPills residency={residency} />
{isActive ? (
<Pill tone="primary">
<CheckCircle2 className="mr-1 size-3" />
{copy.activePill}
</Pill>
) : (
<Button
className={cn(activating && '[&_svg]:animate-spin')}
disabled={anyActivationRunning(jobs)}
onClick={() => void activateModel(scope, m.id, m.id)}
size="sm"
>
{activating ? <Loader2 /> : <Check />}
{copy.useAction}
</Button>
)}
{residency.isLoaded && <EjectModelButton modelId={m.id} />}
<DeleteModelButton modelId={m.id} />
</div>
}
description={<span>{copy.addedByYou}</span>}
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>
}
/>
)
}

View File

@@ -0,0 +1,79 @@
import type { ReactElement } from 'react'
import { Button } from '@/components/ui/button'
import { useI18n } from '@/i18n'
import { Download, Search } from '@/lib/icons'
import type { LocalCatalogModel, LocalModelsStatus, LocalRuntimeJob } from '@/types/hermes'
import { CatalogModelRow, SideloadedModelRow } from './local-models-model-rows'
import { ListRow, SettingsSection } from './primitives'
// 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 interface LocalModelsModelsSectionProps {
status: LocalModelsStatus
catalog: LocalCatalogModel[]
jobs: readonly LocalRuntimeJob[]
lastError: LocalRuntimeJob | undefined
}
export function LocalModelsModelsSection({
status,
catalog,
jobs,
lastError
}: LocalModelsModelsSectionProps): ReactElement {
const { t } = useI18n()
const copy = t.settings.localModels
const hasRecommendation = catalog.some(c => c.recommended)
const sortedCatalog = [...catalog].sort((a, b) => fitRank(a) - fitRank(b))
return (
<SettingsSection icon={Download} meta={`${catalog.length}`} title={copy.modelsTitle}>
{!hasRecommendation && (
<ListRow
action={
<Button
onClick={() =>
document.getElementById('local-model-browse')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
size="sm"
>
<Search />
{copy.noRecommendationAction}
</Button>
}
description={copy.noRecommendationDetail}
title={copy.noRecommendationTitle}
/>
)}
<div className="grid gap-1">
{sortedCatalog.map(model => (
<CatalogModelRow jobs={jobs} key={model.id} model={model} status={status} />
))}
{status.models
.filter(m => !catalog.some(c => c.downloaded_model_id === m.id || c.model_id === m.id))
.map(m => (
<SideloadedModelRow jobs={jobs} key={m.id} model={m} status={status} />
))}
</div>
{lastError?.kind === 'model-download' && <p className="text-[0.75rem] text-destructive">{lastError.error}</p>}
</SettingsSection>
)
}

View File

@@ -0,0 +1,20 @@
import { createContext, useContext } from 'react'
import type { LocalModelsOwner } from '@/store/local-runtime-jobs'
// The pane remounts per owner and every request inside it must stay pinned to
// the owner it mounted with; re-deriving from the live stores in a child
// could retarget an in-flight action at the next connection or profile.
const LocalModelsOwnerContext = createContext<LocalModelsOwner | null>(null)
export const LocalModelsOwnerProvider = LocalModelsOwnerContext.Provider
export function useScopedLocalModelsOwner(): LocalModelsOwner {
const owner: LocalModelsOwner | null = useContext(LocalModelsOwnerContext)
if (!owner) {
throw new Error('useScopedLocalModelsOwner must be used inside LocalModelsOwnerProvider')
}
return owner
}

View File

@@ -0,0 +1,131 @@
import type { ReactElement } from 'react'
import { Button } from '@/components/ui/button'
import { CheckCircle2, Cpu, Loader2, Pause, Zap } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { LocalCatalogModel, LocalRuntimeJob } from '@/types/hermes'
import { downloadStatusText, LocalModelDownloadActions, ProgressBar } from './local-model-download-progress'
import { type LocalModelsActionScope, runQuickstart, useLocalModelsActionScope } from './local-models-actions'
import { SettingsContent } from './primitives'
export interface LocalModelsQuickstartProps {
job: LocalRuntimeJob | null
heroModel: LocalCatalogModel | null
lastError: LocalRuntimeJob | undefined
onConfigure: () => void
}
// ── 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 'Let me choose' click away.
export function LocalModelsQuickstart({
job: qJob,
heroModel,
lastError,
onConfigure
}: LocalModelsQuickstartProps): ReactElement {
const scope: LocalModelsActionScope = useLocalModelsActionScope()
const { copy, owner } = scope
// 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]
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 ? (
qJob.status === 'paused' ? (
<Pause className="size-7 text-muted-foreground" />
) : (
<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 ? (
<>
{/* The shared status line: state · bytes · speed · ETA. Each
stage recomputes percent against ITS OWN download plan, so
the composer drops speed/ETA when bytes aren't moving —
showing a stale rate across a stage hand-off would read as
progress loss. A paused job keeps its frozen counter. */}
<p className="mt-2 min-h-10 text-[0.8rem] leading-5 text-muted-foreground">
{downloadStatusText(qJob, copy)}
</p>
<div className="mt-5">
<ProgressBar paused={qJob.status === 'paused'} percent={qJob.percent} />
</div>
{/* Pause / Resume — the backend's can_pause/can_resume gate
when controls exist (engine legs, server start report
false); the paused state always offers Resume. */}
<div className="mt-5 flex items-center justify-center gap-3">
<LocalModelDownloadActions job={qJob} owner={owner} />
</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={onConfigure} size="sm" variant="outline">
{copy.quickstartConfigure}
</Button>
<Button onClick={() => void runQuickstart(scope)} 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>
)
}

View File

@@ -0,0 +1,163 @@
import { useIsMutating } from '@tanstack/react-query'
import { type ReactElement, useState } from 'react'
import { Button } from '@/components/ui/button'
import { CheckCircle2, Download, Loader2, Pause, StopFilled, Zap } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { localModelsKey, runningRuntimeInstall, startLocalRuntimeInstall } from '@/store/local-runtime-jobs'
import type { LocalModelsStatus, LocalRuntimeJob } from '@/types/hermes'
import { LocalModelDownloadActions, LocalModelDownloadProgress } from './local-model-download-progress'
import { type LocalModelsActionScope, setServerRunning, useLocalModelsActionScope } from './local-models-actions'
import { ListRow, Pill, SettingsSection } from './primitives'
export interface LocalModelsRuntimeSectionProps {
status: LocalModelsStatus
jobs: readonly LocalRuntimeJob[]
lastError: LocalRuntimeJob | undefined
}
export function LocalModelsRuntimeSection({ status, jobs, lastError }: LocalModelsRuntimeSectionProps): ReactElement {
const scope: LocalModelsActionScope = useLocalModelsActionScope()
const { client, copy, owner } = scope
const installStarting: boolean = useIsMutating({ mutationKey: localModelsKey(owner, 'install') }) > 0
const [serverBusy, setServerBusy] = useState<boolean>(false)
const rJob: LocalRuntimeJob | null = runningRuntimeInstall(jobs)
// 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
async function handleServer(action: 'start' | 'stop'): Promise<void> {
setServerBusy(true)
try {
await setServerRunning(scope, action)
} finally {
setServerBusy(false)
}
}
return (
<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
action={<LocalModelDownloadActions job={rJob} owner={owner} />}
below={<LocalModelDownloadProgress job={rJob} />}
description={rJob.detail || copy.installing}
title={
<span className="inline-flex items-center gap-2">
{rJob.status === 'paused' ? (
<Pause className="size-3.5" />
) : (
<Loader2 className="size-3.5 animate-spin" />
)}
{copy.installing}
</span>
}
/>
) : (
<ListRow
action={
<Button disabled={installStarting} onClick={() => void startLocalRuntimeInstall(owner, client)} size="sm">
<Download />
{copy.installAction}
</Button>
}
description={copy.installDetail}
title={copy.installTitle}
/>
)}
{status.update_available && !rJob && (
<ListRow
action={
<Button disabled={installStarting} onClick={() => void startLocalRuntimeInstall(owner, client)} size="sm">
<Download />
{copy.updateAction}
</Button>
}
description={copy.updateDetail(status.configured_tag, status.tag)}
title={copy.updateTitle}
/>
)}
{rJob && status.runtime_installed && (
<ListRow
action={<LocalModelDownloadActions job={rJob} owner={owner} />}
below={<LocalModelDownloadProgress job={rJob} />}
description={rJob.detail || copy.updating}
title={
<span className="inline-flex items-center gap-2">
{rJob.status === 'paused' ? (
<Pause className="size-3.5" />
) : (
<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>
)
}

File diff suppressed because it is too large Load Diff