From 787c739a2aea7974c9155b546e7979835ec00ba1 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 24 Sep 2026 13:59:57 -0400 Subject: [PATCH] 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). --- .../src/app/settings/local-models-actions.ts | 149 +++ .../src/app/settings/local-models-browse.tsx | 327 +++++ .../local-models-hardware-section.tsx | 48 + .../app/settings/local-models-model-rows.tsx | 338 +++++ .../settings/local-models-models-section.tsx | 79 ++ .../src/app/settings/local-models-owner.ts | 20 + .../app/settings/local-models-quickstart.tsx | 131 ++ .../settings/local-models-runtime-section.tsx | 163 +++ .../app/settings/local-models-settings.tsx | 1091 +---------------- 9 files changed, 1286 insertions(+), 1060 deletions(-) create mode 100644 apps/desktop/src/app/settings/local-models-actions.ts create mode 100644 apps/desktop/src/app/settings/local-models-browse.tsx create mode 100644 apps/desktop/src/app/settings/local-models-hardware-section.tsx create mode 100644 apps/desktop/src/app/settings/local-models-model-rows.tsx create mode 100644 apps/desktop/src/app/settings/local-models-models-section.tsx create mode 100644 apps/desktop/src/app/settings/local-models-owner.ts create mode 100644 apps/desktop/src/app/settings/local-models-quickstart.tsx create mode 100644 apps/desktop/src/app/settings/local-models-runtime-section.tsx diff --git a/apps/desktop/src/app/settings/local-models-actions.ts b/apps/desktop/src/app/settings/local-models-actions.ts new file mode 100644 index 0000000000..616dbec45e --- /dev/null +++ b/apps/desktop/src/app/settings/local-models-actions.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) + } + } +} diff --git a/apps/desktop/src/app/settings/local-models-browse.tsx b/apps/desktop/src/app/settings/local-models-browse.tsx new file mode 100644 index 0000000000..4718d3887e --- /dev/null +++ b/apps/desktop/src/app/settings/local-models-browse.tsx @@ -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('') + const [hits, setHits] = useState([]) + const [searching, setSearching] = useState(false) + const [openRepo, setOpenRepo] = useState(null) + const [files, setFiles] = useState([]) + const [listing, setListing] = useState(false) + const [error, setError] = useState(null) + // Guard against the past: a stale search result must never overwrite a + // newer query's hits (the desktop guide's out-of-order rule). + const searchSeq = useRef(0) + + useEffect(() => { + const q = query.trim() + + if (q.length < 2) { + setHits([]) + setSearching(false) + + return + } + + const seq = ++searchSeq.current + setSearching(true) + + const handle: ReturnType = 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>): 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 | undefined => { + if (!paths.length) { + return + } + + return sideloadLocalModel(paths[0], localModelsRequestScope(owner)).then( + (r: Awaited>): 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 ( + + + {copy.sideloadButton} + + } + icon={Search} + title={copy.browseTitle} + > +
+

{copy.browseHint}

+ + + + {searching && ( +

+ + {copy.browseSearching} +

+ )} + + {error &&

{error}

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

+ + {copy.browseListing} +

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

{copy.browseNoGguf}

+ )} + + {files.map(group => { + const dJob = runningDownloadFor(jobs, browsedModelId(group)) + + return ( +
+ + + {group.label} + {group.paths.length > 1 ? ` ×${group.paths.length}` : ''} + + + + + + {dJob ? ( + <> + + + + {downloadStatusText(dJob, copy)} + + + + + ) : ( + + + + {group.fit === 'fits-gpu' + ? copy.pillFitsGpu + : group.fit === 'needs-ram' + ? copy.pillUsesRam + : group.fit === 'too-big' + ? copy.pillTooBig + : copy.browseFitUnknown} + + + + {gbLabel(group.total_bytes)} + + + )} +
+ ) + })} +
+ )} +
+ ))} +
+
+
+ ) +} diff --git a/apps/desktop/src/app/settings/local-models-hardware-section.tsx b/apps/desktop/src/app/settings/local-models-hardware-section.tsx new file mode 100644 index 0000000000..7588823e0c --- /dev/null +++ b/apps/desktop/src/app/settings/local-models-hardware-section.tsx @@ -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 ( + + {hardware ? ( +
+ {hardware.gpu_name && ( + + + {hardware.gpu_name} + + )} + + + + {copy.vram(gbLabel(hardware.vram_total_bytes))} + + + + + {copy.ram(gbLabel(hardware.ram_total_bytes))} + + + {hardware.uma && {copy.unifiedMemory}} +
+ ) : ( +

+ {copy.hardwareLoading} +

+ )} +
+ ) +} diff --git a/apps/desktop/src/app/settings/local-models-model-rows.tsx b/apps/desktop/src/app/settings/local-models-model-rows.tsx new file mode 100644 index 0000000000..bb98ebaed0 --- /dev/null +++ b/apps/desktop/src/app/settings/local-models-model-rows.tsx @@ -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 && ( + + + + {livePlacement.granted_window_label ?? livePlacement.window_label ?? ''} + {' · '} + {livePlacement.spilled ? copy.placementSpilled : copy.placementResident} + + + )} + {isLoaded && !livePlacement && {copy.loadedPill}} + + {isLoadingNow && ( + + + {copy.loadingPill} + + )} + + ) +} + +interface ModelIdProps { + modelId: string +} + +function EjectModelButton({ modelId }: ModelIdProps): ReactElement { + const scope: LocalModelsActionScope = useLocalModelsActionScope() + + return ( + + + + ) +} + +function DeleteModelButton({ modelId }: ModelIdProps): ReactElement { + const scope: LocalModelsActionScope = useLocalModelsActionScope() + const [deleting, setDeleting] = useState(false) + + async function handleDelete(): Promise { + if (!window.confirm(scope.copy.deleteConfirm(modelId))) { + return + } + + setDeleting(true) + + try { + await deleteModel(scope, modelId) + } finally { + setDeleting(false) + } + } + + return ( + + + + ) +} + +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 ( + + + + {isActive ? ( + + + + {copy.activePill} + + + ) : ( + + )} + + {isLoaded && } + + + + ) : dJob ? ( + + ) : ( + + ) + } + below={ + dJob ? ( +
+ + +

{downloadStatusText(dJob, copy)}

+
+ ) : undefined + } + description={ + <> + {model.description} + + + {/* Memory: the traffic light. Green = runs fully on + the GPU; amber = spills to system RAM (works, + slower); red = doesn't fit this machine at all. + Detail prose lives in the tooltip. */} + {!model.fits ? ( + + + + {copy.pillTooBig} + + + ) : model.spilled ? ( + + + + {copy.pillUsesRam} + + + ) : ( + + + + {copy.pillFitsGpu} + + + )} + + {/* Context: one pill. Green 'Full X context' only when + the model earned its complete window resident on the + GPU — a big context served from system RAM is slow, + and a green badge there would sell exactly the wrong + model, so a spilled full window goes gray. Anything + starting below its native window gets one quiet + 'Up to' pill instead of a start/grow pair. */} + {model.fits && + model.start_window_label && + (model.start_window && model.start_window >= model.native_context ? ( + + + {copy.pillFullContext(model.native_context_label)} + + + ) : ( + + {copy.pillUpTo(model.native_context_label)} + + ))} + + {!model.fits && {copy.pillUpTo(model.native_context_label)}} + + {model.vision && {copy.pillVision}} + + + {isActive && !isLoaded && !isLoadingNow && status.server_running && ( + {copy.activeNotLoaded} + )} + + } + title={ + + {model.display_name} + + {model.recommended && + (model.recommended_reason ? ( + // The why, straight from the resolver: the tooltip is + // the branch that picked this model, so the shown + // rationale can never drift from the actual decision. + + {copy.recommended} + + ) : ( + {copy.recommended} + ))} + + } + /> + ) +} + +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 ( + + + + {isActive ? ( + + + {copy.activePill} + + ) : ( + + )} + + {residency.isLoaded && } + + + + } + description={{copy.addedByYou}} + title={ + + {m.id} + + {m.size_label} + + } + /> + ) +} diff --git a/apps/desktop/src/app/settings/local-models-models-section.tsx b/apps/desktop/src/app/settings/local-models-models-section.tsx new file mode 100644 index 0000000000..96eff287ae --- /dev/null +++ b/apps/desktop/src/app/settings/local-models-models-section.tsx @@ -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 ( + + {!hasRecommendation && ( + + document.getElementById('local-model-browse')?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + size="sm" + > + + {copy.noRecommendationAction} + + } + description={copy.noRecommendationDetail} + title={copy.noRecommendationTitle} + /> + )} + +
+ {sortedCatalog.map(model => ( + + ))} + + {status.models + .filter(m => !catalog.some(c => c.downloaded_model_id === m.id || c.model_id === m.id)) + .map(m => ( + + ))} +
+ + {lastError?.kind === 'model-download' &&

{lastError.error}

} +
+ ) +} diff --git a/apps/desktop/src/app/settings/local-models-owner.ts b/apps/desktop/src/app/settings/local-models-owner.ts new file mode 100644 index 0000000000..11dcb1a28f --- /dev/null +++ b/apps/desktop/src/app/settings/local-models-owner.ts @@ -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(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 +} diff --git a/apps/desktop/src/app/settings/local-models-quickstart.tsx b/apps/desktop/src/app/settings/local-models-quickstart.tsx new file mode 100644 index 0000000000..536a4efe88 --- /dev/null +++ b/apps/desktop/src/app/settings/local-models-quickstart.tsx @@ -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 ( + +
+
+
+ {qJob ? ( + qJob.status === 'paused' ? ( + + ) : ( + + ) + ) : ( + + )} +
+ +

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

+ + {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. */} +

+ {downloadStatusText(qJob, copy)} +

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

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

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

{lastError.error}

+ )} +
+
+
+ ) +} diff --git a/apps/desktop/src/app/settings/local-models-runtime-section.tsx b/apps/desktop/src/app/settings/local-models-runtime-section.tsx new file mode 100644 index 0000000000..acde9534a1 --- /dev/null +++ b/apps/desktop/src/app/settings/local-models-runtime-section.tsx @@ -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(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 { + setServerBusy(true) + + try { + await setServerRunning(scope, action) + } finally { + setServerBusy(false) + } + } + + return ( + + {status.server_running ? copy.serverRunning : copy.runtimeReady(status.runtime_backend ?? '')} + + ) : undefined + } + icon={Zap} + meta={status.tag} + title={copy.runtimeTitle} + > + {status.runtime_installed ? ( + void handleServer('stop')} + size="sm" + variant="outline" + > + {serverBusy ? : } + {copy.stopServer} + + ) : ( + + ) + } + description={ + status.server_running + ? copy.runtimeRunningDetail + : copy.runtimeInstalledDetail(status.tag, status.runtime_backend ?? 'cpu') + } + title={copy.runtimeInstalled} + /> + ) : rJob ? ( + } + below={} + description={rJob.detail || copy.installing} + title={ + + {rJob.status === 'paused' ? ( + + ) : ( + + )} + {copy.installing} + + } + /> + ) : ( + void startLocalRuntimeInstall(owner, client)} size="sm"> + + {copy.installAction} + + } + description={copy.installDetail} + title={copy.installTitle} + /> + )} + + {status.update_available && !rJob && ( + void startLocalRuntimeInstall(owner, client)} size="sm"> + + {copy.updateAction} + + } + description={copy.updateDetail(status.configured_tag, status.tag)} + title={copy.updateTitle} + /> + )} + + {rJob && status.runtime_installed && ( + } + below={} + description={rJob.detail || copy.updating} + title={ + + {rJob.status === 'paused' ? ( + + ) : ( + + )} + {copy.updating} + + } + /> + )} + + {updateApplied && ( + + + {copy.upToDateTitle} + + } + /> + )} + + {lastError?.kind === 'runtime-install' &&

{lastError.error}

} +
+ ) +} diff --git a/apps/desktop/src/app/settings/local-models-settings.tsx b/apps/desktop/src/app/settings/local-models-settings.tsx index 94311dfbb4..4a1d28f5d0 100644 --- a/apps/desktop/src/app/settings/local-models-settings.tsx +++ b/apps/desktop/src/app/settings/local-models-settings.tsx @@ -1,114 +1,45 @@ -import { type QueryClient, useIsMutating, useQuery, useQueryClient } from '@tanstack/react-query' -import { type ReactElement, useCallback, useEffect, useRef, useState } from 'react' +import { useIsMutating, useQuery } from '@tanstack/react-query' +import { type ReactElement, useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router' import { NEW_CHAT_ROUTE } from '@/app/routes' -import { Button } from '@/components/ui/button' -import { SearchField } from '@/components/ui/search-field' -import { Tip } from '@/components/ui/tooltip' import { - activateLocalModel, - deleteLocalModel, - downloadBrowsedModel, - downloadLocalModel, - ejectLocalModel, - type HFFileGroup, - type HFSearchHit, - listHFRepoFiles, - quickstartLocalModels, - searchHFModels, - setLocalServer, - sideloadLocalModel -} from '@/hermes' -import { useI18n } from '@/i18n' -import { - Check, - CheckCircle2, - Cpu, - Download, - Eject, - FolderOpen, - Loader2, - Monitor, - Package, - Pause, - Search, - StopFilled, - Trash2, - Zap -} from '@/lib/icons' -import { cn } from '@/lib/utils' -import { - isCurrentLocalModelsOwner, localModelsCatalogOptions, localModelsHardwareOptions, localModelsKey, - localModelsNotificationTitle, type LocalModelsOwner, - localModelsRequestScope, - refreshLocalModels, - runningDownloadFor, - runningRuntimeInstall, - startLocalRuntimeInstall, useLocalModelsOwner, useLocalModelsStatus, - useLocalRuntimeJobs, - watchLocalRuntimeJobs + useLocalRuntimeJobs } from '@/store/local-runtime-jobs' -import { notify, notifyError } from '@/store/notifications' -import type { LocalCatalogModel, LocalRuntimeJob } from '@/types/hermes' +import type { LocalRuntimeJob } from '@/types/hermes' -import { - downloadStatusText, - gbLabel, - LocalModelDownloadActions, - LocalModelDownloadProgress, - ProgressBar -} from './local-model-download-progress' -import { ListRow, Pill, SettingsContent, SettingsSection, SettingsSkeleton } from './primitives' +import { isActiveStatus } from './local-models-actions' +import { LocalModelsBrowseSection } from './local-models-browse' +import { LocalModelsHardwareSection } from './local-models-hardware-section' +import { LocalModelsModelsSection } from './local-models-models-section' +import { LocalModelsOwnerProvider, useScopedLocalModelsOwner } from './local-models-owner' +import { LocalModelsQuickstart } from './local-models-quickstart' +import { LocalModelsRuntimeSection } from './local-models-runtime-section' +import { SettingsContent, SettingsSkeleton } from './primitives' import { ActiveProfileNote } from './profile-scope' -// 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 -} - -// Still on its way (or parked mid-way): rows/hero stay visible while -// paused — a paused download vanishing reads as progress loss. -function isActiveStatus(status: LocalRuntimeJob['status']): boolean { - return status === 'paused' || status === 'running' -} - export function LocalModelsSettings(): ReactElement { const owner: LocalModelsOwner = useLocalModelsOwner() - return + return ( + + + + ) } -interface ScopedLocalModelsSettingsProps { - owner: LocalModelsOwner -} - -function ScopedLocalModelsSettings({ owner }: ScopedLocalModelsSettingsProps): ReactElement { - const { t } = useI18n() - const copy = t.settings.localModels - const client: QueryClient = useQueryClient() +function ScopedLocalModelsSettings(): ReactElement { + const owner: LocalModelsOwner = useScopedLocalModelsOwner() const installStarting: boolean = useIsMutating({ mutationKey: localModelsKey(owner, 'install') }) > 0 const { data: status } = useLocalModelsStatus(owner) const { data: hardware } = useQuery(localModelsHardwareOptions(owner)) const { data: catalog } = useQuery(localModelsCatalogOptions(owner)) - const [deleting, setDeleting] = useState(null) - const [serverBusy, setServerBusy] = useState(false) // Quickstart escape hatch: true once the user asks for the full pane // (model list, HF browser) instead of the one-button setup card. const [configure, setConfigure] = useState(false) @@ -118,114 +49,6 @@ function ScopedLocalModelsSettings({ owner }: ScopedLocalModelsSettingsProps): R (value: readonly LocalRuntimeJob[]): readonly LocalRuntimeJob[] => value ) - const refresh = useCallback((): void => refreshLocalModels(owner, client), [owner, client]) - - async function handleInstallRuntime(): Promise { - await startLocalRuntimeInstall(owner, client) - } - - async function handleQuickstart(): Promise { - try { - await quickstartLocalModels(undefined, localModelsRequestScope(owner)) - watchLocalRuntimeJobs(owner, client) - } catch (err) { - if (isCurrentLocalModelsOwner(owner)) { - notifyError(err, copy.quickstartFailed) - } - } - } - - async function handleDownload(model: LocalCatalogModel): Promise { - try { - const res = await downloadLocalModel(model.id, localModelsRequestScope(owner)) - - if (res.already_downloaded || !res.job_id) { - refresh() - - return - } - - watchLocalRuntimeJobs(owner, client) - } catch (err) { - if (isCurrentLocalModelsOwner(owner)) { - notifyError(err, copy.downloadFailed(model.display_name)) - } - } - } - - async function handleActivate(target: null | string, displayName: string): Promise { - if (!target) { - return - } - - try { - await activateLocalModel(target, localModelsRequestScope(owner)) - watchLocalRuntimeJobs(owner, client) - } catch (err) { - if (isCurrentLocalModelsOwner(owner)) { - notifyError(err, copy.activateFailed(displayName)) - } - } - } - - async function handleEject(modelId: string): Promise { - try { - await ejectLocalModel(modelId, localModelsRequestScope(owner)) - notify({ durationMs: 3_000, kind: 'success', message: copy.ejected, title: localModelsNotificationTitle(owner) }) - refresh() - } catch (err) { - if (isCurrentLocalModelsOwner(owner)) { - notifyError(err, copy.ejectFailed) - } - } - } - - async function handleServer(action: 'start' | 'stop'): Promise { - setServerBusy(true) - - try { - await setLocalServer(action, localModelsRequestScope(owner)) - notify({ - durationMs: 3_500, - kind: 'success', - message: action === 'stop' ? copy.serverStopped : copy.serverStarted, - title: localModelsNotificationTitle(owner) - }) - refresh() - } catch (err) { - if (isCurrentLocalModelsOwner(owner)) { - notifyError(err, action === 'stop' ? copy.serverStopFailed : copy.serverStartFailed) - } - } finally { - setServerBusy(false) - } - } - - async function handleDelete(target: string, rowId: string): Promise { - if (!window.confirm(copy.deleteConfirm(target))) { - return - } - - setDeleting(rowId) - - try { - await deleteLocalModel(target, localModelsRequestScope(owner)) - notify({ - durationMs: 2_500, - kind: 'success', - message: copy.deleted(target), - title: localModelsNotificationTitle(owner) - }) - refresh() - } catch (err) { - if (isCurrentLocalModelsOwner(owner)) { - 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 @@ -260,23 +83,17 @@ function ScopedLocalModelsSettings({ owner }: ScopedLocalModelsSettingsProps): R return } - const rJob = runningRuntimeInstall(jobs) const lastError = jobs.find(j => j.status === 'error') - const sortedCatalog = [...catalog].sort((a, b) => fitRank(a) - fitRank(b)) - - // ── Quickstart: the dummy-proof front door ── // Until something is servable (runtime + at least one model), the pane - // leads with a hero that does everything in one click; the full pane - // stays one 'Let me choose' click away. A running quickstart pins this - // view so its progress has a home even after a remount. + // leads with the quickstart hero. 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 // The setup hero is reserved for an automatic recommendation. A // spilled model remains visible below, but setup must not silently choose it. const heroModel = catalog.find(c => c.recommended && c.fits) ?? null - const hasRecommendation = catalog.some(c => c.recommended) // An active runtime install/update or model download needs the FULL pane // (its row lives there with its controls) — the setup hero must not hide @@ -290,869 +107,23 @@ function ScopedLocalModelsSettings({ owner }: ScopedLocalModelsSettingsProps): R ) if ((qJob || (needsSetup && !configure && heroModel)) && !otherActiveJob && !installStarting && !failedInstall) { - // 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 ( - -
-
-
- {qJob ? ( - qJob.status === 'paused' ? ( - - ) : ( - - ) - ) : ( - - )} -
- -

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

- - {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. */} -

- {downloadStatusText(qJob, copy)} -

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

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

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

{lastError.error}

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

{lastError.error}

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

- {copy.hardwareLoading} -

- )} -
- - {/* ── Models ── */} - - {!hasRecommendation && ( - - document.getElementById('local-model-browse')?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } - size="sm" - > - - {copy.noRecommendationAction} - - } - description={copy.noRecommendationDetail} - title={copy.noRecommendationTitle} - /> - )} - -
- {sortedCatalog.map(model => { - const dJob = 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 = activateTarget ? status.loaded_models[activateTarget] : undefined - const isLoaded = residency === 'loaded' || residency === 'ready' - const isLoadingNow = residency === 'loading' - const livePlacement = activateTarget ? status.placement?.[activateTarget] : undefined - - const aJob = jobs.find( - j => j.kind === 'model-activate' && j.status === 'running' && j.model_id === activateTarget - ) - - const anyActivateRunning = jobs.some(j => j.kind === 'model-activate' && j.status === 'running') - - return ( - - {isLoaded && livePlacement && ( - - - - {livePlacement.granted_window_label ?? livePlacement.window_label ?? ''} - {' · '} - {livePlacement.spilled ? copy.placementSpilled : copy.placementResident} - - - )} - {isLoaded && !livePlacement && {copy.loadedPill}} - - {isLoadingNow && ( - - - {copy.loadingPill} - - )} - - {isActive ? ( - - - - {copy.activePill} - - - ) : ( - - )} - - {isLoaded && ( - - - - )} - - - - -
- ) : dJob ? ( - - ) : ( - - ) - } - below={ - dJob ? ( -
- - -

{downloadStatusText(dJob, copy)}

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

{lastError.error}

} -
- - + + + +
) } - -function fitTone(fit: HFFileGroup['fit']): 'destructive' | 'muted' | 'success' | 'warn' { - if (fit === 'fits-gpu') { - return 'success' - } - - if (fit === 'needs-ram') { - return 'warn' - } - - if (fit === 'too-big') { - return 'destructive' - } - - return 'muted' -} - -function browsedModelId(group: HFFileGroup): string { - // Mirrors the backend's derivation: first file's name, split-part - // suffix stripped — the id the download job carries. - const first = group.paths[0].split('/').pop() ?? group.paths[0] - - return first.replace(/-\d{5}-of-\d{5}\.gguf$/i, '').replace(/\.gguf$/i, '') -} - -interface BrowseSectionProps { - owner: LocalModelsOwner - onChanged: () => void -} - -function BrowseSection({ owner, onChanged }: BrowseSectionProps): ReactElement { - const client: QueryClient = useQueryClient() - const { t } = useI18n() - const copy = t.settings.localModels - - const jobs: readonly LocalRuntimeJob[] = useLocalRuntimeJobs( - owner, - (value: readonly LocalRuntimeJob[]): readonly LocalRuntimeJob[] => value, - false - ) - - const [query, setQuery] = useState('') - const [hits, setHits] = useState([]) - const [searching, setSearching] = useState(false) - const [openRepo, setOpenRepo] = useState(null) - const [files, setFiles] = useState([]) - const [listing, setListing] = useState(false) - const [error, setError] = useState(null) - // Guard against the past: a stale search result must never overwrite a - // newer query's hits (the desktop guide's out-of-order rule). - const searchSeq = useRef(0) - - useEffect(() => { - const q = query.trim() - - if (q.length < 2) { - setHits([]) - setSearching(false) - - return - } - - const seq = ++searchSeq.current - setSearching(true) - - const handle: ReturnType = 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>): 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 | undefined => { - if (!paths.length) { - return - } - - return sideloadLocalModel(paths[0], localModelsRequestScope(owner)).then( - (r: Awaited>): 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 ( - - - {copy.sideloadButton} - - } - icon={Search} - title={copy.browseTitle} - > -
-

{copy.browseHint}

- - - - {searching && ( -

- - {copy.browseSearching} -

- )} - - {error &&

{error}

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

- - {copy.browseListing} -

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

{copy.browseNoGguf}

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