ui : add download manager pieces and the list search component

- ModelsDiscoverListSearch: extracted search input from the list
- ModelsDiscoverModelDetailsMetadata: description + metadata chips
  extracted from the details header
- ModelsDiscoverModelDetailsCommands: quant + draft sidecar selectors
  embedded in the inline command text
- ModelsDownloadManager: tracked downloads with per-file progress and
  a delete action
- ModelsDownloadManagerDownloadStatusToast: one toast per download
  with a progress bar per file (main + sidecars) and a CTA to open
  the download manager
- DialogModelsDownloadManager: dialog shell for the manager

Assisted-by: pi
This commit is contained in:
Aleksander Grygier
2026-09-01 00:22:58 +02:00
parent c00a7ccd83
commit 41c67c0c55
16 changed files with 403 additions and 139 deletions
@@ -0,0 +1,28 @@
<script lang="ts">
import ModelsDownloadManager from '$lib/components/app/models/download-manager/ModelsDownloadManager.svelte';
import * as Dialog from '$lib/components/ui/dialog';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
let { onOpenChange, open = $bindable(false) }: Props = $props();
function handleOpenChange(next: boolean) {
open = next;
onOpenChange?.(next);
}
</script>
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content
class="md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-200!"
>
<Dialog.Header>
<Dialog.Title class="text-sm font-semibold">Download manager</Dialog.Title>
</Dialog.Header>
<ModelsDownloadManager />
</Dialog.Content>
</Dialog.Root>
@@ -536,3 +536,13 @@ export { default as DialogMermaidPreview } from './DialogMermaidPreview.svelte';
* @see ModelsDiscover in $lib/components/app/models/discover
*/
export { default as DialogModelsDiscover } from './DialogModelsDiscover.svelte';
/**
* **DialogModelsDownloadManager** - download manager dialog.
*
* Lists all tracked model downloads with per-file progress, cancel and
* delete actions.
*
* @see ModelsDownloadManager in $lib/components/app/models/download-manager
*/
export { default as DialogModelsDownloadManager } from './DialogModelsDownloadManager.svelte';
@@ -1,7 +1,7 @@
<script lang="ts">
import { SearchInput } from '$lib/components/app';
import {
ModelsDiscoverList,
ModelsDiscoverListSearch,
ModelsDiscoverModelDetails
} from '$lib/components/app/models/discover';
import { HuggingFaceService } from '$lib/services';
@@ -96,14 +96,7 @@
<aside
class="w-108 shrink-0 self-start border-r border-border/40 bg-background overflow-y-auto md:p-4 h-full space-y-1"
>
<div class="p-2 sticky top-0 z-99">
<SearchInput
bind:value={searchQuery}
class=""
onInput={handleSearchInput}
placeholder="Search models..."
/>
</div>
<ModelsDiscoverListSearch bind:value={searchQuery} onSearch={handleSearchInput} />
<div>
{#if modelsHubStore.loading}
@@ -0,0 +1,26 @@
<script lang="ts">
import { SearchInput } from '$lib/components/app';
interface Props {
value?: string;
/** Debounced search callback (300ms). */
onSearch?: (query: string) => void;
placeholder?: string;
}
let { onSearch, placeholder = 'Search models...', value = $bindable('') }: Props = $props();
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
function handleInput(next: string) {
value = next;
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => onSearch?.(value), 300);
}
</script>
<div class="sticky top-0 z-99 p-2">
<SearchInput bind:value onInput={(v) => handleInput(v)} {placeholder} />
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import ModelsDiscoverModelDetailsCommands from './ModelsDiscoverModelDetailsCommands.svelte';
import ModelsDiscoverDetailsDownloadOptions from './ModelsDiscoverModelDetailsDownloadOptions.svelte';
import ModelsDiscoverModelDetailsDownloadOptions from './ModelsDiscoverModelDetailsDownloadOptions.svelte';
import ModelsDiscoverDetailsHeader from './ModelsDiscoverModelDetailsHeader.svelte';
import ModelsDiscoverDetailsReadme from './ModelsDiscoverModelDetailsReadme.svelte';
import { isAuxSidecar, type ModelSidecar } from '$lib/constants';
@@ -62,6 +62,16 @@
});
type BitDepthRow = { bitDepth: number; files: HfModelSibling[] };
let quants = $derived(
Array.from(
new Set(
files
.map((f) => HuggingFaceService.extractQuantMeta(f.path)?.quant)
.filter((q): q is string => Boolean(q))
.map((q) => q.toUpperCase())
)
)
);
let bitDepthRows = $derived.by<BitDepthRow[]>(() => {
const rows = new SvelteMap<number, HfModelSibling[]>();
@@ -106,9 +116,9 @@
{modelId}
/>
<ModelsDiscoverDetailsDownloadOptions {bitDepthRows} {modelId} />
<ModelsDiscoverModelDetailsDownloadOptions {bitDepthRows} {modelId} />
<ModelsDiscoverModelDetailsCommands {modelId} sidecars={draftSidecars} />
<ModelsDiscoverModelDetailsCommands {modelId} {quants} sidecars={draftSidecars} />
<ModelsDiscoverDetailsReadme {readme} />
</div>
@@ -5,14 +5,19 @@
import { copyToClipboard } from '$lib/utils';
interface Props {
/** Full HuggingFace model id, The draft sidecar sits in the same repo. */
/** Full HuggingFace repo id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
modelId: string;
/** Draft sidecar variants present in the repo (mtp, dflash, dspark, eagle3). */
/** Available quantization tags, e.g. `Q4_K_M`, `Q8_0`. */
quants: string[];
/** Draft sidecars present in the repo (mtp, dflash, dspark, eagle3). */
sidecars?: ModelSidecar[];
}
let { modelId, sidecars = [] }: Props = $props();
let { modelId, quants, sidecars = [] }: Props = $props();
let pickedQuant = $state<string | null>(null);
let selectedQuant = $derived(pickedQuant ?? quants[0] ?? null);
let selectedSidecar = $state<ModelSidecar | null>(null);
// llama.cpp --spec-type value for each draft sidecar.
const SPEC_TYPE: Record<ModelSidecar, string> = {
@@ -31,27 +36,60 @@
setTimeout(() => (copiedIndex = null), 1500);
}
// One box per binary (serve / cli). Each box lists one command per available
// draft sidecar, or just the base command when none is present.
// One box per binary (serve / cli). The command embeds inline selectors for
// the quant and the draft sidecar type.
let boxes = $derived.by(() => {
const variants = sidecars.filter((v) => !isAuxSidecar(v));
const quant = selectedQuant ?? quants[0];
if (!quant) return [];
const draft = selectedSidecar && !isAuxSidecar(selectedSidecar) ? selectedSidecar : null;
const specType = draft ? `--spec-type ${SPEC_TYPE[draft]}` : '';
const build = (bin: string) => {
const base = `llama ${bin} -hf ${modelId}`;
const parts = ['llama', bin, '-hfd', modelId];
if (variants.length === 0) return [base];
if (specType) parts.push(specType);
return variants.map((v) => `${base} -hfd ${modelId} --spec-type ${SPEC_TYPE[v]}`);
return parts.join(' ');
};
return [
{ commands: build('serve'), icon: Server, title: 'Serve' },
{ commands: build('cli'), icon: SquareTerminal, title: 'CLI' }
{ command: build('serve'), icon: Server, title: 'Serve' },
{ command: build('cli'), icon: SquareTerminal, title: 'CLI' }
];
});
</script>
<div class="space-y-3">
{#each boxes as box (box.title)}
<div class="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
<span>Quant</span>
<select
bind:value={selectedQuant}
class="rounded border bg-background px-1.5 py-0.5 font-mono text-xs"
>
{#each quants as quant (quant)}
<option value={quant}>{quant}</option>
{/each}
</select>
{#if sidecars.length}
<span>Draft</span>
<select
bind:value={selectedSidecar}
class="rounded border bg-background px-1.5 py-0.5 font-mono text-xs"
>
<option value={null}>none</option>
{#each sidecars as sidecar (sidecar)}
<option value={sidecar}>{sidecar}</option>
{/each}
</select>
{/if}
</div>
{#each boxes as box, i (box.title)}
<div
class="overflow-hidden rounded-md"
style="background: var(--code-background); border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);"
@@ -65,27 +103,21 @@
<span class="text-xs font-medium text-foreground/80">{box.title}</span>
</div>
<div class="space-y-1 p-2">
{#each box.commands as cmd, i (cmd)}
<div
class="group flex items-center justify-between gap-2 rounded px-2 py-1 font-mono text-xs"
>
<span class="truncate text-foreground/90">{cmd}</span>
<div class="flex items-center justify-between gap-2 p-2">
<span class="truncate font-mono text-xs text-foreground/90">{box.command}</span>
<button
aria-label="Copy command"
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-foreground"
onclick={() => handleCopy(i, cmd)}
type="button"
>
{#if copiedIndex === i}
<Check class="h-3.5 w-3.5 text-green-500" />
{:else}
<Copy class="h-3.5 w-3.5" />
{/if}
</button>
</div>
{/each}
<button
aria-label="Copy command"
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-foreground"
onclick={() => handleCopy(i, box.command)}
type="button"
>
{#if copiedIndex === i}
<Check class="h-3.5 w-3.5 text-green-500" />
{:else}
<Copy class="h-3.5 w-3.5" />
{/if}
</button>
</div>
</div>
{/each}
@@ -1,21 +1,11 @@
<script lang="ts">
import ModelsDiscoverAvatar from './ModelsDiscoverAvatar.svelte';
import ModelsDiscoverChatTemplateDialog from './ModelsDiscoverChatTemplateDialog.svelte';
import {
Download,
ExternalLink,
Heart,
Image,
Lightbulb,
MessageSquareCode,
Wrench
} from '@lucide/svelte';
import ModelsDiscoverModelDetailsMetadata from './ModelsDiscoverModelDetailsMetadata.svelte';
import { Download, ExternalLink, Heart, Image, Lightbulb, Wrench } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ICON_CLASS_SM } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import { modelsHubStore } from '$lib/stores';
import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';
import { formatParameters } from '$lib/utils';
interface Props {
modelId: string;
@@ -37,13 +27,6 @@
let baseOrg = $derived(baseModels[0]?.split('/')[0]);
let avatarOrg = $derived(baseOrg || repoOrg);
let quantOrg = $derived(baseOrg && baseOrg !== repoOrg ? repoOrg : undefined);
// Catalog family description when curated, else the HF card description.
let description = $derived(
modelsHubStore.descriptionFor(modelId) ?? details.cardData?.description
);
let chatTemplateOpen = $state(false);
</script>
<header class="space-y-3">
@@ -148,70 +131,5 @@
{/if}
</div>
{#if description}
<p class="text-sm text-muted-foreground">{description}</p>
{/if}
<!-- Metadata chips: label | value pairs, matching the HF model page style -->
<div class="flex flex-wrap items-center gap-1.5">
{#if gguf?.total}
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
<span class="px-2.5 py-1 text-muted-foreground">Model size</span>
<span class="px-2.5 py-1 font-medium">{formatParameters(gguf.total)} params</span>
</span>
{/if}
{#if gguf?.context_length}
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
<span class="px-2.5 py-1 text-muted-foreground">Context</span>
<span class="px-2.5 py-1 font-medium">{gguf.context_length.toLocaleString()}</span>
</span>
{/if}
{#if gguf?.architecture}
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
<span class="px-2.5 py-1 text-muted-foreground">Architecture</span>
<span class="px-2.5 py-1 font-medium">{gguf.architecture}</span>
</span>
{/if}
{#if gguf?.chat_template}
<button
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-muted"
onclick={() => (chatTemplateOpen = true)}
type="button"
>
<MessageSquareCode class="h-3 w-3" />
Chat template
</button>
{/if}
{#if licenseTag}
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
<span class="px-2.5 py-1 text-muted-foreground">License</span>
<span class="px-2.5 py-1 font-medium">{licenseTag}</span>
</span>
<span class="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground"> </span>
{/if}
{#if details.gated === true}
<span
class="rounded bg-yellow-500/10 px-2 py-0.5 text-xs font-medium text-yellow-600 dark:text-yellow-400"
>
gated
</span>
{/if}
</div>
<ModelsDiscoverModelDetailsMetadata {details} {gguf} {licenseTag} {modelId} />
</header>
{#if gguf?.chat_template}
<ModelsDiscoverChatTemplateDialog
bind:open={chatTemplateOpen}
chatTemplate={gguf.chat_template}
/>
{/if}
@@ -0,0 +1,89 @@
<script lang="ts">
import ModelsDiscoverChatTemplateDialog from './ModelsDiscoverChatTemplateDialog.svelte';
import { MessageSquareCode } from '@lucide/svelte';
import { modelsHubStore } from '$lib/stores';
import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';
import { formatParameters } from '$lib/utils';
interface Props {
/** Full HuggingFace model id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
modelId: string;
details: HfModelDetailInfo;
gguf?: HfModelGguf;
licenseTag: string | null;
}
let { details, gguf, licenseTag, modelId }: Props = $props();
// Catalog family description when curated, else the HF card description.
let description = $derived(
modelsHubStore.descriptionFor(modelId) ?? details.cardData?.description
);
let chatTemplateOpen = $state(false);
</script>
{#if description}
<p class="text-sm text-muted-foreground">{description}</p>
{/if}
<!-- Metadata chips: label | value pairs, matching the HF model page style -->
<div class="flex flex-wrap items-center gap-1.5">
{#if gguf?.total}
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
<span class="px-2.5 py-1 text-muted-foreground">Model size</span>
<span class="px-2.5 py-1 font-medium">{formatParameters(gguf.total)} params</span>
</span>
{/if}
{#if gguf?.context_length}
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
<span class="px-2.5 py-1 text-muted-foreground">Context</span>
<span class="px-2.5 py-1 font-medium">{gguf.context_length.toLocaleString()}</span>
</span>
{/if}
{#if gguf?.architecture}
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
<span class="px-2.5 py-1 text-muted-foreground">Architecture</span>
<span class="px-2.5 py-1 font-medium">{gguf.architecture}</span>
</span>
{/if}
{#if gguf?.chat_template}
<button
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-muted"
onclick={() => (chatTemplateOpen = true)}
type="button"
>
<MessageSquareCode class="h-3 w-3" />
Chat template
</button>
{/if}
{#if licenseTag}
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
<span class="px-2.5 py-1 text-muted-foreground">License</span>
<span class="px-2.5 py-1 font-medium">{licenseTag}</span>
</span>
{/if}
{#if details.gated === true}
<span
class="rounded bg-yellow-500/10 px-2 py-0.5 text-xs font-medium text-yellow-600 dark:text-yellow-400"
>
gated
</span>
{/if}
</div>
{#if gguf?.chat_template}
<ModelsDiscoverChatTemplateDialog
bind:open={chatTemplateOpen}
chatTemplate={gguf.chat_template}
/>
{/if}
@@ -24,6 +24,13 @@ export { default as ModelsDiscover } from './ModelsDiscover.svelte';
*/
export { default as ModelsDiscoverList } from './ModelsDiscoverList.svelte';
/**
* **ModelsDiscoverListSearch** - Sidebar search input
*
* Debounced search field for the model list.
*/
export { default as ModelsDiscoverListSearch } from './ModelsDiscoverListSearch.svelte';
/**
* **ModelsDiscoverItem** - Single sidebar row
*
@@ -0,0 +1,51 @@
<script lang="ts">
import { Trash2 } from '@lucide/svelte';
import DownloadProgressBar from '$lib/components/app/models/discover/DownloadProgressBar.svelte';
import { modelsStore } from '$lib/stores';
interface Props {
open?: boolean;
}
let { open = false }: Props = $props();
</script>
{#if open}
<div class="space-y-2">
{#each modelsStore.status.downloadEntries() as entry (entry.repoWithTag)}
<div class="flex flex-col gap-1 rounded-md border p-3">
<div class="flex items-center justify-between gap-2">
<span class="truncate font-mono text-xs">{entry.repoWithTag}</span>
<button
aria-label="Delete model"
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-destructive"
onclick={() => void modelsStore.status.cancelDownload(entry.repoWithTag)}
type="button"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
{#each Object.entries(entry.progress.files) as [file, fileProgress] (file)}
<div class="space-y-0.5">
<div class="flex items-center justify-between text-muted-foreground">
<span class="truncate font-mono text-xs">{file}</span>
<span class="font-mono tabular-nums">
{fileProgress.total > 0
? Math.round((fileProgress.done / fileProgress.total) * 100)
: 0}%
</span>
</div>
<DownloadProgressBar
downloadedBytes={fileProgress.done}
totalBytes={fileProgress.total}
/>
</div>
{/each}
</div>
{/each}
</div>
{/if}
@@ -0,0 +1,65 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import DownloadProgressBar from '$lib/components/app/models/discover/DownloadProgressBar.svelte';
import type { ModelDownloadProgress } from '$lib/types';
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;
}
let { onDismiss, onOpenManager, progress, repoId }: Props = $props();
let files = $derived(Object.entries(progress.files));
function percent(done: number, total: number): number {
return total > 0 ? Math.round((done / total) * 100) : 0;
}
</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>
<button
aria-label="Dismiss"
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-foreground"
onclick={() => onDismiss?.()}
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>
<span class="shrink-0 font-mono tabular-nums">
{percent(fileProgress.done, fileProgress.total)}%
</span>
</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>
{/if}
</div>
@@ -109,3 +109,18 @@ export { default as ModelBadge } from './ModelBadge.svelte';
* Respects the user's `showRawModelNames` setting.
*/
export { default as ModelId } from './ModelId.svelte';
/**
* **ModelsDownloadManager** - tracked downloads list
*
* Lists every in-flight download with per-file progress and a delete action.
*/
export { default as ModelsDownloadManager } from './download-manager/ModelsDownloadManager.svelte';
/**
* **ModelsDownloadManagerDownloadStatusToast** - per-entry download toast
*
* One toast per download with a progress bar per file (main + sidecars) and
* a CTA to open the download manager.
*/
export { default as ModelsDownloadManagerDownloadStatusToast } from './download-manager/ModelsDownloadManagerDownloadStatusToast.svelte';
@@ -124,6 +124,16 @@ export class ModelStatusManager {
constructor(private host: ModelStatusHost) {}
/**
* All tracked downloads (in flight), as a list for the download manager.
*/
downloadEntries(): { progress: ModelDownloadProgress; repoWithTag: string }[] {
return Array.from(this.downloadProgress, ([repoWithTag, progress]) => ({
progress,
repoWithTag
}));
}
/**
* Trigger a model download from HuggingFace via POST /models
* (ggml-org/llama.cpp#23976). The download runs in the background on the
@@ -332,7 +342,11 @@ export class ModelStatusManager {
total += file?.total ?? 0;
}
this.downloadProgress.set(event.model, { downloadedBytes: downloaded, totalBytes: total });
this.downloadProgress.set(event.model, {
downloadedBytes: downloaded,
files: progress,
totalBytes: total
});
}
/**
+1
View File
@@ -109,6 +109,7 @@ export type {
ModelCapabilities,
ModelModalities,
ModelOption,
ModelDownloadFileProgress,
ModelDownloadProgress,
ModelLoadProgress,
ModalityCapabilities
+9 -8
View File
@@ -40,19 +40,20 @@ export interface ModelLoadProgress {
* Per-byte download progress for one in-flight model download, driven by the
* /models/sse feed. Lives only while a download runs.
*/
export interface ModelDownloadProgress {
downloadedBytes: number;
totalBytes: number;
export interface ModelDownloadFileProgress {
/** Bytes downloaded for this file so far. */
done: number;
/** Total bytes of the file. */
total: number;
}
/**
* Per-byte download progress for one in-flight model download, driven by the
* /models/sse feed. Lives only while a download runs.
*/
export interface ModelDownloadProgress {
/** Summed progress across all files of the download plan. */
downloadedBytes: number;
/** Summed plan size across all files. */
totalBytes: number;
/** Per-file progress keyed by file URL, as reported by the feed. */
files: Record<string, ModelDownloadFileProgress>;
}
// LLAMA-APP-REUSE: parsed model id shape
@@ -74,7 +74,10 @@
<Story name="Terminal commands (no sidecars)">
<div class="w-200 p-4">
<ModelsDiscoverModelDetailsCommands modelId="ggml-org/Qwen3.8-27B-GGUF" />
<ModelsDiscoverModelDetailsCommands
modelId="ggml-org/Qwen3.8-27B-GGUF"
quants={['Q4_K_M', 'Q8_0']}
/>
</div>
</Story>
@@ -82,6 +85,7 @@
<div class="w-200 p-4">
<ModelsDiscoverModelDetailsCommands
modelId="ggml-org/gemma-4-12b-it-GGUF"
quants={['Q4_K_M', 'Q8_0']}
sidecars={[ModelDraftSidecar.MTP]}
/>
</div>