Compare commits

...
Author SHA1 Message Date
Aleksander Grygier a22c1d344e ui : apply ESLint formatting to models discover components
Assisted-by: Claude
2026-08-28 09:42:04 +02:00
Aleksander Grygier ed857de31d feat: WIP 2026-08-28 09:42:04 +02:00
Aleksander Grygier 2282e8598f feat: WIP 2026-08-28 09:42:04 +02:00
Aleksander Grygier 59c905e6ba feat: WIP 2026-08-28 09:42:04 +02:00
Aleksander Grygier 3fa85af123 feat: WIP 2026-08-28 09:40:57 +02:00
Aleksander Grygier 9ffdcb7865 fix: layout 2026-08-28 09:40:57 +02:00
Aleksander Grygier a56fef4aa5 feat: WIP 2026-08-28 09:40:57 +02:00
Aleksander Grygier 941d7923c6 feat: WIP 2026-08-28 09:40:57 +02:00
Aleksander Grygier 4efb65d24c feat: WIP 2026-08-28 09:40:53 +02:00
Aleksander Grygier 84d6a812ca chore: Format 2026-08-28 09:40:52 +02:00
Aleksander Grygier fe26a3a59e feat(ui): make base dialog responsive and support sticky headers 2026-08-28 09:38:45 +02:00
Aleksander Grygier b7a8df052e chore: Format 2026-08-28 09:37:57 +02:00
Aleksander Grygier 3ef212034b ui : open MCP servers in a dialog from the chat form
Replace the MCP servers submenu with a single "MCP Servers" item that opens
a new DialogMcpServers dialog instead of navigating to the /mcp-servers route.

Assisted-by: pi
2026-08-28 09:37:41 +02:00
Aleksander Grygier e3e74a1bfb ui : fix eslint issues in chat form and model selector 2026-08-28 09:37:03 +02:00
Aleksander Grygier fb20f31a47 ui : add raw model id tooltip to model selector options 2026-08-28 09:36:50 +02:00
Aleksander Grygier 3f7e6974d7 ui : add show-org-name-in-trigger display setting 2026-08-28 09:36:45 +02:00
Aleksander Grygier f140d848ef ui : keep reasoning submenu visible regardless of model state 2026-08-28 09:36:45 +02:00
87 changed files with 5886 additions and 222 deletions
+2
View File
@@ -26,6 +26,7 @@
--border: oklch(0.875 0 0);
--input: oklch(0.92 0 0);
--ring: oklch(0.708 0 0);
--brand: #f65e00;
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
@@ -115,6 +116,7 @@
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-brand: var(--brand);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
+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}
@@ -0,0 +1,51 @@
<script lang="ts">
import { FolderOpen, Server, Zap } from '@lucide/svelte';
import { McpLogo } from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { getChatFormActionsContext } from '$lib/contexts';
const chatFormActions = getChatFormActionsContext();
function handleServersClick() {
chatFormActions.onMcpSettingsClick?.();
}
</script>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<McpLogo class={ICON_CLASS_DEFAULT} />
<span>MCP</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-48">
<DropdownMenu.Item class="flex cursor-pointer items-center gap-2" onclick={handleServersClick}>
<Server class={ICON_CLASS_DEFAULT} />
<span>Servers</span>
</DropdownMenu.Item>
{#if chatFormActions.hasMcpPromptsSupport}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={chatFormActions.onMcpPromptClick}
>
<Zap class={ICON_CLASS_DEFAULT} />
<span>Prompts</span>
</DropdownMenu.Item>
{/if}
{#if chatFormActions.hasMcpResourcesSupport}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={chatFormActions.onMcpResourcesClick}
>
<FolderOpen class={ICON_CLASS_DEFAULT} />
<span>Resources</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
@@ -92,7 +92,7 @@
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
{/if}
{#if reasoning.thinkingEnabled}
{#if reasoning.isReasoningActive}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
{:else if reasoning.isOff}
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
@@ -1,5 +1,12 @@
<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 +31,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,7 +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>
@@ -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';
@@ -48,7 +48,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';
@@ -56,7 +56,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,7 +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>
@@ -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,7 +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>
@@ -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}"
/>
+13 -1
View File
@@ -221,7 +221,19 @@ export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/Chat
export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte';
/**
* Dropdown submenu for selecting reasoning effort level.
* Dropdown submenu for MCP prompts and resources in the chat form.
*
* Shows an "MCP" sub-menu item with entries for MCP Prompts and MCP
* Resources. Only visible when the server supports them.
*
* @example
* ```svelte
* <ChatFormActionAddMcpSubmenu />
* ```
*/
export { default as ChatFormActionAddMcpSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpSubmenu.svelte';
/** Dropdown submenu for selecting reasoning effort level.
*
* Shows a "Reasoning" sub-menu item with a lightbulb icon indicating
* thinking status, and a nested list of effort levels.
@@ -55,6 +55,7 @@
import { detectIncompleteCodeBlock, highlightCode, type IncompleteCodeBlock } from '$lib/utils';
import { sanitizeSvg } from '$lib/utils/sanitize-svg';
import { mountSvgShadow } from '$lib/utils/svg-shadow';
import DOMPurify from 'dompurify';
import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
@@ -77,6 +78,8 @@
content: string;
class?: string;
disableMath?: boolean;
/** Render raw HTML found in the markdown (sanitized) instead of escaping it. */
allowHtml?: boolean;
}
interface MarkdownBlock {
@@ -85,7 +88,13 @@
contentHash?: string;
}
let { attachments, class: className = '', content, disableMath = false }: Props = $props();
let {
allowHtml = false,
attachments,
class: className = '',
content,
disableMath = false
}: Props = $props();
let containerRef = $state<HTMLDivElement>();
let renderedBlocks = $state<MarkdownBlock[]>([]);
@@ -148,6 +157,7 @@
let processor = $derived(() => {
void attachments;
void allowHtml;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let proc: any = remark().use(remarkGfm); // GitHub Flavored Markdown
@@ -155,10 +165,15 @@
proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math
}
proc = proc
.use(remarkBreaks) // Convert line breaks to <br>
.use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation
.use(remarkRehype); // Convert Markdown AST to rehype
proc = proc.use(remarkBreaks); // Convert line breaks to <br>
if (!allowHtml) {
// Treat raw HTML as literal text with preserved indentation
proc = proc.use(remarkLiteralHtml);
}
// Convert Markdown AST to rehype. Keep raw HTML as-is when allowHtml is set.
proc = proc.use(remarkRehype, allowHtml ? { allowDangerousHtml: true } : undefined);
if (!disableMath) {
proc = proc.use(rehypeKatex); // Render math using KaTeX
@@ -261,10 +276,11 @@
const singleNodeRoot = { children: [node], type: 'root' };
const transformedRoot = (await processorInstance.run(singleNodeRoot as MdastRoot)) as HastRoot;
const html = processorInstance.stringify(transformedRoot);
const safeHtml = allowHtml ? (DOMPurify.sanitize(html) as unknown as string) : html;
transformCache.set(hash, html);
transformCache.set(hash, safeHtml);
return { hash, html };
return { hash, html: safeHtml };
}
/**
@@ -463,6 +479,10 @@
)) as HastRoot;
unstableHtml = processorInstance.stringify(transformedRoot);
if (allowHtml) {
unstableHtml = DOMPurify.sanitize(unstableHtml) as unknown as string;
}
}
renderedBlocks = nextBlocks;
@@ -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,25 @@
<script lang="ts">
import { ModelsDiscover } from '$lib/components/app/models/discover';
import * as Dialog from '$lib/components/ui/dialog';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
let { onOpenChange, open = $bindable(false) }: Props = $props();
function handleOpenChange(value: boolean) {
open = value;
onOpenChange?.(value);
}
</script>
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content
class="grid gap-0 p-0 md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-360!"
style="grid-template-columns: auto 1fr;"
>
<ModelsDiscover />
</Dialog.Content>
</Dialog.Root>
@@ -26,6 +26,16 @@ export { default as DialogMcpServerAddNew } from './DialogMcpServerAddNew.svelte
*/
export { default as DialogMcpServers } from './DialogMcpServers.svelte';
/**
* **DialogModelsDiscover** - Models Hub discovery dialog
*
* Two-pane HuggingFace GGUF browser in a modal dialog: a sidebar model list
* (ModelsDiscoverList) and a detail view (ModelsDiscoverDetails). Always opens the
* first model. Used for discovery and downloading; the `/models-hub` route is
* reserved for model management.
*/
export { default as DialogModelsDiscover } from './DialogModelsDiscover.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/discover';
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>
@@ -1,45 +1,67 @@
<script lang="ts">
import { Database, Image, Lightbulb, Mic, ScrollText, Video, Wrench } from '@lucide/svelte';
import { TruncatedText } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
CAPABILITY_FLAG_KEYS,
CAPABILITY_ICONS,
CAPABILITY_LABELS,
MODALITY_FLAG_KEYS,
MODALITY_ICONS,
MODALITY_LABELS
} from '$lib/constants';
import { ModelCapability, ModelModality } from '$lib/enums';
import { type DraftVariant } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import { ModelsService } from '$lib/services/models.service';
import { settingsStore } from '$lib/stores';
import type { ModelCapabilities, ModelModalities } from '$lib/types/models';
import type { ModelModalities } from '$lib/types/models';
import { formatParameters } from '$lib/utils';
interface Props {
modelId: string;
hideOrgName?: boolean;
hideName?: boolean;
hideModalities?: boolean;
hideReasoning?: boolean;
hideParameters?: boolean;
showRaw?: boolean;
showRawTooltip?: boolean;
hideQuantization?: boolean;
hideTags?: boolean;
aliases?: string[];
tags?: string[];
/** Render the capability/modality/context icons on a second row. */
iconsOnNewLine?: boolean;
modalities?: ModelModalities;
capabilities?: ModelCapabilities;
supportsThinking?: boolean;
supportsToolUse?: boolean;
/** Context length in tokens; renders a context icon when set. */
contextLength?: number;
/** Min/max GGUF file size (main + draft) across quants; renders a range when set. */
sizeRange?: { min: number; max: number } | null;
/** Params badge fallback (formatted) when the model id carries no params token. */
params?: string;
draftVariants?: DraftVariant[];
/** Allow badges to wrap onto new lines instead of truncating. */
wrap?: boolean;
class?: string;
}
let {
aliases,
capabilities,
class: className = '',
contextLength,
draftVariants,
hideModalities = false,
hideName = false,
hideOrgName = false,
hideParameters = false,
hideQuantization,
hideReasoning = false,
hideTags,
iconsOnNewLine = false,
modalities,
modelId,
params,
showRaw = undefined,
showRawTooltip = false,
sizeRange,
supportsThinking = false,
supportsToolUse = false,
tags,
wrap = false,
...rest
}: Props = $props();
@@ -47,6 +69,8 @@
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25';
const tagBadgeClass =
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground';
const variantBadgeClass =
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md bg-primary px-1.5 py-0 text-[10px] font-mono font-semibold uppercase tracking-wide text-primary-foreground';
let parsed = $derived(ModelsService.parseModelId(modelId));
let resolvedShowRaw = $derived(
@@ -59,16 +83,8 @@
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
const allModalities = [ModelModality.VISION, ModelModality.VIDEO, ModelModality.AUDIO] as const;
const allCapabilities: ModelCapability[] = [ModelCapability.REASONING];
let activeModalities = $derived(
allModalities.filter((modality) => modalities?.[MODALITY_FLAG_KEYS[modality]])
);
let activeCapabilities = $derived(
allCapabilities.filter((capability) => capabilities?.[CAPABILITY_FLAG_KEYS[capability]])
);
let uniqueDraftVariants = $derived([...new Set(draftVariants ?? [])]);
let hasModalityIcons = $derived(modalities?.vision || modalities?.video || modalities?.audio);
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
@@ -78,17 +94,31 @@
<TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} {...rest} />
{:else}
{#snippet nameAndBadges()}
<span class="min-w-0 truncate font-medium">
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
</span>
{#if !hideName}
<span class="min-w-0 truncate font-medium">
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
</span>
{/if}
<span class="inline-flex items-center gap-1">
{#if parsed.params}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
<span class="inline-flex items-center gap-1 {wrap ? 'flex-wrap' : ''}">
{#if parsed.variant}
<span class={variantBadgeClass} title={`${parsed.variant.toUpperCase()} draft model`}>
{parsed.variant}
</span>
{/if}
{#if (parsed.params || params) && !hideParameters}
<span class={badgeClass}>
{parsed.params ?? params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
</span>
{/if}
{#each uniqueDraftVariants as variant (variant)}
<span class={variantBadgeClass} title={`${variant.toUpperCase()} draft model available`}>
{variant}
</span>
{/each}
{#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>
{parsed.quantization}
@@ -113,51 +143,110 @@
</span>
{/snippet}
<span class="flex min-w-0 items-center gap-1.5 {className}" {...rest}>
{#if showRawTooltip}
<Tooltip.Root>
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
{@render nameAndBadges()}
</Tooltip.Trigger>
<span
class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''} {iconsOnNewLine
? 'flex-col items-start'
: ''} {className}"
{...rest}
>
<span class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''}">
{#if showRawTooltip}
<Tooltip.Root>
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
{@render nameAndBadges()}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{modelId}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render nameAndBadges()}
{/if}
<Tooltip.Content>
<p>{modelId}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render nameAndBadges()}
{/if}
{#if activeCapabilities.length > 0 || activeModalities.length > 0}
<span class="inline-flex items-center gap-1.25 text-muted-foreground">
{#each activeCapabilities as capability (capability)}
{@const CapabilityIcon = CAPABILITY_ICONS[capability]}
{#if supportsToolUse}
<Tooltip.Root>
<Tooltip.Trigger>
<Wrench class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Root>
<Tooltip.Trigger>
<CapabilityIcon class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Tool use</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<Tooltip.Content>
<p>{CAPABILITY_LABELS[capability]}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
{#if supportsThinking && !hideReasoning}
<Tooltip.Root>
<Tooltip.Trigger>
<Lightbulb class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
{#each activeModalities as modality (modality)}
{@const ModalityIcon = MODALITY_ICONS[modality]}
<Tooltip.Content>
<p>Reasoning</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<Tooltip.Root>
<Tooltip.Trigger>
<ModalityIcon class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
{#if hasModalityIcons && !hideModalities}
<span class="inline-flex items-center gap-1.25 text-muted-foreground">
{#if modalities?.vision}
<Tooltip.Root>
<Tooltip.Trigger>
<Image class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>{MODALITY_LABELS[modality]}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</span>
{/if}
<Tooltip.Content>
<p>Vision</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if modalities?.video}
<Tooltip.Root>
<Tooltip.Trigger>
<Video class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Video</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if modalities?.audio}
<Tooltip.Root>
<Tooltip.Trigger>
<Mic class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Audio</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</span>
{/if}
</span>
<span class="inline-flex items-center gap-1.5">
{#if contextLength}
<span class="inline-flex items-center gap-1 text-muted-foreground">
<ScrollText class="h-3 w-3" />
<span class="text-xs">{formatParameters(contextLength)}</span>
</span>
{/if}
{#if sizeRange}
<span class="inline-flex items-center gap-1 text-muted-foreground">
<Database class="h-3 w-3" />
<span class="text-xs"
>{HuggingFaceService.formatSizeRange(sizeRange.min, sizeRange.max)}</span
>
</span>
{/if}
</span>
</span>
{/if}
@@ -0,0 +1,329 @@
<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 onOpenChange={handleOpenChange} {open}>
<AlertDialog.Content class="max-w-md" onkeydown={handleKeydown}>
<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
aria-label="Delete model from cache"
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"
onclick={() => (showDeleteConfirm = true)}
type="button"
>
<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}
cancelText="Cancel"
confirmText="Delete"
description={`Remove "${hfRepoWithTag}" from your cache? Any cached files will be deleted from disk.`}
icon={Trash2}
onCancel={() => (showDeleteConfirm = false)}
onConfirm={handleConfirmDelete}
title="Delete model"
variant="destructive"
/>
@@ -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,70 @@
<script lang="ts">
import { SearchInput } from '$lib/components/app';
import { ModelsDiscoverDetails, ModelsDiscoverList } from '$lib/components/app/models/discover';
import { modelsHubStore } from '$lib/stores';
let selectedId = $state<string | null>(null);
let searchQuery = $state('');
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
// Load the sidebar list on mount (the component is mounted when the dialog opens).
$effect(() => {
void modelsHubStore.fetch();
void modelsHubStore.search('');
});
// Auto-select the first model.
$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>
<aside
class="w-108 shrink-0 self-start border-r border-border/40 bg-background overflow-y-auto md:p-4 h-full space-y-1"
>
<div class="p-2 sticky top-0 z-99">
<SearchInput
bind:value={searchQuery}
class=""
onInput={handleSearchInput}
placeholder="Search models..."
/>
</div>
<div>
{#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}
<ModelsDiscoverList
activeId={selectedId}
models={modelsHubStore.models}
onSelect={(id) => (selectedId = id)}
showBaseModelAvatar
/>
{/if}
</div>
</aside>
<main class="overflow-y-auto">
{#if selectedId}
<ModelsDiscoverDetails modelId={selectedId} />
{/if}
</main>
@@ -0,0 +1,107 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip';
import { DARK_INVERT_AVATAR_ORGS } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
interface Props {
/** Org whose avatar is shown (may differ from the repo's org for base models). */
org: string;
/** Repo's own org, shown as a small corner badge when provided. */
quantOrg?: string;
/** Tailwind size classes for the main avatar (default `h-9 w-9`). */
size?: string;
/** Extra classes appended to the base (main) image. */
baseImageClass?: string;
/** Size classes for the quant corner badge image (default `h-full w-full`). */
quantImageClass?: string;
/** Positioning classes for the quant corner badge (default `-bottom-0.75 -right-0.75`). */
quantPositionClass?: string;
}
let {
baseImageClass = '',
org,
quantImageClass = 'h-full w-full',
quantOrg,
quantPositionClass = '-bottom-0.75 -right-0.75',
size = 'h-9 w-9'
}: Props = $props();
let avatarError = $state(false);
let quantError = $state(false);
let invertAvatar = $derived(DARK_INVERT_AVATAR_ORGS.includes(org));
let invertQuant = $derived(DARK_INVERT_AVATAR_ORGS.includes(quantOrg ?? ''));
// Monogram fallback: 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 < org.length; i++) h = (h * 31 + org.charCodeAt(i)) >>> 0;
return h % 360;
});
let quantHue = $derived.by(() => {
const name = quantOrg ?? '';
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return h % 360;
});
</script>
<span class="relative mt-0.5 inline-flex shrink-0">
{#if avatarError}
<span
aria-hidden="true"
class="flex {size} items-center justify-center rounded-md text-sm font-semibold text-white"
style="background-color: hsl({hue} 60% 45%)"
>
{org.charAt(0).toUpperCase()}
</span>
{:else}
<div class="rounded-md">
<img
alt=""
class="{size} rounded-md {invertAvatar ? 'dark:invert' : ''} {baseImageClass}"
loading="lazy"
onerror={() => (avatarError = true)}
src={HuggingFaceService.getAvatarUrl(org)}
/>
</div>
{/if}
{#if quantOrg && quantOrg !== org}
<Tooltip.Root>
<Tooltip.Trigger
class="absolute {quantPositionClass} h-4.25 w-4.25 overflow-hidden rounded-full border border-background bg-muted "
>
{#if quantError}
<span
aria-hidden="true"
class="flex h-full w-full items-center justify-center rounded-full text-[8px] font-semibold text-white"
style="background-color: hsl({quantHue} 60% 45%)"
>
{quantOrg.charAt(0).toUpperCase()}
</span>
{:else}
<img
alt=""
class="{quantImageClass} rounded-full {invertQuant ? 'dark:invert' : ''}"
loading="lazy"
onerror={() => (quantError = true)}
src={HuggingFaceService.getAvatarUrl(quantOrg)}
/>
{/if}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{quantOrg}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</span>
@@ -0,0 +1,40 @@
<script lang="ts">
import { Copy } from '@lucide/svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { copyToClipboard } from '$lib/utils';
interface Props {
open?: boolean;
chatTemplate: string;
onOpenChange?: (open: boolean) => void;
}
let { chatTemplate, onOpenChange, open = $bindable(false) }: Props = $props();
function handleOpenChange(value: boolean) {
open = value;
onOpenChange?.(value);
}
</script>
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content
class="flex max-h-[calc(100vh-4rem)] flex-col gap-0 p-0 md:w-[calc(100vw-4rem)]! md:max-w-4xl!"
>
<Dialog.Header class="flex-row items-center justify-between border-b border-border/40 p-4">
<Dialog.Title class="text-sm font-semibold">Chat template</Dialog.Title>
<button
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-colors hover:bg-muted"
onclick={() => copyToClipboard(chatTemplate)}
type="button"
>
<Copy class="h-3.5 w-3.5" />
Copy
</button>
</Dialog.Header>
<pre
class="flex-1 overflow-auto p-4 font-mono text-xs break-all whitespace-pre-wrap text-muted-foreground">{chatTemplate}</pre>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,148 @@
<script lang="ts">
import ModelsDiscoverDetailsDownloadOptions from './ModelsDiscoverDetailsDownloadOptions.svelte';
import ModelsDiscoverDetailsHeader from './ModelsDiscoverDetailsHeader.svelte';
import ModelsDiscoverDetailsReadme from './ModelsDiscoverDetailsReadme.svelte';
import TerminalCommands from './TerminalCommands.svelte';
import { type DraftVariant } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types/huggingface';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
interface Props {
/** Full HuggingFace model id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
modelId: string;
}
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 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))
);
// Draft sidecar variants (mtp, dflash, dspark, eagle3) present in the repo,
// e.g. speculative-decoding drafts. mmproj is excluded: it is vision.
let draftVariants = $derived.by<DraftVariant[]>(() => {
const set = new SvelteSet<DraftVariant>();
for (const file of files) {
const variant = HuggingFaceService.extractQuantMeta(file.path)?.variant;
if (variant && variant !== 'mmproj') set.add(variant);
}
return [...set];
});
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(
HuggingFaceService.collapseGgufShards(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">
<ModelsDiscoverDetailsHeader
{baseModels}
{details}
{gguf}
{hasReasoning}
{hasTools}
{hasVision}
{licenseTag}
{modelId}
/>
<ModelsDiscoverDetailsDownloadOptions
{bitDepthRows}
{files}
{modelId}
nativeCtxTokens={gguf?.context_length ?? 0}
/>
<TerminalCommands {draftVariants} {modelId} />
<ModelsDiscoverDetailsReadme {readme} />
</div>
{/if}
@@ -0,0 +1,236 @@
<script lang="ts">
import DialogModelDownload from './DialogModelDownload.svelte';
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { Check, Cpu, Download, TriangleAlert, X } from '@lucide/svelte';
import { browser } from '$app/environment';
import * as Tooltip from '$lib/components/ui/tooltip';
import type { GgufVariantTagInput } from '$lib/services';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore, settingsStore } from '$lib/stores';
import type { HfModelSibling } from '$lib/types/huggingface';
import { computeFileCompatibilityTiers, detectOs, resolveDeviceMemoryGb } from '$lib/utils';
interface Props {
modelId: string;
files: HfModelSibling[];
bitDepthRows: BitDepthRow[];
nativeCtxTokens: number;
}
interface PendingDownload {
filePath: string;
sizeBytes: number | null;
quant: string | null;
variant: GgufVariantTagInput['variant'];
}
type BitDepthRow = { bitDepth: number; files: HfModelSibling[] };
let { bitDepthRows, files, modelId, nativeCtxTokens }: Props = $props();
let pendingDownload = $state<PendingDownload | null>(null);
let deviceMemoryGb = $derived(
resolveDeviceMemoryGb(Number(settingsStore.config.deviceMemoryGb) || 0)
);
let osLabel = $derived(browser ? detectOs(navigator.userAgent) : 'unknown');
let tiers = $derived(computeFileCompatibilityTiers(files, nativeCtxTokens, deviceMemoryGb));
function buttonClass(parts: {
isDownloaded: boolean;
isFailed: boolean;
isUnavailable: boolean;
}): string {
const { isDownloaded, isFailed, isUnavailable } = parts;
const classes = [
'relative inline-flex items-center gap-1 overflow-hidden rounded-md border bg-muted px-2 py-1 text-left font-mono text-xs transition-colors'
];
// Buttons stay neutral; only the leading compatibility badge carries
// color (green/yellow/red). Unavailable quants are greyed + disabled.
if (isUnavailable) {
classes.push('cursor-not-allowed opacity-50');
} else {
classes.push('cursor-pointer hover:border-primary/60 hover:bg-primary/5');
}
if (isDownloaded && !isFailed) {
classes.push('border-foreground bg-muted');
} else if (isFailed) {
classes.push('border-destructive');
}
return classes.join(' ');
}
</script>
{#if bitDepthRows.length}
<section class="rounded-xl border">
<div class="flex flex-wrap items-center justify-between gap-2 px-4 pt-3 pb-1">
<h2 class="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<Download class="h-4 w-4" />
Downloadable options
</h2>
<span
class="inline-flex items-center gap-1.5 rounded-full border bg-background px-2.5 py-1 text-xs font-medium"
>
<Cpu class="h-3 w-3 text-muted-foreground" />
{osLabel}
{#if deviceMemoryGb > 0}
<span class="text-muted-foreground">({deviceMemoryGb} GB)</span>
{/if}
</span>
</div>
<div class="divide-y px-4 pb-1">
{#each bitDepthRows as row (row.bitDepth)}
<div class="grid grid-cols-[5rem_1fr] items-start gap-3 py-3">
<div class="pt-1 text-sm tabular-nums text-muted-foreground">
{#if row.bitDepth === 99}
Other
{:else}
{row.bitDepth}-bit
{/if}
</div>
<div class="flex flex-wrap justify-end gap-1.5">
{#each row.files as file (file.path)}
{@const meta = HuggingFaceService.extractQuantMeta(file.path)}
{@const basename = file.path.split('/').pop() ?? file.path}
{@const label = meta?.quant ?? basename.replace(/\.gguf$/i, '')}
{@const 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)}
{@const tier = tiers.get(file.path)}
{@const isUnavailable =
tier === 'none' && !isDownloaded && !isDownloading && !isFailed}
{@const isAvailable = tier === 'full' && !isDownloaded && !isDownloading && !isFailed}
{@const isLimited =
tier === 'limited' && !isDownloaded && !isDownloading && !isFailed}
{@const tooltipText = isDownloading
? `Downloading ${file.path}`
: isDownloaded
? `Already downloaded: ${file.path}`
: isFailed
? `Last attempt failed: ${file.path}. Click to retry.`
: isUnavailable
? `Does not fit this device: ${file.path}`
: `Download ${file.path}`}
<Tooltip.Root>
<Tooltip.Trigger
aria-disabled={isUnavailable}
class={buttonClass({
isDownloaded,
isFailed,
isUnavailable
})}
onclick={() => {
if (isUnavailable) return;
pendingDownload = {
filePath: file.path,
quant: meta?.quant ?? null,
sizeBytes: file.size ?? null,
variant: meta?.variant ?? null
};
}}
type="button"
>
{#if isAvailable}
<span
class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-green-600"
>
<Check class="h-2.5 w-2.5 text-white" />
</span>
{:else if isLimited}
<span
class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-yellow-500"
>
<TriangleAlert class="h-2.5 w-2.5 text-white" />
</span>
{:else if isUnavailable}
<span
class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-red-600"
>
<X class="h-2.5 w-2.5 text-white" />
</span>
{/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 {isDownloaded ? '' : 'text-muted-foreground/80'}"
>{label}</span
>
<span class="-my-1 w-px self-stretch bg-border"></span>
<span class={isDownloaded ? '' : 'text-muted-foreground/80'}>
{#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
downloadedBytes={progress.downloadedBytes}
overlay
totalBytes={progress.totalBytes}
/>
{/if}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{tooltipText}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
</div>
{/each}
</div>
</section>
{/if}
{#if pendingDownload}
<DialogModelDownload
bind:open={
() => pendingDownload !== null,
(v) => {
if (!v) pendingDownload = null;
}
}
filePath={pendingDownload.filePath}
formattedSize={pendingDownload.sizeBytes != null
? HuggingFaceService.formatFileSize(pendingDownload.sizeBytes)
: undefined}
onCancel={() => (pendingDownload = null)}
onConfirm={() => (pendingDownload = null)}
quant={pendingDownload.quant}
repoId={modelId}
variant={pendingDownload.variant}
/>
{/if}
@@ -0,0 +1,156 @@
<script lang="ts">
import ModelsDiscoverAvatar from './ModelsDiscoverAvatar.svelte';
import ModelsDiscoverChatTemplateDialog from './ModelsDiscoverChatTemplateDialog.svelte';
import ModelsDiscoverDetailsName from './ModelsDiscoverDetailsName.svelte';
import { Download, ExternalLink, Heart, MessageSquareCode } from '@lucide/svelte';
import { ICON_CLASS_SM } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import { modelsHubStore } from '$lib/stores';
import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';
import { formatParameters } from '$lib/utils';
interface Props {
modelId: string;
details: HfModelDetailInfo;
gguf?: HfModelGguf;
baseModels: string[];
licenseTag: string | null;
hasVision: boolean;
hasTools: boolean;
hasReasoning: boolean;
}
let { baseModels, details, gguf, hasReasoning, hasTools, hasVision, licenseTag, modelId }: Props =
$props();
// Avatar shows the base model's org (e.g. the Qwen logo for a ggml-org GGUF)
// with the quant org as a corner badge when they differ.
let repoOrg = $derived(details.id?.split('/')[0] ?? modelId.split('/')[0] ?? modelId);
let baseOrg = $derived(baseModels[0]?.split('/')[0]);
let avatarOrg = $derived(baseOrg || repoOrg);
let quantOrg = $derived(baseOrg && baseOrg !== repoOrg ? repoOrg : undefined);
// Catalog family description when curated, else the HF card description.
let description = $derived(
modelsHubStore.descriptionFor(modelId) ?? details.cardData?.description
);
let chatTemplateOpen = $state(false);
</script>
<header class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<ModelsDiscoverAvatar org={avatarOrg} {quantOrg} />
<ModelsDiscoverDetailsName
{baseModels}
{hasReasoning}
{hasTools}
{hasVision}
modelId={details.id ?? modelId}
/>
</div>
<a
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"
href={HuggingFaceService.getModelUrl(modelId)}
rel="noopener noreferrer"
target="_blank"
>
<img alt="" class="h-3.5 w-3.5" src="/recommended-mcp/huggingface.ico" />
View on Hugging Face
<ExternalLink class={ICON_CLASS_SM} />
</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 description}
<p class="text-sm text-muted-foreground">{description}</p>
{/if}
<!-- Metadata chips: label | value pairs, matching the HF model page style -->
<div class="flex flex-wrap items-center gap-1.5">
{#if gguf?.total}
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
<span class="px-2.5 py-1 text-muted-foreground">Model size</span>
<span class="px-2.5 py-1 font-medium">{formatParameters(gguf.total)} params</span>
</span>
{/if}
{#if gguf?.context_length}
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
<span class="px-2.5 py-1 text-muted-foreground">Context</span>
<span class="px-2.5 py-1 font-medium">{gguf.context_length.toLocaleString()}</span>
</span>
{/if}
{#if gguf?.architecture}
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
<span class="px-2.5 py-1 text-muted-foreground">Architecture</span>
<span class="px-2.5 py-1 font-medium">{gguf.architecture}</span>
</span>
{/if}
{#if gguf?.chat_template}
<button
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-muted"
onclick={() => (chatTemplateOpen = true)}
type="button"
>
<MessageSquareCode class="h-3 w-3" />
Chat template
</button>
{/if}
{#if licenseTag}
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
<span class="px-2.5 py-1 text-muted-foreground">License</span>
<span class="px-2.5 py-1 font-medium">{licenseTag}</span>
</span>
<span class="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
</span>
{/if}
{#if details.gated === true}
<span
class="rounded bg-yellow-500/10 px-2 py-0.5 text-xs font-medium text-yellow-600 dark:text-yellow-400"
>
gated
</span>
{/if}
</div>
</header>
{#if gguf?.chat_template}
<ModelsDiscoverChatTemplateDialog
bind:open={chatTemplateOpen}
chatTemplate={gguf.chat_template}
/>
{/if}
@@ -0,0 +1,79 @@
<script lang="ts">
import { ExternalLink, Image, Lightbulb, Wrench } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { HuggingFaceService } from '$lib/services';
interface Props {
/** Full HuggingFace model id (quant org + name), e.g. `ggml-org/Qwen3.8-27B-GGUF`. */
modelId: string;
/** Base model ids, shown as small text under the quant name. */
baseModels: string[];
hasVision: boolean;
hasTools: boolean;
hasReasoning: boolean;
}
let { baseModels, hasReasoning, hasTools, hasVision, modelId }: Props = $props();
</script>
<div class="min-w-0">
<div class="flex items-center gap-2">
<h1 class="truncate text-lg font-semibold">{modelId}</h1>
{#if hasVision || hasTools || hasReasoning}
<div class="flex shrink-0 items-center gap-2.5 text-muted-foreground">
{#if hasVision}
<Tooltip.Root>
<Tooltip.Trigger>
<Image class="h-4 w-4" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Vision</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if hasTools}
<Tooltip.Root>
<Tooltip.Trigger>
<Wrench class="h-4 w-4" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Tool use</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if hasReasoning}
<Tooltip.Root>
<Tooltip.Trigger>
<Lightbulb class="h-4 w-4" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Reasoning</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
{/if}
</div>
{#if baseModels.length}
<div class="flex items-center gap-1">
<span class="truncate text-xs text-muted-foreground">{baseModels.join(', ')}</span>
<a
aria-label="View base model on HuggingFace"
class="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
href={HuggingFaceService.getModelUrl(baseModels[0])}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink class="h-3 w-3" />
</a>
</div>
{/if}
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { MarkdownContent } from '$lib/components/app';
interface Props {
readme: string | null;
}
let { readme }: Props = $props();
</script>
{#if readme}
<section class="space-y-2 bg-muted/50 p-3 rounded-xl">
<h2 class="text-xs font-semibold tracking-wide text-muted-foreground uppercase">README</h2>
<MarkdownContent allowHtml class="prose-sm max-w-none" content={readme} />
</section>
{/if}
@@ -0,0 +1,150 @@
<script lang="ts">
import ModelId from '../ModelId.svelte';
import { type DraftVariant } from '$lib/constants';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsHubStore } from '$lib/stores';
import type { HfModelInfo } from '$lib/types/huggingface';
import type { ModelModalities } from '$lib/types/models';
import { detectToolUseSupport, formatParameters } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
model: HfModelInfo;
}
let { model }: Props = $props();
let contextLength = $derived(model.gguf?.context_length);
// Params badge fallback: the id usually carries the count (`Qwen3.8-27B`),
// but ids like `Kimi-K3` do not. Fall back to the HF param count
// (`gguf.total`); search results omit `gguf`, so fetch details lazily only
// when the name has no params token.
let fetchedParams = $state<number | null>(null);
$effect(() => {
fetchedParams = null;
if (model.gguf?.total || ModelsService.parseModelId(model.id).params) return;
let cancelled = false;
void HuggingFaceService.getDetails(model.id).then((info) => {
if (!cancelled && info?.gguf?.total) fetchedParams = info.gguf.total;
});
return () => {
cancelled = true;
};
});
let hfParams = $derived(model.gguf?.total ?? fetchedParams);
let paramsFallback = $derived(
hfParams && !ModelsService.parseModelId(model.id).params
? formatParameters(hfParams, 0)
: undefined
);
// Reasoning support from the chat template, matching the details view.
let supportsThinking = $derived(
Boolean(model.gguf?.chat_template && /think|reasoning/i.test(model.gguf.chat_template))
);
// Tool use support from the chat template.
let supportsToolUse = $derived(detectToolUseSupport(model.gguf?.chat_template ?? ''));
// Modalities derived from HF metadata: vision from an mmproj sidecar or a
// multimodal pipeline tag, audio/video from their pipeline tags.
let modalities = $derived.by<ModelModalities>(() => {
const tag = model.pipeline_tag ?? '';
const vision =
['image-text-to-text', 'image-to-text', 'text-to-image', 'image-to-video'].includes(tag) ||
Boolean(model.siblings?.some((s) => s.rfilename.toLowerCase().includes('mmproj')));
const audio = [
'audio-classification',
'audio-to-audio',
'automatic-speech-recognition',
'text-to-speech',
'voice-activity-detection'
].includes(tag);
const video = ['text-to-video', 'image-to-video', 'video-to-video'].includes(tag);
return { audio, video, vision };
});
// Draft sidecars (mtp, dflash, dspark, eagle3) present in the repo, e.g.
// speculative-decoding drafts. mmproj is excluded: it is vision, already
// conveyed by the modalities.
let draftVariants = $derived.by<DraftVariant[]>(() => {
const set = new SvelteSet<DraftVariant>();
for (const sibling of model.siblings ?? []) {
const variant = HuggingFaceService.extractQuantMeta(sibling.rfilename)?.variant;
if (variant && variant !== 'mmproj') set.add(variant);
}
return [...set];
});
// Combined min/max size: the catalog gives main-model sizes per quant, and
// the repo file tree carries draft sidecar sizes (the detail siblings do
// not). Min = smallest main + smallest draft, max = largest main + largest
// draft, so the stored model fits within the range.
let sizeRange = $state<{ min: number; max: number } | null>(null);
$effect(() => {
const base = modelsHubStore.sizeRangeFor(model.id);
let cancelled = false;
if (draftVariants.length === 0) {
sizeRange = base ?? null;
return;
}
void HuggingFaceService.getTree(model.id).then((tree) => {
if (cancelled) return;
const drafts = tree
.filter((f) => {
const variant = HuggingFaceService.extractQuantMeta(f.path)?.variant;
return variant && variant !== 'mmproj';
})
.map((f) => f.size ?? 0)
.filter((size) => size > 0);
if (base && drafts.length > 0) {
sizeRange = {
max: base.max + Math.max(...drafts),
min: base.min + Math.min(...drafts)
};
} else {
sizeRange = base ?? null;
}
});
return () => {
cancelled = true;
};
});
</script>
<span class="min-w-0 flex-1">
<ModelId
class="min-w-0"
{contextLength}
{draftVariants}
hideOrgName
iconsOnNewLine
{modalities}
modelId={model.id}
params={paramsFallback}
{sizeRange}
{supportsThinking}
{supportsToolUse}
wrap
/>
</span>
@@ -0,0 +1,43 @@
<script lang="ts">
import ModelsDiscoverAvatar from './ModelsDiscoverAvatar.svelte';
import ModelsDiscoverInfo from './ModelsDiscoverInfo.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 { active = false, model, onSelect, showBaseModelAvatar = false }: Props = $props();
let org = $derived(model.id.split('/')[0] ?? model.id);
// 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;
});
</script>
<li>
<button
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'}"
onclick={() => onSelect?.(model.id)}
type="button"
>
<ModelsDiscoverAvatar org={avatarOrg} quantOrg={showBaseModelAvatar ? org : undefined} />
<ModelsDiscoverInfo {model} />
</button>
</li>
@@ -0,0 +1,20 @@
<script lang="ts">
import ModelsDiscoverItem from './ModelsDiscoverItem.svelte';
import type { HfModelInfo } from '$lib/types/huggingface';
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 { activeId = null, models, onSelect, showBaseModelAvatar = false }: Props = $props();
</script>
<ul class="space-y-0.5 p-2">
{#each models as model (model.id)}
<ModelsDiscoverItem active={model.id === activeId} {model} {onSelect} {showBaseModelAvatar} />
{/each}
</ul>
@@ -0,0 +1,90 @@
<script lang="ts">
import { Check, Copy, Server, SquareTerminal } from '@lucide/svelte';
import { type DraftVariant } from '$lib/constants';
import { copyToClipboard } from '$lib/utils';
interface Props {
/** Full HuggingFace model id, The draft sidecar sits in the same repo. */
modelId: string;
/** Draft sidecar variants present in the repo (mtp, dflash, dspark, eagle3). */
draftVariants?: DraftVariant[];
}
let { draftVariants = [], modelId }: Props = $props();
// llama.cpp --spec-type value for each draft variant.
const SPEC_TYPE: Record<DraftVariant, string> = {
dflash: 'draft-dflash',
dspark: 'draft-dspark',
eagle3: 'eagle3',
mmproj: '',
mtp: 'draft-mtp'
};
let copiedIndex = $state<number | null>(null);
async function handleCopy(index: number, text: string) {
await copyToClipboard(text);
copiedIndex = index;
setTimeout(() => (copiedIndex = null), 1500);
}
// One box per binary (serve / cli). Each box lists one command per available
// draft sidecar, or just the base command when none is present.
let boxes = $derived.by(() => {
const variants = draftVariants.filter((v) => v !== 'mmproj');
const build = (bin: string) => {
const base = `llama ${bin} -hf ${modelId}`;
if (variants.length === 0) return [base];
return variants.map((v) => `${base} -hfd ${modelId} --spec-type ${SPEC_TYPE[v]}`);
};
return [
{ commands: build('serve'), icon: Server, title: 'Serve' },
{ commands: build('cli'), icon: SquareTerminal, title: 'CLI' }
];
});
</script>
<div class="space-y-3">
{#each boxes as box (box.title)}
<div
class="overflow-hidden rounded-md"
style="background: var(--code-background); border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);"
>
<div
class="flex items-center gap-2 px-3 py-2"
style="border-bottom: 1px solid color-mix(in oklch, var(--border) 30%, transparent);"
>
<box.icon class="h-3.5 w-3.5 text-muted-foreground/60" />
<span class="text-xs font-medium text-foreground/80">{box.title}</span>
</div>
<div class="space-y-1 p-2">
{#each box.commands as cmd, i (cmd)}
<div
class="group flex items-center justify-between gap-2 rounded px-2 py-1 font-mono text-xs"
>
<span class="truncate text-foreground/90">{cmd}</span>
<button
aria-label="Copy command"
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-foreground"
onclick={() => handleCopy(i, cmd)}
type="button"
>
{#if copiedIndex === i}
<Check class="h-3.5 w-3.5 text-green-500" />
{:else}
<Copy class="h-3.5 w-3.5" />
{/if}
</button>
</div>
{/each}
</div>
</div>
{/each}
</div>
@@ -0,0 +1,117 @@
/**
*
* 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.
*
*/
/**
* **ModelsDiscover** - Models hub explorer
*
* The complete discovery layout: a sidebar search + model list on the left and a
* detail view for the selected model on the right. Used as the body of the
* discovery dialog.
*/
export { default as ModelsDiscover } from './ModelsDiscover.svelte';
/**
* **ModelsDiscoverList** - 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 ModelsDiscoverList } from './ModelsDiscoverList.svelte';
/**
* **ModelsDiscoverItem** - Single sidebar row
*
* One model entry in the sidebar list. Links to `/models-hub/[org]/[model]`.
*/
export { default as ModelsDiscoverItem } from './ModelsDiscoverItem.svelte';
/**
* **ModelsDiscoverAvatar** - Org avatar for a model row
*
* Shows the org's avatar image, falling back to a monogram on a stable hue
* derived from the org name when the image fails to load.
*/
export { default as ModelsDiscoverAvatar } from './ModelsDiscoverAvatar.svelte';
/**
* **ModelsDiscoverInfo** - Model name + metadata for a row
*
* Renders the model name via ModelId and the remaining metadata (org, last
* modified, params, downloads, likes, vision) below it.
*/
export { default as ModelsDiscoverInfo } from './ModelsDiscoverInfo.svelte';
/**
* **ModelsDiscoverDetails** - 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 ModelsDiscoverDetails } from './ModelsDiscoverDetails.svelte';
/**
* **ModelsDiscoverDetailsHeader** - Detail view header
*
* Shows the model avatar (base org + quant org corner badge), name, base model
* info, stats, metadata chips and capability badges.
*/
export { default as ModelsDiscoverDetailsHeader } from './ModelsDiscoverDetailsHeader.svelte';
/**
* **ModelsDiscoverDetailsName** - Model name block
*
* Shows the quant model name with capability icons (vision, tool use,
* reasoning) beside it, and the base model name with a smaller external-link
* icon on the line below.
*/
export { default as ModelsDiscoverDetailsName } from './ModelsDiscoverDetailsName.svelte';
/**
* **ModelsDiscoverDetailsDownloadOptions** - GGUF download options
*
* Groups GGUF files by bit depth and renders per-file download buttons with
* progress, owned by the download confirmation dialog.
*/
export { default as ModelsDiscoverDetailsDownloadOptions } from './ModelsDiscoverDetailsDownloadOptions.svelte';
/**
* **ModelsDiscoverChatTemplateDialog** - Chat template viewer
*
* Shows the model's chat template in a scrollable dialog with a copy button.
*/
export { default as ModelsDiscoverChatTemplateDialog } from './ModelsDiscoverChatTemplateDialog.svelte';
/**
* **ModelsDiscoverDetailsReadme** - Detail view README
*
* Renders the model card README as markdown.
*/
export { default as ModelsDiscoverDetailsReadme } from './ModelsDiscoverDetailsReadme.svelte';
/**
* **TerminalCommands** - Terminal command block
*
* Shows the `llama serve` / `llama cli` commands for a model with copy buttons.
*/
export { default as TerminalCommands } from './TerminalCommands.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';
@@ -42,7 +42,7 @@
* />
* ```
*/
export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte';
export { default as ModelsSelectorDropdown } from './selector/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 './selector/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 './selector/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 './selector/ModelsSelectorSheet.svelte';
/** * **ModelBadge** - Model name display badge
*
@@ -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 { ChevronDown, Lightbulb, LoaderCircle, PackageSearch } from '@lucide/svelte';
import {
ChatFormActionAddReasoningSubmenu,
DialogModelInformation,
DialogModelsDiscover,
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);
@@ -96,9 +98,7 @@
for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
for (const group of ms.groupedFilteredOptions.available) {
for (const item of group.items) order.push(item.option.id);
}
for (const item of ms.groupedFilteredOptions.available) order.push(item.option.id);
return order;
});
@@ -169,7 +169,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>
@@ -212,7 +212,7 @@
class={[
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
? 'bg-red-400/10 text-red-400! hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
@@ -243,7 +243,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}
@@ -264,7 +264,7 @@
<DropdownMenu.Content
align="end"
class="w-full md:min-w-64 md:max-w-80 max-w-[calc(100vw-2rem)]"
class="w-full md:min-w-64 max-w-[calc(100vw-2rem)]"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenu.Sub bind:open={modelSubOpen}>
@@ -283,7 +283,7 @@
{/if}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-100 max-w-[calc(100vw-2rem)] pt-0">
<DropdownMenu.SubContent class="max-w-[calc(100vw-2rem)] md:max-w-108 pt-0">
<DropdownMenuSearchable
emptyMessage="No models found."
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
@@ -313,14 +313,15 @@
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
{/if}
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
{#snippet modelOption(item: ModelItem, _hideOrgName: boolean, compact = false)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
{@const isHighlighted = option.id === highlightedId}
{@const isFav = ms.isFavorite(option.model)}
<ModelsSelectorOption
{hideOrgName}
{compact}
hideOrgName={false}
{isFav}
{isHighlighted}
{isSelected}
@@ -352,6 +353,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 +404,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 +427,5 @@
open={ms.showModelDialog}
/>
{/if}
<DialogModelsDiscover 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';
@@ -8,10 +8,9 @@
currentModel: string | null;
activeId: string | null;
sectionHeaderClass?: string;
orgHeaderClass?: string;
onSelect: (modelId: string) => void;
onInfoClick: (modelName: string) => void;
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
renderOption?: import('svelte').Snippet<[ModelItem, boolean, boolean?]>;
}
let {
@@ -20,19 +19,19 @@
groups,
onInfoClick,
onSelect,
orgHeaderClass = 'px-2 py-2 text-[11px] font-semibold text-muted-foreground/50 select-none [&:not(:first-child)]:mt-1',
renderOption,
sectionHeaderClass = 'my-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none'
sectionHeaderClass = 'my-1 px-2 py-2 text-xs font-semibold text-muted-foreground/70 select-none'
}: Props = $props();
let render = $derived(renderOption ?? defaultOption);
</script>
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean, compact = false)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || activeId === option.id}
{@const isFav = modelsStore.favoriteModelIds.has(option.model)}
<ModelsSelectorOption
{compact}
{hideOrgName}
{isFav}
isHighlighted={false}
@@ -64,13 +63,7 @@
{#if groups.available.length > 0}
<p class={sectionHeaderClass}>Available models</p>
{#each groups.available as group (group.orgName)}
{#if group.orgName}
<p class={orgHeaderClass}>{group.orgName}</p>
{/if}
{#each group.items as item (item.option.id)}
{@render render(item, true)}
{/each}
{#each groups.available as item (`avail-${item.option.id}`)}
{@render render(item, false)}
{/each}
{/if}
@@ -1,16 +1,16 @@
<script lang="ts">
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
import {
CircleAlert,
Heart,
HeartOff,
Info,
Loader2,
LoaderCircle,
Power,
PowerOff,
RotateCw
} from '@lucide/svelte';
import { ActionIcon, ModelId } from '$lib/components/app';
import { ActionIcon, ModelId, ModelsDiscoverAvatar } from '$lib/components/app';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores';
@@ -23,6 +23,8 @@
isHighlighted: boolean;
isFav: boolean;
hideOrgName?: boolean;
/** Show only the quant/param badges, hiding the avatar and org/model name. */
compact?: boolean;
onSelect: (modelId: string) => void;
onMouseEnter: () => void;
onKeyDown: (e: KeyboardEvent) => void;
@@ -30,6 +32,7 @@
}
let {
compact = false,
hideOrgName = false,
isFav,
isHighlighted,
@@ -59,9 +62,9 @@
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
let loadTitle = $derived(modelLoadProgressText(loadProgress));
let modalities = $derived(option.modalities);
let capabilities = $derived.by(() => ({
reasoning: modelsStore.props.checkModelSupportsThinking(option.model)
}));
let supportsThinking = $derived(modelsStore.props.checkModelSupportsThinking(option.model));
let quantOrg = $derived(option.parsedId?.orgName || option.model.split('/')[0] || option.model);
let baseOrg = $derived(option.baseModel?.org || quantOrg);
</script>
<div
@@ -83,14 +86,27 @@
tabindex="0"
title={loadTitle}
>
{#if !compact}
<ModelsDiscoverAvatar
org={baseOrg}
quantImageClass="size-3.25"
{quantOrg}
quantPositionClass="-right-1.25 -bottom-1.25"
size="size-5"
/>
{/if}
<ModelId
aliases={option.aliases}
{capabilities}
class="flex-1"
hideModalities={compact}
hideName={compact}
{hideOrgName}
hideParameters={compact}
{modalities}
modelId={option.model}
showRawTooltip
{supportsThinking}
tags={option.tags}
/>
@@ -133,7 +149,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}
@@ -166,7 +166,6 @@
groups={ms.groupedFilteredOptions}
onInfoClick={ms.handleInfoClick}
onSelect={ms.handleSelect}
orgHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
/>
</div>
@@ -194,7 +193,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}
@@ -1,21 +1,15 @@
import { ModelModality } from '$lib/enums';
import type { ModelOption } from '$lib/types/models';
import { SvelteMap } from 'svelte/reactivity';
export interface ModelItem {
option: ModelOption;
flatIndex: number;
}
export interface OrgGroup {
orgName: string | null;
items: ModelItem[];
}
export interface GroupedModelOptions {
loaded: ModelItem[];
favorites: ModelItem[];
available: OrgGroup[];
available: ModelItem[];
}
function matchesModality(option: ModelOption, term: string): boolean {
@@ -77,24 +71,15 @@ export function groupModelOptions(
}
}
// Available models grouped by org (excluding loaded and favorites)
const available: OrgGroup[] = [];
const orgGroups = new SvelteMap<string, ModelItem[]>();
// Available models (excluding loaded and favorites)
const available: ModelItem[] = [];
for (let i = 0; i < filteredOptions.length; i++) {
const option = filteredOptions[i];
if (loadedModelIds.has(option.model) || favoriteIds.has(option.model)) continue;
const key = option.parsedId?.orgName ?? '';
if (!orgGroups.has(key)) orgGroups.set(key, []);
orgGroups.get(key)!.push({ flatIndex: i, option });
}
for (const [orgName, items] of orgGroups) {
available.push({ items, orgName: orgName || null });
available.push({ flatIndex: i, option });
}
return { available, favorites, loaded };
@@ -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',
+1
View File
@@ -44,6 +44,7 @@ export * from './message-export.constants';
export * from './path-display.constants';
export * from './model-id.constants';
export * from './model-loading.constants';
export * from './models-discover.constants';
export * from './precision.constants';
export * from './pwa.constants';
export * from './routes.constants';
@@ -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,
/**
* 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,
/**
* 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,
/** 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,
@@ -27,10 +43,11 @@ export const MODEL_ID = {
PARAMS_RE: /^[Ee]?\d+(\.\d+)?[BbMmKkTt]$/,
/**
* Matches a quantization/precision segment, e.g. `Q4_K_M`, `IQ4_XS`, `F16`, `BF16`, `MXFP4`.
* Case-insensitive to handle both uppercase and lowercase inputs.
* Matches a quantization/precision segment, e.g. `Q4_K_M`, `IQ4_XS`, `TQ1_0`,
* `F16`, `BF16`, `MXFP4`. Case-insensitive to handle both cases.
*/
QUANTIZATION_SEGMENT_RE: /^(I?Q\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i,
QUANTIZATION_SEGMENT_RE:
/^(I?Q\d+(_[A-Z0-9]+)*|TQ\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i,
/** Separates the model path from the quantization tag, e.g. `model:Q4_K_M`. */
QUANTIZATION_SEPARATOR: ':',
@@ -41,3 +58,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';
@@ -0,0 +1,18 @@
/**
* Curated default models shown in the Discover Models sidebar, in display order.
*/
export const CURATED_MODEL_IDS = [
'ggml-org/Qwen3.8-27B-GGUF',
'ggml-org/DeepSeek-V4-Flash-0731-GGUF',
'ggml-org/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-GGUF',
'ggml-org/Qwen3.6-35B-A3B-GGUF',
'ggml-org/Laguna-S-2.1-GGUF',
'ggml-org/gemma-4-31B-it-GGUF',
'ggml-org/gemma-4-26B-A4B-it-GGUF',
'ggml-org/gemma-4-12B-it-GGUF',
'ggml-org/Qwen3.5-0.8B-GGUF',
'ggml-org/gemma-4-E2B-it-GGUF',
'ggml-org/gemma-4-E4B-it-GGUF',
'ggml-org/gpt-oss-120b-GGUF',
'ggml-org/gpt-oss-20b-GGUF'
];
@@ -11,8 +11,14 @@ 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',
/** MCP servers. */
MCP_SERVERS: '#/mcp-servers',
/** Model manager - installed models from /v1/models. */
MODEL_MANAGER: '#/model-manager',
/** Search — mobile-only full-page conversation search. */
SEARCH: '#/search',
/** Root — start of the app. */
@@ -16,6 +16,7 @@ export const SETTINGS_KEYS = {
CUSTOM_CSS: 'customCss',
// PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
CUSTOM_JSON: 'customJson',
DEVICE_MEMORY_GB: 'deviceMemoryGb',
DISABLE_AUTO_SCROLL: 'disableAutoScroll',
// Developer
DISABLE_REASONING_PARSING: 'disableReasoningParsing',
@@ -117,6 +117,14 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
label: 'Show microphone on empty input',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: 0,
help: 'Total device memory (RAM) in GB, used to estimate which models can run. Set to 0 to auto-detect from the browser when possible.',
isPositiveInteger: true,
key: SETTINGS_KEYS.DEVICE_MEMORY_GB,
label: 'Device memory (GB)',
type: SettingsFieldType.INPUT
},
{
defaultValue: false,
help: 'Enable "Continue" button for assistant messages, including reasoning models.',
@@ -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';
@@ -44,6 +45,9 @@ export const STATS_UNITS = {
export const DEFAULT_MOBILE_BREAKPOINT = 768;
/** Orgs whose avatar is dark and needs inverting in dark mode. */
export const DARK_INVERT_AVATAR_ORGS = ['openai'];
/** Icon used for the model selector and the `/model` slash command. */
export const MODEL_SELECTOR_ICON = Package;
@@ -61,6 +65,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',
@@ -1,8 +1,10 @@
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import { modelsStore, serverStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { onMount } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
export interface UseModelsSelectorOptions {
currentModel: () => string | null;
@@ -76,14 +78,47 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
let searchTerm = $state('');
let showModelDialog = $state(false);
let infoModelId = $state<string | null>(null);
let menuOpen = $state(false);
// Base model org per base repo (e.g. `Qwen` for `ggml-org/Qwen3.8-27B-GGUF`),
// resolved lazily from the HF card while the menu is open.
const baseOrgs = new SvelteMap<string, string>();
const filteredOptions = $derived(filterModelOptions(options, searchTerm));
// Augment each option with its base model; the org falls back to the quant
// org until the HF lookup resolves.
const optionsWithBaseModel = $derived(
filteredOptions.map((option) => {
const repo = option.model.split(':')[0];
const org = baseOrgs.get(repo) ?? option.parsedId?.orgName ?? repo.split('/')[0] ?? '';
const name = option.parsedId?.modelName ?? option.name ?? option.model;
return { ...option, baseModel: { name, org } };
})
);
const groupedFilteredOptions = $derived(
groupModelOptions(filteredOptions, modelsStore.favoriteModelIds, (m) =>
groupModelOptions(optionsWithBaseModel, modelsStore.favoriteModelIds, (m) =>
modelsStore.isModelLoaded(m)
)
);
// Fetch base model orgs for the visible repos while the menu is open. The
// service caches per repo, so repeated opens never re-hit the HF API.
$effect(() => {
if (!menuOpen) return;
const repos = new SvelteSet<string>();
for (const option of options) repos.add(option.model.split(':')[0]);
for (const repo of repos) {
if (baseOrgs.has(repo)) continue;
void HuggingFaceService.getBaseModel(repo).then((info) => {
baseOrgs.set(repo, info?.org ?? repo.split('/')[0] ?? '');
});
}
});
function handleInfoClick(modelName: string) {
infoModelId = modelName;
showModelDialog = true;
@@ -98,6 +133,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
function handleOpenChange(open: boolean) {
if (loading || updating) return;
menuOpen = open;
if (isRouter) {
searchTerm = '';
@@ -0,0 +1,803 @@
import { type DraftVariant, MODEL_ID } from '$lib/constants';
import type {
HfCatalogEntry,
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';
// Cached base model lookups keyed by repo id, so repeated selector opens
// never re-hit the HF API for the same repo.
private static baseModelCache = new Map<string, { org: string; name: string } | null>();
private static baseModelPending = new Map<
string,
Promise<{ org: string; name: string } | null>
>();
private static readonly DEFAULT_LIMIT = 50;
private static readonly MAX_LIMIT = 100;
// GGUF Model Searching
/**
* 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
};
/**
* Collapse split GGUF shard sets (`-00001-of-00015.gguf`, ...) to their first
* shard, summing every shard's size so the kept entry reflects the whole
* quant. Non-sharded files pass through unchanged. Downloads are tag-based
* (`repo:quant`), so the first shard is enough to represent the set.
*/
static collapseGgufShards(siblings: HfModelSibling[]): HfModelSibling[] {
const sizeByPath = new Map(siblings.map((f) => [f.path, f.size ?? 0]));
const result: HfModelSibling[] = [];
for (const file of siblings) {
const match = /-(\d{5})-of-(\d{5})\.gguf$/i.exec(file.path);
if (!match) {
result.push(file);
continue;
}
// Keep only the first shard; its size becomes the whole shard set's.
if (match[1] !== '00001') continue;
const total = parseInt(match[2], 10);
const stem = file.path.slice(0, file.path.length - match[0].length);
let size = 0;
for (let i = 1; i <= total; i++) {
const shard = `${stem}-${String(i).padStart(5, '0')}-of-${String(total).padStart(5, '0')}.gguf`;
size += sizeByPath.get(shard) ?? 0;
}
result.push({ ...file, size });
}
return result;
}
// GGUF Model Browsing
/**
* 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 quantIdx = segments.findIndex((seg) => MODEL_ID.QUANTIZATION_SEGMENT_RE.test(seg));
let quant = quantIdx >= 0 ? segments[quantIdx].toUpperCase() : null;
// Recombine a `UD-` (Unsloth Dynamic) prefix, e.g. `...-UD-Q4_K_XL.gguf`.
if (quant && quantIdx > 0 && segments[quantIdx - 1].toUpperCase() === 'UD') {
quant = `UD-${quant}`;
}
return { quant, variant, variantForm };
}
/**
* 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();
}
/**
* 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`;
}
/**
* Format a min-max size range with a single shared unit and no spaces
* around the dash, e.g. `19.0-28.6 GB`.
*/
static formatSizeRange(min: number, max: number): string {
const unit = max >= 1_000_000_000 ? 'GB' : max >= 1_000_000 ? 'MB' : max >= 1_000 ? 'KB' : 'B';
const div =
unit === 'GB' ? 1_000_000_000 : unit === 'MB' ? 1_000_000 : unit === 'KB' ? 1_000 : 1;
const fmt = (n: number) => (div === 1 ? `${n}` : `${(n / div).toFixed(1)}`);
return `${fmt(min)}-${fmt(max)} ${unit}`;
}
// Model Details & Files
/**
* 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}`;
}
/**
* Resolve the original (non-GGUF) base model `{ org, name }` for a GGUF repo
* from its HF card (`cardData.base_model`). Returns null when the card has no
* base model. Results are cached per repo.
*/
static getBaseModel(repoId: string): Promise<{ org: string; name: string } | null> {
const cached = this.baseModelCache.get(repoId);
if (cached !== undefined) return Promise.resolve(cached);
const pending = this.baseModelPending.get(repoId);
if (pending) return pending;
const promise = (async () => {
const details = await this.getDetails(repoId);
const base = this.getBaseModels(details)[0];
if (!base) return null;
const [org, ...rest] = base.split('/');
return { name: rest.join('/'), org };
})();
this.baseModelPending.set(repoId, promise);
promise
.then((result) => this.baseModelCache.set(repoId, result))
.finally(() => this.baseModelPending.delete(repoId));
return promise;
}
/**
* 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]));
}
/**
* Look up the average bit-depth for a known GGUF quantization.
* Returns `null` for unrecognized tokens.
*/
static getBitDepth(quant: string): number | null {
// Strip a leading `UD-` (Unsloth Dynamic) prefix before lookup.
const base = quant.replace(/^UD-/i, '');
const direct = HuggingFaceService.QUANT_BIT_DEPTH[base];
if (direct !== undefined) return direct;
// Fall back to the leading precision digits for variants missing from the
// map, e.g. `Q4_K_XL` -> 4, `IQ2_XXS` -> 2, `TQ1_0` -> 1, `BF16` -> 16.
const match = /^(?:I?Q|TQ|BF|F|MXFP)?(\d+)/i.exec(base);
return match ? parseInt(match[1], 10) : 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
});
}
/**
* Get detailed information about a specific GGUF model
*/
/**
* Fetch the llama.app model catalog (https://llama.app/v1/catalog.json).
* Returns an empty array on failure so callers can fall back gracefully.
*/
static async getCatalog(): Promise<HfCatalogEntry[]> {
const url = 'https://llama.app/v1/catalog.json';
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch catalog: ${response.status}`);
return (await response.json()) as HfCatalogEntry[];
} catch (error) {
console.error('Error fetching catalog:', error);
return [];
}
}
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 model URL on Hugging Face Hub
*/
static getModelUrl(modelId: string): string {
return `https://huggingface.co/${modelId}`;
}
// Utility Methods
/**
* 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' });
}
/**
* 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;
}
}
/**
* Get repository file tree to list available GGUF variants. Recursive so
* repos that keep quants in per-quant subdirectories (e.g. `UD-Q4_K_XL/`)
* are included; follows cursor pagination for repos over one page.
*/
static async getTree(modelId: string): Promise<HfModelSibling[]> {
const files: HfModelSibling[] = [];
let url: string | null =
`https://huggingface.co/api/models/${modelId}/tree/main?recursive=true`;
try {
while (url) {
const response: Response = await fetch(url);
if (!response.ok) return files;
const data = (await response.json()) as HfModelSibling[];
files.push(...data.filter((f) => f.type !== 'directory'));
url = HuggingFaceService.parseNextPageUrl(response.headers.get('Link'));
}
} catch {
// Return whatever was fetched before the failure.
}
return files;
}
/**
* Get trending GGUF models
*/
static async getTrending(
limit: number = HuggingFaceService.DEFAULT_LIMIT
): Promise<HfModelInfo[]> {
return this.search({ limit, sort: 'trendingScore' });
}
/**
* 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 { file: match[2], repo: `${parts[0]}/${parts.slice(1).join('--')}` };
}
/**
* 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()}`;
}
/**
* 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 };
}
/** 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
});
}
/**
* 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));
}
// Internal Methods
/**
* 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;
}
}
/** Extract the `rel="next"` URL from an RFC 5988 `Link` header, if present. */
private static parseNextPageUrl(linkHeader: string | null): string | null {
if (!linkHeader) return null;
const match = /<([^>]+)>;\s*rel="next"/.exec(linkHeader);
return match ? match[1] : null;
}
/** 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;
}
}
+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,140 @@
/**
* modelsHubStore - Model Hub browse state
*
* Owns the HuggingFace GGUF model list shown in the hub sidebar
* (DialogModelsDiscover). The hub has no "nothing selected" screen: it always opens
* a model, so `firstModel` drives the initial selection. By default the list
* shows a curated set of official ggml-org GGUF models in a fixed display order;
* search replaces the list with matching models across all of HuggingFace.
* Detail data is loaded by ModelsDiscoverDetails, not here.
*/
import { HuggingFaceService } from '$lib/services';
import type { HfCatalogEntry, HfModelInfo } from '$lib/types/huggingface';
class ModelsHubStore {
error = $state<string | null>(null);
models = $state<HfModelInfo[]>([]);
/** First model in the list - the hub auto-opens this one. */
firstModel = $derived(this.models[0] ?? null);
loading = $state(false);
private catalog: HfCatalogEntry[] = [];
private defaultModels: HfModelInfo[] = [];
private fetched = false;
private searchRequestId = 0;
/**
* Catalog family description for a repo id, or undefined when the repo is
* not part of the catalog (e.g. a search result outside the curated list).
*/
descriptionFor(modelId: string): string | undefined {
return this.catalog.find((entry) =>
entry.sizes.some((size) => size.builds.some((build) => build.repo === modelId))
)?.description;
}
/**
* Fetch the default list from the llama.app catalog, flattened to a flat
* list of ggml-org repo ids in catalog order (one per size). Each repo is
* fetched directly by ID, so the list is independent of download ranking.
* 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 catalog = await HuggingFaceService.getCatalog();
this.catalog = catalog;
const ids = this.catalogModelIds(catalog);
// getDetails returns full metadata (downloads, likes, lastModified,
// siblings, tags, gguf) for a single model.
this.defaultModels = (
await Promise.all(ids.map((id) => HuggingFaceService.getDetails(id)))
).filter((m): m is HfModelInfo => m !== null);
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, { full: true, limit: 50 });
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';
}
}
}
/**
* Min/max GGUF file size (bytes) across the available quants for a repo,
* or undefined when the repo is not part of the catalog.
*/
sizeRangeFor(modelId: string): { min: number; max: number } | undefined {
for (const entry of this.catalog) {
for (const size of entry.sizes) {
const builds = size.builds.filter((b) => b.repo === modelId);
if (builds.length === 0) continue;
const bytes = builds.map((b) => b.sizeBytes);
return { max: Math.max(...bytes), min: Math.min(...bytes) };
}
}
return undefined;
}
/**
* Flatten the catalog to a flat list of ggml-org repo ids, newest family
* first (by release date). Returns an empty array when the catalog is empty.
*/
private catalogModelIds(catalog: HfCatalogEntry[]): string[] {
return [...catalog]
.sort((a, b) => b.released.localeCompare(a.released))
.flatMap((entry) =>
entry.sizes.flatMap((size) => {
const build = size.builds.find((b) => b.repo.startsWith('ggml-org/'));
return build ? [build.repo] : [];
})
);
}
}
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,6 +34,33 @@ export interface ModelStatusHost {
}
export class ModelStatusManager {
/**
* 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;
});
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>();
// /models/sse feed state, the single source of truth for status and load progress
@@ -41,14 +71,105 @@ 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;
}
}
/**
* 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;
}
}
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 +177,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 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}`);
}
/**
* 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);
}
isOperationInProgress(modelId: string): boolean {
return this.loadingStates.get(modelId) ?? false;
}
@@ -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
+221
View File
@@ -0,0 +1,221 @@
/**
* 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[];
/** GGUF metadata (context length, architecture, etc.) */
gguf?: HfModelGguf;
}
// 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;
}
// llama.app model catalog (https://llama.app/v1/catalog.json)
/** A single GGUF build/repo within a catalog size. */
export interface HfCatalogBuild {
quant: string;
size: string;
sizeBytes: number;
repo: string;
}
/** A size variant (e.g. `GPT-OSS 20B`) within a catalog entry. */
export interface HfCatalogSize {
name: string;
params: string;
builds: HfCatalogBuild[];
}
/** A single model family in the catalog. `featured` marks the staff picks. */
export interface HfCatalogEntry {
name: string;
brand: string;
description: string;
details: string;
released: string;
license: string;
featured?: boolean;
maxMemGb?: number;
sizes: HfCatalogSize[];
}
+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 { ModelOption } from './models';
import type { DraftVariant } from '$lib/constants';
/**
* 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[];
}
+13
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 {
@@ -22,6 +23,8 @@ export interface ModelOption {
parsedId?: ParsedModelId;
aliases?: string[];
tags?: string[];
/** Original (non-GGUF) base model, resolved from the HF card. */
baseModel?: { org: string; name: string };
}
/**
@@ -35,6 +38,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 +54,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.
+1 -1
View File
@@ -16,7 +16,7 @@ import {
} from '$lib/constants';
import type { ToolExecutionResult } from '$lib/types';
function detectOs(userAgent: string): string {
export function detectOs(userAgent: string): string {
for (const [pattern, os] of BROWSER_INFO_OS_UA_PATTERNS) {
if (pattern.test(userAgent)) return os;
}
@@ -0,0 +1,28 @@
/**
* Detects whether a model's chat template supports tool calling.
*
* There is no server flag for tool support, so we infer it from the chat
* template. A template that accepts a `tools` array or emits tool-call tokens
* is treated as tool-capable.
*/
/** Tool-call tokens emitted by the template for assistant tool calls. */
const TOOL_CALL_TOKENS = [
'tool_call',
'tool_calls',
'function_call',
'tool_use',
'<tool',
'<|tool',
'TOOL_CALL'
];
/** Jinja reference to the `tools` array passed in by the caller. */
const JINJA_TOOLS_VAR = /\{\{[^{}]*\btools\b[^{}]*\}\}|\{%[^{}]*\btools\b[^{}]*%\}/i;
export function detectToolUseSupport(t: string): boolean {
if (!t) return false;
if (JINJA_TOOLS_VAR.test(t)) return true;
return TOOL_CALL_TOKENS.some((token) => t.includes(token));
}
+10 -5
View File
@@ -26,24 +26,29 @@ export function formatFileSize(bytes: number | unknown): string {
}
/**
* Format parameter count to human-readable format (B, M, K)
* Format parameter count to human-readable format (T, B, M, K)
*
* @param params - Parameter count
* @param decimals - Decimal places to keep (0 rounds to a full number)
* @returns Human-readable parameter count
*/
export function formatParameters(params: number | unknown): string {
export function formatParameters(params: number | unknown, decimals = 2): string {
if (typeof params !== 'number') return 'Unknown';
if (params >= 1e12) {
return `${(params / 1e12).toFixed(decimals)}T`;
}
if (params >= 1e9) {
return `${(params / 1e9).toFixed(2)}B`;
return `${(params / 1e9).toFixed(decimals)}B`;
}
if (params >= 1e6) {
return `${(params / 1e6).toFixed(2)}M`;
return `${(params / 1e6).toFixed(decimals)}M`;
}
if (params >= 1e3) {
return `${(params / 1e3).toFixed(2)}K`;
return `${(params / 1e3).toFixed(decimals)}K`;
}
return params.toString();
+21 -2
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,23 @@ export {
// Model name utilities
export { normalizeModelName, isValidModelName } from './model-names';
// Model manager tree utilities
export {
buildModelManagerTree,
getRepoId,
heuristicParentId,
normalizeParentName,
resolveBaseModel
} from './model-manager';
// Model hardware-compatibility estimation
export {
computeFileCompatibilityTiers,
deviceMemoryBudgetMb,
resolveDeviceMemoryGb
} from './model-compatibility';
export type { CompatibilityTier } from './model-compatibility';
// Portal utilities
export { portalToBody } from './portal-to-body';
@@ -254,6 +271,8 @@ export {
detectThinkingSupportWithReason
} from './chat-template-thinking-detector';
export { detectToolUseSupport } from './chat-template-tool-detector';
// Agentic content utilities (structured section derivation)
export {
deriveAgenticSections,
@@ -339,7 +358,7 @@ export { buildSandboxToolDefinition, SANDBOX_TOOL_DEFINITION } from './sandbox-t
export { executeGetDatetimeTool } from './get-datetime';
// Browser fallback for the server's get_info tool
export { executeBrowserInfoTool } from './browser-info';
export { detectOs, executeBrowserInfoTool } from './browser-info';
// Cryptography utilities
@@ -0,0 +1,298 @@
/**
* Model hardware-compatibility estimation, ported from ggml-org/llama-macos
* (`Model+Compatibility.swift`, `HFRepoResolver.swift`, `SidecarPicker.swift`).
*
* A quant "fits" when its estimated runtime memory stays within the device
* budget. The budget mirrors llama.cpp's own fit target: the GPU working set
* (approximated from RAM) minus a fit slack, clamped by an OS floor so the
* desktop keeps enough to run. We cannot read Metal's working set from a
* browser, so the working set is approximated as 75% of RAM (Apple's ratio on
* the machines llama-macos targets).
*
* Compatibility tiers:
* - `full` -> fits at the model's native max context (green)
* - `limited` -> fits only at a reduced context (yellow)
* - `none` -> does not fit even at the minimum context (red)
*/
import { browser } from '$app/environment';
import { HuggingFaceService } from '$lib/services';
import type { HfModelSibling } from '$lib/types/huggingface';
/** llama.cpp's default `--fit-target` margin, in MB. */
const FIT_SLACK_MB = 1024;
/** Physical RAM kept out of a model's reach for the OS and other apps, in MB. */
const OS_FLOOR_MB = 4096;
/** Overhead multiplier applied to the file size when estimating weight memory. */
const WEIGHT_OVERHEAD_MULTIPLIER = 1.05;
/** Fraction of RAM the GPU working set is approximated as (Apple's ~75%). */
const WORKING_SET_FRACTION = 0.75;
/** Minimum context a model must support to launch, matching llama.cpp's default. */
const MIN_CTX_TOKENS = 4096;
/** Standard context tiers, ascending, used to find the largest fitting one. */
const CTX_TIERS = [4096, 8192, 16384, 32768, 65536, 131072, 262144] as const;
export type CompatibilityTier = 'full' | 'limited' | 'none';
const MB = 1024 * 1024;
/** Hardcoded device RAM (GB) for testing the compatibility UI; 0 disables it. */
const TEST_DEVICE_MEMORY_GB = 128;
/**
* Resolve the device memory in GB: the user's settings override when set,
* else the browser's `navigator.deviceMemory` (Chrome/Edge only, capped at 8).
* Returns 0 when neither is available, which callers treat as "unknown".
*/
export function resolveDeviceMemoryGb(configuredGb: number): number {
// Hardcoded device RAM for testing the compatibility UI; 0 disables the
// override. TODO: remove once the device-memory source is trusted.
if (TEST_DEVICE_MEMORY_GB > 0) return TEST_DEVICE_MEMORY_GB;
if (configuredGb > 0) return configuredGb;
if (!browser) return 0;
const nav = navigator as Navigator & { deviceMemory?: number };
return typeof nav.deviceMemory === 'number' && nav.deviceMemory > 0 ? nav.deviceMemory : 0;
}
/**
* Memory a model may use, in MB: whichever of the GPU working set (less the
* fit slack) or the RAM-less-OS-floor binds first. Returns 0 when unknown.
*/
export function deviceMemoryBudgetMb(deviceMemoryGb: number): number {
if (deviceMemoryGb <= 0) return 0;
const physicalMb = deviceMemoryGb * 1024;
const gpuLimbMb = physicalMb * WORKING_SET_FRACTION - FIT_SLACK_MB;
const ramLimbMb = physicalMb - OS_FLOOR_MB;
return Math.max(Math.min(gpuLimbMb, ramLimbMb), 0);
}
/**
* Map every GGUF file in the repo to a compatibility tier. Main quants get
* their own tier; their shards and sidecars (mmproj + draft head) inherit the
* matched main quant's tier so the whole group reads consistently.
*/
export function computeFileCompatibilityTiers(
files: HfModelSibling[],
nativeCtxTokens: number,
deviceMemoryGb: number
): Map<string, CompatibilityTier> {
const tiers = new Map<string, CompatibilityTier>();
// Unknown device memory: leave every file untiered (neutral) rather than
// guessing a fit we cannot back up.
if (deviceMemoryGb <= 0) return tiers;
const allPaths = new Set(files.map((f) => f.path));
const sizeByPath = new Map(files.map((f) => [f.path, f.size ?? 0]));
const budgetMb = deviceMemoryBudgetMb(deviceMemoryGb);
// Candidate mains: skip draft heads, mmproj, imatrix, and non-first shards.
const mains = files.filter((f) => {
const meta = HuggingFaceService.extractQuantMeta(f.path);
if (!meta || meta.variant !== null) return false;
if ((f.path.split('/').pop() ?? f.path).toLowerCase().includes('imatrix')) return false;
return !isNonFirstShard(f.path);
});
// Track each main's quant bits + directory so draft sidecars can be matched
// to their closest-quant main for a tier below.
const mainInfos: MainQuantInfo[] = [];
for (const main of mains) {
// Aggregate size: main + shards + mmproj + quant-matched draft.
const picked = expandShards(main.path, allPaths);
const mmproj = pickSidecar(main.path, files, (v) => v === 'mmproj');
const draft = pickSidecar(main.path, files, (v) => v !== null && v !== 'mmproj');
if (mmproj) picked.push(mmproj.path);
const mainBytes = picked.reduce((sum, p) => sum + (sizeByPath.get(p) ?? 0), 0);
const draftBytes = draft ? (sizeByPath.get(draft.path) ?? 0) : 0;
const tier = compatibilityTier(mainBytes + draftBytes, nativeCtxTokens, budgetMb);
for (const p of picked) tiers.set(p, tier);
if (draft) tiers.set(draft.path, tier);
const meta = HuggingFaceService.extractQuantMeta(main.path);
const bits = meta?.quant ? (HuggingFaceService.getBitDepth(meta.quant) ?? 0) : 0;
mainInfos.push({ bits, dirs: dirComponents(main.path), tier });
}
// Assign every remaining draft sidecar (mtp, dflash, ...) the tier of its
// closest-quant main, so all variant badges show a fit icon - not just the
// single draft counted toward a main's size.
for (const file of files) {
if (tiers.has(file.path)) continue;
const meta = HuggingFaceService.extractQuantMeta(file.path);
if (!meta?.variant || meta.variant === 'mmproj') continue;
const main = bestMainForSidecar(file.path, meta.quant, mainInfos);
if (main) tiers.set(file.path, main.tier);
}
return tiers;
}
interface MainQuantInfo {
bits: number;
dirs: string[];
tier: CompatibilityTier;
}
/**
* Find the main quant a draft sidecar pairs with: among mains in the
* sidecar's directory or a descendant of it, the one with the closest quant
* bit depth.
*/
function bestMainForSidecar(
sidecarPath: string,
sidecarQuant: string | null,
mains: MainQuantInfo[]
): MainQuantInfo | null {
const sidecarDirs = dirComponents(sidecarPath);
const sidecarBits = sidecarQuant ? (HuggingFaceService.getBitDepth(sidecarQuant) ?? 0) : 0;
let best: { diff: number; main: MainQuantInfo } | null = null;
for (const main of mains) {
// The sidecar's directory must be the main's directory or an ancestor.
if (sidecarDirs.length > main.dirs.length) continue;
if (!sidecarDirs.every((d, i) => main.dirs[i] === d)) continue;
const diff = Math.abs(main.bits - sidecarBits);
if (!best || diff < best.diff) best = { diff, main };
}
return best?.main ?? null;
}
/**
* Weight memory (MB) is the file size with overhead; context memory scales
* with the requested window. A quant is `full` when it fits at the native max
* context, `limited` when it only fits at a smaller standard tier, and `none`
* when it does not fit even at the minimum context.
*/
function compatibilityTier(
totalBytes: number,
nativeCtxTokens: number,
budgetMb: number
): CompatibilityTier {
// A known-but-tiny budget (<= 0) means nothing fits; unknown memory is
// handled by the caller returning no tiers at all.
const weightMb = (totalBytes / MB) * WEIGHT_OVERHEAD_MULTIPLIER;
const ctxBytesPer1k = ctxBytesPer1kTokens(nativeCtxTokens);
const fits = (ctxTokens: number) =>
weightMb + (ctxBytesPer1k * (ctxTokens / 1000)) / MB <= budgetMb;
if (nativeCtxTokens < MIN_CTX_TOKENS) return 'none';
if (fits(nativeCtxTokens)) return 'full';
// Find the largest standard tier that still fits within the native window.
const largestFitting = [...CTX_TIERS]
.filter((t) => t <= nativeCtxTokens)
.reverse()
.find((t) => fits(t));
return largestFitting !== undefined ? 'limited' : 'none';
}
/**
* Approximate KV-cache bytes per 1k tokens. Without a MemProfile probe (which
* only exists post-launch in llama-macos) we estimate from the native context
* window; ~0.1 MB per 1k tokens is a conservative mid-range for modern models.
*/
function ctxBytesPer1kTokens(_nativeCtxTokens: number): number {
return 0.1 * MB;
}
/** Directory components of a repo-relative path (`Q4_K_M/a.gguf` -> `["Q4_K_M"]`). */
function dirComponents(path: string): string[] {
return path.split('/').slice(0, -1);
}
/** True for a split-shard continuation (`-00002-of-00003.gguf`), not the first shard. */
function isNonFirstShard(path: string): boolean {
const match = /-(\d{5})-of-(\d{5})\.gguf$/i.exec(path);
return match !== null && match[1] !== '00001';
}
/** Expand a main GGUF to its full shard set; non-sharded files return `[main]`. */
function expandShards(main: string, allPaths: Set<string>): string[] {
const match = /-(\d{5})-of-(\d{5})\.gguf$/i.exec(main);
if (!match) return [main];
const total = parseInt(match[2], 10);
const stem = main.slice(0, main.length - match[0].length);
const shards: string[] = [];
for (let i = 1; i <= total; i++) {
const shard = `${stem}-${String(i).padStart(5, '0')}-of-${String(total).padStart(5, '0')}.gguf`;
if (allPaths.has(shard)) shards.push(shard);
}
return shards.length > 0 ? shards : [main];
}
/**
* Pick the best sidecar (mmproj or draft head) for a main file, mirroring
* llama.cpp's `find_best_sibling`: the candidate's directory must be the main
* file's directory or an ancestor; candidates rank by deepest directory, then
* exact quant-tag match, then closest quant-bit distance.
*/
function pickSidecar(
main: string,
files: HfModelSibling[],
isCandidate: (variant: string | null) => boolean
): HfModelSibling | null {
const mainDirs = dirComponents(main);
const mainMeta = HuggingFaceService.extractQuantMeta(main);
const mainBits = mainMeta?.quant ? (HuggingFaceService.getBitDepth(mainMeta.quant) ?? 0) : 0;
const mainTag = mainMeta?.quant?.toUpperCase();
let best: { depth: number; diff: number; exact: boolean; file: HfModelSibling } | null = null;
for (const file of files) {
const meta = HuggingFaceService.extractQuantMeta(file.path);
if (!meta || !isCandidate(meta.variant)) continue;
const dirs = dirComponents(file.path);
if (dirs.length > mainDirs.length || !dirs.every((d, i) => mainDirs[i] === d)) continue;
const depth = dirs.length;
const bits = meta.quant ? (HuggingFaceService.getBitDepth(meta.quant) ?? 0) : 0;
const diff = Math.abs(bits - mainBits);
const exact = mainTag ? file.path.toUpperCase().includes(`-${mainTag}.`) : false;
if (best) {
const better =
depth > best.depth ||
(depth === best.depth && exact && !best.exact) ||
(depth === best.depth && exact === best.exact && diff < best.diff);
if (!better) continue;
}
best = { depth, diff, exact, file };
}
return best?.file ?? null;
}
+152
View File
@@ -0,0 +1,152 @@
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 = { drafts: [], main: option, mmproj: null, quant };
quantMap.set(key, entry);
}
if (variant === 'mmproj') {
entry.mmproj = option;
} else if (variant) {
entry.drafts.push({ option, variant });
} 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({ orgName, quants, repoId });
}
// 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,273 @@
<script lang="ts">
import {
Copy,
ExternalLink,
Heart,
HeartOff,
LoaderCircle,
Plus,
Power,
PowerOff,
Trash2,
X
} from '@lucide/svelte';
import { ActionIcon, DialogModelsDiscover, 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 = 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
aliases={option.aliases}
{hideQuantization}
modalities={option.modalities}
modelId={option.model}
showRawTooltip
supportsThinking={modelsStore.props.checkModelSupportsThinking(option.model)}
tags={option.tags}
/>
</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}
onclick={() => modelsStore.status.cancelLoad(option.model)}
tooltip="Cancel load"
/>
{:else if isDownloading}
<ActionIcon
icon={X}
onclick={() => modelsStore.status.cancelDownload(option.model)}
tooltip="Cancel download"
/>
{:else if isLoaded}
<ActionIcon
icon={PowerOff}
onclick={() => modelsStore.status.unload(option.model)}
tooltip="Unload model"
/>
{:else}
<ActionIcon
icon={Power}
onclick={() => modelsStore.status.load(option.model)}
tooltip="Load model"
/>
{/if}
{#if canRemove && !isDownloading}
<ActionIcon
icon={Trash2}
onclick={() => modelsStore.status.cancelDownload(option.model)}
tooltip="Delete model"
/>
{/if}
{/if}
<ActionIcon
icon={isFav ? HeartOff : Heart}
onclick={() => modelsStore.toggleFavorite(option.model)}
tooltip={isFav ? 'Remove from favorites' : 'Add to favorites'}
/>
<ActionIcon
icon={Copy}
onclick={() => copyToClipboard(option.model)}
tooltip="Copy model id"
/>
{#if hfLink}
<a
aria-label="View on HuggingFace"
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"
href={hfLink}
rel="noreferrer"
target="_blank"
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 onclick={() => (modelsHubOpen = true)} size="sm">
<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 onclick={() => (modelsHubOpen = true)} size="sm" variant="outline">
<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 class="px-3 py-1.5 text-xs font-semibold text-muted-foreground" colspan="3">
{parent.parentId}
</td>
</tr>
{#each parent.quantOrgs as org (org.repoId)}
<tr class="border-t bg-muted/10">
<td class="px-6 py-1.5 text-xs font-medium text-muted-foreground" colspan="3">
{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>
<DialogModelsDiscover 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 aria-label="Breadcrumb" class={className}>
<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
class="text-muted-foreground transition-colors hover:text-foreground"
href={item.href}
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,196 @@
<script lang="ts">
import DownloadToast from './DownloadToast.svelte';
import { Download, LoaderCircle, TriangleAlert } from '@lucide/svelte';
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';
import { toast } from 'svelte-sonner';
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';
let phase = $state<Phase>('pending');
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';
});
// 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));
function handleKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER && phase === 'pending') {
event.preventDefault();
void trigger();
}
}
// The dialog is always closable - downloads run in the background and are
// tracked by a toast, so closing mid-flight never aborts the download.
function handleOpenChange(newOpen: boolean) {
if (newOpen) {
lastError = null;
phase = 'pending';
return;
}
onCancel();
}
async function trigger() {
if (phase === 'starting') return;
phase = 'starting';
lastError = null;
// 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);
// Download runs on the server; hand progress off to a toast and close.
showDownloadToast();
onConfirm();
} catch (error) {
lastError = error instanceof Error ? error.message : 'Failed to start download';
phase = 'pending';
}
}
function showDownloadToast() {
toast.custom(DownloadToast, {
componentProps: {
displayName: filePath,
repoId,
repoWithTag: hfRepoWithTag
},
dismissible: true,
duration: Infinity
});
}
</script>
<AlertDialog.Root onOpenChange={handleOpenChange} {open}>
<AlertDialog.Content class="max-w-md" onkeydown={handleKeydown}>
<AlertDialog.Header>
<AlertDialog.Title class="flex items-center gap-2">
<Download class="h-5 w-5 text-primary" />
Download this model?
</AlertDialog.Title>
<AlertDialog.Description>
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. The download runs
in the background and its progress is shown in a notification.
</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>
<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>
</div>
{#if lastError}
<p class="text-xs text-destructive">{lastError}</p>
{/if}
<AlertDialog.Footer>
<AlertDialog.Cancel disabled={phase === 'starting'} onclick={onCancel}>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action disabled={phase === 'starting'} onclick={trigger}>
{#if phase === 'starting'}
<LoaderCircle class="mr-1.5 h-4 w-4 animate-spin" />
Starting...
{:else}
<Download class="mr-1.5 h-4 w-4" />
{previousFailure ? 'Retry download' : 'Download'}
{/if}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -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,94 @@
<script lang="ts">
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { ArrowUpRight, Check, TriangleAlert } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { modelsStore } from '$lib/stores';
interface Props {
/** `<repo>:<tag>` identifier the server reports progress under. */
repoWithTag: string;
/** Repo id (no tag) used by the "Open details" CTA. */
repoId: string;
/** Short label for the file being downloaded. */
displayName: string;
/** Injected by sonner; dismisses this toast. */
closeToast?: () => void;
}
let { closeToast, displayName, repoId, repoWithTag }: Props = $props();
// Live progress from the /models/sse feed. The map entry is deleted on
// download_finished / download_failed, so we latch on first sight to tell
// "not started yet" apart from "finished".
let progress = $derived(modelsStore.status.getDownloadProgress(repoWithTag));
let hasSeenProgress = $state(false);
let percent = $derived.by(() => {
if (!progress || progress.totalBytes <= 0) return 0;
return Math.round((progress.downloadedBytes / progress.totalBytes) * 100);
});
$effect(() => {
if (progress) hasSeenProgress = true;
});
// A download is done when the feed drops our entry after we saw progress, or
// when the model landed in /v1/models / a failure was recorded (covers fast
// downloads that settle before the first progress event reaches this toast).
let failed = $derived(modelsStore.status.hasFailedDownload(repoWithTag));
let modelReady = $derived(modelsStore.status.isModelDownloaded(repoWithTag));
let done = $derived(
(hasSeenProgress && !modelsStore.status.isDownloadInProgress(repoWithTag)) ||
failed ||
modelReady
);
// Auto-dismiss once the download settles; keep the failure visible a bit longer.
$effect(() => {
if (!done) return;
const timer = setTimeout(() => closeToast?.(), failed ? 4000 : 2500);
return () => clearTimeout(timer);
});
function openDetails() {
closeToast?.();
goto(`/temp/models-hub/${repoId}`);
}
</script>
<div class="flex w-full flex-col gap-2">
<div class="flex items-center justify-between gap-3">
<span class="flex items-center gap-1.5 text-sm font-medium">
{#if done && failed}
<TriangleAlert class="h-4 w-4 text-destructive" />
Download failed
{:else if done}
<Check class="h-4 w-4 text-emerald-500" />
Download complete
{:else}
<span class="h-4 w-4 animate-pulse rounded-full bg-primary/30"></span>
Downloading
{/if}
</span>
<span class="font-mono text-xs tabular-nums text-muted-foreground">{percent}%</span>
</div>
<p class="truncate text-xs text-muted-foreground">{displayName}</p>
<DownloadProgressBar
downloadedBytes={progress?.downloadedBytes ?? 0}
totalBytes={progress?.totalBytes ?? 0}
/>
<button
class="inline-flex items-center justify-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 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
onclick={openDetails}
type="button"
>
Open details
<ArrowUpRight class="h-3.5 w-3.5" />
</button>
</div>
@@ -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,474 @@
<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} onclick={handleBack} tooltip="Back to models" />
</div>
<h1 class="min-w-0 flex-1 truncate font-semibold">{modelId}</h1>
<button
aria-label="Copy model id"
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"
onclick={handleCopy}
type="button"
>
{#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
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"
href={`https://huggingface.co/${modelId}`}
rel="noopener noreferrer"
target="_blank"
>
<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
class="px-4 pt-4 md:px-6"
items={[{ href: ROUTES.MANAGE_MODELS, label: 'Models' }, { label: modelId }]}
/>
{#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 class="text-muted-foreground" size={13} />
<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
class:bg-muted={isFullyDownloaded && !isDownloading && !isFailed}
class:border-destructive={isFailed && !isDownloading}
class:border-foreground={isFullyDownloaded && !isDownloading && !isFailed}
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"
onclick={() =>
(pendingDownload = {
filePath: file.path,
quant: meta?.quant ?? null,
sizeBytes: file.size ?? null,
variant: meta?.variant ?? null
})}
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}`}
type="button"
>
{#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
downloadedBytes={downloadProgress.downloadedBytes}
overlay
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;
}
}
filePath={pendingDownload.filePath}
formattedSize={pendingDownload.sizeBytes !== null
? HuggingFaceService.formatFileSize(pendingDownload.sizeBytes)
: ''}
onCancel={() => (pendingDownload = null)}
onConfirm={() => (pendingDownload = null)}
quant={pendingDownload.quant}
repoId={modelId}
variant={pendingDownload.variant}
/>
{/if}
@@ -0,0 +1,352 @@
<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
aria-selected={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'}"
onclick={() => (feed = 'trending')}
role="tab"
type="button"
>
Trending
</button>
<button
aria-selected={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'}"
onclick={() => (feed = 'recommended')}
role="tab"
type="button"
>
<Sparkles class="h-3 w-3 text-primary" />
ggml-org
</button>
</div>
{/if}
<SearchInput
bind:value={searchQuery}
onInput={handleSearchInput}
placeholder="Search models..."
/>
{#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
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'}"
onclick={() => (activeFilter = null)}
type="button"
>
All
</button>
{#each availableFilters as f (f.tag)}
{@const isActive = activeFilter === f.tag}
<button
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'}"
onclick={() => (activeFilter = isActive ? null : f.tag)}
type="button"
>
<IconFromName class="h-3 w-3" name={HuggingFaceService.pipelineTagIcon(f.tag)} />
{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
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'}"
onclick={() => openModelDetails(model)}
type="button"
>
<div
class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"
>
<IconFromName
class="h-4 w-4"
name={HuggingFaceService.pipelineTagIcon(model.pipeline_tag)}
/>
</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 class="h-full" modelId={selectedModelId} />
{: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} />
@@ -1,7 +1,7 @@
<script lang="ts" module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import ModelsSelectorList from '$lib/components/app/models/ModelsSelectorList.svelte';
import ModelsSelectorOption from '$lib/components/app/models/ModelsSelectorOption.svelte';
import ModelsSelectorList from '$lib/components/app/models/selector/ModelsSelectorList.svelte';
import ModelsSelectorOption from '$lib/components/app/models/selector/ModelsSelectorOption.svelte';
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/models/utils';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores/models/index.svelte';
@@ -82,26 +82,11 @@
const groupedOptions: GroupedModelOptions = {
available: [
{
items: [availableModels[0]],
orgName: 'deepseek'
},
{
items: [availableModels[1]],
orgName: 'google'
},
{
items: [availableModels[2]],
orgName: 'microsoft'
},
{
items: [availableModels[3]],
orgName: 'codellama'
},
{
items: [availableModels[4]],
orgName: 'intel'
}
availableModels[0],
availableModels[1],
availableModels[2],
availableModels[3],
availableModels[4]
],
favorites: favoriteModels,
loaded: loadedModels