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