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:
149
apps/desktop/src/app/settings/local-models-actions.ts
Normal file
149
apps/desktop/src/app/settings/local-models-actions.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
327
apps/desktop/src/app/settings/local-models-browse.tsx
Normal file
327
apps/desktop/src/app/settings/local-models-browse.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
338
apps/desktop/src/app/settings/local-models-model-rows.tsx
Normal file
338
apps/desktop/src/app/settings/local-models-model-rows.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
20
apps/desktop/src/app/settings/local-models-owner.ts
Normal file
20
apps/desktop/src/app/settings/local-models-owner.ts
Normal 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
|
||||
}
|
||||
131
apps/desktop/src/app/settings/local-models-quickstart.tsx
Normal file
131
apps/desktop/src/app/settings/local-models-quickstart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
163
apps/desktop/src/app/settings/local-models-runtime-section.tsx
Normal file
163
apps/desktop/src/app/settings/local-models-runtime-section.tsx
Normal 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
Reference in New Issue
Block a user