feat: WIP

This commit is contained in:
Aleksander Grygier
2026-08-24 15:01:16 +02:00
parent 84d6a812ca
commit 4efb65d24c
60 changed files with 4240 additions and 71 deletions
+9 -1
View File
@@ -20,11 +20,14 @@ import type {
ApiModelDataEntry,
ApiModelListResponse,
ApiModelLoadStage,
ApiModelsDownloadProgressData,
ApiModelsSseData,
ApiModelsSseEvent,
ApiModelsSseProgress,
ApiProcessingState,
ApiRouterModelMeta,
ApiRouterModelsDownloadRequest,
ApiRouterModelsDownloadResponse,
ApiRouterModelsListResponse,
ApiRouterModelsLoadRequest,
ApiRouterModelsLoadResponse,
@@ -52,6 +55,7 @@ import type {
DatabaseMessageExtraVideoFile,
ExportedConversation,
ExportedConversations,
ModelDownloadProgress,
ModelLoadProgress,
// Model types
ModelModalities,
@@ -89,14 +93,17 @@ declare global {
ApiModelsSseProgress,
ApiModelsSseData,
ApiModelsSseEvent,
ApiModelsDownloadProgressData,
ApiModelListResponse,
ApiProcessingState,
ApiRouterModelMeta,
ApiRouterModelsDownloadRequest,
ApiRouterModelsDownloadResponse,
ApiRouterModelsListResponse,
ApiRouterModelsLoadRequest,
ApiRouterModelsLoadResponse,
ApiRouterModelsStatusRequest,
ApiRouterModelsStatusResponse,
ApiRouterModelsListResponse,
ApiRouterModelsUnloadRequest,
ApiRouterModelsUnloadResponse,
// Chat types
@@ -127,6 +134,7 @@ declare global {
ModelModalities,
ModelOption,
ModelLoadProgress,
ModelDownloadProgress,
// Settings types
SettingsChatServiceOptions,
SettingsConfigValue,
@@ -1,5 +1,5 @@
<script lang="ts">
import { AlertCircle, Loader2 } from '@lucide/svelte';
import { AlertCircle, LoaderCircle } from '@lucide/svelte';
import { X } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -45,7 +45,7 @@
type="button"
>
{#if attachment.loading}
<Loader2 class="h-3 w-3 animate-spin text-muted-foreground" />
<LoaderCircle class="h-3 w-3 animate-spin text-muted-foreground" />
{:else if attachment.error}
<AlertCircle class="h-3 w-3 text-red-500" />
{:else}
@@ -1,5 +1,5 @@
<script lang="ts">
import { Check, ChevronDown, ChevronRight, Info, Loader2, PencilRuler } from '@lucide/svelte';
import { Check, ChevronDown, ChevronRight, Info, LoaderCircle, PencilRuler } from '@lucide/svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
@@ -24,7 +24,7 @@
{#if toolsPanel.totalToolCount === 0}
{#if toolsStore.loading}
<div class="px-3 py-4 text-center text-sm text-muted-foreground">
<Loader2 class="mx-auto mb-1 {ICON_CLASS_DEFAULT} animate-spin" />
<LoaderCircle class="mx-auto mb-1 {ICON_CLASS_DEFAULT} animate-spin" />
Loading tools...
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { Loader2 } from '@lucide/svelte';
import { LoaderCircle } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
interface Props {
@@ -19,8 +19,7 @@
</div>
{:else if isLoading}
<div class="flex items-center gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
<LoaderCircle class="h-3.5 w-3.5 animate-spin" />
<span>Loading model...</span>
</div>
{/if}
@@ -4,7 +4,7 @@
// shared chrome shell.
import ToolCallBlock from './ToolCallBlock.svelte';
import { Loader2 } from '@lucide/svelte';
import { LoaderCircle } from '@lucide/svelte';
import { MarkdownContent, SyntaxHighlightedCode } from '$lib/components/app';
import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import { AttachmentType, FileTypeText, MimeTypeAudio, ToolResultKind } from '$lib/enums';
@@ -41,7 +41,7 @@
<span>Input</span>
{#if ctx.isStreaming}
<Loader2 class="h-3 w-3 animate-spin" />
<LoaderCircle class="h-3 w-3 animate-spin" />
{/if}
</div>
@@ -86,7 +86,7 @@
<span>Output</span>
{#if ctx.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
<LoaderCircle class="h-3 w-3 animate-spin" />
{/if}
</div>
@@ -8,7 +8,7 @@
import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
import ToolCallBlock from './ToolCallBlock.svelte';
import { AlertTriangle, Check, Loader2, XCircle } from '@lucide/svelte';
import { AlertTriangle, Check, LoaderCircle, XCircle } from '@lucide/svelte';
import { CollapsibleTerminalBlock } from '$lib/components/app';
import { SETTINGS_KEYS, TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants';
import { AttachmentType } from '$lib/enums';
@@ -204,7 +204,7 @@
{#snippet children(_meta, ctx)}
{#if ctx.isPending}
<div class="flex items-start gap-2 text-xs text-muted-foreground/70">
<Loader2 class="h-3 w-3 animate-spin" />
<LoaderCircle class="h-3 w-3 animate-spin" />
Running...
</div>
{:else if execShellError}
@@ -1,5 +1,5 @@
<script lang="ts">
import { Clock, Loader2 } from '@lucide/svelte';
import { Clock, LoaderCircle } from '@lucide/svelte';
import { AgenticSectionType } from '$lib/enums';
import type { AgenticSection } from '$lib/types';
@@ -47,8 +47,7 @@
{#if showSpinner}
<span class="text-foreground/80 text-sm font-medium">Current time</span>
<Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
<LoaderCircle class="text-muted-foreground/70 h-3 w-3 animate-spin" />
{:else if dateMeta.errorMessage}
<span class="text-foreground/80 text-sm font-medium">Current time&nbsp;</span>
@@ -1,5 +1,5 @@
<script lang="ts">
import { Info, Loader2 } from '@lucide/svelte';
import { Info, LoaderCircle } from '@lucide/svelte';
import { AgenticSectionType } from '$lib/enums';
import { toolsStore } from '$lib/stores';
import type { AgenticSection } from '$lib/types';
@@ -55,8 +55,7 @@
{#if showSpinner}
<span class="text-foreground/80 text-sm font-medium">Runtime info</span>
<Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
<LoaderCircle class="text-muted-foreground/70 h-3 w-3 animate-spin" />
{:else if infoMeta.errorMessage}
<span class="text-foreground/80 text-sm font-medium">Runtime info&nbsp;</span>
@@ -1,5 +1,5 @@
<script lang="ts">
import { Globe, Loader2 } from '@lucide/svelte';
import { Globe, LoaderCircle } from '@lucide/svelte';
import { CollapsibleContentBlock } from '$lib/components/app';
import * as HoverCard from '$lib/components/ui/hover-card';
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants';
@@ -33,7 +33,7 @@
// MCP-server branding is consistent across both views. Spinner wins
// while the call is in flight so the user sees execution status.
const iconUrl = $derived(showSpinner ? null : mcpStore.getServerFaviconForTool(section.toolName));
const icon = $derived(showSpinner ? Loader2 : undefined);
const icon = $derived(showSpinner ? LoaderCircle : undefined);
const iconClass = $derived(showSpinner ? ICON_CLASS_SPIN : ICON_CLASS_DEFAULT);
// Verb reflects state: "Searching" while the call is in flight, "Searched"
@@ -167,8 +167,7 @@
</div>
{:else if showSpinner}
<div class="text-muted-foreground/70 flex items-center gap-2 py-1 text-xs italic">
<Loader2 class="h-3 w-3 animate-spin" />
<LoaderCircle class="h-3 w-3 animate-spin" />
<span>Searching...</span>
</div>
{:else}
@@ -9,7 +9,7 @@
// Components supply only their `meta`, a title snippet, and a body
// snippet - everything around them is this single source of truth.
import { Loader2, Wrench } from '@lucide/svelte';
import { LoaderCircle, Wrench } from '@lucide/svelte';
import { CollapsibleContentBlock } from '$lib/components/app';
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants';
import { AgenticSectionType } from '$lib/enums';
@@ -43,7 +43,7 @@
*/
extraLiveStreaming?: boolean;
/**
* Swap the title-row icon for a spinning `Loader2` while the
* Swap the title-row icon for a spinning `LoaderCircle` while the
* spinner is showing. Only meaningful for tools where "live"
* is interesting (e.g. exec_shell_command showing the in-flight
* process). Other tools leave it off and render the spinner
@@ -84,7 +84,7 @@
const toolUi: ToolUiEntry | null = $derived(getToolUi(section.toolName));
const toolIcon: Component = $derived(
spinIconWhenActive && showSpinner ? Loader2 : (toolUi?.icon ?? Wrench)
spinIconWhenActive && showSpinner ? LoaderCircle : (toolUi?.icon ?? Wrench)
);
const toolIconClass = $derived(
spinIconWhenActive && showSpinner ? ICON_CLASS_SPIN : ICON_CLASS_DEFAULT
@@ -1,5 +1,5 @@
<script lang="ts">
import { AlertTriangle, Loader2, RefreshCw } from '@lucide/svelte';
import { AlertTriangle, LoaderCircle, RefreshCw } from '@lucide/svelte';
import * as Alert from '$lib/components/ui/alert';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { serverStore } from '$lib/stores';
@@ -12,7 +12,7 @@
<div class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1">
<Alert.Root variant={isLoadingModel ? 'default' : 'destructive'}>
{#if isLoadingModel}
<Loader2 class="{ICON_CLASS_DEFAULT} animate-spin" />
<LoaderCircle class="{ICON_CLASS_DEFAULT} animate-spin" />
{:else}
<AlertTriangle class={ICON_CLASS_DEFAULT} />
{/if}
@@ -1,5 +1,5 @@
<script lang="ts">
import { Loader2 } from '@lucide/svelte';
import { LoaderCircle } from '@lucide/svelte';
import { StreamConnectionState } from '$lib/enums';
import { chatStore } from '$lib/stores';
@@ -12,8 +12,7 @@
class="pointer-events-auto mx-auto mt-2 mb-2 flex max-w-[48rem] items-center gap-2 rounded-md border border-blue-400/40 bg-blue-50/60 px-3 py-1.5 text-sm text-blue-700 dark:bg-blue-950/40 dark:text-blue-200"
role="status"
>
<Loader2 class="h-3.5 w-3.5 animate-spin" />
<LoaderCircle class="h-3.5 w-3.5 animate-spin" />
<span>Reconnecting to the stream...</span>
</div>
{/if}
@@ -1,5 +1,5 @@
<script lang="ts">
import { Loader2, Square, SquarePen, X } from '@lucide/svelte';
import { LoaderCircle, Square, SquarePen, X } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { cn } from '$lib/components/ui/utils';
import { ICON_CLASS_SM, ICON_CLASS_XS, ROUTES, UI_DATA_ATTRS } from '$lib/constants';
@@ -85,7 +85,7 @@
class="stop-button relative z-10 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground"
onclick={(e) => handleActionClick(e, () => onStop?.(tab.id, e))}
>
<Loader2
<LoaderCircle
class="loading-icon {ICON_CLASS_SM} animate-spin transition-opacity duration-300 {contentOpacity}"
/>
@@ -1,5 +1,5 @@
<script lang="ts">
import { Braces, FolderOpen, Loader2, Plus } from '@lucide/svelte';
import { Braces, FolderOpen, LoaderCircle, Plus } from '@lucide/svelte';
import {
McpResourcePreview,
McpResourcesBrowser,
@@ -306,7 +306,7 @@
{#if templatePreviewLoading}
<div class="flex flex-1 items-center justify-center">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
<LoaderCircle class="h-6 w-6 animate-spin text-muted-foreground" />
</div>
{:else if templatePreviewError}
<div class="flex flex-1 flex-col items-center justify-center gap-2 text-red-500">
@@ -367,7 +367,7 @@
{#if hasTemplateResult}
<Button disabled={isAttaching} onclick={handleAttachTemplateResource}>
{#if isAttaching}
<Loader2 class="mr-2 {ICON_CLASS_DEFAULT} animate-spin" />
<LoaderCircle class="mr-2 {ICON_CLASS_DEFAULT} animate-spin" />
{:else}
<Plus class="mr-2 {ICON_CLASS_DEFAULT}" />
{/if}
@@ -377,7 +377,7 @@
{:else}
<Button disabled={selectedResources.size === 0 || isAttaching} onclick={handleAttach}>
{#if isAttaching}
<Loader2 class="mr-2 {ICON_CLASS_DEFAULT} animate-spin" />
<LoaderCircle class="mr-2 {ICON_CLASS_DEFAULT} animate-spin" />
{:else}
<Plus class="mr-2 {ICON_CLASS_DEFAULT}" />
{/if}
@@ -0,0 +1,100 @@
<script lang="ts">
import { SearchInput } from '$lib/components/app';
import { ModelsHubList, ModelsHubModelDetails } from '$lib/components/app/models-hub';
import * as Dialog from '$lib/components/ui/dialog';
import { modelsHubStore } from '$lib/stores';
import { untrack } from 'svelte';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
let { onOpenChange, open = $bindable(false) }: Props = $props();
let selectedId = $state<string | null>(null);
let searchQuery = $state('');
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
function handleOpenChange(value: boolean) {
open = value;
onOpenChange?.(value);
}
// Load the sidebar list when the dialog opens, and reset browsing state.
// Store writes are untracked so this effect only depends on `open`.
$effect(() => {
if (open) {
untrack(() => {
void modelsHubStore.fetch();
void modelsHubStore.search('');
});
searchQuery = '';
selectedId = null;
}
});
// Auto-select the first model only when the dialog opens (selection reset).
$effect(() => {
const first = modelsHubStore.firstModel;
if (!selectedId && first) {
selectedId = first.id;
}
});
function handleSearchInput(value: string) {
searchQuery = value;
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
void modelsHubStore.search(value);
}, 300);
}
</script>
<Dialog.Root {open} onOpenChange={handleOpenChange}>
<Dialog.Content
class="flex flex-col md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-360!"
>
<Dialog.Header>
<Dialog.Title>Discover Models</Dialog.Title>
</Dialog.Header>
<div class="flex min-h-0 flex-1">
<aside class="flex w-72 shrink-0 flex-col border-r border-border/40">
<div class="p-2">
<SearchInput
bind:value={searchQuery}
placeholder="Search models..."
onInput={handleSearchInput}
/>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
{#if modelsHubStore.loading}
<p class="p-4 text-sm text-muted-foreground">Loading models...</p>
{:else if modelsHubStore.error}
<p class="p-4 text-sm text-destructive">{modelsHubStore.error}</p>
{:else if modelsHubStore.models.length === 0}
<p class="p-4 text-sm text-muted-foreground">No models found</p>
{:else}
<ModelsHubList
models={modelsHubStore.models}
activeId={selectedId}
showBaseModelAvatar
onSelect={(id) => (selectedId = id)}
/>
{/if}
</div>
</aside>
<main class="min-w-0 flex-1 overflow-y-auto">
{#if selectedId}
<ModelsHubModelDetails modelId={selectedId} />
{/if}
</main>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -26,6 +26,16 @@ export { default as DialogMcpServerAddNew } from './DialogMcpServerAddNew.svelte
*/
export { default as DialogMcpServers } from './DialogMcpServers.svelte';
/**
* **DialogModelsHub** - Models Hub discovery dialog
*
* Two-pane HuggingFace GGUF browser in a modal dialog: a sidebar model list
* (ModelsHubList) and a detail view (ModelsHubModelDetails). Always opens the
* first model. Used for discovery and downloading; the `/models-hub` route is
* reserved for model management.
*/
export { default as DialogModelsHub } from './DialogModelsHub.svelte';
/**
* **DialogSettingsChat** - Chat settings shown in a modal dialog
*
+1
View File
@@ -8,5 +8,6 @@ export * from './mcp';
export * from './misc';
export * from './settings';
export * from './models';
export * from './models-hub';
export * from './navigation';
export * from './server';
@@ -1,5 +1,5 @@
<script lang="ts">
import { AlertCircle, Download, FileText, Loader2 } from '@lucide/svelte';
import { AlertCircle, Download, FileText, LoaderCircle } from '@lucide/svelte';
import { ActionIconCopyToClipboard } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
@@ -116,7 +116,7 @@
<div class="min-h-[200px] overflow-auto rounded-md border bg-muted/30 p-3 break-all">
{#if isLoading}
<div class="flex items-center justify-center py-8">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
<LoaderCircle class="h-6 w-6 animate-spin text-muted-foreground" />
</div>
{:else if error}
<div class="flex flex-col items-center justify-center gap-2 py-8 text-red-500">
@@ -1,5 +1,5 @@
<script lang="ts">
import { Loader2, RefreshCw } from '@lucide/svelte';
import { LoaderCircle, RefreshCw } from '@lucide/svelte';
import { SearchInput } from '$lib/components/app/forms';
import { Button } from '$lib/components/ui/button';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
@@ -31,7 +31,7 @@
variant="ghost"
>
{#if isLoading}
<Loader2 class="{ICON_CLASS_DEFAULT} animate-spin" />
<LoaderCircle class="{ICON_CLASS_DEFAULT} animate-spin" />
{:else}
<RefreshCw class={ICON_CLASS_DEFAULT} />
{/if}
@@ -5,7 +5,7 @@
type ResourceTreeNode,
sortTreeChildren
} from './mcp-resources-browser';
import { Braces, ChevronDown, ChevronRight, FolderOpen, Loader2 } from '@lucide/svelte';
import { Braces, ChevronDown, ChevronRight, FolderOpen, LoaderCircle } from '@lucide/svelte';
import { McpServerIdentity } from '$lib/components/app/mcp';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
@@ -177,7 +177,7 @@
</span>
{#if serverRes.loading}
<Loader2 class="ml-auto h-3 w-3 animate-spin text-muted-foreground" />
<LoaderCircle class="ml-auto h-3 w-3 animate-spin text-muted-foreground" />
{/if}
</Collapsible.Trigger>
@@ -0,0 +1,316 @@
<script lang="ts">
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { Download, LoaderCircle, Trash2, TriangleAlert } from '@lucide/svelte';
import { DialogConfirmation } from '$lib/components/app';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import type { DraftVariant } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
import { type GgufVariantTagInput, ModelsService } from '$lib/services/models.service';
import { modelsStore } from '$lib/stores';
interface Props {
open: boolean;
repoId: string;
filePath: string;
quant: string | null;
variant: DraftVariant | null;
formattedSize?: string;
onConfirm: () => void;
onCancel: () => void;
}
let {
filePath,
formattedSize,
onCancel,
onConfirm,
open = $bindable(),
quant,
repoId,
variant
}: Props = $props();
type Phase = 'pending' | 'starting' | 'downloading' | 'finished';
let phase = $state<Phase>('pending');
let hasSeenProgress = $state(false);
let lastError: string | null = $state(null);
let tagInput = $derived<GgufVariantTagInput | null>(
quant || variant ? { quant: quant ?? '', variant } : null
);
let hfRepoWithTag = $derived(ModelsService.buildDownloadTag(repoId, tagInput));
let tagDisplay = $derived.by(() => {
if (quant && variant) return `${quant}-${variant.toUpperCase()}`;
if (quant) return quant;
if (variant) return variant.toUpperCase();
return 'default';
});
let inFlight = $derived(phase === 'starting' || phase === 'downloading');
// True when a previous SSE `download_failed` left a recorded failure for the
// same <repo>:<tag>. The dialog swaps Download for a delete-&-retry flow
// because POST /models rejects already-existing partial entries.
let previousFailure = $derived(modelsStore.status.hasFailedDownload(hfRepoWithTag));
let cancelling = $state(false);
let lastCancelError: string | null = $state(null);
// Only offer Delete when the model is registered with the server (a fully
// downloaded entry in /v1/models). For an in-flight download the partial
// files are cleaned up by the Retry path, so Delete would be redundant.
let canDelete = $derived(
phase === 'finished' && modelsStore.status.isModelDownloaded(hfRepoWithTag)
);
let showDeleteConfirm = $state(false);
async function handleConfirmDelete() {
showDeleteConfirm = false;
await modelsStore.status.cancelDownload(hfRepoWithTag);
// Close the download dialog too - removing the entry makes the wizard moot.
onCancel();
}
// Reactive: while the SSE feed reports progress for our download, surface it.
// The downloadProgress map is deleted on download_finished/download_failed.
let progress = $derived(modelsStore.status.getDownloadProgress(hfRepoWithTag));
let progressPercent = $derived.by(() => {
if (!progress || progress.totalBytes <= 0) return 0;
return Math.round((progress.downloadedBytes / progress.totalBytes) * 100);
});
function handleKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER && phase === 'pending') {
event.preventDefault();
void trigger();
}
}
function handleOpenChange(newOpen: boolean) {
if (newOpen) {
lastError = null;
lastCancelError = null;
showDeleteConfirm = false;
phase = 'pending';
hasSeenProgress = false;
return;
}
if (!inFlight) onCancel();
}
async function trigger() {
if (inFlight) return;
phase = 'starting';
hasSeenProgress = false;
lastError = null;
lastCancelError = null;
showDeleteConfirm = false;
// A recorded failure for the same <repo>:<tag> means the server still holds
// a partial entry that POST /models would reject; remove it before retrying.
if (modelsStore.status.hasFailedDownload(hfRepoWithTag)) {
await ModelsService.cancelDownload(hfRepoWithTag);
}
try {
await modelsStore.status.downloadModel(hfRepoWithTag, filePath);
phase = 'downloading';
} catch (error) {
lastError = error instanceof Error ? error.message : 'Failed to start download';
phase = 'pending';
}
}
async function cancel() {
if (cancelling) return;
cancelling = true;
lastCancelError = null;
try {
const ok = await modelsStore.status.cancelDownload(hfRepoWithTag);
if (!ok) {
lastCancelError = 'Cancel request failed. Try again in a moment.';
}
} finally {
cancelling = false;
}
}
// Latch once we've seen real progress so we know what 'no longer in flight'
// actually means. Without this the dialog auto-closed shortly after the POST
// resolved because no SSE event had landed yet.
$effect(() => {
if (phase !== 'downloading') return;
if (progress) hasSeenProgress = true;
});
// Promote to 'finished' only after progress was observed and the feed then
// drops our entry. Auto-close after a short pause.
$effect(() => {
if (phase !== 'downloading') return;
if (!hasSeenProgress) return;
const stillInFlight = modelsStore.status.isDownloadInProgress(hfRepoWithTag);
if (!stillInFlight) {
phase = 'finished';
const timer = setTimeout(() => onConfirm(), 600);
return () => clearTimeout(timer);
}
});
</script>
<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
<AlertDialog.Content onkeydown={handleKeydown} class="max-w-md">
<AlertDialog.Header>
<AlertDialog.Title class="flex items-center gap-2">
<Download class="h-5 w-5 text-primary" />
{#if phase === 'pending'}
Download this model?
{:else}
Downloading {tagDisplay}
{/if}
</AlertDialog.Title>
<AlertDialog.Description>
{#if phase === 'pending'}
llama-server will download this file (and related sidecar weights such as multimodal
projectors or draft models) from Hugging Face into your local model cache.
{:else}
Download runs in the background; this dialog tracks live progress.
{/if}
</AlertDialog.Description>
{#if previousFailure && phase === 'pending'}
<div
class="mt-2 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
role="status"
>
<TriangleAlert class="mt-0.5 h-4 w-4 shrink-0" />
<span>
A previous attempt for this tag failed and left partial files on disk. The server will
reject a fresh download until those files are removed. The Retry button below deletes
the partial files automatically.
</span>
</div>
{/if}
</AlertDialog.Header>
{#if canDelete}
<div class="flex justify-end">
<button
type="button"
onclick={() => (showDeleteConfirm = true)}
class="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 px-2 py-1 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
aria-label="Delete model from cache"
>
<Trash2 class="h-3.5 w-3.5" />
Delete from cache
</button>
</div>
{/if}
<div class="space-y-3 rounded-md border bg-muted/40 p-3 text-xs">
<div class="flex flex-col gap-1">
<span class="text-muted-foreground">Request</span>
<code class="break-all font-mono"
>POST /models&nbsp;&middot;&nbsp;{`{ model: "${hfRepoWithTag}" }`}</code
>
</div>
<div class="flex flex-col gap-1">
<span class="text-muted-foreground">File</span>
<code class="break-all font-mono">{filePath}</code>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded bg-primary/15 px-2 py-0.5 font-mono font-semibold text-primary">
{tagDisplay}
</span>
{#if formattedSize}
<span class="text-muted-foreground">{formattedSize}</span>
{/if}
{#if variant}
<span
class="rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground"
>
{variant}
</span>
{/if}
</div>
{#if phase === 'downloading' || phase === 'finished'}
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between text-muted-foreground">
<span>
{#if phase === 'finished'}
Complete
{:else if progress && progress.totalBytes > 0}
Downloading
{:else}
Preparing download
{/if}
</span>
<span class="font-mono tabular-nums">{progressPercent}%</span>
</div>
<DownloadProgressBar
downloadedBytes={progress?.downloadedBytes ?? 0}
totalBytes={progress?.totalBytes ?? 0}
/>
</div>
{/if}
</div>
{#if lastError}
<p class="text-xs text-destructive">{lastError}</p>
{/if}
{#if lastCancelError}
<p class="text-xs text-destructive">{lastCancelError}</p>
{/if}
<AlertDialog.Footer>
{#if phase === 'downloading'}
<AlertDialog.Action disabled={cancelling} onclick={cancel}>
{#if cancelling}
<LoaderCircle class="mr-1.5 h-4 w-4 animate-spin" />
Cancelling...
{:else}
Cancel download
{/if}
</AlertDialog.Action>
{:else}
<AlertDialog.Cancel disabled={inFlight} onclick={onCancel}>
{#if phase === 'finished'}Close{:else}Cancel{/if}
</AlertDialog.Cancel>
{/if}
{#if phase === 'pending'}
<AlertDialog.Action disabled={inFlight} onclick={trigger}>
<Download class="mr-1.5 h-4 w-4" />
{previousFailure ? 'Retry download' : 'Download'}
</AlertDialog.Action>
{:else if phase === 'starting'}
<AlertDialog.Action disabled>
<LoaderCircle class="mr-1.5 h-4 w-4 animate-spin" />
Starting...
</AlertDialog.Action>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<DialogConfirmation
bind:open={showDeleteConfirm}
title="Delete model"
description={`Remove "${hfRepoWithTag}" from your cache? Any cached files will be deleted from disk.`}
confirmText="Delete"
cancelText="Cancel"
variant="destructive"
icon={Trash2}
onConfirm={handleConfirmDelete}
onCancel={() => (showDeleteConfirm = false)}
/>
@@ -0,0 +1,35 @@
<script lang="ts">
interface Props {
/** Bytes downloaded so far. Caller supplies the value; we normalize 0..1. */
downloadedBytes: number;
/** Total bytes for the download plan. */
totalBytes: number;
/** Pin to the bottom edge as a thin overlay like `ModelLoadHighlight`. */
overlay?: boolean;
}
let { downloadedBytes, overlay = false, totalBytes }: Props = $props();
let fraction = $derived.by(() => {
if (totalBytes <= 0) return 0;
return Math.min(Math.max(downloadedBytes / totalBytes, 0), 1);
});
let percent = $derived(Math.round(fraction * 100));
</script>
{#if overlay}
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm">
<div
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
style="width: {percent}%"
></div>
</div>
{:else}
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
<div
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
style="width: {percent}%"
></div>
</div>
{/if}
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HfModelInfo } from '$lib/types/huggingface';
import ModelsHubListItem from './ModelsHubListItem.svelte';
interface Props {
models: HfModelInfo[];
activeId?: string | null;
/** Show the original (base) model's org avatar instead of the repo's org. */
showBaseModelAvatar?: boolean;
onSelect?: (modelId: string) => void;
}
let { models, activeId = null, showBaseModelAvatar = false, onSelect }: Props = $props();
</script>
<ul class="space-y-0.5 p-2">
{#each models as model (model.id)}
<ModelsHubListItem {model} active={model.id === activeId} {showBaseModelAvatar} {onSelect} />
{/each}
</ul>
@@ -0,0 +1,104 @@
<script lang="ts">
import { Download, Eye, Heart } from '@lucide/svelte';
import { HuggingFaceService } from '$lib/services';
import type { HfModelInfo } from '$lib/types/huggingface';
interface Props {
model: HfModelInfo;
active?: boolean;
/** Show the original (base) model's org avatar instead of the repo's org. */
showBaseModelAvatar?: boolean;
onSelect?: (modelId: string) => void;
}
let { model, active = false, showBaseModelAvatar = false, onSelect }: Props = $props();
let org = $derived(model.id.split('/')[0] ?? model.id);
let name = $derived(model.id.split('/')[1] ?? model.id);
let params = $derived(HuggingFaceService.parseParamCount(model.id));
let avatarError = $state(false);
// Org whose avatar is shown: the base model's org when showBaseModelAvatar
// (e.g. the Qwen logo for ggml-org/Qwen3.8-27B-GGUF), else the repo's org.
let avatarOrg = $derived.by(() => {
if (!showBaseModelAvatar) return org;
const base = HuggingFaceService.getBaseModels(model)[0];
return base?.split('/')[0] || org;
});
// Vision = a multimodal pipeline tag or an mmproj projector in the repo.
let hasVision = $derived(
model.pipeline_tag === 'image-text-to-text' ||
Boolean(model.siblings?.some((s) => s.rfilename.toLowerCase().includes('mmproj')))
);
// Monogram fallback: avatar org initial on a hue derived from its name, so
// each org gets a stable distinct color.
let hue = $derived.by(() => {
let h = 0;
for (let i = 0; i < avatarOrg.length; i++) h = (h * 31 + avatarOrg.charCodeAt(i)) >>> 0;
return h % 360;
});
</script>
<li>
<button
type="button"
onclick={() => onSelect?.(model.id)}
aria-current={active ? 'page' : undefined}
class="flex w-full cursor-pointer items-start gap-2.5 rounded-lg p-2.5 text-left transition-colors {active
? 'bg-primary/10 hover:bg-primary/15'
: 'hover:bg-muted/60'}"
>
{#if avatarError}
<span
class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-sm font-semibold text-white"
style="background-color: hsl({hue} 60% 45%)"
aria-hidden="true"
>
{avatarOrg.charAt(0).toUpperCase()}
</span>
{:else}
<img
src={HuggingFaceService.getAvatarUrl(avatarOrg)}
onerror={() => (avatarError = true)}
class="mt-0.5 h-9 w-9 shrink-0 rounded-md bg-gray-200 p-0.5"
alt=""
loading="lazy"
/>
{/if}
<span class="min-w-0 flex-1">
<span class="flex items-center justify-between gap-2">
<span class="truncate text-sm font-medium">{name}</span>
{#if hasVision}
<Eye class="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-label="Vision" />
{/if}
</span>
<span class="mt-0.5 block truncate text-xs text-muted-foreground">
{org}
{#if model.lastModified}
· {HuggingFaceService.formatRelativeTime(model.lastModified)}
{/if}
</span>
<span class="mt-1 flex items-center gap-2.5 text-xs text-muted-foreground">
{#if params}
<span class="font-medium tabular-nums">{params}</span>
{/if}
<span class="flex items-center gap-1">
<Download class="h-3 w-3" />
{HuggingFaceService.formatDownloads(model.downloads)}
</span>
<span class="flex items-center gap-1">
<Heart class="h-3 w-3" />
{HuggingFaceService.formatLikes(model.likes)}
</span>
</span>
</span>
</button>
</li>
@@ -0,0 +1,374 @@
<script lang="ts">
import {
Check,
Copy,
Download,
ExternalLink,
Eye,
Heart,
MessageSquareCode,
Sparkles,
Wrench
} from '@lucide/svelte';
import { MarkdownContent } from '$lib/components/app';
import { HuggingFaceService, ModelsService } from '$lib/services';
import type { GgufVariantTagInput } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types/huggingface';
import { copyToClipboard } from '$lib/utils';
import { SvelteMap } from 'svelte/reactivity';
import DialogModelDownload from './DialogModelDownload.svelte';
import DownloadProgressBar from './DownloadProgressBar.svelte';
interface Props {
/** Full HuggingFace model id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
modelId: string;
}
interface PendingDownload {
filePath: string;
sizeBytes: number | null;
quant: string | null;
variant: GgufVariantTagInput['variant'];
}
let { modelId }: Props = $props();
let details = $state<HfModelDetailInfo | null>(null);
let files = $state<HfModelSibling[]>([]);
let readme = $state<string | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let pendingDownload = $state<PendingDownload | null>(null);
let gguf = $derived(details?.gguf);
let baseModels = $derived(HuggingFaceService.getBaseModels(details));
let licenseTag = $derived.by(() => {
const tags = details?.tags ?? [];
return tags.find((t) => t.startsWith('license:'))?.replace('license:', '') ?? null;
});
// Capabilities derived from HF metadata. Vision comes from an mmproj sidecar
// or a multimodal pipeline tag; tool use / reasoning from the chat template.
let hasMmproj = $derived(
files.some((f) => HuggingFaceService.extractQuantMeta(f.path)?.variant === 'mmproj')
);
let hasVision = $derived(hasMmproj || details?.pipeline_tag === 'image-text-to-text');
let hasTools = $derived(Boolean(gguf?.chat_template && /tools?[_\s}]/i.test(gguf.chat_template)));
let hasReasoning = $derived(
Boolean(gguf?.chat_template && /think|reasoning/i.test(gguf.chat_template))
);
type BitDepthRow = { bitDepth: number; files: HfModelSibling[] };
let bitDepthRows = $derived.by<BitDepthRow[]>(() => {
const rows = new SvelteMap<number, HfModelSibling[]>();
for (const file of files) {
const meta = HuggingFaceService.extractQuantMeta(file.path);
// mmproj sidecars are already conveyed by the Vision capability badge.
if (meta?.variant === 'mmproj') continue;
const depth = meta?.quant ? HuggingFaceService.getBitDepth(meta.quant) : null;
const bucket = depth ?? 99;
const list = rows.get(bucket) ?? [];
list.push(file);
rows.set(bucket, list);
}
return Array.from(rows.entries())
.map(([bitDepth, rowFiles]) => ({ bitDepth, files: rowFiles }))
.sort((a, b) => a.bitDepth - b.bitDepth);
});
async function load(id: string) {
loading = true;
error = null;
try {
const [info, tree, readmeText] = await Promise.all([
HuggingFaceService.getDetails(id),
HuggingFaceService.getTree(id),
HuggingFaceService.getReadme(id)
]);
if (!info) {
error = 'Model not found';
return;
}
details = info;
files = HuggingFaceService.filterByExtension(tree, '.gguf');
readme = readmeText;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load model';
} finally {
loading = false;
}
}
// Re-fetch when the dialog selects a different model (component is reused).
$effect(() => {
void load(modelId);
});
</script>
{#if loading}
<div class="flex h-full items-center justify-center py-20">
<p class="text-sm text-muted-foreground">Loading model...</p>
</div>
{:else if error}
<div class="flex h-full items-center justify-center py-20">
<p class="text-sm text-destructive">{error}</p>
</div>
{:else if details}
<div class="space-y-6 p-6">
<!-- Header -->
<header class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<h1 class="truncate text-lg font-semibold">{details.id}</h1>
<button
type="button"
onclick={() => copyToClipboard(details?.id ?? modelId)}
class="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
aria-label="Copy model id"
>
<Copy class="h-4 w-4" />
</button>
</div>
<a
href={HuggingFaceService.getModelUrl(modelId)}
target="_blank"
rel="noopener noreferrer"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
>
<ExternalLink class="h-3.5 w-3.5" />
View on HF
</a>
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
{#if typeof details.downloads === 'number'}
<span class="inline-flex items-center gap-1.5">
<Download class="h-3.5 w-3.5" />
{HuggingFaceService.formatDownloads(details.downloads)}
</span>
{/if}
{#if typeof details.likes === 'number'}
<span class="inline-flex items-center gap-1.5">
<Heart class="h-3.5 w-3.5" />
{HuggingFaceService.formatLikes(details.likes)}
</span>
{/if}
{#if details.lastModified}
<span>Updated {HuggingFaceService.formatRelativeTime(details.lastModified)}</span>
{/if}
</div>
{#if details.cardData?.description}
<p class="text-sm text-muted-foreground">{details.cardData.description}</p>
{/if}
<!-- Metadata chips -->
<div class="flex flex-wrap items-center gap-1.5">
{#if gguf?.total}
<span class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground">
{HuggingFaceService.formatFileSize(gguf.total).replace(' B', '')}B params
</span>
{/if}
{#if gguf?.architecture}
<span class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground capitalize">
{gguf.architecture.replace(/_/g, ' ')}
</span>
{/if}
{#if gguf?.context_length}
<span class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground">
{gguf.context_length.toLocaleString()} ctx
</span>
{/if}
{#if licenseTag}
<span class="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{licenseTag}
</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>
<!-- Capability badges -->
{#if hasVision || hasTools || hasReasoning}
<div class="flex flex-wrap items-center gap-1.5">
{#if hasVision}
<span class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Eye class="h-3 w-3" />
Vision
</span>
{/if}
{#if hasTools}
<span class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Wrench class="h-3 w-3" />
Tool use
</span>
{/if}
{#if hasReasoning}
<span class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Sparkles class="h-3 w-3" />
Reasoning
</span>
{/if}
</div>
{/if}
</header>
<!-- Download options -->
{#if bitDepthRows.length}
<section class="space-y-3">
<h2 class="flex items-center gap-1.5 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
<MessageSquareCode class="h-3.5 w-3.5" />
Download options
</h2>
<div class="space-y-2">
{#each bitDepthRows as row (row.bitDepth)}
<div class="grid grid-cols-[5rem_1fr] items-start gap-3">
<div class="pt-1 text-xs font-semibold tabular-nums text-muted-foreground">
{#if row.bitDepth === 99}
Other
{:else}
{row.bitDepth}-bit
{/if}
</div>
<div class="flex flex-wrap 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 tagInput = meta?.quant
? { quant: meta.quant, variant: meta.variant ?? null }
: null}
{@const hfRepoWithTag = ModelsService.buildDownloadTag(modelId, tagInput)}
{@const progress = modelsStore.status.getDownloadProgress(hfRepoWithTag)}
{@const isDownloading = modelsStore.status.isDownloadInProgress(hfRepoWithTag)}
{@const isDownloaded = meta?.variant
? modelsStore.status.isDraftDownloaded(modelId, file.path)
: modelsStore.status.isModelDownloaded(hfRepoWithTag)}
{@const isFailed = modelsStore.status.hasFailedDownload(hfRepoWithTag)}
<button
type="button"
onclick={() =>
(pendingDownload = {
filePath: file.path,
sizeBytes: file.size ?? null,
quant: meta?.quant ?? null,
variant: meta?.variant ?? null
})}
title={isDownloading
? `Downloading ${file.path}`
: isDownloaded
? `Already downloaded: ${file.path}`
: isFailed
? `Last attempt failed: ${file.path}. Click to retry.`
: `Download ${file.path}`}
class="relative inline-flex cursor-pointer items-center gap-1 overflow-hidden rounded-md border bg-background px-2 py-1 text-left font-mono text-xs transition-colors hover:border-primary/60 hover:bg-primary/5"
class:border-foreground={isDownloaded && !isDownloading && !isFailed}
class:bg-muted={isDownloaded && !isDownloading && !isFailed}
class:border-destructive={isFailed && !isDownloading}
>
{#if isDownloaded && !isDownloading}
<Check class="h-3 w-3 text-foreground/70" />
{/if}
{#if isFailed && !isDownloading && !isDownloaded}
<span class="rounded bg-destructive px-1 py-0.5 text-[10px] font-semibold tracking-wide text-destructive-foreground uppercase">
Failed
</span>
{/if}
{#if meta?.variant}
<span class="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase">
{meta.variant}
</span>
{/if}
<span class="font-medium">{label}</span>
<span class="text-muted-foreground">
{#if isDownloading && progress && progress.totalBytes > 0}
{Math.round((progress.downloadedBytes / progress.totalBytes) * 100)}%
{:else}
{HuggingFaceService.formatFileSize(file.size ?? 0)}
{/if}
</span>
{#if isDownloading && progress}
<DownloadProgressBar
overlay
downloadedBytes={progress.downloadedBytes}
totalBytes={progress.totalBytes}
/>
{/if}
</button>
{/each}
</div>
</div>
{/each}
</div>
</section>
{/if}
<!-- Base model -->
{#if baseModels.length}
<section class="space-y-2">
<h2 class="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
Base model
</h2>
<ul class="space-y-1">
{#each baseModels as base (base)}
<li>
<a
href={HuggingFaceService.getModelUrl(base)}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 font-mono text-xs transition-colors hover:bg-muted"
>
{base}
<ExternalLink class="h-3 w-3 opacity-60" />
</a>
</li>
{/each}
</ul>
</section>
{/if}
<!-- README -->
{#if readme}
<section class="space-y-2">
<h2 class="text-xs font-semibold tracking-wide text-muted-foreground uppercase">README</h2>
<article class="rounded-lg border bg-card p-4">
<MarkdownContent content={readme} class="prose-sm max-w-none" />
</article>
</section>
{/if}
</div>
{/if}
{#if pendingDownload}
<DialogModelDownload
bind:open={
() => pendingDownload !== null,
(v) => {
if (!v) pendingDownload = null;
}
}
repoId={modelId}
filePath={pendingDownload.filePath}
quant={pendingDownload.quant}
variant={pendingDownload.variant}
formattedSize={pendingDownload.sizeBytes != null
? HuggingFaceService.formatFileSize(pendingDownload.sizeBytes)
: undefined}
onConfirm={() => (pendingDownload = null)}
onCancel={() => (pendingDownload = null)}
/>
{/if}
@@ -0,0 +1,46 @@
/**
*
* MODELS HUB
*
* Components for the Models Hub route (`/models-hub`): a sidebar list of
* HuggingFace GGUF models and a detail view for the selected model.
*
*/
/**
* **ModelsHubList** - Sidebar model list
*
* Renders the hub's model list as a navigable column. Each row links to the
* model's detail route and highlights the active one.
*/
export { default as ModelsHubList } from './ModelsHubList.svelte';
/**
* **ModelsHubListItem** - Single sidebar row
*
* One model entry in the sidebar list. Links to `/models-hub/[org]/[model]`.
*/
export { default as ModelsHubListItem } from './ModelsHubListItem.svelte';
/**
* **ModelsHubModelDetails** - Model detail view
*
* Detail pane for the selected model. Loads its own data (details + GGUF file
* list) from HuggingFaceService based on the `modelId` route param.
*/
export { default as ModelsHubModelDetails } from './ModelsHubModelDetails.svelte';
/**
* **DialogModelDownload** - Download confirmation / progress dialog
*
* Confirms a single GGUF download, tracks live progress over the SSE feed and
* offers cancel / delete-&-retry flows.
*/
export { default as DialogModelDownload } from './DialogModelDownload.svelte';
/**
* **DownloadProgressBar** - Thin download progress bar
*
* Normalizes bytes to a 0..100% bar; can pin to the bottom edge as an overlay.
*/
export { default as DownloadProgressBar } from './DownloadProgressBar.svelte';
@@ -1,10 +1,11 @@
<script lang="ts">
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import type { ModelItem } from './utils';
import { ChevronDown, Lightbulb, Loader2 } from '@lucide/svelte';
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
import type { ModelItem } from '../utils';
import { Boxes, ChevronDown, Lightbulb, LoaderCircle, PackageSearch, Plus } from '@lucide/svelte';
import {
ChatFormActionAddReasoningSubmenu,
DialogModelInformation,
DialogModelsHub,
DropdownMenuSearchable,
ModelId,
ModelsSelectorList,
@@ -39,6 +40,7 @@
let isOpen = $state(false);
let highlightedId = $state<string | null>(null);
let modelsHubOpen = $state(false);
// The model submenu opens together with the menu so the list and its search
// box are immediately available, as before the submenu was introduced
let modelSubOpen = $state(false);
@@ -169,7 +171,7 @@
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
{#if ms.loading && ms.options.length === 0 && ms.isRouter}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
<LoaderCircle class="h-3.5 w-3.5 animate-spin" />
Loading models…
</div>
@@ -243,7 +245,7 @@
</span>
{#if ms.updating || ms.isLoadingModel}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
<LoaderCircle class="h-3 w-3.5 shrink-0 animate-spin" />
{:else}
<ChevronDown class="h-3 w-3.5 shrink-0" />
{/if}
@@ -352,6 +354,17 @@
</DropdownMenu.Sub>
<ChatFormActionAddReasoningSubmenu />
<DropdownMenu.Separator />
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent"
onclick={() => (modelsHubOpen = true)}
>
<PackageSearch class="h-4 w-4 shrink-0 text-muted-foreground" />
<span>Discover models</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
@@ -392,7 +405,7 @@
{/if}
{#if ms.updating}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
<LoaderCircle class="h-3 w-3.5 shrink-0 animate-spin" />
{/if}
</button>
{/snippet}
@@ -415,3 +428,5 @@
open={ms.showModelDialog}
/>
{/if}
<DialogModelsHub bind:open={modelsHubOpen} />
@@ -1,5 +1,5 @@
<script lang="ts">
import type { GroupedModelOptions, ModelItem } from './utils';
import type { GroupedModelOptions, ModelItem } from '../utils';
import { ModelsSelectorOption } from '$lib/components/app';
import { modelsStore } from '$lib/stores';
@@ -1,11 +1,11 @@
<script lang="ts">
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
import {
CircleAlert,
Heart,
HeartOff,
Info,
Loader2,
LoaderCircle,
Power,
PowerOff,
RotateCw
@@ -134,7 +134,7 @@
{#if isLoading}
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-5">
<Loader2 class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
<LoaderCircle class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
</div>
{:else if isFailed}
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
@@ -1,6 +1,6 @@
<script lang="ts">
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
import { ChevronDown, LoaderCircle, Package } from '@lucide/svelte';
import {
DialogModelInformation,
ModelId,
@@ -58,7 +58,7 @@
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
{#if ms.loading && ms.options.length === 0 && ms.isRouter}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
<LoaderCircle class="h-3.5 w-3.5 animate-spin" />
Loading models…
</div>
{:else if ms.options.length === 0 && ms.isRouter}
@@ -110,7 +110,7 @@
{/if}
{#if ms.updating || ms.isLoadingModel}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
<LoaderCircle class="h-3 w-3.5 shrink-0 animate-spin" />
{:else}
<ChevronDown class="h-3 w-3.5 shrink-0" />
{/if}
@@ -194,7 +194,7 @@
<ModelId class="font-medium" hideQuantization modelId={selectedOption?.model || ''} />
{#if ms.updating}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
<LoaderCircle class="h-3 w-3.5 shrink-0 animate-spin" />
{/if}
</button>
{/if}
@@ -42,7 +42,7 @@
* />
* ```
*/
export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte';
export { default as ModelsSelectorDropdown } from './ModelsSelector/ModelsSelectorDropdown.svelte';
/**
* **ModelsSelectorList** - Grouped model options list
@@ -54,7 +54,7 @@ export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svel
* Accepts an optional `renderOption` snippet to customize how each option is
* rendered (e.g., to add keyboard navigation or highlighting).
*/
export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
export { default as ModelsSelectorList } from './ModelsSelector/ModelsSelectorList.svelte';
/**
* **ModelsSelectorOption** - Single model option row
@@ -63,7 +63,7 @@ export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
* load/unload actions, status indicators, and an info button.
* Used inside ModelsSelectorList or directly in custom render snippets.
*/
export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
export { default as ModelsSelectorOption } from './ModelsSelector/ModelsSelectorOption.svelte';
/**
* **ModelsSelectorSheet** - Mobile model selection sheet
@@ -72,7 +72,7 @@ export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
* on mobile devices. Same functionality as ModelsSelectorDropdown but uses Sheet UI
* instead of DropdownMenu.
*/
export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.svelte';
export { default as ModelsSelectorSheet } from './ModelsSelector/ModelsSelectorSheet.svelte';
/** * **ModelBadge** - Model name display badge
*
@@ -3,7 +3,7 @@
Download,
GitBranch,
ListChecks,
Loader2,
LoaderCircle,
MoreHorizontal,
Pencil,
Pin,
@@ -225,7 +225,7 @@
role="button"
tabindex="0"
>
<Loader2 class="loading-icon h-3.5 w-3.5 animate-spin" />
<LoaderCircle class="loading-icon h-3.5 w-3.5 animate-spin" />
<Square class="stop-icon hidden h-3 w-3 fill-current text-destructive" />
</div>
@@ -1,4 +1,6 @@
export const API_MODELS = {
DELETE: '/models',
DOWNLOAD: '/models',
LIST: '/v1/models',
LOAD: '/models/load',
SSE: '/models/sse',
@@ -11,8 +11,24 @@ export const MODEL_ID = {
/** Matches prefix for custom quantization types, e.g. `UD-Q8_K_XL`. */
CUSTOM_QUANTIZATION_PREFIX_RE: /^UD$/i,
/**
* Trailing `-<variant>` suffix marking a GGUF with an embedded draft in the
* same weight file (MTP) or a sidecar download entry, e.g.
* `Hy3-IQ1_M-mtp.gguf`, `Q4_K_M-dspark`. The captured prefix is the
* candidate model id; the caller decides whether it looks quantized.
*/
DRAFT_VARIANT_SUFFIX_RE: /^(.*)-(mtp|dflash|dspark|eagle3)$/i,
/**
* Sidecar prefix that wraps a model id with a draft/aux variant, e.g.
* `mtp-<name>.gguf`, `dflash-<name>.gguf`, `dspark-<name>.gguf`,
* `eagle3-<name>.gguf`, `mmproj-<name>.gguf`. Captures the bare variant
* token for typed lookup.
*/
DRAFT_VARIANT_PREFIX_RE: /^(mtp|dflash|dspark|eagle3|mmproj)-(.*)$/i,
/** Container format segments to exclude from tags (every model uses these). */
IGNORED_SEGMENTS: new Set(['GGUF', 'GGML']),
/** Sentinel value returned by `indexOf` when a substring is not found. */
NOT_FOUND: -1,
@@ -41,3 +57,13 @@ export const MODEL_ID = {
/** Matches a trailing weight file extension, e.g. `model.gguf` -> `model`. */
WEIGHT_EXTENSION_RE: /\.(gguf|ggml)$/i
};
/**
* Auxiliary / draft variant segments that show up in GGUF filenames and HF repo IDs.
* - `mtp` multi-token-prediction draft model
* - `dflash` diffusion-flash draft
* - `dspark` DSpark speculative-decoding draft
* - `eagle3` Eagle3 speculative-decoding draft
* - `mmproj` multimodal projector sidecar
*/
export type DraftVariant = 'mtp' | 'dflash' | 'dspark' | 'eagle3' | 'mmproj';
@@ -11,6 +11,12 @@ export const URL_PARAMS = {
export const ROUTES = {
/** Chat base — for dynamic chat URLs use RouterService. */
CHAT: '#/chat',
/** Model detail - for dynamic model URLs use RouterService. */
MANAGE_MODEL: '#/models-hub/[modelId]',
/** Model hub - browse and download HuggingFace GGUF models. */
MANAGE_MODELS: '#/models-hub',
/** Model manager - installed models from /v1/models. */
MODEL_MANAGER: '#/model-manager',
/** MCP servers. */
MCP_SERVERS: '#/mcp-servers',
/** Search — mobile-only full-page conversation search. */
@@ -1,4 +1,5 @@
import { Package, Search, Settings, SquarePen } from '@lucide/svelte';
import { ROUTES } from '$lib/constants/routes.constants';
import { SidebarAction, ToolSource } from '$lib/enums';
import type { DesktopIconStripItem } from '$lib/types';
@@ -61,6 +62,12 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
tooltip: 'New chat'
},
{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
{
activeRoutePrefix: '/model-manager',
icon: Package,
route: ROUTES.MODEL_MANAGER,
tooltip: 'Models'
},
{
action: SidebarAction.SETTINGS,
icon: Settings,
+4
View File
@@ -13,6 +13,8 @@ export enum ServerRole {
* Used as the `value` field in the status object from /models endpoint
*/
export enum ServerModelStatus {
DOWNLOADED = 'downloaded',
DOWNLOADING = 'downloading',
FAILED = 'failed',
LOADED = 'loaded',
LOADING = 'loading',
@@ -26,6 +28,8 @@ export enum ServerModelStatus {
* tools/server/server-models.cpp from the C++ server.
*/
export enum ServerModelsSseEventType {
DOWNLOAD_FAILED = 'download_failed',
DOWNLOAD_FINISHED = 'download_finished',
DOWNLOAD_PROGRESS = 'download_progress',
MODEL_REMOVE = 'model_remove',
MODEL_STATUS = 'model_status',
@@ -0,0 +1,651 @@
import { type DraftVariant,MODEL_ID } from '$lib/constants';
import type {
HfModelDetailInfo,
HfModelInfo,
HfModelSearchParams,
HfModelSibling,
HfModelSort} from '$lib/types/huggingface';
/** Variant flag in a GGUF filename (e.g. draft-mtp, diffusion-flash, multimodal projector). */
export type GgufVariant = DraftVariant;
/**
* Where the `mtp` / `dflash` / `mmproj` token sits in the filename.
* - `prefix` sidecar file that lives next to the main weights, e.g. `mtp-Q4_0.gguf`
* - `suffix` embedded draft baked into the main weights, e.g. `Hy3-IQ1_M-mtp.gguf`
*/
export type GgufVariantForm = 'prefix' | 'suffix';
// Constants
export const HF_TASKS: Record<string, string> = {
'audio-classification': 'Audio Classification',
'audio-to-audio': 'Audio-to-Audio',
'automatic-speech-recognition': 'Speech Recognition',
conversational: 'Conversational',
'depth-estimation': 'Depth Estimation',
'feature-extraction': 'Feature Extraction',
'fill-mask': 'Fill Mask',
'image-classification': 'Image Classification',
'image-feature-extraction': 'Image Feature Extraction',
'image-segmentation': 'Image Segmentation',
'image-text-to-text': 'Image-Text-to-Text',
'image-to-text': 'Image-to-Text',
'image-to-video': 'Image-to-Video',
'object-detection': 'Object Detection',
'question-answering': 'Question Answering',
'reinforcement-learning': 'Reinforcement Learning',
robotics: 'Robotics',
'sentence-similarity': 'Sentence Similarity',
summarization: 'Summarization',
'text2text-generation': 'Text2Text Generation',
'text-classification': 'Text Classification',
'text-generation': 'Text Generation',
'text-to-image': 'Text-to-Image',
'text-to-speech': 'Text to Speech',
'text-to-video': 'Text-to-Video',
'token-classification': 'Token Classification',
translation: 'Translation',
'video-to-video': 'Video-to-Video',
'voice-activity-detection': 'Voice Activity Detection',
'zero-shot-classification': 'Zero-Shot Classification'
};
/**
* Best-effort readable label for an HF pipeline tag. Falls back to a
* title-cased version of the kebab-case `pipeline_tag` (e.g. `image-text-to-text`
* becomes `Image-Text-to-Text`) when we don't have an explicit entry above.
*/
function pipelineTagLabel(tag: string): string {
if (HF_TASKS[tag]) return HF_TASKS[tag];
return tag
.split('-')
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
.join('-');
}
/**
* Lucide icon name (string identifier, used to lazy-import the Svelte component)
* matching the HF pipeline_tag. Used for the filter chips on the model browser.
* Returns `null` for unknown tags so the consumer can render a generic icon.
*/
const HF_PIPELINE_ICONS: Record<string, string> = {
'audio-classification': 'mic',
'audio-to-audio': 'audio-lines',
'automatic-speech-recognition': 'mic',
conversational: 'message-circle',
'depth-estimation': 'layers',
'feature-extraction': 'hash',
'fill-mask': 'replace',
'image-classification': 'image',
'image-feature-extraction': 'image',
'image-segmentation': 'image',
'image-text-to-text': 'image-plus',
'image-to-text': 'image',
'image-to-video': 'video',
'object-detection': 'scan',
'question-answering': 'help-circle',
'sentence-similarity': 'equal',
summarization: 'list-collapse',
'text2text-generation': 'message-square-more',
'text-generation': 'message-square',
'text-to-image': 'image',
'text-to-speech': 'volume-2',
'text-to-video': 'video',
translation: 'languages',
'video-to-video': 'video',
'voice-activity-detection': 'mic'
};
function pipelineTagIcon(tag: string): string | null {
return HF_PIPELINE_ICONS[tag] ?? null;
}
export const HF_LIBRARIES: Record<string, string> = {
gguf: 'GGUF',
mlx: 'MLX',
onnx: 'ONNX',
safetensors: 'Safetensors',
transformers: 'Transformers',
vllm: 'vLLM'
};
/**
* HuggingFaceService - Service for browsing and searching GGUF models on Hugging Face Hub
*/
export class HuggingFaceService {
// Configuration
/** Available library names with display labels */
static readonly LIBRARIES: Record<string, string> = HF_LIBRARIES;
/** Sort option display labels */
static readonly SORT_LABELS: Record<HfModelSort, string> = {
createdAt: 'Newest',
downloads: 'Most Downloads',
lastModified: 'Recently Updated',
likes: 'Most Likes',
trendingScore: 'Trending'
};
/** Available sort options */
static readonly SORT_OPTIONS: HfModelSort[] = [
'downloads',
'likes',
'trendingScore',
'createdAt'
];
// Available options for filtering
/** Available pipeline tasks with display labels */
static readonly TASKS: Record<string, string> = HF_TASKS;
private static readonly BASE_URL = 'https://huggingface.co/api/models';
private static readonly DEFAULT_LIMIT = 50;
private static readonly MAX_LIMIT = 100;
/**
* Map of quant token to its average bit-depth in bits-per-weight (bpw).
*/
private static readonly QUANT_BIT_DEPTH: Record<string, number> = {
BF16: 16,
F16: 16,
IQ1_M: 1,
IQ1_S: 1,
IQ1_XS: 1,
IQ1_XXS: 1,
IQ2_M: 2,
IQ2_S: 2,
IQ2_XS: 2,
IQ2_XXS: 2,
IQ3_M: 3,
IQ3_S: 3,
IQ3_XS: 3,
IQ3_XXS: 3,
Q2_K: 2,
Q2_K_M: 2,
Q2_K_S: 2,
Q3_K: 3,
Q3_K_L: 3,
Q3_K_M: 3,
Q3_K_S: 3,
Q4_0: 4,
Q4_1: 4,
Q4_K: 4,
Q4_K_M: 4,
Q4_K_S: 4,
Q5_0: 5,
Q5_1: 5,
Q5_K: 5,
Q5_K_M: 5,
Q5_K_S: 5,
Q6_K: 6,
Q8_0: 8
};
/**
* Extract the GGUF quantization token (e.g. `Q4_K_M`) and any draft/aux variant
* (`mtp`, `dflash`, `mmproj`) from a `.gguf` filename. The variant shows up
* either as a sidecar prefix (`mtp-<name>.gguf`, `dflash-<name>.gguf`,
* `mmproj-<name>.gguf`) or as the `-mtp` suffix when the draft model is
* embedded in the same GGUF weight file.
*
* `variantForm` records which side of the filename the variant token sat
* on so callers can render badges differently (e.g. prefix on the left of
* the quant label, suffix appended to it).
* `quant` is `null` for files that don't carry a bit-depth token
* (e.g. `*-BF16.gguf`); `variant` is `null` if no draft/aux flag is present.
* Returns `null` only when the filename doesn't end in `.gguf`.
*/
static extractQuantMeta(filename: string): {
quant: string | null;
variant: GgufVariant | null;
variantForm: GgufVariantForm | null;
} | null {
if (!MODEL_ID.WEIGHT_EXTENSION_RE.test(filename)) return null;
let source = filename.replace(MODEL_ID.WEIGHT_EXTENSION_RE, '');
let variant: GgufVariant | null = null;
let variantForm: GgufVariantForm | null = null;
const prefixMatch = source.match(MODEL_ID.DRAFT_VARIANT_PREFIX_RE);
if (prefixMatch) {
variant = prefixMatch[1].toLowerCase() as GgufVariant;
variantForm = 'prefix';
source = prefixMatch[2];
} else {
const suffixMatch = source.match(MODEL_ID.DRAFT_VARIANT_SUFFIX_RE);
if (suffixMatch) {
const candidate = suffixMatch[1];
const headSeg = candidate.split(MODEL_ID.SEGMENT_SEPARATOR).pop();
if (headSeg && MODEL_ID.QUANTIZATION_SEGMENT_RE.test(headSeg)) {
variant = suffixMatch[2].toLowerCase() as GgufVariant;
variantForm = 'suffix';
source = candidate;
}
}
}
// Scan dash-separated segments left-to-right for the first quant match.
// - For sidecars like `mtp-Q4_0-180MB.gguf` the quant is `Q4_0`.
// - For embedded MTP like `Hy3-IQ1_M-mtp.gguf` we have `Hy3-IQ1_M` and `IQ1_M` matches.
// - For main files like `Llama-3-8B-Q4_K_M.gguf` we land on the trailing quant.
const segments = source.split(MODEL_ID.SEGMENT_SEPARATOR);
const quantSeg = segments.find((seg) => MODEL_ID.QUANTIZATION_SEGMENT_RE.test(seg));
const quant = quantSeg ? quantSeg.toUpperCase() : null;
return { quant, variant, variantForm };
}
// GGUF Model Searching
/**
* Filter raw siblings by file extension and sort by size descending.
*/
static filterByExtension(siblings: HfModelSibling[], ext: string): HfModelSibling[] {
return siblings
.filter((f) => f.path.toLowerCase().endsWith(ext.toLowerCase()) && (f.size ?? 0) > 0)
.sort((a, b) => (b.size ?? 0) - (a.size ?? 0));
}
/**
* Format model downloads count with K/M/B suffix
*/
static formatDownloads(downloads: number): string {
if (downloads >= 1_000_000) {
return `${(downloads / 1_000_000).toFixed(1)}M`;
}
if (downloads >= 1_000) {
return `${(downloads / 1_000).toFixed(1)}K`;
}
return downloads.toString();
}
// GGUF Model Browsing
/**
* Format file size in bytes to human-readable string
*/
static formatFileSize(bytes: number): string {
if (bytes >= 1_000_000_000) {
return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
}
if (bytes >= 1_000_000) {
return `${(bytes / 1_000_000).toFixed(1)} MB`;
}
if (bytes >= 1_000) {
return `${(bytes / 1_000).toFixed(1)} KB`;
}
return `${bytes} B`;
}
/**
* Format likes count with K suffix if applicable
*/
static formatLikes(likes: number): string {
if (likes >= 1_000) {
return `${(likes / 1_000).toFixed(1)}K`;
}
return likes.toString();
}
/**
* Format timestamp to relative time
*/
static formatRelativeTime(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
return `${Math.floor(diffDays / 365)} years ago`;
}
/**
* Look up the average bit-depth for a known GGUF quantization.
* Returns `null` for unrecognized tokens.
*/
static getBitDepth(quant: string): number | null {
return HuggingFaceService.QUANT_BIT_DEPTH[quant] ?? null;
}
/**
* Get GGUF models by pipeline task
*/
static async getByTask(
pipelineTag: string,
params: Omit<HfModelSearchParams, 'pipeline_tag'> = {}
): Promise<HfModelInfo[]> {
return this.search({
...params,
pipeline_tag: pipelineTag
});
}
// Model Details & Files
/**
* Get detailed information about a specific GGUF model
*/
static async getDetails(modelId: string): Promise<HfModelDetailInfo | null> {
// Do not encode the modelId, it contains slashes for author/name.
// `full=true` includes cardData (description, base_model) and safetensors.
const url = `https://huggingface.co/api/models/${modelId}?full=true`;
try {
const response = await fetch(url);
if (response.status === 404) return null;
if (!response.ok) throw new Error(`Failed to fetch model details: ${response.status}`);
const data = (await response.json()) as HfModelDetailInfo;
return data;
} catch (error) {
console.error(`Error fetching details for ${modelId}:`, error);
return null;
}
}
/**
* Get most liked GGUF models
*/
static async getMostLiked(
limit: number = HuggingFaceService.DEFAULT_LIMIT
): Promise<HfModelInfo[]> {
return this.search({ limit, sort: 'likes' });
}
/**
* Get newly released GGUF models
*/
static async getNew(limit: number = HuggingFaceService.DEFAULT_LIMIT): Promise<HfModelInfo[]> {
return this.search({ limit, sort: 'createdAt' });
}
/**
* Get most popular GGUF models by downloads
*/
static async getPopular(
limit: number = HuggingFaceService.DEFAULT_LIMIT
): Promise<HfModelInfo[]> {
return this.search({ limit, sort: 'downloads' });
}
/**
* Get repository file tree to list available GGUF variants
*/
static async getTree(modelId: string): Promise<HfModelSibling[]> {
const url = `https://huggingface.co/api/models/${modelId}/tree/main`;
try {
const response = await fetch(url);
if (!response.ok) return [];
const data = (await response.json()) as HfModelSibling[];
return data.filter((f) => f.type !== 'directory');
} catch {
return [];
}
}
/**
* Get trending GGUF models
*/
static async getTrending(
limit: number = HuggingFaceService.DEFAULT_LIMIT
): Promise<HfModelInfo[]> {
return this.search({ limit, sort: 'trendingScore' });
}
/**
* Fetch the raw README.md for a repo, with the YAML frontmatter stripped.
*/
static async getReadme(modelId: string): Promise<string | null> {
// Do not encode the modelId, it contains slashes for author/name
const url = `https://huggingface.co/${modelId}/raw/main/README.md`;
try {
const response = await fetch(url);
if (response.status === 404) return null;
if (!response.ok) throw new Error(`Failed to fetch README: ${response.status}`);
return HuggingFaceService.stripFrontmatter(await response.text());
} catch (error) {
console.error(`Error fetching README for ${modelId}:`, error);
return null;
}
}
// Utility Methods
/**
* Get model URL on Hugging Face Hub
*/
static getModelUrl(modelId: string): string {
return `https://huggingface.co/${modelId}`;
}
/**
* Avatar URL for an author (org or user). 404s when the author does not
* exist, so callers should provide a fallback.
*/
static getAvatarUrl(author: string): string {
return `https://huggingface.co/api/avatars/${author}`;
}
/**
* Parse a local HF cache file path
* (`.../models--<org>--<name>/snapshots/<sha>/<file>`) into its repo id and
* repo-relative file path. Returns null when the path is not an HF cache path.
*/
static parseCachePath(path: string): { repo: string; file: string } | null {
const match = /models--(.+?)\/snapshots\/[^/]+\/(.+)$/.exec(path);
if (!match) return null;
const parts = match[1].split('--');
if (parts.length < 2) return null;
return { repo: `${parts[0]}/${parts.slice(1).join('--')}`, file: match[2] };
}
/**
* Extract the original (non-GGUF) base model ids for a repo, from
* `cardData.base_model` (string or list) and the `base_model:` tags.
*/
static getBaseModels(model: HfModelDetailInfo | null): string[] {
if (!model) return [];
const cardBase = model.cardData?.base_model;
const fromCard: string[] = Array.isArray(cardBase) ? cardBase : cardBase ? [cardBase] : [];
const fromTags = (model.tags ?? [])
.map((t) => /^base_model:(?:quantized:)?(.+)$/.exec(t)?.[1])
.filter((v): v is string => Boolean(v));
return Array.from(new Set([...fromCard, ...fromTags]));
}
/** Strip a leading YAML frontmatter block (--- ... ---) from a markdown document. */
private static stripFrontmatter(text: string): string {
const match = text.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
return match ? text.slice(match[0].length) : text;
}
/**
* Parse model tags to extract useful information
*/
static parseTags(tags: string[]): {
license: string | null;
isGated: boolean;
isGguf: boolean;
isSafetensors: boolean;
tasks: string[];
} {
const license = tags.find((tag) => tag.startsWith('license:'))?.replace('license:', '') || null;
const isGated = tags.includes('gated');
const isGguf = tags.includes('gguf');
const isSafetensors = tags.includes('safetensors');
const tasks = tags.filter((tag) => Object.keys(HuggingFaceService.TASKS).includes(tag));
return { isGated, isGguf, isSafetensors, license, tasks };
}
/**
* Best-effort parameter count parsed from a model id/name, e.g. `27B` from
* `Qwen3.8-27B-GGUF` or `300M` from `embeddinggemma-300M-GGUF`. Returns null
* when no size token is present.
*/
static parseParamCount(name: string): string | null {
const match = /(?:^|[^a-z0-9])(\d+(?:[._]\d+)?)\s*([bm])(?![a-z0-9])/i.exec(name);
if (!match) return null;
return `${match[1]}${match[2].toUpperCase()}`;
}
/** Resolve a pipeline_tag to a lucide icon name, or null when unknown. */
static pipelineTagIcon(tag: string | null | undefined): string | null {
if (!tag) return null;
return pipelineTagIcon(tag);
}
/** Resolve a pipeline_tag to a human-readable label. */
static pipelineTagLabel(tag: string | null | undefined): string | null {
if (!tag) return null;
return pipelineTagLabel(tag);
}
/**
* Search GGUF models with various filters and options
*/
static async search(params: HfModelSearchParams = {}): Promise<HfModelInfo[]> {
const { limit = HuggingFaceService.DEFAULT_LIMIT, ...restParams } = params;
const url = this.buildUrl({
...restParams,
filter: 'gguf',
limit: Math.min(limit, HuggingFaceService.MAX_LIMIT)
});
return this.fetchWithRetry(url);
}
/**
* Search models by query string
*/
static async searchByQuery(
query: string,
params: Omit<HfModelSearchParams, 'search'> = {}
): Promise<HfModelInfo[]> {
return this.search({
...params,
search: query
});
}
// Internal Methods
/**
* Build API URL from search parameters
*/
private static buildUrl(params: HfModelSearchParams): string {
const url = new URL(this.BASE_URL);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
if (Array.isArray(value)) {
value.forEach((v) => url.searchParams.append(key, v));
} else {
url.searchParams.set(key, String(value));
}
}
});
return url.toString();
}
/**
* Delay helper for retry logic
*/
private static delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Fetch data with retry logic for resilience
*/
private static async fetchWithRetry(url: string, attempt: number = 1): Promise<HfModelInfo[]> {
const RETRY_ATTEMPTS = 3;
const RETRY_DELAY_MS = 1000;
try {
const response = await fetch(url);
if (!response.ok) {
if (response.status === 404) {
return [];
}
if (response.status >= 500 && attempt < RETRY_ATTEMPTS) {
await this.delay(RETRY_DELAY_MS * attempt);
return this.fetchWithRetry(url, attempt + 1);
}
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (Array.isArray(data)) {
return data as HfModelInfo[];
}
if (data && Array.isArray(data.data)) {
return data.data as HfModelInfo[];
}
throw new Error('Unexpected API response format');
} catch (error) {
if (attempt < RETRY_ATTEMPTS) {
await this.delay(RETRY_DELAY_MS * attempt);
return this.fetchWithRetry(url, attempt + 1);
}
throw error;
}
}
}
+26
View File
@@ -146,6 +146,7 @@ export { ConversationTransferService } from './conversation-transfer.service';
* @see modelsStore in stores/models/index.svelte.ts — primary consumer for reactive model state
*/
export { ModelsService } from './models.service';
export type { GgufVariantTagInput } from './models.service';
/**
* **PropsService** - Server properties and capabilities retrieval
@@ -349,3 +350,28 @@ export { MigrationService } from './migration.service';
* @see settingsStore in stores/settings/index.svelte.ts - reactive state + business logic
*/
export { SettingsService } from './settings.service';
/**
* **HuggingFaceService** - HuggingFace Hub model browsing and searching
*
* Stateless HTTP access to the HuggingFace Hub API for discovering, browsing,
* and inspecting GGUF models. Supports search, trending/popular/liked/new
* browsing, filtering by pipeline task, and fetching model details + file trees.
*
* **Architecture & Relationships:**
* - **HuggingFaceService** (this class): Stateless HTTP communication with the HF API
* - **model-hub routes**: Primary consumers for the model browsing/detail UI
*
* **Key Responsibilities:**
* - Search and browse GGUF models with filters (query, task, author, sort)
* - Fetch model details and repository file listings
* - Extract quantization/variant metadata from GGUF filenames
* - Format downloads, likes, file sizes, and timestamps for display
*
* **API Endpoints:**
* - `GET https://huggingface.co/api/models` - Model search/browsing
* - `GET https://huggingface.co/api/models/{modelId}` - Model details
* - `GET https://huggingface.co/api/models/{modelId}/tree/main` - Repository file tree
*/
export { HuggingFaceService } from './huggingface.service';
export type { GgufVariantForm } from './huggingface.service';
+104 -3
View File
@@ -7,10 +7,11 @@
*/
import { base } from '$app/paths';
import { API_MODELS, MODEL_ID } from '$lib/constants';
import { API_MODELS, type DraftVariant, MODEL_ID } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import type { ParsedModelId } from '$lib/types/models';
import {
apiDelete,
apiFetch,
apiPost,
extractSseDataPayload,
@@ -19,9 +20,81 @@ import {
} from '$lib/utils';
import { getAuthHeaders } from '$lib/utils/api-headers';
/** Building block for the `<repo>:<tag>` string consumed by POST /models. */
export interface GgufVariantTagInput {
quant: string;
variant: DraftVariant | null;
}
export class ModelsService {
private static readonly SSE_RECONNECT_MS = 1000;
/**
* Build the `<repo>:<tag>` string expected by POST /models from a parsed
* filename quant + optional draft variant. Used by the model-hub download
* dialog so callers don't have to know about the suffix convention.
*
* @param repoId - HuggingFace repo id (e.g. `ggml-org/gemma-3-4b-it-GGUF`)
* @param quant - Quantization token, may include a draft variant
* @returns Repo id possibly suffixed with `:tag`
*/
static buildDownloadTag(repoId: string, quant: GgufVariantTagInput | null): string {
if (!quant) return repoId;
const tag = quant.variant ? `${quant.quant}-${quant.variant.toUpperCase()}` : quant.quant;
return `${repoId}:${tag}`;
}
/**
*
*
* Download
*
*
*/
/**
* Cancel an in-flight download or remove a previously downloaded/failed
* entry from the server's model cache (ROUTER mode only).
*
* Sends DELETE `/models?model=<hfRepoWithTag>`:
* - while a download is running, the child subprocess is asked to exit
* and any partial `.tmp` files are removed;
* - once the entry has finished downloading or has failed, the cached
* files are removed from disk.
*
* @param hfRepoWithTag - HuggingFace repo id in the same `<repo>:<tag>`
* format returned by `buildDownloadTag`.
* @returns Server acknowledgement containing the success flag
*/
static async cancelDownload(hfRepoWithTag: string): Promise<ApiRouterModelsDownloadResponse> {
return apiDelete<ApiRouterModelsDownloadResponse>(API_MODELS.DELETE, {
model: hfRepoWithTag
});
}
/**
* Trigger a model download from HuggingFace (ROUTER mode only).
*
* Sends a POST request to `/models` as introduced in
* ggml-org/llama.cpp#23976. The response returns immediately; the actual
* download runs in the background and tracks progress through `/models/sse`.
* The server picks the file that matches the supplied tag (when present)
* and additionally pulls mmproj / MTP sidecar weights as appropriate for
* the model.
*
* @param hfRepoWithTag - HuggingFace repo id, optionally suffixed with
* `:<tag>` (e.g. `ggml-org/gemma-3-4b-it-GGUF:Q4_K_M`
* or `:IQ1_M-MTP` for an embedded-draft GGUF).
* @returns Server acknowledgement containing the success flag
*/
static async downloadModel(hfRepoWithTag: string): Promise<ApiRouterModelsDownloadResponse> {
const payload: ApiRouterModelsDownloadRequest = { model: hfRepoWithTag };
return apiPost<ApiRouterModelsDownloadResponse>(API_MODELS.DOWNLOAD, payload);
}
/**
* Check if a model is loaded based on its metadata.
*
@@ -108,11 +181,39 @@ export class ModelsService {
params: null,
quantization: null,
raw: modelId,
tags: []
tags: [],
variant: null
};
// strip directory path and weight extension so a bare `-m /path/file.gguf`
// parses like a clean repo id; the HF `org/model` form is preserved
const source = normalizeModelName(modelId).replace(MODEL_ID.WEIGHT_EXTENSION_RE, '');
let source = normalizeModelName(modelId).replace(MODEL_ID.WEIGHT_EXTENSION_RE, '');
// 0. Detect sidecar variant prefix (mtp-, dflash-, mmproj-) before any other
// splitting so the inner id parses cleanly.
const prefixVariantMatch = source.match(MODEL_ID.DRAFT_VARIANT_PREFIX_RE);
if (prefixVariantMatch) {
result.variant = prefixVariantMatch[1].toLowerCase() as DraftVariant;
source = prefixVariantMatch[2];
} else {
// 0b. Detect `-<variant>` suffix (`-mtp`, `-dflash`, `-dspark`, `-eagle3`).
// Only strip it when the segment preceding it looks like a real quant
// token, so a model literally named `MyModel-mtp` is not mistaken for a
// draft one.
const suffixMatch = source.match(MODEL_ID.DRAFT_VARIANT_SUFFIX_RE);
if (suffixMatch) {
const candidate = suffixMatch[1];
const headSeg = candidate.split(MODEL_ID.SEGMENT_SEPARATOR).pop();
if (headSeg && MODEL_ID.QUANTIZATION_SEGMENT_RE.test(headSeg)) {
result.variant = suffixMatch[2].toLowerCase() as DraftVariant;
source = candidate;
}
}
}
// 1. Extract colon-separated quantization (e.g. `model:Q4_K_M`)
const colonIdx = source.indexOf(MODEL_ID.QUANTIZATION_SEPARATOR);
@@ -10,4 +10,24 @@ export class RouterService {
static chat(id: string): string {
return `${ROUTES.CHAT}/${id}`;
}
/**
* Build a Model Hub URL from a model id that may carry a `:tag` suffix
* (the form used by the server in /v1/models - e.g.
* `ggml-org/gemma-3-4b-it-GGUF:Q4_K_M`). The Model Hub detail page lists
* every available quantization for the repo, so we always drop the tag and
* land on the base repo page.
*
* @param modelId - Model id, optionally suffixed with `:tag`
* @returns Model Hub URL pointing at the repo (tag stripped)
*/
static fromModelId(modelId: string): string {
const stripped = modelId.split(':')[0] ?? modelId;
return RouterService.model(stripped);
}
static model(modelId: string): string {
return ROUTES.MANAGE_MODEL.replace('[modelId]', modelId);
}
}
+3
View File
@@ -40,6 +40,9 @@ export { mcpStore } from './mcp/index.svelte';
// MODELS
export { modelsStore } from './models/index.svelte';
// MODELS HUB (HuggingFace browse)
export { modelsHubStore } from './models-hub/index.svelte';
// SERVER
export { serverStore } from './server.svelte';
@@ -0,0 +1,92 @@
/**
* modelsHubStore - Model Hub browse state
*
* Owns the HuggingFace GGUF model list shown in the hub sidebar
* (DialogModelsHub). The hub has no "nothing selected" screen: it always opens
* a model, so `firstModel` drives the initial selection. By default the list
* shows the official ggml-org GGUF models sorted by popularity (downloads);
* search replaces the list with matching models across all of HuggingFace.
* Detail data is loaded by ModelsHubModelDetails, not here.
*/
import { HuggingFaceService } from '$lib/services';
import type { HfModelInfo } from '$lib/types/huggingface';
class ModelsHubStore {
/** Org whose models the sidebar lists by default. */
private static readonly DEFAULT_AUTHOR = 'ggml-org';
models = $state<HfModelInfo[]>([]);
loading = $state(false);
error = $state<string | null>(null);
/** First model in the list - the hub auto-opens this one. */
firstModel = $derived(this.models[0] ?? null);
private fetched = false;
private defaultModels: HfModelInfo[] = [];
private searchRequestId = 0;
/**
* Fetch the default list: official ggml-org GGUF models sorted by popularity
* (downloads). No-op when already loaded or in flight.
*/
async fetch(): Promise<void> {
if (this.loading || this.fetched) return;
this.loading = true;
this.error = null;
try {
const results = await HuggingFaceService.search({
author: ModelsHubStore.DEFAULT_AUTHOR,
sort: 'downloads',
limit: 50,
full: true
});
// `models-moved` is a redirect placeholder, not a browsable model
this.defaultModels = results.filter((m) => m.id !== 'ggml-org/models-moved');
this.models = this.defaultModels;
this.fetched = true;
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to fetch models';
} finally {
this.loading = false;
}
}
/**
* 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.
*/
async search(query: string): Promise<void> {
const trimmed = query.trim();
this.searchRequestId++;
if (!trimmed) {
this.models = this.defaultModels;
this.error = null;
return;
}
const requestId = this.searchRequestId;
try {
const results = await HuggingFaceService.searchByQuery(trimmed, { limit: 50, full: true });
if (requestId === this.searchRequestId) {
this.models = results;
this.error = null;
}
} catch (err) {
if (requestId === this.searchRequestId) {
this.error = err instanceof Error ? err.message : 'Search failed';
}
}
}
}
export const modelsHubStore = new ModelsHubStore();
+201 -2
View File
@@ -8,11 +8,14 @@
*/
import { ServerModelsSseEventType, ServerModelStatus } from '$lib/enums';
import { HuggingFaceService } from '$lib/services/huggingface.service';
import { ModelsService } from '$lib/services/models.service';
import type { ModelPropsManager } from '$lib/stores/models/props.svelte';
// direct imports between stores, not via the barrel, to avoid circular deps
import { serverStore } from '$lib/stores/server.svelte';
import { SvelteMap } from 'svelte/reactivity';
// explicit type imports: the app.d.ts globals resolve to `any`, so import the real types
import type { ApiModelsDownloadProgressData, ModelDownloadProgress } from '$lib/types';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
/**
@@ -31,8 +34,35 @@ export interface ModelStatusHost {
}
export class ModelStatusManager {
private downloadProgress = new SvelteMap<string, ModelDownloadProgress>();
/** `<repo>:<tag>` strings whose most recent download attempt failed (download_failed). */
private failedDownloads = new SvelteSet<string>();
private loadingStates = new SvelteMap<string, boolean>();
private loadProgress = new SvelteMap<string, ModelLoadProgress>();
/**
* Draft sidecar files pulled by registered models, as `<repo>/<file>` keys.
* Drafts are not separate /v1/models entries - the router pulls them as
* sidecars of a main model and records them in its `--model-draft` arg.
*/
private downloadedDrafts = $derived.by(() => {
const result = new SvelteSet<string>();
for (const m of this.host.routerModels) {
const args = m.status?.args;
if (!args) continue;
for (let i = 0; i < args.length - 1; i++) {
if (args[i] !== '--model-draft' && args[i] !== '-md') continue;
const parsed = HuggingFaceService.parseCachePath(args[i + 1]);
if (parsed) result.add(`${parsed.repo}/${parsed.file}`);
}
}
return result;
});
// /models/sse feed state, the single source of truth for status and load progress
private statusAbort: AbortController | null = null;
private statusReaderActive = false;
@@ -41,14 +71,85 @@ export class ModelStatusManager {
{ target: ServerModelStatus; resolve: () => void; reject: (e: Error) => void }
>();
/**
* Cancel an in-flight download or remove a previously downloaded/failed model
* from the server cache (ROUTER mode only). The cached row is dropped via the
* feed's model_remove event.
*/
async cancelDownload(repoWithTag: string): Promise<boolean> {
if (!serverStore.isRouterMode) {
toast.error('Model downloads are only available in router mode');
return false;
}
this.subscribe();
try {
const res = await ModelsService.cancelDownload(repoWithTag);
const ok = res.success === true;
if (ok) {
this.downloadProgress.delete(repoWithTag);
this.failedDownloads.delete(repoWithTag);
}
return ok;
} catch (error) {
toast.error(`Failed to cancel: ${error instanceof Error ? error.message : 'unknown error'}`);
return false;
}
}
constructor(private host: ModelStatusHost) {}
/**
* 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.
*/
async downloadModel(repoWithTag: string, displayName?: string): Promise<void> {
if (!serverStore.isRouterMode) {
toast.error('Model downloads are only available in router mode');
return;
}
// 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 {
throw new Error(res.error?.message ?? 'Server rejected the download request');
}
} catch (error) {
toast.error(`Download failed: ${label}`);
throw error;
}
}
async ensureLoaded(modelId: string): Promise<void> {
if (this.host.isModelLoaded(modelId)) return;
await this.load(modelId);
}
/**
* Current download progress (bytes) for a `<repo>:<tag>` identifier, or null
* when no download is being reported by the /models/sse feed.
*/
getDownloadProgress(repoWithTag: string): ModelDownloadProgress | null {
return this.downloadProgress.get(repoWithTag) ?? null;
}
/**
* Current load progress for a model, or null when not loading.
*/
@@ -56,6 +157,35 @@ export class ModelStatusManager {
return this.loadProgress.get(modelId) ?? null;
}
/** Whether the most recent download attempt for the given entry failed. */
hasFailedDownload(repoWithTag: string): boolean {
return this.failedDownloads.has(repoWithTag);
}
/**
* True when the feed reports an active download for the given `<repo>:<tag>`.
* Cleared on download_finished / download_failed.
*/
isDownloadInProgress(repoWithTag: string): boolean {
return this.downloadProgress.has(repoWithTag);
}
/**
* True when the given `<repo>:<tag>` is already a fully downloaded model
* registered with the server (i.e. it shows up in the /v1/models list).
*/
isModelDownloaded(repoWithTag: string): boolean {
return this.host.routerModels.some((m) => m.id === repoWithTag);
}
/**
* True when the given draft sidecar file (repo-relative path) has been pulled
* as the `--model-draft` of some registered model.
*/
isDraftDownloaded(repoId: string, filePath: string): boolean {
return this.downloadedDrafts.has(`${repoId}/${filePath}`);
}
isOperationInProgress(modelId: string): boolean {
return this.loadingStates.get(modelId) ?? false;
}
@@ -133,6 +263,26 @@ export class ModelStatusManager {
}
}
/**
* Cancel an in-flight load (ROUTER mode only). The server force-kills a
* LOADING model on unload; the feed reports the settled status, so no
* waiter is registered here.
*/
async cancelLoad(modelId: string): Promise<void> {
if (!serverStore.isRouterMode) return;
this.subscribe();
try {
await ModelsService.unload(modelId);
toast.info(`Load cancelled: ${this.host.toDisplayName(modelId)}`);
} catch (error) {
toast.error(`Failed to cancel load: ${this.host.toDisplayName(modelId)}`);
throw error;
}
}
/**
* Close the /models/sse feed and drop transient progress.
*/
@@ -141,6 +291,48 @@ export class ModelStatusManager {
this.statusAbort?.abort();
this.statusAbort = null;
this.loadProgress.clear();
this.downloadProgress.clear();
this.failedDownloads.clear();
}
/**
* Drop the stored progress for the model and toast the outcome.
* Marks failed entries so the UI can offer a delete-and-retry path.
*/
private applyDownloadFinished(event: ApiModelsSseEvent): void {
this.downloadProgress.delete(event.model);
const ok = event.event === ServerModelsSseEventType.DOWNLOAD_FINISHED;
if (ok) {
this.failedDownloads.delete(event.model);
toast.success(`Download finished: ${this.host.toDisplayName(event.model)}`);
} else {
this.failedDownloads.add(event.model);
toast.error(`Download failed: ${this.host.toDisplayName(event.model)}`);
}
}
/**
* Bucket the per-file byte counts from a `download_progress` envelope.
* Total = sum of `total` across files (plan size), downloaded sum of `done`.
*/
private applyDownloadProgress(event: ApiModelsSseEvent): void {
const data = event.data;
if (!data || !('progress' in data)) return;
const progress = (data as ApiModelsDownloadProgressData).progress;
let downloaded = 0;
let total = 0;
for (const file of Object.values(progress)) {
downloaded += file?.done ?? 0;
total += file?.total ?? 0;
}
this.downloadProgress.set(event.model, { downloadedBytes: downloaded, totalBytes: total });
}
/**
@@ -151,7 +343,7 @@ export class ModelStatusManager {
const model = event.model;
const data = event.data;
if (!model || !data?.status) return;
if (!model || !data || !('status' in data) || !data.status) return;
const status = data.status;
@@ -202,6 +394,13 @@ export class ModelStatusManager {
break;
case ServerModelsSseEventType.DOWNLOAD_PROGRESS:
this.applyDownloadProgress(event);
break;
case ServerModelsSseEventType.DOWNLOAD_FINISHED:
case ServerModelsSseEventType.DOWNLOAD_FAILED:
this.applyDownloadFinished(event);
break;
}
}
+35 -1
View File
@@ -71,6 +71,10 @@ export interface ApiModelStatus {
value: ServerModelStatus;
/** Command line arguments used when loading (only for loaded models) */
args?: string[];
/** Set when the model failed to load (unloaded with a non-zero exit code) */
failed?: boolean;
/** Process exit code, present when the model failed */
exit_code?: number;
}
/**
@@ -100,6 +104,10 @@ export interface ApiModelDataEntry {
tags?: string[];
/** Modality capabilities, reported by the router for every model regardless of load state */
architecture?: ApiModelArchitecture;
/** Whether the model can be removed from the server cache (DELETE /models) */
can_remove?: boolean;
/** Model source: preset, models_dir, cache, or unknown */
source?: string;
/** Legacy meta field (may be present in older responses) */
meta?: Record<string, unknown> | null;
}
@@ -138,6 +146,14 @@ export interface ApiModelsSseData {
exit_code?: number;
}
/**
* Per-file size snapshot reported by the download_progress SSE envelope.
* Keys are file URLs, values are byte counters (done <= total).
*/
export interface ApiModelsDownloadProgressData {
progress: Record<string, { done: number; total: number }>;
}
/**
* Event kind multiplexed on the /models/sse feed.
* Only the status_* events carry a status payload, models_reload signals a
@@ -150,7 +166,7 @@ export interface ApiModelsSseData {
export interface ApiModelsSseEvent {
model: string;
event: ServerModelsSseEventType;
data: ApiModelsSseData;
data?: ApiModelsSseData | ApiModelsDownloadProgressData;
}
export interface ApiModelDetails {
@@ -525,6 +541,24 @@ export interface ApiRouterModelsUnloadResponse {
error?: string;
}
/**
* Request body for POST /models (PR #23976).
* `model` is a HuggingFace repo id, optionally suffixed with `:<tag>` to
* pin a quantization or variant file (e.g. `ggml-org/gemma-3-4b-it-GGUF:Q4_K_M`).
*/
export interface ApiRouterModelsDownloadRequest {
model: string;
}
/**
* Response from POST /models. The endpoint returns immediately; the
* download itself runs in the background and emits events on /models/sse.
*/
export interface ApiRouterModelsDownloadResponse {
success: boolean;
error?: { code: number; message: string; type: string };
}
/**
* Entry returned by POST /v1/streams/lookup. The client passes the conv ids it owns in the body
* and the server returns one entry per matching live or recently completed background streaming
+189
View File
@@ -0,0 +1,189 @@
/**
* HuggingFace Hub Model Browsing Types
*
* Types for the HuggingFace REST API (/api/models)
* Reference: https://huggingface.co/docs/huggingface_hub/package_reference/hf_api
*/
// Search Options
export interface HfModelSearchParams {
/** Full-text search query */
search?: string;
/** Filter by pipeline task (e.g., "text-generation", "image-generation") */
pipeline_tag?: string;
/** Filter by library (e.g., "transformers", "diffusers", "gguf") */
library_name?: string;
/** Filter by tag (e.g., "gguf") */
filter?: string;
/** Filter by author or organization */
author?: string;
/** Sort field */
sort?: HfModelSort;
/** Results per page (1-100) */
limit?: number;
/** Pagination offset */
offset?: number;
/** Filter by model config */
config?: string;
/** Return full model info */
full?: boolean;
/** Filter by visibility */
private?: boolean;
/** Filter by gated status */
gated?: boolean;
}
export type HfModelSort = 'downloads' | 'likes' | 'createdAt' | 'lastModified' | 'trendingScore';
// Model Info (from /api/models)
export interface HfModelInfo {
/** Unique document ID */
_id: string;
/** Model ID (e.g., "meta-llama/Llama-3.1-8B-Instruct") */
id: string;
/** Number of likes */
likes: number;
/** Trending score */
trendingScore: number;
/** Whether the model is private */
private: boolean;
/** Number of downloads */
downloads: number;
/** Model tags */
tags: string[];
/** Pipeline task (e.g., "text-generation") */
pipeline_tag: string | null;
/** Library name (e.g., "transformers", "diffusers") */
library_name: string | null;
/** Creation timestamp */
createdAt: string;
/** Model ID (alias for id) */
modelId: string;
/** Author / organization (present when full=true) */
author?: string;
/** Last modified timestamp (present when full=true) */
lastModified?: string;
/** Repository file listing (present when full=true) */
siblings?: HfModelSiblingRef[];
}
// Model Details (with full=true)
export interface HfModelCardData {
/** License identifier */
license?: string;
/** License URL */
license_link?: string;
/** Model description */
description?: string;
/** Model library */
language?: string[];
/** Tags */
tags?: string[];
/** Original (non-GGUF) model(s) this repo was converted from, e.g. `Qwen/Qwen3.8-27B`. The API returns a single string or a list. */
base_model?: string | string[];
/** Org that produced the quant, e.g. `bartowski` */
quantized_by?: string;
[key: string]: unknown;
}
/** GGUF metadata returned by /api/models/{id}?full=true for GGUF repos. */
export interface HfModelGguf {
/** Total parameter count */
total?: number;
/** Architecture, e.g. `gemma3`, `qwen3` */
architecture?: string;
/** Context length */
context_length?: number;
/** Chat template (Jinja) */
chat_template?: string;
bos_token?: string;
eos_token?: string;
/** Total size of all GGUF files in the repo, in bytes */
totalFileSize?: number;
}
export interface HfModelDetails {
/** Model ID */
id?: string;
/** SHA256 digest */
sha?: string;
/** Last modified timestamp */
lastModified?: string;
/** Downloads count */
downloads?: number;
/** Number of likes */
likes?: number;
/** Whether the model is gated */
gated?: boolean;
/** Model card data */
cardData?: HfModelCardData;
/** Tags */
tags?: string[];
/** Pipeline tag */
pipeline_tag?: string | null;
/** Library name */
library_name?: string | null;
/** Safe tensors info */
safetensors?: Record<string, unknown>;
/** Model size in bytes */
size?: number;
[key: string]: unknown;
}
export interface HfModelDetailInfo extends HfModelInfo {
/** Whether the model is gated (true/false/'auto') */
gated?: boolean | string;
/** Repository file listing mirrors of /api/models/{id}/tree/main */
siblings?: HfModelSiblingRef[];
/** Author / organization */
author?: string;
/** Last modified timestamp */
lastModified?: string;
/** Model card YAML data (only present when full=true) */
cardData?: HfModelCardData;
/** GGUF metadata (only present when full=true for GGUF repos) */
gguf?: HfModelGguf;
/** Model config (only present when full=true) */
config?: Record<string, unknown>;
/** Total repo storage in bytes (only present when full=true) */
usedStorage?: number;
/** Sample widget prompts */
widgetData?: Array<{ text?: string }>;
/** Related spaces */
spaces?: string[];
}
/** A single entry in a model repository's file tree (`/tree` responses) */
export interface HfModelSibling {
/** Relative path of the file or directory within the repo */
path: string;
/** Size in bytes (omitted for directories) */
size?: number;
/** Whether this entry is a directory */
type?: 'file' | 'directory';
/** OID/hash for the blob */
oid?: string;
[key: string]: unknown;
}
/**
* A single file entry in a model's `siblings` list. List (`/api/models`) and
* detail (`/api/models/{id}`) responses use `rfilename`, unlike `/tree`.
*/
export interface HfModelSiblingRef {
/** Relative file name within the repo */
rfilename: string;
[key: string]: unknown;
}
// API Response
export interface HfModelApiResponse {
/** List of models */
data: HfModelInfo[];
/** Total count (if available) */
total?: number;
}
+24
View File
@@ -34,6 +34,9 @@ export type {
ApiRouterModelsListResponse,
ApiRouterModelsUnloadRequest,
ApiRouterModelsUnloadResponse,
ApiRouterModelsDownloadRequest,
ApiRouterModelsDownloadResponse,
ApiModelsDownloadProgressData,
AudioInputFormat,
ApiStreamSession
} from './api';
@@ -93,6 +96,7 @@ export type {
ModelModalities,
ModelOption,
ModelLoadProgress,
ModelDownloadProgress,
ModalityCapabilities
} from './models';
@@ -216,3 +220,23 @@ export type { ReasoningEffortLevel } from './reasoning';
// Splash
export type { SplashDimensions } from './splash';
// HuggingFace types
export type {
HfModelSearchParams,
HfModelSort,
HfModelInfo,
HfModelCardData,
HfModelDetails,
HfModelDetailInfo,
HfModelSibling,
HfModelApiResponse
} from './huggingface';
// Model manager tree types
export type {
ModelManagerDraft,
ModelManagerQuant,
ModelManagerQuantOrg,
ModelManagerParent
} from './model-manager';
+44
View File
@@ -0,0 +1,44 @@
import type { DraftVariant } from '$lib/constants';
import type { ModelOption } from './models';
/**
* A draft sidecar model attached to a quant (speculative decoding).
*/
export interface ModelManagerDraft {
variant: DraftVariant;
option: ModelOption;
}
/**
* One quantization of a GGUF repo: main weights plus attached sidecars.
*/
export interface ModelManagerQuant {
/** Quantization token, e.g. `Q4_K_M`, or null when the entry has none. */
quant: string | null;
/** Main model entry (no draft variant). */
main: ModelOption;
/** Draft sidecar entries (mtp, dflash, dspark, eagle3). */
drafts: ModelManagerDraft[];
/** Multimodal projector sidecar, when registered separately. */
mmproj: ModelOption | null;
}
/**
* A GGUF repo by a converter org, e.g. `ggml-org/Qwen3.8-27B-GGUF`.
*/
export interface ModelManagerQuantOrg {
/** Repo id without the `:quant` tag. */
repoId: string;
/** Converter org, e.g. `ggml-org`. */
orgName: string;
quants: ModelManagerQuant[];
}
/**
* The original (non-GGUF) model, e.g. `Qwen/Qwen3.8-27B`.
*/
export interface ModelManagerParent {
/** Display id: HF base_model when resolved, else the heuristic name. */
parentId: string;
quantOrgs: ModelManagerQuantOrg[];
}
+11
View File
@@ -1,3 +1,4 @@
import type { DraftVariant } from '$lib/constants/model-id.constants';
import type { ApiModelDataEntry, ApiModelDetails, ApiModelLoadStage } from '$lib/types/api';
export interface ModelModalities {
@@ -35,6 +36,15 @@ export interface ModelLoadProgress {
value: 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 {
downloadedBytes: number;
totalBytes: number;
}
export interface ParsedModelId {
raw: string;
orgName: string | null;
@@ -42,6 +52,7 @@ export interface ParsedModelId {
params: string | null;
activatedParams: string | null;
quantization: string | null;
variant: DraftVariant | null;
tags: string[];
}
+20
View File
@@ -137,6 +137,26 @@ export async function apiPost<T, B = unknown>(
});
}
/**
* Send a DELETE request to an API endpoint, optionally with query parameters.
*
* @param path - API path (query string is appended if `params` is provided)
* @param params - Optional record of query parameters
* @param options - Additional fetch options
* @returns Parsed JSON response
*/
export async function apiDelete<T>(
path: string,
params?: Record<string, string>,
options: ApiFetchOptions = {}
): Promise<T> {
if (params && Object.keys(params).length > 0) {
return apiFetchWithParams<T>(path, params, { ...options, method: 'DELETE' });
}
return apiFetch<T>(path, { ...options, method: 'DELETE' });
}
/**
* Parse error message from a failed response.
* Tries to extract error message from JSON body, falls back to status text.
+10 -1
View File
@@ -9,7 +9,7 @@
// API utilities
export { getAuthHeaders, getJsonHeaders, sanitizeHeaders } from './api-headers';
export { ApiError, apiFetch, apiFetchWithParams, apiPost } from './api-fetch';
export { ApiError, apiDelete, apiFetch, apiFetchWithParams, apiPost } from './api-fetch';
export { validateApiKey } from './api-key-validation';
// Attachment utilities
@@ -107,6 +107,15 @@ export {
// Model name utilities
export { normalizeModelName, isValidModelName } from './model-names';
// Model manager tree utilities
export {
buildModelManagerTree,
getRepoId,
heuristicParentId,
normalizeParentName,
resolveBaseModel
} from './model-manager';
// Portal utilities
export { portalToBody } from './portal-to-body';
+153
View File
@@ -0,0 +1,153 @@
import { HuggingFaceService } from '$lib/services';
import type {
ModelManagerParent,
ModelManagerQuant,
ModelManagerQuantOrg
} from '$lib/types';
import type { ModelOption } from '$lib/types/models';
/** Strip the `:quant` / `:quant-VARIANT` tag from a model id to get the repo id. */
export function getRepoId(modelId: string): string {
return modelId.split(':')[0] ?? modelId;
}
/**
* Normalize a model id to a parent grouping key: last path segment with the
* container-format suffix (`-GGUF`, `-GGML`) stripped. `Qwen/Qwen3.8-27B` and
* `ggml-org/Qwen3.8-27B-GGUF` both normalize to `Qwen3.8-27B`.
*/
export function normalizeParentName(id: string): string {
const slashIdx = id.lastIndexOf('/');
const name = slashIdx !== -1 ? id.slice(slashIdx + 1) : id;
return name.replace(/-(GGUF|GGML)$/i, '');
}
/** Heuristic parent id for a repo: normalized repo name (org dropped). */
export function heuristicParentId(repoId: string): string {
return normalizeParentName(repoId);
}
// Module-level cache so base models survive route navigation.
const baseModelCache = new Map<string, string | null>();
/**
* Resolve the original (non-GGUF) model for a repo via HF metadata. Prefers
* `cardData.base_model` (a string or a list), falling back to the
* `base_model:<org>/<name>` tag. Cached per repo; null means "not found".
*/
export async function resolveBaseModel(repoId: string): Promise<string | null> {
if (baseModelCache.has(repoId)) return baseModelCache.get(repoId) ?? null;
let resolved: string | null = null;
try {
const details = await HuggingFaceService.getDetails(repoId);
const base = details?.cardData?.base_model;
const first = Array.isArray(base) ? base[0] : base;
if (first && first.trim()) {
resolved = first.trim();
} else {
// tag fallback, e.g. `base_model:Qwen/Qwen3-8B`
const tag = details?.tags?.find((t) => t.startsWith('base_model:') && !t.startsWith('base_model:quantized:'));
const fromTag = tag?.slice('base_model:'.length).trim();
if (fromTag) resolved = fromTag;
}
} catch {
// fall through to the heuristic
}
baseModelCache.set(repoId, resolved);
return resolved;
}
/**
* Build the manager tree from installed models and resolved base models.
* Repos are grouped under a parent keyed by the heuristic name; the display id
* prefers the resolved HF base_model.
*/
export function buildModelManagerTree(
models: ModelOption[],
baseModels: ReadonlyMap<string, string | null>
): ModelManagerParent[] {
// repoId -> quant -> { main, drafts, mmproj }
const repoMap = new Map<string, Map<string, ModelManagerQuant>>();
for (const option of models) {
const parsed = option.parsedId;
const repoId = getRepoId(option.model);
const quant = parsed?.quantization ?? null;
const variant = parsed?.variant ?? null;
let quantMap = repoMap.get(repoId);
if (!quantMap) {
quantMap = new Map();
repoMap.set(repoId, quantMap);
}
const key = quant ?? '';
let entry = quantMap.get(key);
if (!entry) {
entry = { quant, main: option, drafts: [], mmproj: null };
quantMap.set(key, entry);
}
if (variant === 'mmproj') {
entry.mmproj = option;
} else if (variant) {
entry.drafts.push({ variant, option });
} else {
entry.main = option;
}
}
// Build quant orgs.
const quantOrgs: ModelManagerQuantOrg[] = [];
for (const [repoId, quantMap] of repoMap) {
const quants = Array.from(quantMap.values()).sort((a, b) =>
(a.quant ?? '').localeCompare(b.quant ?? '')
);
quants.forEach((q) => q.drafts.sort((a, b) => a.variant.localeCompare(b.variant)));
const slashIdx = repoId.indexOf('/');
const orgName = slashIdx !== -1 ? repoId.slice(0, slashIdx) : repoId;
quantOrgs.push({ repoId, orgName, quants });
}
// Group quant orgs under parents.
const parentMap = new Map<string, ModelManagerParent>();
for (const org of quantOrgs) {
const resolved = baseModels.get(org.repoId) ?? null;
// Key by the model name so repos of the same model group together even
// when only some of them resolved a base_model.
const key = normalizeParentName(resolved ?? org.repoId);
let parent = parentMap.get(key);
if (!parent) {
parent = { parentId: resolved ?? key, quantOrgs: [] };
parentMap.set(key, parent);
} else if (resolved && parent.parentId === key) {
// Prefer the resolved base_model for display once known.
parent.parentId = resolved;
}
parent.quantOrgs.push(org);
}
const parents = Array.from(parentMap.values());
parents.forEach((p) => p.quantOrgs.sort((a, b) => a.repoId.localeCompare(b.repoId)));
parents.sort((a, b) => a.parentId.localeCompare(b.parentId));
return parents;
}
@@ -0,0 +1,264 @@
<script lang="ts">
import {
Copy,
ExternalLink,
Heart,
HeartOff,
LoaderCircle,
Plus,
Power,
PowerOff,
Trash2,
X
} from '@lucide/svelte';
import { ActionIcon, DialogModelsHub, ModelId } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore, serverStore } from '$lib/stores';
import type { ApiModelDataEntry } from '$lib/types';
import type { ModelOption } from '$lib/types/models';
import { buildModelManagerTree, copyToClipboard, getRepoId, resolveBaseModel } from '$lib/utils';
import { onMount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
let modelsHubOpen = $state(false);
let baseModels = $state(new SvelteMap<string, string | null>());
let isRouter = $derived(serverStore.isRouterMode);
let tree = $derived(buildModelManagerTree(modelsStore.models, baseModels));
onMount(() => {
void modelsStore.fetch();
modelsStore.status.subscribe();
});
// Resolve the original model for each installed repo (cached, best-effort).
$effect(() => {
const repoIds = new Set(modelsStore.models.map((m) => getRepoId(m.model)));
for (const repoId of repoIds) {
void resolveBaseModel(repoId).then((base) => {
baseModels.set(repoId, base);
});
}
});
function getRouterEntry(modelId: string): ApiModelDataEntry | undefined {
return modelsStore.routerModels.find((m) => m.id === modelId);
}
function hfUrl(modelId: string): string | null {
const repoId = getRepoId(modelId);
if (!repoId.includes('/')) return null;
return `https://huggingface.co/${repoId}`;
}
</script>
{#snippet modelRow(option: ModelOption, indent: boolean, hideQuantization: boolean)}
{@const entry = getRouterEntry(option.model)}
{@const status = entry?.status}
{@const statusValue = status?.value}
{@const isLoaded = statusValue === ServerModelStatus.LOADED || statusValue === ServerModelStatus.SLEEPING}
{@const isLoading = statusValue === ServerModelStatus.LOADING}
{@const isDownloading = statusValue === ServerModelStatus.DOWNLOADING}
{@const isFailed = statusValue === ServerModelStatus.FAILED || status?.failed === true}
{@const canRemove = entry?.can_remove === true}
{@const isFav = modelsStore.isFavorite(option.model)}
{@const hfLink = hfUrl(option.model)}
{@const downloadProgress = modelsStore.status.getDownloadProgress(option.model)}
<tr class="border-t transition-colors hover:bg-muted/40">
<td class="px-3 py-2 {indent ? 'pl-8' : ''}">
<ModelId
modelId={option.model}
aliases={option.aliases}
tags={option.tags}
modalities={option.modalities}
supportsThinking={modelsStore.props.checkModelSupportsThinking(option.model)}
hideQuantization={hideQuantization}
showRawTooltip
/>
</td>
<td class="px-3 py-2">
<span class="inline-flex items-center gap-1.5 text-xs">
{#if isLoading}
<LoaderCircle class="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span>Loading</span>
{:else if isDownloading}
<LoaderCircle class="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span>Downloading</span>
{:else if isLoaded}
<span class="h-2 w-2 rounded-full bg-green-500"></span>
<span>{statusValue === ServerModelStatus.SLEEPING ? 'Sleeping' : 'Loaded'}</span>
{:else if isFailed}
<span class="h-2 w-2 rounded-full bg-red-500"></span>
<span>Failed</span>
{:else if statusValue === ServerModelStatus.DOWNLOADED}
<span class="h-2 w-2 rounded-full bg-muted-foreground/50"></span>
<span>Downloaded</span>
{:else}
<span class="h-2 w-2 rounded-full bg-muted-foreground/50"></span>
<span>Unloaded</span>
{/if}
</span>
{#if isDownloading && downloadProgress}
<div class="mt-1 h-1 w-32 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-primary"
style="width: {downloadProgress.totalBytes > 0
? Math.round((downloadProgress.downloadedBytes / downloadProgress.totalBytes) * 100)
: 0}%"
></div>
</div>
{/if}
</td>
<td class="px-3 py-2">
<div class="flex items-center justify-end gap-0.5">
{#if isRouter}
{#if isLoading}
<ActionIcon
icon={X}
tooltip="Cancel load"
onclick={() => modelsStore.status.cancelLoad(option.model)}
/>
{:else if isDownloading}
<ActionIcon
icon={X}
tooltip="Cancel download"
onclick={() => modelsStore.status.cancelDownload(option.model)}
/>
{:else if isLoaded}
<ActionIcon
icon={PowerOff}
tooltip="Unload model"
onclick={() => modelsStore.status.unload(option.model)}
/>
{:else}
<ActionIcon
icon={Power}
tooltip="Load model"
onclick={() => modelsStore.status.load(option.model)}
/>
{/if}
{#if canRemove && !isDownloading}
<ActionIcon
icon={Trash2}
tooltip="Delete model"
onclick={() => modelsStore.status.cancelDownload(option.model)}
/>
{/if}
{/if}
<ActionIcon
icon={isFav ? HeartOff : Heart}
tooltip={isFav ? 'Remove from favorites' : 'Add to favorites'}
onclick={() => modelsStore.toggleFavorite(option.model)}
/>
<ActionIcon
icon={Copy}
tooltip="Copy model id"
onclick={() => copyToClipboard(option.model)}
/>
{#if hfLink}
<a
href={hfLink}
target="_blank"
rel="noreferrer"
class="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted-foreground/10 hover:text-foreground"
aria-label="View on HuggingFace"
title="View on HuggingFace"
>
<ExternalLink class="h-3.5 w-3.5" />
</a>
{/if}
</div>
</td>
</tr>
{/snippet}
<div class="mx-auto flex h-full w-full max-w-5xl flex-col gap-4 p-4 md:p-6">
<header class="flex items-center justify-between gap-2">
<div>
<h1 class="text-lg font-semibold">Model Manager</h1>
<p class="text-sm text-muted-foreground">
Models installed on this machine and available through /v1/models.
</p>
</div>
<Button size="sm" onclick={() => (modelsHubOpen = true)}>
<Plus class="h-4 w-4" />
Add more models
</Button>
</header>
{#if modelsStore.loading}
<div class="flex items-center justify-center py-16">
<p class="text-sm text-muted-foreground">Loading models...</p>
</div>
{:else if modelsStore.error}
<div class="rounded-lg border border-destructive/50 bg-destructive/5 p-4 text-center">
<p class="text-sm text-destructive">{modelsStore.error}</p>
</div>
{:else if modelsStore.models.length === 0}
<div class="flex flex-col items-center justify-center gap-3 py-16 text-center">
<p class="text-sm text-muted-foreground">No models installed.</p>
<Button size="sm" variant="outline" onclick={() => (modelsHubOpen = true)}>
<Plus class="h-4 w-4" />
Add more models
</Button>
</div>
{:else}
<div class="overflow-hidden rounded-lg border">
<table class="w-full text-sm">
<thead class="bg-muted/40 text-left text-xs text-muted-foreground">
<tr>
<th class="px-3 py-2 font-medium">Model</th>
<th class="px-3 py-2 font-medium">Status</th>
<th class="px-3 py-2 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#each tree as parent (parent.parentId)}
<tr class="border-t bg-muted/20">
<td colspan="3" class="px-3 py-1.5 text-xs font-semibold text-muted-foreground">
{parent.parentId}
</td>
</tr>
{#each parent.quantOrgs as org (org.repoId)}
<tr class="border-t bg-muted/10">
<td colspan="3" class="px-6 py-1.5 text-xs font-medium text-muted-foreground">
{org.repoId}
</td>
</tr>
{#each org.quants as quant (quant.quant ?? 'default')}
{@render modelRow(quant.main, false, false)}
{#each quant.drafts as draft (draft.option.id)}
{@render modelRow(draft.option, true, true)}
{/each}
{/each}
{/each}
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<DialogModelsHub bind:open={modelsHubOpen} />
@@ -0,0 +1,50 @@
<script lang="ts">
import { ChevronRight } from '@lucide/svelte';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
interface BreadcrumbItem {
label: string;
href?: string;
}
interface Props {
items: BreadcrumbItem[];
class?: string;
}
let { class: className, items }: Props = $props();
function navigateTo(href: string | undefined) {
if (href && browser) {
goto(href);
}
}
</script>
<nav class={className} aria-label="Breadcrumb">
<ol class="flex list-none items-center gap-1.5 p-0 text-sm">
{#each items as item, i (item.label)}
<li class="flex items-center gap-1.5">
{#if i > 0}
<ChevronRight class="h-3.5 w-3.5 text-muted-foreground" />
{/if}
{#if item.href && i < items.length - 1}
<a
href={item.href}
class="text-muted-foreground transition-colors hover:text-foreground"
onclick={(e) => {
e.preventDefault();
navigateTo(item.href);
}}
>
{item.label}
</a>
{:else}
<span class="font-medium text-foreground">{item.label}</span>
{/if}
</li>
{/each}
</ol>
</nav>
@@ -0,0 +1,316 @@
<script lang="ts">
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { Download, LoaderCircle, Trash2, TriangleAlert } from '@lucide/svelte';
import { DialogConfirmation } from '$lib/components/app';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import type { DraftVariant } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
import { type GgufVariantTagInput, ModelsService } from '$lib/services/models.service';
import { modelsStore } from '$lib/stores';
interface Props {
open: boolean;
repoId: string;
filePath: string;
quant: string | null;
variant: DraftVariant | null;
formattedSize?: string;
onConfirm: () => void;
onCancel: () => void;
}
let {
filePath,
formattedSize,
onCancel,
onConfirm,
open = $bindable(),
quant,
repoId,
variant
}: Props = $props();
type Phase = 'pending' | 'starting' | 'downloading' | 'finished';
let phase = $state<Phase>('pending');
let hasSeenProgress = $state(false);
let lastError: string | null = $state(null);
let tagInput = $derived<GgufVariantTagInput | null>(
quant || variant ? { quant: quant ?? '', variant } : null
);
let hfRepoWithTag = $derived(ModelsService.buildDownloadTag(repoId, tagInput));
let tagDisplay = $derived.by(() => {
if (quant && variant) return `${quant}-${variant.toUpperCase()}`;
if (quant) return quant;
if (variant) return variant.toUpperCase();
return 'default';
});
let inFlight = $derived(phase === 'starting' || phase === 'downloading');
// True when a previous SSE `download_failed` left a recorded failure for the
// same <repo>:<tag>. The dialog swaps Download for a delete-&-retry flow
// because POST /models rejects already-existing partial entries.
let previousFailure = $derived(modelsStore.status.hasFailedDownload(hfRepoWithTag));
let cancelling = $state(false);
let lastCancelError: string | null = $state(null);
// Only offer Delete when the model is registered with the server (a fully
// downloaded entry in /v1/models). For an in-flight download the partial
// files are cleaned up by the Retry path, so Delete would be redundant.
let canDelete = $derived(
phase === 'finished' && modelsStore.status.isModelDownloaded(hfRepoWithTag)
);
let showDeleteConfirm = $state(false);
async function handleConfirmDelete() {
showDeleteConfirm = false;
await modelsStore.status.cancelDownload(hfRepoWithTag);
// Close the download dialog too - removing the entry makes the wizard moot.
onCancel();
}
// Reactive: while the SSE feed reports progress for our download, surface it.
// The downloadProgress map is deleted on download_finished/download_failed.
let progress = $derived(modelsStore.status.getDownloadProgress(hfRepoWithTag));
let progressPercent = $derived.by(() => {
if (!progress || progress.totalBytes <= 0) return 0;
return Math.round((progress.downloadedBytes / progress.totalBytes) * 100);
});
function handleKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER && phase === 'pending') {
event.preventDefault();
void trigger();
}
}
function handleOpenChange(newOpen: boolean) {
if (newOpen) {
lastError = null;
lastCancelError = null;
showDeleteConfirm = false;
phase = 'pending';
hasSeenProgress = false;
return;
}
if (!inFlight) onCancel();
}
async function trigger() {
if (inFlight) return;
phase = 'starting';
hasSeenProgress = false;
lastError = null;
lastCancelError = null;
showDeleteConfirm = false;
// A recorded failure for the same <repo>:<tag> means the server still holds
// a partial entry that POST /models would reject; remove it before retrying.
if (modelsStore.status.hasFailedDownload(hfRepoWithTag)) {
await ModelsService.cancelDownload(hfRepoWithTag);
}
try {
await modelsStore.status.downloadModel(hfRepoWithTag, filePath);
phase = 'downloading';
} catch (error) {
lastError = error instanceof Error ? error.message : 'Failed to start download';
phase = 'pending';
}
}
async function cancel() {
if (cancelling) return;
cancelling = true;
lastCancelError = null;
try {
const ok = await modelsStore.status.cancelDownload(hfRepoWithTag);
if (!ok) {
lastCancelError = 'Cancel request failed. Try again in a moment.';
}
} finally {
cancelling = false;
}
}
// Latch once we've seen real progress so we know what 'no longer in flight'
// actually means. Without this the dialog auto-closed shortly after the POST
// resolved because no SSE event had landed yet.
$effect(() => {
if (phase !== 'downloading') return;
if (progress) hasSeenProgress = true;
});
// Promote to 'finished' only after progress was observed and the feed then
// drops our entry. Auto-close after a short pause.
$effect(() => {
if (phase !== 'downloading') return;
if (!hasSeenProgress) return;
const stillInFlight = modelsStore.status.isDownloadInProgress(hfRepoWithTag);
if (!stillInFlight) {
phase = 'finished';
const timer = setTimeout(() => onConfirm(), 600);
return () => clearTimeout(timer);
}
});
</script>
<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
<AlertDialog.Content onkeydown={handleKeydown} class="max-w-md">
<AlertDialog.Header>
<AlertDialog.Title class="flex items-center gap-2">
<Download class="h-5 w-5 text-primary" />
{#if phase === 'pending'}
Download this model?
{:else}
Downloading {tagDisplay}
{/if}
</AlertDialog.Title>
<AlertDialog.Description>
{#if phase === 'pending'}
llama-server will download this file (and related sidecar weights such as multimodal
projectors or draft models) from Hugging Face into your local model cache.
{:else}
Download runs in the background; this dialog tracks live progress.
{/if}
</AlertDialog.Description>
{#if previousFailure && phase === 'pending'}
<div
class="mt-2 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
role="status"
>
<TriangleAlert class="mt-0.5 h-4 w-4 shrink-0" />
<span>
A previous attempt for this tag failed and left partial files on disk. The server will
reject a fresh download until those files are removed. The Retry button below deletes
the partial files automatically.
</span>
</div>
{/if}
</AlertDialog.Header>
{#if canDelete}
<div class="flex justify-end">
<button
type="button"
onclick={() => (showDeleteConfirm = true)}
class="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 px-2 py-1 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
aria-label="Delete model from cache"
>
<Trash2 class="h-3.5 w-3.5" />
Delete from cache
</button>
</div>
{/if}
<div class="space-y-3 rounded-md border bg-muted/40 p-3 text-xs">
<div class="flex flex-col gap-1">
<span class="text-muted-foreground">Request</span>
<code class="break-all font-mono"
>POST /models&nbsp;&middot;&nbsp;{`{ model: "${hfRepoWithTag}" }`}</code
>
</div>
<div class="flex flex-col gap-1">
<span class="text-muted-foreground">File</span>
<code class="break-all font-mono">{filePath}</code>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded bg-primary/15 px-2 py-0.5 font-mono font-semibold text-primary">
{tagDisplay}
</span>
{#if formattedSize}
<span class="text-muted-foreground">{formattedSize}</span>
{/if}
{#if variant}
<span
class="rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground"
>
{variant}
</span>
{/if}
</div>
{#if phase === 'downloading' || phase === 'finished'}
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between text-muted-foreground">
<span>
{#if phase === 'finished'}
Complete
{:else if progress && progress.totalBytes > 0}
Downloading
{:else}
Preparing download
{/if}
</span>
<span class="font-mono tabular-nums">{progressPercent}%</span>
</div>
<DownloadProgressBar
downloadedBytes={progress?.downloadedBytes ?? 0}
totalBytes={progress?.totalBytes ?? 0}
/>
</div>
{/if}
</div>
{#if lastError}
<p class="text-xs text-destructive">{lastError}</p>
{/if}
{#if lastCancelError}
<p class="text-xs text-destructive">{lastCancelError}</p>
{/if}
<AlertDialog.Footer>
{#if phase === 'downloading'}
<AlertDialog.Action disabled={cancelling} onclick={cancel}>
{#if cancelling}
<LoaderCircle class="mr-1.5 h-4 w-4 animate-spin" />
Cancelling...
{:else}
Cancel download
{/if}
</AlertDialog.Action>
{:else}
<AlertDialog.Cancel disabled={inFlight} onclick={onCancel}>
{#if phase === 'finished'}Close{:else}Cancel{/if}
</AlertDialog.Cancel>
{/if}
{#if phase === 'pending'}
<AlertDialog.Action disabled={inFlight} onclick={trigger}>
<Download class="mr-1.5 h-4 w-4" />
{previousFailure ? 'Retry download' : 'Download'}
</AlertDialog.Action>
{:else if phase === 'starting'}
<AlertDialog.Action disabled>
<LoaderCircle class="mr-1.5 h-4 w-4 animate-spin" />
Starting...
</AlertDialog.Action>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<DialogConfirmation
bind:open={showDeleteConfirm}
title="Delete model"
description={`Remove "${hfRepoWithTag}" from your cache? Any cached files will be deleted from disk.`}
confirmText="Delete"
cancelText="Cancel"
variant="destructive"
icon={Trash2}
onConfirm={handleConfirmDelete}
onCancel={() => (showDeleteConfirm = false)}
/>
@@ -0,0 +1,35 @@
<script lang="ts">
interface Props {
/** Bytes downloaded so far. Caller supplies the value; we normalize 0..1. */
downloadedBytes: number;
/** Total bytes for the download plan. */
totalBytes: number;
/** Pin to the bottom edge as a thin overlay like `ModelLoadHighlight`. */
overlay?: boolean;
}
let { downloadedBytes, overlay = false, totalBytes }: Props = $props();
let fraction = $derived.by(() => {
if (totalBytes <= 0) return 0;
return Math.min(Math.max(downloadedBytes / totalBytes, 0), 1);
});
let percent = $derived(Math.round(fraction * 100));
</script>
{#if overlay}
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm">
<div
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
style="width: {percent}%"
></div>
</div>
{:else}
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
<div
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
style="width: {percent}%"
></div>
</div>
{/if}
@@ -0,0 +1,60 @@
<script lang="ts" module>
import {
AudioLines,
Circle,
Equal,
Hash,
HelpCircle,
Image as ImageIcon,
ImagePlus,
Languages,
Layers,
ListCollapse,
MessageCircle,
MessageSquare,
MessageSquareMore,
Mic,
Replace,
Scan,
Video,
Volume2
} from '@lucide/svelte';
import type { Component } from 'svelte';
/** Lucide icon name -> component; only pipeline-tag icons we use today. */
const ICONS: Record<string, Component> = {
'audio-lines': AudioLines,
equal: Equal,
hash: Hash,
'help-circle': HelpCircle,
image: ImageIcon,
'image-plus': ImagePlus,
languages: Languages,
layers: Layers,
'list-collapse': ListCollapse,
'message-circle': MessageCircle,
'message-square': MessageSquare,
'message-square-more': MessageSquareMore,
mic: Mic,
replace: Replace,
scan: Scan,
video: Video,
'volume-2': Volume2
};
/** Default icon used when the requested name is unknown. */
const FALLBACK: Component = Circle;
</script>
<script lang="ts">
interface Props {
name: string | null;
class?: string;
}
let { class: className = 'h-3.5 w-3.5', name }: Props = $props();
let Icon = $derived(name && ICONS[name] ? ICONS[name] : FALLBACK);
</script>
<Icon class={className} />
@@ -0,0 +1,446 @@
<script lang="ts">
import Breadcrumb from './Breadcrumb.svelte';
import DialogModelDownload from './DialogModelDownload.svelte';
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { ArrowLeft, Check, Copy, Cpu, Download, ExternalLink, Heart } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { ActionIcon } from '$lib/components/app';
import type { DraftVariant } from '$lib/constants';
import { ROUTES } from '$lib/constants';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types/huggingface';
import { copyToClipboard } from '$lib/utils';
import { onMount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
interface Props {
modelId: string;
class?: string;
}
let { class: className, modelId }: Props = $props();
let modelInfo: HfModelDetailInfo | null = $state(null);
let siblings: HfModelSibling[] = $state([]);
let loading = $state(true);
let error: string | null = $state(null);
let copied = $state(false);
let pendingDownload = $state<{
filePath: string;
sizeBytes: number | null;
quant: string | null;
variant: DraftVariant | null;
} | null>(null);
let details = $derived.by(() => modelInfo);
let gguf = $derived.by(() => modelInfo?.gguf);
let ggufFiles = $derived(HuggingFaceService.filterByExtension(siblings, '.gguf'));
let description = $derived.by(() => modelInfo?.cardData?.description ?? null);
let licenseTag = $derived.by(() => {
const tags = modelInfo?.tags ?? [];
return tags.find((t) => t.startsWith('license:'))?.replace('license:', '') ?? null;
});
let author = $derived.by(() => modelInfo?.author ?? modelId.split('/')[0] ?? '');
type BitDepthRow = { bitDepth: number; files: HfModelSibling[] };
let bitDepthRows = $derived.by<BitDepthRow[]>(() => {
const rows = new SvelteMap<number, HfModelSibling[]>();
for (const file of ggufFiles) {
const meta = HuggingFaceService.extractQuantMeta(file.path);
if (meta?.variant === 'mmproj') continue;
const depth = meta?.quant ? HuggingFaceService.getBitDepth(meta.quant) : null;
const bucket = depth ?? 99;
const list = rows.get(bucket) ?? [];
list.push(file);
rows.set(bucket, list);
}
return Array.from(rows.entries())
.map(([bitDepth, files]) => ({ bitDepth, files }))
.sort((a, b) => a.bitDepth - b.bitDepth);
});
function handleBack() {
goto(ROUTES.MANAGE_MODELS);
}
async function handleCopy() {
await copyToClipboard(modelId);
copied = true;
setTimeout(() => (copied = false), 1500);
}
async function loadModel() {
loading = true;
error = null;
try {
const [info, tree] = await Promise.all([
HuggingFaceService.getDetails(modelId),
HuggingFaceService.getTree(modelId)
]);
if (!info) {
error = 'Model not found on Hugging Face.';
return;
}
modelInfo = info;
siblings = tree;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load model';
} finally {
loading = false;
}
}
// Warm the router-models cache so already-downloaded quants show their
// checkmark (otherwise isModelDownloaded(...) stays false on hard refresh).
onMount(() => {
void modelsStore.fetchRouterModels();
});
$effect(() => {
loadModel();
});
</script>
<div class="flex h-full flex-col {className}">
<!-- Pane header: back, model id + copy, View on HF -->
<header
class="flex shrink-0 items-center gap-2 border-b border-border/40 bg-background/90 px-4 py-3 backdrop-blur"
>
<div class="lg:hidden">
<ActionIcon icon={ArrowLeft} tooltip="Back to models" onclick={handleBack} />
</div>
<h1 class="min-w-0 flex-1 truncate font-semibold">{modelId}</h1>
<button
type="button"
onclick={handleCopy}
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:border-border hover:text-foreground"
aria-label="Copy model id"
>
{#if copied}
<Check class="h-3.5 w-3.5 text-primary" />
{:else}
<Copy class="h-3.5 w-3.5" />
{/if}
{copied ? 'Copied' : 'Copy'}
</button>
<a
href={`https://huggingface.co/${modelId}`}
target="_blank"
rel="noopener noreferrer"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<ExternalLink size={14} />
<span class="hidden sm:inline">View on HF</span>
</a>
</header>
<div class="min-h-0 flex-1 overflow-y-auto">
<Breadcrumb
items={[{ href: ROUTES.MANAGE_MODELS, label: 'Models' }, { label: modelId }]}
class="px-4 pt-4 md:px-6"
/>
{#if error}
<div
class="mx-4 mt-4 rounded-lg border border-destructive/50 bg-destructive/5 p-4 text-center md:mx-6"
>
<p class="text-destructive">{error}</p>
</div>
{/if}
{#if loading}
<div class="flex items-center justify-center py-20">
<p class="text-muted-foreground">Loading model details...</p>
</div>
{/if}
{#if !loading && modelInfo}
<!-- Metadata chip row -->
<div class="flex flex-wrap items-center gap-1.5 px-4 pt-4 md:px-6">
{#if author}
<span
class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground"
>
{author}
</span>
{/if}
{#if modelInfo.pipeline_tag}
<span class="rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{HuggingFaceService.pipelineTagLabel(modelInfo.pipeline_tag)}
</span>
{/if}
{#if modelInfo.library_name}
<span
class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground"
>
{modelInfo.library_name}
</span>
{/if}
{#if modelInfo.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}
{#if licenseTag}
<span class="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{licenseTag}
</span>
{/if}
</div>
<!-- Two-column content -->
<div class="grid gap-6 px-4 py-6 md:px-6 xl:grid-cols-3">
<main class="min-w-0 space-y-6 xl:col-span-2">
{#if description}
<section class="rounded-lg border bg-card p-5">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
About
</h2>
<p class="text-sm whitespace-pre-line text-foreground/90">{description}</p>
</section>
{/if}
{#if gguf}
<section class="rounded-lg border bg-card p-5">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
GGUF Specs
</h2>
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 md:grid-cols-3">
{#if gguf.architecture}
<div>
<dt class="text-xs text-muted-foreground">Architecture</dt>
<dd class="text-sm font-medium capitalize">
{gguf.architecture.replace(/_/g, ' ')}
</dd>
</div>
{/if}
{#if gguf.total}
<div>
<dt class="text-xs text-muted-foreground">Total params</dt>
<dd class="text-sm font-medium tabular-nums">
{HuggingFaceService.formatFileSize(gguf.total).replace(' B', '')}B
</dd>
</div>
{/if}
{#if gguf.context_length}
<div>
<dt class="text-xs text-muted-foreground">Context length</dt>
<dd class="text-sm font-medium tabular-nums">
{gguf.context_length.toLocaleString()}
</dd>
</div>
{/if}
</dl>
</section>
{/if}
</main>
<aside class="space-y-4 xl:sticky xl:top-4 xl:self-start">
<section class="rounded-lg border bg-card p-4">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Stats
</h2>
<dl class="space-y-2.5 text-sm">
{#if typeof modelInfo.downloads === 'number'}
<div class="flex items-center justify-between">
<dt class="flex items-center gap-1.5 text-muted-foreground">
<Download size={13} />
Downloads
</dt>
<dd class="font-medium tabular-nums">
{HuggingFaceService.formatDownloads(modelInfo.downloads)}
</dd>
</div>
{/if}
{#if typeof modelInfo.likes === 'number'}
<div class="flex items-center justify-between">
<dt class="flex items-center gap-1.5 text-muted-foreground">
<Heart size={13} />
Likes
</dt>
<dd class="font-medium tabular-nums">
{HuggingFaceService.formatLikes(modelInfo.likes)}
</dd>
</div>
{/if}
{#if details?.lastModified}
<div class="flex items-center justify-between">
<dt class="text-muted-foreground">Last modified</dt>
<dd class="font-medium">
{HuggingFaceService.formatRelativeTime(details.lastModified)}
</dd>
</div>
{/if}
{#if gguf?.totalFileSize}
<div class="flex items-center justify-between">
<dt class="text-muted-foreground">Total size</dt>
<dd class="font-medium tabular-nums">
{HuggingFaceService.formatFileSize(gguf.totalFileSize)}
</dd>
</div>
{/if}
</dl>
</section>
{#if ggufFiles.length}
<section class="rounded-lg border bg-card p-4">
<header class="mb-3 flex items-center gap-1.5">
<Cpu size={13} class="text-muted-foreground" />
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Available files
</h2>
</header>
<div class="space-y-2">
{#each bitDepthRows as row (row.bitDepth)}
<div class="grid grid-cols-[5rem_1fr] items-center gap-3">
<div class="text-xs font-semibold tabular-nums text-muted-foreground">
{#if row.bitDepth === 99}
Other
{:else}
{row.bitDepth}-bit
{/if}
</div>
<div class="flex flex-wrap 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 fallbackLabel = basename
.replace(/\.gguf$/i, '')
.replace(/^(?:mtp|dflash|mmproj)-/i, '')
.replace(/-mtp$/i, '')}
{@const label = meta?.quant ?? fallbackLabel}
{@const downloadTagInput = meta?.quant
? { quant: meta.quant, variant: meta.variant ?? null }
: null}
{@const hfRepoWithTag = ModelsService.buildDownloadTag(
modelId,
downloadTagInput
)}
{@const downloadProgress =
modelsStore.status.getDownloadProgress(hfRepoWithTag)}
{@const isDownloading =
modelsStore.status.isDownloadInProgress(hfRepoWithTag)}
{@const isFullyDownloaded =
modelsStore.status.isModelDownloaded(hfRepoWithTag)}
{@const isFailed = modelsStore.status.hasFailedDownload(hfRepoWithTag)}
{@const chipState = isDownloading
? 'downloading'
: isFullyDownloaded
? 'downloaded'
: isFailed
? 'failed'
: 'idle'}
<button
type="button"
onclick={() =>
(pendingDownload = {
filePath: file.path,
quant: meta?.quant ?? null,
sizeBytes: file.size ?? null,
variant: meta?.variant ?? null
})}
class="relative inline-flex cursor-pointer items-center gap-1 overflow-hidden rounded-md border bg-background px-2 py-1 text-left font-mono text-xs transition-colors hover:border-primary/60 hover:bg-primary/5"
class:border-foreground={isFullyDownloaded && !isDownloading && !isFailed}
class:bg-muted={isFullyDownloaded && !isDownloading && !isFailed}
class:border-destructive={isFailed && !isDownloading}
title={chipState === 'downloading'
? `In progress: ${file.path}. Click to view cancel options.`
: chipState === 'downloaded'
? `Already downloaded: ${file.path}`
: chipState === 'failed'
? `Last attempt failed: ${file.path}. Click to delete partial files and retry.`
: `Download ${file.path}`}
>
{#if isFullyDownloaded && !isDownloading}
<Check class="h-3 w-3 text-foreground/70" />
{/if}
{#if isFailed && !isDownloading && !isFullyDownloaded}
<span
class="rounded bg-destructive px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-destructive-foreground"
>
Failed
</span>
{/if}
{#if meta?.variant && meta.variantForm === 'prefix'}
<span
class="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground"
>
{meta.variant}
</span>
{/if}
<span class="font-medium">{label}</span>
{#if meta?.variant && meta.variantForm === 'suffix'}
<span
class="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground"
>
{meta.variant}
</span>
{/if}
<span class="text-muted-foreground">
{#if isDownloading && downloadProgress && downloadProgress.totalBytes > 0}
{Math.round(
(downloadProgress.downloadedBytes / downloadProgress.totalBytes) *
100
)}%
{:else}
{HuggingFaceService.formatFileSize(file.size ?? 0)}
{/if}
</span>
{#if isDownloading && downloadProgress}
<DownloadProgressBar
overlay
downloadedBytes={downloadProgress.downloadedBytes}
totalBytes={downloadProgress.totalBytes}
/>
{/if}
</button>
{/each}
</div>
</div>
{/each}
</div>
</section>
{/if}
</aside>
</div>
{/if}
</div>
</div>
{#if pendingDownload}
<DialogModelDownload
bind:open={
() => pendingDownload !== null,
(v) => {
if (!v) pendingDownload = null;
}
}
repoId={modelId}
filePath={pendingDownload.filePath}
quant={pendingDownload.quant}
variant={pendingDownload.variant}
formattedSize={pendingDownload.sizeBytes !== null
? HuggingFaceService.formatFileSize(pendingDownload.sizeBytes)
: ''}
onConfirm={() => (pendingDownload = null)}
onCancel={() => (pendingDownload = null)}
/>
{/if}
@@ -0,0 +1,341 @@
<script lang="ts">
import IconFromName from './IconFromName.svelte';
import ModelDetail from './ModelDetail.svelte';
import { Download, Heart, Sparkles } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { SearchInput } from '$lib/components/app';
import { HuggingFaceService, RouterService } from '$lib/services';
import type { HfModelInfo } from '$lib/types/huggingface';
import { SvelteMap } from 'svelte/reactivity';
type SortOption = (typeof HuggingFaceService.SORT_OPTIONS)[number];
interface Props {
/** Selected model id from the route (`#/temp/models-hub/<id>`), empty for the list-only view. */
modelId?: string;
class?: string;
}
let { class: className, modelId = '' }: Props = $props();
let trendingModels: HfModelInfo[] = $state([]);
let recommendedModels: HfModelInfo[] = $state([]);
let searchResults: HfModelInfo[] = $state([]);
let searchQuery = $state('');
let searchLoading = $state(false);
let searchError: string | null = $state(null);
let loading = $state(true);
let error: string | null = $state(null);
let activeFilter = $state<string | null>(null);
let sortBy = $state<SortOption>('downloads');
let feed = $state<'trending' | 'recommended'>('trending');
let selectedModelId = $derived(modelId);
let isSearching = $derived(searchQuery.trim().length > 0);
let availableFilters = $derived.by(() => {
const counts = new SvelteMap<string, number>();
for (const m of trendingModels) {
if (!m.pipeline_tag) continue;
counts.set(m.pipeline_tag, (counts.get(m.pipeline_tag) ?? 0) + 1);
}
return Array.from(counts.entries())
.map(([tag, count]) => ({ count, tag }))
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
});
let baseModels = $derived(
isSearching ? searchResults : feed === 'recommended' ? recommendedModels : trendingModels
);
let sortedModels = $derived.by(() => {
const list = [...baseModels];
list.sort((a, b) => {
const key = sortBy === 'lastModified' ? 'createdAt' : sortBy;
return (
((b as unknown as Record<string, number>)[key] ?? 0) -
((a as unknown as Record<string, number>)[key] ?? 0)
);
});
return list;
});
let filteredModels = $derived.by(() => {
let list = sortedModels;
if (activeFilter) list = list.filter((m) => m.pipeline_tag === activeFilter);
return list;
});
async function loadInitial() {
loading = true;
error = null;
try {
const [trending, recommended] = await Promise.all([
HuggingFaceService.getTrending(100),
HuggingFaceService.search({ author: 'ggml-org', limit: 100, sort: 'downloads' })
]);
trendingModels = trending.filter((m) => m.tags.includes('gguf'));
recommendedModels = recommended.filter((m) => m.tags.includes('gguf')).slice(0, 12);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to fetch models';
} finally {
loading = false;
}
}
async function performSearch(query: string) {
const trimmed = query.trim();
if (!trimmed) {
await loadInitial();
return;
}
searchLoading = true;
searchError = null;
try {
searchResults = await HuggingFaceService.searchByQuery(trimmed, { limit: 100 });
} catch (err) {
searchError = err instanceof Error ? err.message : 'Search failed';
searchResults = [];
} finally {
searchLoading = false;
}
}
let timeout: ReturnType<typeof setTimeout> | null = null;
function handleSearchInput(value: string) {
searchQuery = value;
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
performSearch(value);
}, 300);
}
function openModelDetails(model: HfModelInfo) {
goto(RouterService.model(model.id));
}
$effect(() => {
loadInitial();
return () => {
if (timeout) clearTimeout(timeout);
};
});
</script>
<div class="flex min-h-0 flex-col lg:h-full lg:flex-row lg:overflow-hidden {className}">
<div
class="{selectedModelId
? 'hidden lg:flex'
: 'flex'} h-full min-h-0 w-full flex-col lg:w-80 lg:shrink-0 lg:border-r lg:border-border/40"
>
<!-- List header: controls stay pinned while the results scroll below -->
<div class="shrink-0 space-y-3 border-b border-border/40 bg-background/80 p-3 backdrop-blur">
<div class="flex items-center justify-between gap-2">
<h1 class="text-base font-semibold">Models</h1>
{#if !loading}
<span class="text-xs text-muted-foreground">{filteredModels.length}</span>
{/if}
</div>
{#if !isSearching}
<div class="flex rounded-md border bg-muted/40 p-0.5 text-xs font-medium" role="tablist">
<button
type="button"
role="tab"
aria-selected={feed === 'trending'}
onclick={() => (feed = 'trending')}
class="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded px-2 py-1 transition-colors {feed ===
'trending'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
>
Trending
</button>
<button
type="button"
role="tab"
aria-selected={feed === 'recommended'}
onclick={() => (feed = 'recommended')}
class="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded px-2 py-1 transition-colors {feed ===
'recommended'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
>
<Sparkles class="h-3 w-3 text-primary" />
ggml-org
</button>
</div>
{/if}
<SearchInput
bind:value={searchQuery}
placeholder="Search models..."
onInput={handleSearchInput}
/>
{#if !isSearching}
<label class="flex items-center gap-2 text-xs text-muted-foreground">
<span>Sort</span>
<select
bind:value={sortBy}
class="flex-1 rounded-md border bg-background px-2 py-1 text-xs font-medium text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
{#each HuggingFaceService.SORT_OPTIONS as opt (opt)}
{#if opt !== 'trendingScore'}
<option value={opt}>{HuggingFaceService.SORT_LABELS[opt]}</option>
{/if}
{/each}
</select>
</label>
{/if}
{#if !isSearching && availableFilters.length > 0}
<div class="flex flex-wrap items-center gap-1">
<button
type="button"
onclick={() => (activeFilter = null)}
class="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors {activeFilter ===
null
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-background text-muted-foreground hover:border-primary/40 hover:text-foreground'}"
>
All
</button>
{#each availableFilters as f (f.tag)}
{@const isActive = activeFilter === f.tag}
<button
type="button"
onclick={() => (activeFilter = isActive ? null : f.tag)}
class="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors {isActive
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-background text-muted-foreground hover:border-primary/40 hover:text-foreground'}"
>
<IconFromName name={HuggingFaceService.pipelineTagIcon(f.tag)} class="h-3 w-3" />
{HuggingFaceService.pipelineTagLabel(f.tag)}
<span class="text-muted-foreground">{f.count}</span>
</button>
{/each}
</div>
{/if}
</div>
<!-- Scrollable results -->
<div class="min-h-0 flex-1 overflow-y-auto p-2">
{#if error}
<div class="mx-1 rounded-lg border border-destructive/50 bg-destructive/5 p-4 text-center">
<p class="text-xs text-destructive">{error}</p>
</div>
{/if}
{#if searchError}
<div class="mx-1 rounded-lg border border-destructive/50 bg-destructive/5 p-4 text-center">
<p class="text-xs text-destructive">{searchError}</p>
</div>
{/if}
{#if loading || searchLoading}
<div class="flex items-center justify-center py-16">
<p class="text-xs text-muted-foreground">
{isSearching ? 'Searching...' : 'Loading models...'}
</p>
</div>
{:else if filteredModels.length === 0}
<div class="py-16 text-center">
<p class="text-xs text-muted-foreground">
{#if isSearching}
No models found matching "{searchQuery}".
{:else}
No models found.
{/if}
</p>
</div>
{:else}
<div>
{#each filteredModels as model (model.id)}
{@const isActive = model.id === selectedModelId}
<button
type="button"
onclick={() => openModelDetails(model)}
class="flex w-full cursor-pointer items-start gap-2.5 rounded-lg p-2.5 text-left transition-colors {isActive
? 'bg-primary/10 hover:bg-primary/15'
: 'hover:bg-muted/60'}"
>
<div
class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"
>
<IconFromName
name={HuggingFaceService.pipelineTagIcon(model.pipeline_tag)}
class="h-4 w-4"
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<span class="truncate text-sm font-medium">{model.id}</span>
<span class="shrink-0 text-[10px] text-muted-foreground">
{HuggingFaceService.formatRelativeTime(model.createdAt)}
</span>
</div>
{#if model.pipeline_tag}
<p class="mt-0.5 truncate text-xs text-muted-foreground">
{HuggingFaceService.pipelineTagLabel(model.pipeline_tag)}
</p>
{/if}
<div class="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span class="flex items-center gap-1">
<Download class="h-3 w-3" />
{HuggingFaceService.formatDownloads(model.downloads)}
</span>
<span class="flex items-center gap-1">
<Heart class="h-3 w-3" />
{HuggingFaceService.formatLikes(model.likes)}
</span>
</div>
</div>
</button>
{/each}
</div>
{/if}
</div>
</div>
<!-- RIGHT: detail pane -->
<main
class="min-w-0 flex-col {selectedModelId
? 'flex'
: 'hidden lg:flex'} h-full flex-1 lg:border-l lg:border-border/40"
>
{#if selectedModelId}
<ModelDetail modelId={selectedModelId} class="h-full" />
{:else}
<div
class="flex h-full items-center justify-center px-8 text-center text-sm text-muted-foreground"
>
<div class="flex flex-col items-center gap-3">
<div class="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted/60">
<Download class="h-6 w-6 text-muted-foreground/60" />
</div>
<p class="max-w-xs">
Select a model from the list to see its details and download options.
</p>
</div>
</div>
{/if}
</main>
</div>
@@ -0,0 +1,12 @@
<script lang="ts">
import ModelsHub from '../ModelsHub.svelte';
import { page } from '$app/state';
let modelId = $derived(page.params.modelId ?? '');
</script>
<svelte:head>
<title>{modelId ? `${modelId} · llama.cpp` : 'Models · llama.cpp'}</title>
</svelte:head>
<ModelsHub {modelId} />