feat: WIP

This commit is contained in:
Aleksander Grygier
2026-09-01 10:34:12 +02:00
parent 065e8d883a
commit 10f985a047
10 changed files with 481 additions and 148 deletions
@@ -98,16 +98,17 @@
>
<ModelsDiscoverListSearch bind:value={searchQuery} onSearch={handleSearchInput} />
<!-- One list instance, so the rows keep their state across search round trips;
skeleton rows replace them while the initial catalog or a query loads. -->
<div>
{#if modelsHubStore.loading}
<p class="p-4 text-sm text-muted-foreground">Loading models...</p>
{:else if modelsHubStore.error}
{#if modelsHubStore.error}
<p class="p-4 text-sm text-destructive">{modelsHubStore.error}</p>
{:else if modelsHubStore.models.length === 0}
{:else if !modelsHubStore.loading && !modelsHubStore.searching && modelsHubStore.models.length === 0}
<p class="p-4 text-sm text-muted-foreground">No models found</p>
{:else}
<ModelsDiscoverList
activeId={selectedId}
loading={modelsHubStore.loading || modelsHubStore.searching}
models={modelsHubStore.models}
onSelect={(id) => (selectedId = id)}
showBaseModelAvatar
@@ -16,6 +16,8 @@
quantImageClass?: string;
/** Positioning classes for the quant corner badge (default `-bottom-0.75 -right-0.75`). */
quantPositionClass?: string;
/** Size classes for the quant corner badge container (default `h-4.25 w-4.25`). */
quantSize?: string;
}
let {
@@ -24,6 +26,7 @@
quantImageClass = 'h-full w-full',
quantOrg,
quantPositionClass = '-bottom-0.75 -right-0.75',
quantSize = 'h-4.25 w-4.25',
size = 'h-9 w-9'
}: Props = $props();
@@ -78,7 +81,7 @@
{#if quantOrg && quantOrg !== org}
<Tooltip.Root>
<Tooltip.Trigger
class="absolute {quantPositionClass} h-4.25 w-4.25 overflow-hidden rounded-full border border-background bg-muted "
class="absolute {quantPositionClass} {quantSize} overflow-hidden rounded-full border border-background bg-muted "
>
{#if quantError}
<span
@@ -1,25 +1,43 @@
<script lang="ts">
import ModelsDiscoverListItem from './ModelsDiscoverListItem.svelte';
import ModelsDiscoverListItemSkeleton from './ModelsDiscoverListItemSkeleton.svelte';
import type { HfModelInfo } from '$lib/types/huggingface';
interface Props {
models: HfModelInfo[];
activeId?: string | null;
/** Render skeleton rows instead of the list while models load. */
loading?: boolean;
/** Number of skeleton rows to render while loading. */
loadingCount?: number;
/** Show the original (base) model's org avatar instead of the repo's org. */
showBaseModelAvatar?: boolean;
onSelect?: (modelId: string) => void;
}
let { activeId = null, models, onSelect, showBaseModelAvatar = false }: Props = $props();
let {
activeId = null,
loading = false,
loadingCount = 8,
models,
onSelect,
showBaseModelAvatar = false
}: Props = $props();
</script>
<ul class="space-y-0.5 p-2">
{#each models as model (model.id)}
<ModelsDiscoverListItem
active={model.id === activeId}
{model}
{onSelect}
{showBaseModelAvatar}
/>
{/each}
{#if loading}
{#each Array(loadingCount) as _, index (index)}
<ModelsDiscoverListItemSkeleton />
{/each}
{:else}
{#each models as model (model.id)}
<ModelsDiscoverListItem
active={model.id === activeId}
{model}
{onSelect}
{showBaseModelAvatar}
/>
{/each}
{/if}
</ul>
@@ -0,0 +1,22 @@
<script lang="ts">
import { Skeleton } from '$lib/components/ui/skeleton';
</script>
<!-- Static skeleton of ModelsDiscoverListItem: avatar, name and badge rows. -->
<li>
<div class="flex w-full items-start gap-2.5 rounded-lg p-2.5 text-left">
<Skeleton class="h-9 w-9 rounded-md" />
<div class="min-w-0 flex-1 space-y-1.5">
<Skeleton class="h-4 w-48 max-w-full" />
<div class="flex items-center gap-1">
<Skeleton class="h-3.5 w-12 rounded" />
<Skeleton class="h-3.5 w-14 rounded" />
<Skeleton class="h-3.5 w-10 rounded" />
</div>
</div>
</div>
</li>
@@ -1,6 +1,7 @@
<script lang="ts">
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { Check, Copy, Download } from '@lucide/svelte';
import ModelsDownloadManagerDownloadStatusToast from '$lib/components/app/models/download-manager/ModelsDownloadManagerDownloadStatusToast.svelte';
import { ToggleGroup, ToggleGroupItem } from '$lib/components/ui/toggle-group';
import * as Tooltip from '$lib/components/ui/tooltip';
import { isAuxSidecar, type ModelSidecar } from '$lib/constants';
@@ -9,6 +10,7 @@
import { modelsStore } from '$lib/stores';
import type { HfModelSibling } from '$lib/types/huggingface';
import { copyToClipboard, minMemoryTierGb } from '$lib/utils';
import { toast } from 'svelte-sonner';
/** Download state of a single repo entry, injected by the integration layer. */
export interface DownloadEntryState {
@@ -20,10 +22,16 @@
type BitDepthRow = { bitDepth: number; files: HfModelSibling[] };
interface SelectedDownload {
filePath: string;
quant: string | null;
sidecar: ModelSidecar | null;
/** A selectable GGUF, tagged with its kind: main weights, draft, or aux (mmproj). */
type SelectableFile = HfModelSibling & { kind: 'main' | 'draft' | 'aux' };
/** Option of a quant `<select>`; already-downloaded files stay non-selectable. */
interface QuantOption {
disabled: boolean;
/** Quant token, or the file name when the file carries no quant (e.g. BF16). */
label: string;
path: string;
size: number;
}
interface Props {
@@ -43,6 +51,20 @@
let selectedPaths = $state<string[]>([]);
// Quant picks shown inline in the command. `basePick` is preselected so the
// command is readable before the user touches anything.
let basePick = $state('');
let draftPick = $state('');
/** Bit depth to preselect for the base model; falls back to the closest one. */
const DEFAULT_BASE_BIT_DEPTH = 4;
// min-w keeps the value clear of the native chevron: Safari sizes a select
// to its widest option, so an exactly-as-wide value would otherwise let the
// chevron overlap the text (draft selects are all same-width quants).
const selectClass =
'h-6 min-w-18 max-w-40 shrink-0 cursor-pointer rounded-md border border-input bg-transparent py-0 pr-3 pl-2 font-mono text-xs outline-none transition-colors hover:bg-accent/40 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]';
function stateFor(repoWithTag: string, filePath: string, isSidecar: boolean): DownloadEntryState {
if (getDownloadState) return getDownloadState(repoWithTag, filePath, isSidecar);
@@ -65,6 +87,139 @@
return isAuxSidecar(sidecar) ? 'aux' : 'draft';
}
/** Display label of a file: its quant, else the file name without the extension. */
function labelFor(path: string): string {
const quant = HuggingFaceService.extractQuantMeta(path)?.quant;
if (quant) return quant;
const basename = path.split('/').pop() ?? path;
return basename.replace(/\.gguf$/i, '');
}
/**
* Every selectable file with its kind and download state, in row order.
* Single source of truth for the toggle group rows, the selects and the
* command.
*/
let selectableFiles = $derived.by(() => {
const files: (SelectableFile & { state: DownloadEntryState })[] = [];
for (const row of bitDepthRows) {
for (const file of row.files) {
const meta = HuggingFaceService.extractQuantMeta(file.path);
const tag = ModelsService.buildDownloadTag(
modelId,
meta?.quant ?? null,
meta?.sidecar ?? null
);
files.push({
...file,
kind: classify(file.path),
state: stateFor(tag, file.path, Boolean(meta?.sidecar))
});
}
}
return files;
});
let mainFiles = $derived(selectableFiles.filter((f) => f.kind === 'main'));
let draftFiles = $derived(selectableFiles.filter((f) => f.kind === 'draft'));
/** Paths already downloaded on the server; static chips, disabled options. */
let downloadedPaths = $derived(
new Set(selectableFiles.filter((f) => f.state.isDownloaded).map((f) => f.path))
);
/** Bit depth of a file; `99` (Other) when it carries no quant token. */
function bitDepthOf(path: string): number {
const quant = HuggingFaceService.extractQuantMeta(path)?.quant ?? '';
const match = quant.match(/^UD-(?=.)/i) ? quant.slice(3) : quant;
const bit = match.match(/(?:I?Q|F)(\d+)/i)?.[1];
return bit ? Number(bit) : 99;
}
function optionFor(file: SelectableFile & { state: DownloadEntryState }): QuantOption {
return {
disabled: file.state.isDownloaded,
label: labelFor(file.path),
path: file.path,
size: file.size ?? 0
};
}
/**
* Options per kind, in row (bit depth) order, so like quants line up
* between the two selects. Draft options carry their sidecar type (MTP,
* DFLASH...) since a repo can ship more than one draft flavour.
*/
let baseOptions = $derived(mainFiles.map(optionFor));
let draftOptions = $derived(
draftFiles.map((f) => ({
...optionFor(f),
badge: HuggingFaceService.extractQuantMeta(f.path)?.sidecar
}))
);
/**
* Default base quant: the untouched selection falls back to the 4-bit file,
* or the lowest bit depth available when there is none. Downloaded files
* are skipped, so the command points at something there is still to fetch.
*/
function defaultBasePath(): string {
const candidates = mainFiles.filter((f) => !downloadedPaths.has(f.path));
const pool = candidates.length ? candidates : mainFiles;
const preferred = pool.find((f) => bitDepthOf(f.path) === DEFAULT_BASE_BIT_DEPTH);
if (preferred) return preferred.path;
const ranked = [...pool].sort((a, b) => bitDepthOf(a.path) - bitDepthOf(b.path));
return ranked[0]?.path ?? '';
}
/**
* Selection source of truth: the toggle group, the selects and the command
* all read it; the selects also mirror it. A pick replaces the previous one
* of its kind, an empty value drops it, aux sidecars are left alone.
*/
function setPick(kind: 'main' | 'draft', path: string) {
const rest = selectedPaths.filter((p) => classify(p) !== kind);
selectedPaths = path ? [...rest, path] : rest;
}
/**
* Mirror the selection into the selects. With no main chip on, the default
* quant (4-bit, or the nearest one still to fetch) is preselected in both
* the command and the chips, so the command is always complete; the dashed
* border tells a default-only pick from a deliberate one.
*/
$effect(() => {
const paths = selectedPaths;
const mainPath = mainFiles.find((f) => paths.includes(f.path))?.path ?? '';
if (mainPath) {
basePick = mainPath;
} else {
const fallback = defaultBasePath();
basePick = fallback;
// Still fetchable: also toggle the chip on. When everything is
// downloaded the chips stay off and only the command previews it.
if (fallback && !downloadedPaths.has(fallback)) {
selectedPaths = [...paths.filter((p) => classify(p) === 'aux'), fallback];
}
}
draftPick = draftFiles.find((f) => paths.includes(f.path))?.path ?? '';
});
/**
* Constrained selection: at most one base model and one draft sidecar.
* Aux sidecars (mmproj) are unconstrained.
@@ -85,61 +240,33 @@
selectedPaths = [...base, added];
}
/** Paths already downloaded on the server; those render as static chips. */
let downloadedPaths = $derived(
new Set(
bitDepthRows
.flatMap((r) => r.files)
.filter((f) => {
const meta = HuggingFaceService.extractQuantMeta(f.path);
const tag = ModelsService.buildDownloadTag(
modelId,
meta?.quant ?? null,
meta?.sidecar ?? null
);
return stateFor(tag, f.path, Boolean(meta?.sidecar)).isDownloaded;
})
.map((f) => f.path)
)
);
let copied = $state(false);
/** All repo files in one lookup by path. */
let fileByPath = $derived(new Map(bitDepthRows.flatMap((r) => r.files).map((f) => [f.path, f])));
/** Selection ordered main-quant-first, so the command reads naturally. */
let selected: SelectedDownload[] = $derived.by(() => {
const mains: SelectedDownload[] = [];
const sidecars: SelectedDownload[] = [];
let selected = $derived.by(() => {
const mains: SelectableFile[] = [];
const drafts: SelectableFile[] = [];
for (const path of selectedPaths) {
const file = fileByPath.get(path);
for (const file of selectableFiles) {
if (!selectedPaths.includes(file.path)) continue;
if (!file) continue;
const meta = HuggingFaceService.extractQuantMeta(file.path);
const entry = {
filePath: file.path,
quant: meta?.quant ?? null,
sidecar: meta?.sidecar ?? null
};
if (entry.sidecar && !isAuxSidecar(entry.sidecar)) sidecars.push(entry);
else mains.push(entry);
if (file.kind === 'draft') drafts.push(file);
else mains.push(file);
}
return [...mains, ...sidecars];
return [...mains, ...drafts];
});
/** First selected main quant, drives the `-hf <repo>:<quant>` tag. */
let primaryQuant = $derived(selected.find((s) => !s.sidecar)?.quant ?? null);
/** Selected main weights, drive the `-hf <repo>:<quant>` tag. */
let mainEntry = $derived(selected.find((f) => f.kind === 'main') ?? null);
/** First selected draft sidecar entry. */
let draftEntry = $derived(selected.find((s) => s.sidecar && !isAuxSidecar(s.sidecar)) ?? null);
/** Selected draft sidecar; its type drives the `--spec-type` flag. */
let draftEntry = $derived(selected.find((f) => f.kind === 'draft') ?? null);
/** First selected draft sidecar type, drives the `--spec-type` flag. */
let draft = $derived(draftEntry?.sidecar ?? null);
let draftSidecar = $derived(
draftEntry ? (HuggingFaceService.extractQuantMeta(draftEntry.path)?.sidecar ?? null) : null
);
// LLAMA-APP-REUSE: --spec-type value for each draft sidecar
const SPEC_TYPE: Record<ModelSidecar, string> = {
@@ -150,47 +277,127 @@
[ModelDraftSidecar.MTP]: 'draft-mtp'
};
/** Base file the command shows: the picked one, else whatever the base select holds. */
let commandMain = $derived(mainEntry ?? mainFiles.find((f) => f.path === basePick) ?? null);
/** Quant of the file the `-hf` tag points at; null when the file carries no quant. */
let commandMainQuant = $derived(
commandMain ? (HuggingFaceService.extractQuantMeta(commandMain.path)?.quant ?? null) : null
);
/** Quant of the file the `-hfd` tag points at. */
let commandDraftQuant = $derived(
draftEntry ? (HuggingFaceService.extractQuantMeta(draftEntry.path)?.quant ?? null) : null
);
async function copyCommand() {
await copyToClipboard(serveCommand);
copied = true;
setTimeout(() => (copied = false), 1500);
}
/** Fire downloads for every selected entry. */
/**
* Queue one download and surface a live progress toast keyed by the tag,
* so a retry updates the same toast instead of stacking a new one.
*/
async function queueDownload(tag: string) {
try {
await modelsStore.status.downloadModel(tag);
} catch {
// the store already toasted the failure
return;
}
toast.custom(ModelsDownloadManagerDownloadStatusToast, {
componentProps: { repoWithTag: tag },
duration: Infinity,
id: tag
});
}
/**
* Files the CTA would download: the selection, else the command's base
* pick. The same set feeds the total size on the button label.
*/
let downloadQueue = $derived.by(() =>
mainEntry
? selected
: [
...selected.filter((f) => f.kind === 'main' || f.kind === 'aux'),
...(commandMain ? [commandMain] : [])
]
);
/** Total bytes the CTA would fetch; drives the size in the button label. */
let downloadTotalBytes = $derived(downloadQueue.reduce((sum, file) => sum + (file.size ?? 0), 0));
/**
* Fire downloads for the selection. With no main chip on, the command shows
* the default base quant, so the CTA queues exactly that file too.
*/
function downloadSelected() {
for (const sel of selected) {
const tag = ModelsService.buildDownloadTag(modelId, sel.quant, sel.sidecar);
for (const file of downloadQueue) {
const meta = HuggingFaceService.extractQuantMeta(file.path);
const tag = ModelsService.buildDownloadTag(
modelId,
meta?.quant ?? null,
meta?.sidecar ?? null
);
if (modelsStore.status.hasFailedDownload(tag)) {
void modelsStore.status
.cancelDownload(tag)
.then(() => modelsStore.status.downloadModel(tag, sel.filePath));
void modelsStore.status.cancelDownload(tag).then(() => queueDownload(tag));
} else {
void modelsStore.status.downloadModel(tag, sel.filePath);
void queueDownload(tag);
}
}
selectedPaths = [];
}
/** The llama serve command for the current selection. */
/** The llama serve command; always readable, driven by the inline selects. */
// LLAMA-APP-REUSE: serve command shape (-hf / -hfd / --spec-type)
let serveCommand = $derived.by(() => {
const quantTag = primaryQuant ? `${modelId}:${primaryQuant}` : modelId;
const parts = ['llama', 'serve', '-hf', quantTag];
const mainQuant = commandMain
? HuggingFaceService.extractQuantMeta(commandMain.path)?.quant
: null;
const mainTag = mainQuant ? `${modelId}:${mainQuant}` : modelId;
const parts = ['llama', 'serve', '-hf', mainTag];
if (draft) {
const draftTag = draftEntry?.quant ? `${modelId}:${draftEntry.quant}` : modelId;
if (draftEntry && draftSidecar) {
const draftQuant = HuggingFaceService.extractQuantMeta(draftEntry.path)?.quant;
const draftTag = draftQuant ? `${modelId}:${draftQuant}` : modelId;
parts.push('-hfd', draftTag, '--spec-type', SPEC_TYPE[draft]);
parts.push('-hfd', draftTag, '--spec-type', SPEC_TYPE[draftSidecar]);
}
return parts.join(' ');
});
/**
* CTA label for the command shown: the selection, else the preview base
* quant, plus the total download size.
*/
let downloadLabel = $derived.by(() => {
const main = mainEntry ?? commandMain;
const draftLabel = draftSidecar?.toUpperCase();
let label = 'Download';
if (main && draftLabel) label = `Download ${labelFor(main.path)} + ${draftLabel}`;
else if (draftLabel) label = `Download ${draftLabel} draft`;
else if (main) label = `Download ${labelFor(main.path)}`;
if (downloadTotalBytes > 0) {
label += ` · ${HuggingFaceService.formatFileSize(downloadTotalBytes)}`;
}
return label;
});
</script>
{#if bitDepthRows.length}
<section class="rounded-xl border">
<!-- header row is intentionally hidden for now
<div class="flex flex-wrap items-center justify-between gap-2 px-4 pt-3 pb-1">
<h2 class="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<Download class="h-4 w-4" />
@@ -201,6 +408,7 @@
<span class="text-xs text-muted-foreground">{selected.length} selected</span>
{/if}
</div>
-->
<ToggleGroup
class="flex w-full flex-col items-stretch divide-y px-4 pb-1"
@@ -237,8 +445,7 @@
<div class="flex flex-wrap justify-end gap-1.5">
{#each row.files as file (file.path)}
{@const meta = HuggingFaceService.extractQuantMeta(file.path)}
{@const basename = file.path.split('/').pop() ?? file.path}
{@const label = meta?.quant ?? basename.replace(/\.gguf$/i, '')}
{@const label = labelFor(file.path)}
{@const hfRepoWithTag = ModelsService.buildDownloadTag(
modelId,
meta?.quant ?? null,
@@ -261,12 +468,13 @@
{#if isDownloaded}
<!-- downloaded files are not selectable, just marked as done -->
<div
aria-disabled="true"
aria-label={tooltipText}
class="inline-flex items-center gap-1 rounded-md border border-foreground bg-muted px-2 py-1 font-mono text-xs"
class="inline-flex cursor-default items-center gap-1 rounded-md border bg-muted px-2 py-1 font-mono text-xs opacity-70"
>
{#if meta?.sidecar && !isAuxSidecar(meta.sidecar)}
<span
class="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase"
class="rounded-md bg-primary px-1 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase"
>
{meta.sidecar}
</span>
@@ -283,14 +491,14 @@
{:else}
<ToggleGroupItem
aria-label={tooltipText}
class="relative inline-flex h-auto items-center gap-1 overflow-hidden rounded-md border bg-muted px-2 py-1 text-left font-mono text-xs transition-colors data-[state=on]:border-primary data-[state=on]:bg-primary/10 {isFailed
class="relative inline-flex h-auto items-center gap-1 overflow-hidden rounded-md! border bg-muted px-2 py-1 text-left font-mono text-xs transition-colors data-[state=on]:border-primary data-[state=on]:bg-primary/10 {isFailed
? 'border-destructive'
: ''}"
value={file.path}
>
{#if isFailed && !isDownloading}
<span
class="rounded bg-destructive px-1 py-0.5 text-[10px] font-semibold tracking-wide text-destructive-foreground uppercase"
class="rounded-md bg-destructive px-1 py-0.5 text-[10px] font-semibold tracking-wide text-destructive-foreground uppercase"
>
Failed
</span>
@@ -298,17 +506,17 @@
{#if meta?.sidecar && !isAuxSidecar(meta.sidecar)}
<span
class="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase"
class="rounded-md bg-primary px-1 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase"
>
{meta.sidecar}
</span>
{/if}
<span class="font-medium text-muted-foreground/80">{label}</span>
<span class="font-medium">{label}</span>
<span class="-my-1 w-px self-stretch bg-border"></span>
<span class="text-muted-foreground/80">
<span>
{#if isDownloading && progress && progress.totalBytes > 0}
{Math.round((progress.downloadedBytes / progress.totalBytes) * 100)}%
{:else}
@@ -337,17 +545,71 @@
{/each}
</ToggleGroup>
<!-- Terminal command + download CTA for the current selection -->
<!-- Terminal command with inline quant selects + download CTA -->
<div class="space-y-2 border-t px-4 pt-3 pb-4">
<div
class="flex items-center justify-between gap-2 rounded-md px-3 py-2"
class="flex flex-wrap items-center gap-2 rounded-md px-3 py-2"
style="background: var(--code-background); border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);"
>
<span class="truncate font-mono text-xs text-foreground/90">{serveCommand}</span>
<div
class="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-foreground/90"
>
<span>llama</span>
<span>serve</span>
<span>-hf</span>
<span class="truncate">{modelId}{commandMainQuant ? ':' : ''}</span>
<!-- Base quant: always part of the command, the 8-bit file by default. -->
{#if baseOptions.length}
<select
aria-label="Base model quantization"
class="{selectClass} {mainEntry ? '' : 'border-dashed'} -ml-2"
onchange={(e) => setPick('main', e.currentTarget.value)}
title={mainEntry
? undefined
: 'Default quant - pick a file above or another quant here'}
value={basePick}
>
{#each baseOptions as option (option.path)}
<option disabled={option.disabled} value={option.path}>
{option.label}
</option>
{/each}
</select>
{/if}
<!-- Draft segment: appears once a draft is picked, quant inline too. -->
{#if draftEntry && draftSidecar}
<span>-hfd</span>
<span class="truncate">{modelId}{commandDraftQuant ? ':' : ''}</span>
<select
aria-label="Draft model quantization"
class={selectClass}
onchange={(e) => setPick('draft', e.currentTarget.value)}
value={draftPick}
>
{#each draftOptions as option (option.path)}
<option disabled={option.disabled} value={option.path}>
<!-- {option.badge ? `${option.badge.toUpperCase()} ` : ''} -->
{option.label}
</option>
{/each}
</select>
<span>--spec-type</span>
<span>{SPEC_TYPE[draftSidecar]}</span>
{/if}
</div>
<button
aria-label="Copy command"
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-foreground"
class="ml-auto shrink-0 text-muted-foreground/60 transition-colors hover:text-foreground"
onclick={copyCommand}
type="button"
>
@@ -361,17 +623,13 @@
<button
class="inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
disabled={selected.length === 0}
disabled={selected.length === 0 && !commandMain}
onclick={downloadSelected}
type="button"
>
<Download class="h-4 w-4" />
{#if primaryQuant && draft}
Download {primaryQuant} + {draft}
{:else}
Download
{/if}
{downloadLabel}
</button>
</div>
</section>
@@ -32,7 +32,13 @@
<header class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<ModelsDiscoverAvatar org={avatarOrg} {quantOrg} />
<ModelsDiscoverAvatar
org={avatarOrg}
{quantOrg}
quantPositionClass="-bottom-1.5 -right-1.5"
quantSize="h-6 w-6"
size="h-12 w-12"
/>
<div class="min-w-0">
<div class="flex items-center gap-2">
@@ -38,6 +38,14 @@ export { default as ModelsDiscoverListSearch } from './ModelsDiscoverListSearch.
*/
export { default as ModelsDiscoverListItem } from './ModelsDiscoverListItem.svelte';
/**
* **ModelsDiscoverListItemSkeleton** - Skeleton sidebar row
*
* Pulsing placeholder matching a ModelsDiscoverListItem row, shown while the
* list is loading.
*/
export { default as ModelsDiscoverListItemSkeleton } from './ModelsDiscoverListItemSkeleton.svelte';
/**
* **ModelsDiscoverAvatar** - Org avatar for a model row
*
@@ -1,65 +1,71 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import DownloadProgressBar from '$lib/components/app/models/discover/DownloadProgressBar.svelte';
import type { ModelDownloadProgress } from '$lib/types';
import { modelsStore } from '$lib/stores';
interface Props {
/** HuggingFace repo id of the download. */
repoId: string;
/** Live progress from the /models/sse feed (per-file). */
progress: ModelDownloadProgress;
/** CTA fired to open the download manager dialog. */
onOpenManager?: () => void;
/** Dismiss the toast (does not cancel the download). */
onDismiss?: () => void;
/** `<repo>:<tag>` the download was queued under; progress is read live from the store. */
repoWithTag: string;
/** Injected by svelte-sonner; dismisses this toast. */
closeToast?: () => void;
}
let { onDismiss, onOpenManager, progress, repoId }: Props = $props();
let { closeToast, repoWithTag }: Props = $props();
let files = $derived(Object.entries(progress.files));
// Read live from the /models/sse feed, so the toast updates itself.
let progress = $derived(modelsStore.status.getDownloadProgress(repoWithTag));
function percent(done: number, total: number): number {
return total > 0 ? Math.round((done / total) * 100) : 0;
}
// The feed clears progress once the download settles; close then - the
// store's finished/failed toast reports the outcome.
let hadProgress = $state(false);
$effect(() => {
if (progress) {
hadProgress = true;
} else if (hadProgress) {
closeToast?.();
}
});
</script>
<div class="w-80 space-y-2 rounded-md border bg-background p-3 shadow-sm">
<div class="flex items-center justify-between gap-2">
<span class="truncate text-xs font-medium" title={repoId}>{repoId}</span>
<span class="truncate text-xs font-medium" title={repoWithTag}>{repoWithTag}</span>
<button
aria-label="Dismiss"
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-foreground"
onclick={() => onDismiss?.()}
onclick={() => closeToast?.()}
type="button"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
<div class="space-y-1.5">
{#each files as [file, fileProgress] (file)}
<div class="space-y-0.5">
<div class="flex items-center justify-between gap-2 text-muted-foreground">
<span class="truncate font-mono text-xs">{file}</span>
{#if progress}
<div class="space-y-1.5">
{#each Object.entries(progress.files) as [file, fileProgress] (file)}
<div class="space-y-0.5">
<div class="flex items-center justify-between gap-2 text-muted-foreground">
<span class="truncate font-mono text-xs">{file}</span>
<span class="shrink-0 font-mono tabular-nums">
{percent(fileProgress.done, fileProgress.total)}%
</span>
<span class="shrink-0 font-mono tabular-nums">
{percent(fileProgress.done, fileProgress.total)}%
</span>
</div>
<DownloadProgressBar
downloadedBytes={fileProgress.done}
totalBytes={fileProgress.total}
/>
</div>
<DownloadProgressBar downloadedBytes={fileProgress.done} totalBytes={fileProgress.total} />
</div>
{/each}
</div>
{#if onOpenManager}
<button
class="w-full rounded-md border px-2 py-1 text-xs font-medium transition-colors hover:bg-muted"
onclick={() => onOpenManager?.()}
type="button"
>
Open download manager
</button>
{/each}
</div>
{:else}
<p class="text-xs text-muted-foreground">Waiting for the server to report progress...</p>
{/if}
</div>
@@ -19,6 +19,12 @@ class ModelsHubStore {
firstModel = $derived(this.models[0] ?? null);
loading = $state(false);
/**
* True while a non-empty search query is in flight; drives the skeleton list
* rows. Only the newest request owns this flag, so a stale response clearing
* it cannot blank the list out from under a newer query.
*/
searching = $state(false);
private catalog: HfCatalogEntry[] = [];
private defaultModels: HfModelInfo[] = [];
@@ -69,34 +75,41 @@ class ModelsHubStore {
/**
* Replace the list with GGUF search results. An empty query restores the
* default list. The current list stays visible while a search is in
* flight; stale responses are dropped when a newer search starts.
* default list. While a query is in flight the previous results stay in
* `models` (so detail lookups keep working) but `searching` has the list
* render skeletons instead; stale responses are dropped when a newer
* search starts.
*/
async search(query: string): Promise<void> {
const trimmed = query.trim();
const requestId = ++this.searchRequestId;
this.searchRequestId++;
// An empty query restores the cached default list: no request, no spinner.
if (!trimmed) {
this.searching = false;
this.models = this.defaultModels;
this.error = null;
return;
}
const requestId = this.searchRequestId;
// The previous results stay mounted underneath; the list shows skeletons
// while the query is in flight.
this.searching = true;
try {
const results = await HuggingFaceService.searchByQuery(trimmed, { full: true, limit: 50 });
if (requestId === this.searchRequestId) {
this.models = results;
this.error = null;
}
if (requestId !== this.searchRequestId) return;
this.models = results;
this.error = null;
} catch (err) {
if (requestId === this.searchRequestId) {
this.error = err instanceof Error ? err.message : 'Search failed';
}
if (requestId !== this.searchRequestId) return;
this.error = err instanceof Error ? err.message : 'Search failed';
} finally {
if (requestId === this.searchRequestId) this.searching = false;
}
}
@@ -147,8 +147,10 @@ export class ModelStatusManager {
* Trigger a model download from HuggingFace via POST /models
* (ggml-org/llama.cpp#23976). The download runs in the background on the
* server; the model appears in the list once the feed reports models_reload.
* Progress is reported by the /models/sse feed; the caller owns the
* start/progress UI.
*/
async downloadModel(repoWithTag: string, displayName?: string): Promise<void> {
async downloadModel(repoWithTag: string): Promise<void> {
if (!serverStore.isRouterMode) {
toast.error('Model downloads are only available in router mode');
@@ -158,18 +160,14 @@ export class ModelStatusManager {
// the feed must be live so the resulting models_reload event refreshes the list
this.subscribe();
const label = displayName ?? repoWithTag;
try {
const res = await ModelsService.downloadModel(repoWithTag);
if (res.success) {
toast.success(`Download started: ${label}`);
} else {
if (!res.success) {
throw new Error(res.error?.message ?? 'Server rejected the download request');
}
} catch (error) {
toast.error(`Download failed: ${label}`);
toast.error(`Download failed: ${repoWithTag}`);
throw error;
}