mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-20 01:31:31 +02:00
feat: WIP
This commit is contained in:
@@ -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>
|
||||
+337
-79
@@ -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>
|
||||
|
||||
+7
-1
@@ -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
|
||||
*
|
||||
|
||||
+41
-35
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user