mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-07 16:37:57 +02:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a22c1d344e | ||
|
|
ed857de31d | ||
|
|
2282e8598f | ||
|
|
59c905e6ba | ||
|
|
3fa85af123 | ||
|
|
9ffdcb7865 | ||
|
|
a56fef4aa5 | ||
|
|
941d7923c6 | ||
|
|
4efb65d24c | ||
|
|
84d6a812ca | ||
|
|
fe26a3a59e | ||
|
|
b7a8df052e | ||
|
|
3ef212034b | ||
|
|
e3e74a1bfb | ||
|
|
fb20f31a47 | ||
|
|
3f7e6974d7 | ||
|
|
f140d848ef |
@@ -26,6 +26,7 @@
|
||||
--border: oklch(0.875 0 0);
|
||||
--input: oklch(0.92 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--brand: #f65e00;
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
@@ -115,6 +116,7 @@
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-brand: var(--brand);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
|
||||
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}
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { FolderOpen, Server, Zap } from '@lucide/svelte';
|
||||
import { McpLogo } from '$lib/components/app';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { getChatFormActionsContext } from '$lib/contexts';
|
||||
|
||||
const chatFormActions = getChatFormActionsContext();
|
||||
|
||||
function handleServersClick() {
|
||||
chatFormActions.onMcpSettingsClick?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<McpLogo class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>MCP</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent class="w-48">
|
||||
<DropdownMenu.Item class="flex cursor-pointer items-center gap-2" onclick={handleServersClick}>
|
||||
<Server class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>Servers</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
{#if chatFormActions.hasMcpPromptsSupport}
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={chatFormActions.onMcpPromptClick}
|
||||
>
|
||||
<Zap class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>Prompts</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
{#if chatFormActions.hasMcpResourcesSupport}
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={chatFormActions.onMcpResourcesClick}
|
||||
>
|
||||
<FolderOpen class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>Resources</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
+1
-1
@@ -92,7 +92,7 @@
|
||||
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
{/if}
|
||||
|
||||
{#if reasoning.thinkingEnabled}
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||
{:else if reasoning.isOff}
|
||||
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
|
||||
+9
-2
@@ -1,5 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Check, ChevronDown, ChevronRight, Info, Loader2, PencilRuler } from '@lucide/svelte';
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
PencilRuler
|
||||
} from '@lucide/svelte';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
@@ -24,7 +31,7 @@
|
||||
{#if toolsPanel.totalToolCount === 0}
|
||||
{#if toolsStore.loading}
|
||||
<div class="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
<Loader2 class="mx-auto mb-1 {ICON_CLASS_DEFAULT} animate-spin" />
|
||||
<LoaderCircle class="mx-auto mb-1 {ICON_CLASS_DEFAULT} animate-spin" />
|
||||
|
||||
Loading tools...
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Loader2 } from '@lucide/svelte';
|
||||
import { LoaderCircle } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
{:else if isLoading}
|
||||
<div class="flex items-center gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
<LoaderCircle class="h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
<span>Loading model...</span>
|
||||
</div>
|
||||
|
||||
+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
-2
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Clock, Loader2 } from '@lucide/svelte';
|
||||
import { Clock, LoaderCircle } from '@lucide/svelte';
|
||||
import { AgenticSectionType } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types';
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
{#if showSpinner}
|
||||
<span class="text-foreground/80 text-sm font-medium">Current time</span>
|
||||
|
||||
<Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
|
||||
<LoaderCircle class="text-muted-foreground/70 h-3 w-3 animate-spin" />
|
||||
{:else if dateMeta.errorMessage}
|
||||
<span class="text-foreground/80 text-sm font-medium">Current time </span>
|
||||
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Info, Loader2 } from '@lucide/svelte';
|
||||
import { Info, LoaderCircle } from '@lucide/svelte';
|
||||
import { AgenticSectionType } from '$lib/enums';
|
||||
import { toolsStore } from '$lib/stores';
|
||||
import type { AgenticSection } from '$lib/types';
|
||||
@@ -56,7 +56,7 @@
|
||||
{#if showSpinner}
|
||||
<span class="text-foreground/80 text-sm font-medium">Runtime info</span>
|
||||
|
||||
<Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
|
||||
<LoaderCircle class="text-muted-foreground/70 h-3 w-3 animate-spin" />
|
||||
{:else if infoMeta.errorMessage}
|
||||
<span class="text-foreground/80 text-sm font-medium">Runtime info </span>
|
||||
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Globe, Loader2 } from '@lucide/svelte';
|
||||
import { Globe, LoaderCircle } from '@lucide/svelte';
|
||||
import { CollapsibleContentBlock } from '$lib/components/app';
|
||||
import * as HoverCard from '$lib/components/ui/hover-card';
|
||||
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants';
|
||||
@@ -33,7 +33,7 @@
|
||||
// MCP-server branding is consistent across both views. Spinner wins
|
||||
// while the call is in flight so the user sees execution status.
|
||||
const iconUrl = $derived(showSpinner ? null : mcpStore.getServerFaviconForTool(section.toolName));
|
||||
const icon = $derived(showSpinner ? Loader2 : undefined);
|
||||
const icon = $derived(showSpinner ? LoaderCircle : undefined);
|
||||
const iconClass = $derived(showSpinner ? ICON_CLASS_SPIN : ICON_CLASS_DEFAULT);
|
||||
|
||||
// Verb reflects state: "Searching" while the call is in flight, "Searched"
|
||||
@@ -167,7 +167,7 @@
|
||||
</div>
|
||||
{:else if showSpinner}
|
||||
<div class="text-muted-foreground/70 flex items-center gap-2 py-1 text-xs italic">
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
<LoaderCircle class="h-3 w-3 animate-spin" />
|
||||
|
||||
<span>Searching...</span>
|
||||
</div>
|
||||
|
||||
+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,7 +12,7 @@
|
||||
class="pointer-events-auto mx-auto mt-2 mb-2 flex max-w-[48rem] items-center gap-2 rounded-md border border-blue-400/40 bg-blue-50/60 px-3 py-1.5 text-sm text-blue-700 dark:bg-blue-950/40 dark:text-blue-200"
|
||||
role="status"
|
||||
>
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
<LoaderCircle class="h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
<span>Reconnecting to the stream...</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Loader2, Square, SquarePen, X } from '@lucide/svelte';
|
||||
import { LoaderCircle, Square, SquarePen, X } from '@lucide/svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { ICON_CLASS_SM, ICON_CLASS_XS, ROUTES, UI_DATA_ATTRS } from '$lib/constants';
|
||||
@@ -85,7 +85,7 @@
|
||||
class="stop-button relative z-10 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
onclick={(e) => handleActionClick(e, () => onStop?.(tab.id, e))}
|
||||
>
|
||||
<Loader2
|
||||
<LoaderCircle
|
||||
class="loading-icon {ICON_CLASS_SM} animate-spin transition-opacity duration-300 {contentOpacity}"
|
||||
/>
|
||||
|
||||
|
||||
@@ -221,7 +221,19 @@ export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/Chat
|
||||
export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte';
|
||||
|
||||
/**
|
||||
* Dropdown submenu for selecting reasoning effort level.
|
||||
* Dropdown submenu for MCP prompts and resources in the chat form.
|
||||
*
|
||||
* Shows an "MCP" sub-menu item with entries for MCP Prompts and MCP
|
||||
* Resources. Only visible when the server supports them.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <ChatFormActionAddMcpSubmenu />
|
||||
* ```
|
||||
*/
|
||||
export { default as ChatFormActionAddMcpSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpSubmenu.svelte';
|
||||
|
||||
/** Dropdown submenu for selecting reasoning effort level.
|
||||
*
|
||||
* Shows a "Reasoning" sub-menu item with a lightbulb icon indicating
|
||||
* thinking status, and a nested list of effort levels.
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
import { detectIncompleteCodeBlock, highlightCode, type IncompleteCodeBlock } from '$lib/utils';
|
||||
import { sanitizeSvg } from '$lib/utils/sanitize-svg';
|
||||
import { mountSvgShadow } from '$lib/utils/svg-shadow';
|
||||
import DOMPurify from 'dompurify';
|
||||
import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
|
||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||
@@ -77,6 +78,8 @@
|
||||
content: string;
|
||||
class?: string;
|
||||
disableMath?: boolean;
|
||||
/** Render raw HTML found in the markdown (sanitized) instead of escaping it. */
|
||||
allowHtml?: boolean;
|
||||
}
|
||||
|
||||
interface MarkdownBlock {
|
||||
@@ -85,7 +88,13 @@
|
||||
contentHash?: string;
|
||||
}
|
||||
|
||||
let { attachments, class: className = '', content, disableMath = false }: Props = $props();
|
||||
let {
|
||||
allowHtml = false,
|
||||
attachments,
|
||||
class: className = '',
|
||||
content,
|
||||
disableMath = false
|
||||
}: Props = $props();
|
||||
|
||||
let containerRef = $state<HTMLDivElement>();
|
||||
let renderedBlocks = $state<MarkdownBlock[]>([]);
|
||||
@@ -148,6 +157,7 @@
|
||||
|
||||
let processor = $derived(() => {
|
||||
void attachments;
|
||||
void allowHtml;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let proc: any = remark().use(remarkGfm); // GitHub Flavored Markdown
|
||||
|
||||
@@ -155,10 +165,15 @@
|
||||
proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math
|
||||
}
|
||||
|
||||
proc = proc
|
||||
.use(remarkBreaks) // Convert line breaks to <br>
|
||||
.use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation
|
||||
.use(remarkRehype); // Convert Markdown AST to rehype
|
||||
proc = proc.use(remarkBreaks); // Convert line breaks to <br>
|
||||
|
||||
if (!allowHtml) {
|
||||
// Treat raw HTML as literal text with preserved indentation
|
||||
proc = proc.use(remarkLiteralHtml);
|
||||
}
|
||||
|
||||
// Convert Markdown AST to rehype. Keep raw HTML as-is when allowHtml is set.
|
||||
proc = proc.use(remarkRehype, allowHtml ? { allowDangerousHtml: true } : undefined);
|
||||
|
||||
if (!disableMath) {
|
||||
proc = proc.use(rehypeKatex); // Render math using KaTeX
|
||||
@@ -261,10 +276,11 @@
|
||||
const singleNodeRoot = { children: [node], type: 'root' };
|
||||
const transformedRoot = (await processorInstance.run(singleNodeRoot as MdastRoot)) as HastRoot;
|
||||
const html = processorInstance.stringify(transformedRoot);
|
||||
const safeHtml = allowHtml ? (DOMPurify.sanitize(html) as unknown as string) : html;
|
||||
|
||||
transformCache.set(hash, html);
|
||||
transformCache.set(hash, safeHtml);
|
||||
|
||||
return { hash, html };
|
||||
return { hash, html: safeHtml };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -463,6 +479,10 @@
|
||||
)) as HastRoot;
|
||||
|
||||
unstableHtml = processorInstance.stringify(transformedRoot);
|
||||
|
||||
if (allowHtml) {
|
||||
unstableHtml = DOMPurify.sanitize(unstableHtml) as unknown as string;
|
||||
}
|
||||
}
|
||||
|
||||
renderedBlocks = nextBlocks;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Braces, FolderOpen, Loader2, Plus } from '@lucide/svelte';
|
||||
import { Braces, FolderOpen, LoaderCircle, Plus } from '@lucide/svelte';
|
||||
import {
|
||||
McpResourcePreview,
|
||||
McpResourcesBrowser,
|
||||
@@ -306,7 +306,7 @@
|
||||
|
||||
{#if templatePreviewLoading}
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<LoaderCircle class="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if templatePreviewError}
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-2 text-red-500">
|
||||
@@ -367,7 +367,7 @@
|
||||
{#if hasTemplateResult}
|
||||
<Button disabled={isAttaching} onclick={handleAttachTemplateResource}>
|
||||
{#if isAttaching}
|
||||
<Loader2 class="mr-2 {ICON_CLASS_DEFAULT} animate-spin" />
|
||||
<LoaderCircle class="mr-2 {ICON_CLASS_DEFAULT} animate-spin" />
|
||||
{:else}
|
||||
<Plus class="mr-2 {ICON_CLASS_DEFAULT}" />
|
||||
{/if}
|
||||
@@ -377,7 +377,7 @@
|
||||
{:else}
|
||||
<Button disabled={selectedResources.size === 0 || isAttaching} onclick={handleAttach}>
|
||||
{#if isAttaching}
|
||||
<Loader2 class="mr-2 {ICON_CLASS_DEFAULT} animate-spin" />
|
||||
<LoaderCircle class="mr-2 {ICON_CLASS_DEFAULT} animate-spin" />
|
||||
{:else}
|
||||
<Plus class="mr-2 {ICON_CLASS_DEFAULT}" />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { ModelsDiscover } from '$lib/components/app/models/discover';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
let { onOpenChange, open = $bindable(false) }: Props = $props();
|
||||
|
||||
function handleOpenChange(value: boolean) {
|
||||
open = value;
|
||||
onOpenChange?.(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root onOpenChange={handleOpenChange} {open}>
|
||||
<Dialog.Content
|
||||
class="grid gap-0 p-0 md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-360!"
|
||||
style="grid-template-columns: auto 1fr;"
|
||||
>
|
||||
<ModelsDiscover />
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -26,6 +26,16 @@ export { default as DialogMcpServerAddNew } from './DialogMcpServerAddNew.svelte
|
||||
*/
|
||||
export { default as DialogMcpServers } from './DialogMcpServers.svelte';
|
||||
|
||||
/**
|
||||
* **DialogModelsDiscover** - Models Hub discovery dialog
|
||||
*
|
||||
* Two-pane HuggingFace GGUF browser in a modal dialog: a sidebar model list
|
||||
* (ModelsDiscoverList) and a detail view (ModelsDiscoverDetails). Always opens the
|
||||
* first model. Used for discovery and downloading; the `/models-hub` route is
|
||||
* reserved for model management.
|
||||
*/
|
||||
export { default as DialogModelsDiscover } from './DialogModelsDiscover.svelte';
|
||||
|
||||
/**
|
||||
* **DialogSettingsChat** - Chat settings shown in a modal dialog
|
||||
*
|
||||
|
||||
@@ -8,5 +8,6 @@ export * from './mcp';
|
||||
export * from './misc';
|
||||
export * from './settings';
|
||||
export * from './models';
|
||||
export * from './models/discover';
|
||||
export * from './navigation';
|
||||
export * from './server';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { AlertCircle, Download, FileText, Loader2 } from '@lucide/svelte';
|
||||
import { AlertCircle, Download, FileText, LoaderCircle } from '@lucide/svelte';
|
||||
import { ActionIconCopyToClipboard } from '$lib/components/app';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
@@ -116,7 +116,7 @@
|
||||
<div class="min-h-[200px] overflow-auto rounded-md border bg-muted/30 p-3 break-all">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<LoaderCircle class="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex flex-col items-center justify-center gap-2 py-8 text-red-500">
|
||||
|
||||
+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>
|
||||
|
||||
|
||||
@@ -1,45 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { Database, Image, Lightbulb, Mic, ScrollText, Video, Wrench } from '@lucide/svelte';
|
||||
import { TruncatedText } from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import {
|
||||
CAPABILITY_FLAG_KEYS,
|
||||
CAPABILITY_ICONS,
|
||||
CAPABILITY_LABELS,
|
||||
MODALITY_FLAG_KEYS,
|
||||
MODALITY_ICONS,
|
||||
MODALITY_LABELS
|
||||
} from '$lib/constants';
|
||||
import { ModelCapability, ModelModality } from '$lib/enums';
|
||||
import { type DraftVariant } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { settingsStore } from '$lib/stores';
|
||||
import type { ModelCapabilities, ModelModalities } from '$lib/types/models';
|
||||
import type { ModelModalities } from '$lib/types/models';
|
||||
import { formatParameters } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
modelId: string;
|
||||
hideOrgName?: boolean;
|
||||
hideName?: boolean;
|
||||
hideModalities?: boolean;
|
||||
hideReasoning?: boolean;
|
||||
hideParameters?: boolean;
|
||||
showRaw?: boolean;
|
||||
showRawTooltip?: boolean;
|
||||
hideQuantization?: boolean;
|
||||
hideTags?: boolean;
|
||||
aliases?: string[];
|
||||
tags?: string[];
|
||||
/** Render the capability/modality/context icons on a second row. */
|
||||
iconsOnNewLine?: boolean;
|
||||
modalities?: ModelModalities;
|
||||
capabilities?: ModelCapabilities;
|
||||
supportsThinking?: boolean;
|
||||
supportsToolUse?: boolean;
|
||||
/** Context length in tokens; renders a context icon when set. */
|
||||
contextLength?: number;
|
||||
/** Min/max GGUF file size (main + draft) across quants; renders a range when set. */
|
||||
sizeRange?: { min: number; max: number } | null;
|
||||
/** Params badge fallback (formatted) when the model id carries no params token. */
|
||||
params?: string;
|
||||
draftVariants?: DraftVariant[];
|
||||
/** Allow badges to wrap onto new lines instead of truncating. */
|
||||
wrap?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
aliases,
|
||||
capabilities,
|
||||
class: className = '',
|
||||
contextLength,
|
||||
draftVariants,
|
||||
hideModalities = false,
|
||||
hideName = false,
|
||||
hideOrgName = false,
|
||||
hideParameters = false,
|
||||
hideQuantization,
|
||||
hideReasoning = false,
|
||||
hideTags,
|
||||
iconsOnNewLine = false,
|
||||
modalities,
|
||||
modelId,
|
||||
params,
|
||||
showRaw = undefined,
|
||||
showRawTooltip = false,
|
||||
sizeRange,
|
||||
supportsThinking = false,
|
||||
supportsToolUse = false,
|
||||
tags,
|
||||
wrap = false,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
@@ -47,6 +69,8 @@
|
||||
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25';
|
||||
const tagBadgeClass =
|
||||
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground';
|
||||
const variantBadgeClass =
|
||||
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md bg-primary px-1.5 py-0 text-[10px] font-mono font-semibold uppercase tracking-wide text-primary-foreground';
|
||||
|
||||
let parsed = $derived(ModelsService.parseModelId(modelId));
|
||||
let resolvedShowRaw = $derived(
|
||||
@@ -59,16 +83,8 @@
|
||||
|
||||
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
|
||||
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
|
||||
|
||||
const allModalities = [ModelModality.VISION, ModelModality.VIDEO, ModelModality.AUDIO] as const;
|
||||
const allCapabilities: ModelCapability[] = [ModelCapability.REASONING];
|
||||
|
||||
let activeModalities = $derived(
|
||||
allModalities.filter((modality) => modalities?.[MODALITY_FLAG_KEYS[modality]])
|
||||
);
|
||||
let activeCapabilities = $derived(
|
||||
allCapabilities.filter((capability) => capabilities?.[CAPABILITY_FLAG_KEYS[capability]])
|
||||
);
|
||||
let uniqueDraftVariants = $derived([...new Set(draftVariants ?? [])]);
|
||||
let hasModalityIcons = $derived(modalities?.vision || modalities?.video || modalities?.audio);
|
||||
|
||||
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
|
||||
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
|
||||
@@ -78,17 +94,31 @@
|
||||
<TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} {...rest} />
|
||||
{:else}
|
||||
{#snippet nameAndBadges()}
|
||||
<span class="min-w-0 truncate font-medium">
|
||||
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
|
||||
</span>
|
||||
{#if !hideName}
|
||||
<span class="min-w-0 truncate font-medium">
|
||||
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{#if parsed.params}
|
||||
<span class={badgeClass}>
|
||||
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
|
||||
<span class="inline-flex items-center gap-1 {wrap ? 'flex-wrap' : ''}">
|
||||
{#if parsed.variant}
|
||||
<span class={variantBadgeClass} title={`${parsed.variant.toUpperCase()} draft model`}>
|
||||
{parsed.variant}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if (parsed.params || params) && !hideParameters}
|
||||
<span class={badgeClass}>
|
||||
{parsed.params ?? params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#each uniqueDraftVariants as variant (variant)}
|
||||
<span class={variantBadgeClass} title={`${variant.toUpperCase()} draft model available`}>
|
||||
{variant}
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
{#if parsed.quantization && !resolvedHideQuantization}
|
||||
<span class={badgeClass}>
|
||||
{parsed.quantization}
|
||||
@@ -113,51 +143,110 @@
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
<span class="flex min-w-0 items-center gap-1.5 {className}" {...rest}>
|
||||
{#if showRawTooltip}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
|
||||
{@render nameAndBadges()}
|
||||
</Tooltip.Trigger>
|
||||
<span
|
||||
class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''} {iconsOnNewLine
|
||||
? 'flex-col items-start'
|
||||
: ''} {className}"
|
||||
{...rest}
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''}">
|
||||
{#if showRawTooltip}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
|
||||
{@render nameAndBadges()}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{modelId}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
{@render nameAndBadges()}
|
||||
{/if}
|
||||
<Tooltip.Content>
|
||||
<p>{modelId}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
{@render nameAndBadges()}
|
||||
{/if}
|
||||
|
||||
{#if activeCapabilities.length > 0 || activeModalities.length > 0}
|
||||
<span class="inline-flex items-center gap-1.25 text-muted-foreground">
|
||||
{#each activeCapabilities as capability (capability)}
|
||||
{@const CapabilityIcon = CAPABILITY_ICONS[capability]}
|
||||
{#if supportsToolUse}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Wrench class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<CapabilityIcon class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Tool use</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{CAPABILITY_LABELS[capability]}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/each}
|
||||
{#if supportsThinking && !hideReasoning}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Lightbulb class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
{#each activeModalities as modality (modality)}
|
||||
{@const ModalityIcon = MODALITY_ICONS[modality]}
|
||||
<Tooltip.Content>
|
||||
<p>Reasoning</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<ModalityIcon class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
{#if hasModalityIcons && !hideModalities}
|
||||
<span class="inline-flex items-center gap-1.25 text-muted-foreground">
|
||||
{#if modalities?.vision}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Image class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{MODALITY_LABELS[modality]}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
<Tooltip.Content>
|
||||
<p>Vision</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if modalities?.video}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Video class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Video</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if modalities?.audio}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Mic class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Audio</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
{#if contextLength}
|
||||
<span class="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<ScrollText class="h-3 w-3" />
|
||||
|
||||
<span class="text-xs">{formatParameters(contextLength)}</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if sizeRange}
|
||||
<span class="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Database class="h-3 w-3" />
|
||||
|
||||
<span class="text-xs"
|
||||
>{HuggingFaceService.formatSizeRange(sizeRange.min, sizeRange.max)}</span
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
<script lang="ts">
|
||||
import DownloadProgressBar from './DownloadProgressBar.svelte';
|
||||
import { Download, LoaderCircle, Trash2, TriangleAlert } from '@lucide/svelte';
|
||||
import { DialogConfirmation } from '$lib/components/app';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import type { DraftVariant } from '$lib/constants';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import { type GgufVariantTagInput, ModelsService } from '$lib/services/models.service';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
repoId: string;
|
||||
filePath: string;
|
||||
quant: string | null;
|
||||
variant: DraftVariant | null;
|
||||
formattedSize?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
filePath,
|
||||
formattedSize,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
open = $bindable(),
|
||||
quant,
|
||||
repoId,
|
||||
variant
|
||||
}: Props = $props();
|
||||
|
||||
type Phase = 'pending' | 'starting' | 'downloading' | 'finished';
|
||||
let phase = $state<Phase>('pending');
|
||||
let hasSeenProgress = $state(false);
|
||||
let lastError: string | null = $state(null);
|
||||
|
||||
let tagInput = $derived<GgufVariantTagInput | null>(
|
||||
quant || variant ? { quant: quant ?? '', variant } : null
|
||||
);
|
||||
let hfRepoWithTag = $derived(ModelsService.buildDownloadTag(repoId, tagInput));
|
||||
let tagDisplay = $derived.by(() => {
|
||||
if (quant && variant) return `${quant}-${variant.toUpperCase()}`;
|
||||
|
||||
if (quant) return quant;
|
||||
|
||||
if (variant) return variant.toUpperCase();
|
||||
|
||||
return 'default';
|
||||
});
|
||||
|
||||
let inFlight = $derived(phase === 'starting' || phase === 'downloading');
|
||||
// True when a previous SSE `download_failed` left a recorded failure for the
|
||||
// same <repo>:<tag>. The dialog swaps Download for a delete-&-retry flow
|
||||
// because POST /models rejects already-existing partial entries.
|
||||
let previousFailure = $derived(modelsStore.status.hasFailedDownload(hfRepoWithTag));
|
||||
let cancelling = $state(false);
|
||||
let lastCancelError: string | null = $state(null);
|
||||
|
||||
// Only offer Delete when the model is registered with the server (a fully
|
||||
// downloaded entry in /v1/models). For an in-flight download the partial
|
||||
// files are cleaned up by the Retry path, so Delete would be redundant.
|
||||
let canDelete = $derived(
|
||||
phase === 'finished' && modelsStore.status.isModelDownloaded(hfRepoWithTag)
|
||||
);
|
||||
let showDeleteConfirm = $state(false);
|
||||
async function handleConfirmDelete() {
|
||||
showDeleteConfirm = false;
|
||||
await modelsStore.status.cancelDownload(hfRepoWithTag);
|
||||
// Close the download dialog too - removing the entry makes the wizard moot.
|
||||
onCancel();
|
||||
}
|
||||
|
||||
// Reactive: while the SSE feed reports progress for our download, surface it.
|
||||
// The downloadProgress map is deleted on download_finished/download_failed.
|
||||
let progress = $derived(modelsStore.status.getDownloadProgress(hfRepoWithTag));
|
||||
let progressPercent = $derived.by(() => {
|
||||
if (!progress || progress.totalBytes <= 0) return 0;
|
||||
|
||||
return Math.round((progress.downloadedBytes / progress.totalBytes) * 100);
|
||||
});
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === KeyboardKey.ENTER && phase === 'pending') {
|
||||
event.preventDefault();
|
||||
void trigger();
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (newOpen) {
|
||||
lastError = null;
|
||||
lastCancelError = null;
|
||||
showDeleteConfirm = false;
|
||||
phase = 'pending';
|
||||
hasSeenProgress = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!inFlight) onCancel();
|
||||
}
|
||||
|
||||
async function trigger() {
|
||||
if (inFlight) return;
|
||||
|
||||
phase = 'starting';
|
||||
hasSeenProgress = false;
|
||||
lastError = null;
|
||||
lastCancelError = null;
|
||||
showDeleteConfirm = false;
|
||||
|
||||
// A recorded failure for the same <repo>:<tag> means the server still holds
|
||||
// a partial entry that POST /models would reject; remove it before retrying.
|
||||
if (modelsStore.status.hasFailedDownload(hfRepoWithTag)) {
|
||||
await ModelsService.cancelDownload(hfRepoWithTag);
|
||||
}
|
||||
|
||||
try {
|
||||
await modelsStore.status.downloadModel(hfRepoWithTag, filePath);
|
||||
phase = 'downloading';
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : 'Failed to start download';
|
||||
phase = 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (cancelling) return;
|
||||
|
||||
cancelling = true;
|
||||
lastCancelError = null;
|
||||
try {
|
||||
const ok = await modelsStore.status.cancelDownload(hfRepoWithTag);
|
||||
|
||||
if (!ok) {
|
||||
lastCancelError = 'Cancel request failed. Try again in a moment.';
|
||||
}
|
||||
} finally {
|
||||
cancelling = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Latch once we've seen real progress so we know what 'no longer in flight'
|
||||
// actually means. Without this the dialog auto-closed shortly after the POST
|
||||
// resolved because no SSE event had landed yet.
|
||||
$effect(() => {
|
||||
if (phase !== 'downloading') return;
|
||||
|
||||
if (progress) hasSeenProgress = true;
|
||||
});
|
||||
|
||||
// Promote to 'finished' only after progress was observed and the feed then
|
||||
// drops our entry. Auto-close after a short pause.
|
||||
$effect(() => {
|
||||
if (phase !== 'downloading') return;
|
||||
|
||||
if (!hasSeenProgress) return;
|
||||
|
||||
const stillInFlight = modelsStore.status.isDownloadInProgress(hfRepoWithTag);
|
||||
|
||||
if (!stillInFlight) {
|
||||
phase = 'finished';
|
||||
const timer = setTimeout(() => onConfirm(), 600);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root onOpenChange={handleOpenChange} {open}>
|
||||
<AlertDialog.Content class="max-w-md" onkeydown={handleKeydown}>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title class="flex items-center gap-2">
|
||||
<Download class="h-5 w-5 text-primary" />
|
||||
|
||||
{#if phase === 'pending'}
|
||||
Download this model?
|
||||
{:else}
|
||||
Downloading {tagDisplay}
|
||||
{/if}
|
||||
</AlertDialog.Title>
|
||||
|
||||
<AlertDialog.Description>
|
||||
{#if phase === 'pending'}
|
||||
llama-server will download this file (and related sidecar weights such as multimodal
|
||||
projectors or draft models) from Hugging Face into your local model cache.
|
||||
{:else}
|
||||
Download runs in the background; this dialog tracks live progress.
|
||||
{/if}
|
||||
</AlertDialog.Description>
|
||||
|
||||
{#if previousFailure && phase === 'pending'}
|
||||
<div
|
||||
class="mt-2 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
|
||||
role="status"
|
||||
>
|
||||
<TriangleAlert class="mt-0.5 h-4 w-4 shrink-0" />
|
||||
|
||||
<span>
|
||||
A previous attempt for this tag failed and left partial files on disk. The server will
|
||||
reject a fresh download until those files are removed. The Retry button below deletes
|
||||
the partial files automatically.
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</AlertDialog.Header>
|
||||
|
||||
{#if canDelete}
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
aria-label="Delete model from cache"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 px-2 py-1 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
|
||||
onclick={() => (showDeleteConfirm = true)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
Delete from cache
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3 rounded-md border bg-muted/40 p-3 text-xs">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Request</span>
|
||||
|
||||
<code class="break-all font-mono"
|
||||
>POST /models · {`{ model: "${hfRepoWithTag}" }`}</code
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">File</span>
|
||||
|
||||
<code class="break-all font-mono">{filePath}</code>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded bg-primary/15 px-2 py-0.5 font-mono font-semibold text-primary">
|
||||
{tagDisplay}
|
||||
</span>
|
||||
|
||||
{#if formattedSize}
|
||||
<span class="text-muted-foreground">{formattedSize}</span>
|
||||
{/if}
|
||||
|
||||
{#if variant}
|
||||
<span
|
||||
class="rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground"
|
||||
>
|
||||
{variant}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if phase === 'downloading' || phase === 'finished'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center justify-between text-muted-foreground">
|
||||
<span>
|
||||
{#if phase === 'finished'}
|
||||
Complete
|
||||
{:else if progress && progress.totalBytes > 0}
|
||||
Downloading
|
||||
{:else}
|
||||
Preparing download
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span class="font-mono tabular-nums">{progressPercent}%</span>
|
||||
</div>
|
||||
|
||||
<DownloadProgressBar
|
||||
downloadedBytes={progress?.downloadedBytes ?? 0}
|
||||
totalBytes={progress?.totalBytes ?? 0}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if lastError}
|
||||
<p class="text-xs text-destructive">{lastError}</p>
|
||||
{/if}
|
||||
|
||||
{#if lastCancelError}
|
||||
<p class="text-xs text-destructive">{lastCancelError}</p>
|
||||
{/if}
|
||||
|
||||
<AlertDialog.Footer>
|
||||
{#if phase === 'downloading'}
|
||||
<AlertDialog.Action disabled={cancelling} onclick={cancel}>
|
||||
{#if cancelling}
|
||||
<LoaderCircle class="mr-1.5 h-4 w-4 animate-spin" />
|
||||
Cancelling...
|
||||
{:else}
|
||||
Cancel download
|
||||
{/if}
|
||||
</AlertDialog.Action>
|
||||
{:else}
|
||||
<AlertDialog.Cancel disabled={inFlight} onclick={onCancel}>
|
||||
{#if phase === 'finished'}Close{:else}Cancel{/if}
|
||||
</AlertDialog.Cancel>
|
||||
{/if}
|
||||
|
||||
{#if phase === 'pending'}
|
||||
<AlertDialog.Action disabled={inFlight} onclick={trigger}>
|
||||
<Download class="mr-1.5 h-4 w-4" />
|
||||
{previousFailure ? 'Retry download' : 'Download'}
|
||||
</AlertDialog.Action>
|
||||
{:else if phase === 'starting'}
|
||||
<AlertDialog.Action disabled>
|
||||
<LoaderCircle class="mr-1.5 h-4 w-4 animate-spin" />
|
||||
Starting...
|
||||
</AlertDialog.Action>
|
||||
{/if}
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<DialogConfirmation
|
||||
bind:open={showDeleteConfirm}
|
||||
cancelText="Cancel"
|
||||
confirmText="Delete"
|
||||
description={`Remove "${hfRepoWithTag}" from your cache? Any cached files will be deleted from disk.`}
|
||||
icon={Trash2}
|
||||
onCancel={() => (showDeleteConfirm = false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete model"
|
||||
variant="destructive"
|
||||
/>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
/** Bytes downloaded so far. Caller supplies the value; we normalize 0..1. */
|
||||
downloadedBytes: number;
|
||||
/** Total bytes for the download plan. */
|
||||
totalBytes: number;
|
||||
/** Pin to the bottom edge as a thin overlay like `ModelLoadHighlight`. */
|
||||
overlay?: boolean;
|
||||
}
|
||||
|
||||
let { downloadedBytes, overlay = false, totalBytes }: Props = $props();
|
||||
|
||||
let fraction = $derived.by(() => {
|
||||
if (totalBytes <= 0) return 0;
|
||||
|
||||
return Math.min(Math.max(downloadedBytes / totalBytes, 0), 1);
|
||||
});
|
||||
let percent = $derived(Math.round(fraction * 100));
|
||||
</script>
|
||||
|
||||
{#if overlay}
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm">
|
||||
<div
|
||||
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
import { ModelsDiscoverDetails, ModelsDiscoverList } from '$lib/components/app/models/discover';
|
||||
import { modelsHubStore } from '$lib/stores';
|
||||
|
||||
let selectedId = $state<string | null>(null);
|
||||
let searchQuery = $state('');
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Load the sidebar list on mount (the component is mounted when the dialog opens).
|
||||
$effect(() => {
|
||||
void modelsHubStore.fetch();
|
||||
void modelsHubStore.search('');
|
||||
});
|
||||
|
||||
// Auto-select the first model.
|
||||
$effect(() => {
|
||||
const first = modelsHubStore.firstModel;
|
||||
|
||||
if (!selectedId && first) {
|
||||
selectedId = first.id;
|
||||
}
|
||||
});
|
||||
|
||||
function handleSearchInput(value: string) {
|
||||
searchQuery = value;
|
||||
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
|
||||
searchTimeout = setTimeout(() => {
|
||||
void modelsHubStore.search(value);
|
||||
}, 300);
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="w-108 shrink-0 self-start border-r border-border/40 bg-background overflow-y-auto md:p-4 h-full space-y-1"
|
||||
>
|
||||
<div class="p-2 sticky top-0 z-99">
|
||||
<SearchInput
|
||||
bind:value={searchQuery}
|
||||
class=""
|
||||
onInput={handleSearchInput}
|
||||
placeholder="Search models..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#if modelsHubStore.loading}
|
||||
<p class="p-4 text-sm text-muted-foreground">Loading models...</p>
|
||||
{:else if modelsHubStore.error}
|
||||
<p class="p-4 text-sm text-destructive">{modelsHubStore.error}</p>
|
||||
{:else if modelsHubStore.models.length === 0}
|
||||
<p class="p-4 text-sm text-muted-foreground">No models found</p>
|
||||
{:else}
|
||||
<ModelsDiscoverList
|
||||
activeId={selectedId}
|
||||
models={modelsHubStore.models}
|
||||
onSelect={(id) => (selectedId = id)}
|
||||
showBaseModelAvatar
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="overflow-y-auto">
|
||||
{#if selectedId}
|
||||
<ModelsDiscoverDetails modelId={selectedId} />
|
||||
{/if}
|
||||
</main>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { DARK_INVERT_AVATAR_ORGS } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
|
||||
interface Props {
|
||||
/** Org whose avatar is shown (may differ from the repo's org for base models). */
|
||||
org: string;
|
||||
/** Repo's own org, shown as a small corner badge when provided. */
|
||||
quantOrg?: string;
|
||||
/** Tailwind size classes for the main avatar (default `h-9 w-9`). */
|
||||
size?: string;
|
||||
/** Extra classes appended to the base (main) image. */
|
||||
baseImageClass?: string;
|
||||
/** Size classes for the quant corner badge image (default `h-full w-full`). */
|
||||
quantImageClass?: string;
|
||||
/** Positioning classes for the quant corner badge (default `-bottom-0.75 -right-0.75`). */
|
||||
quantPositionClass?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
baseImageClass = '',
|
||||
org,
|
||||
quantImageClass = 'h-full w-full',
|
||||
quantOrg,
|
||||
quantPositionClass = '-bottom-0.75 -right-0.75',
|
||||
size = 'h-9 w-9'
|
||||
}: Props = $props();
|
||||
|
||||
let avatarError = $state(false);
|
||||
let quantError = $state(false);
|
||||
|
||||
let invertAvatar = $derived(DARK_INVERT_AVATAR_ORGS.includes(org));
|
||||
let invertQuant = $derived(DARK_INVERT_AVATAR_ORGS.includes(quantOrg ?? ''));
|
||||
|
||||
// Monogram fallback: org initial on a hue derived from its name, so each org
|
||||
// gets a stable distinct color.
|
||||
let hue = $derived.by(() => {
|
||||
let h = 0;
|
||||
|
||||
for (let i = 0; i < org.length; i++) h = (h * 31 + org.charCodeAt(i)) >>> 0;
|
||||
|
||||
return h % 360;
|
||||
});
|
||||
|
||||
let quantHue = $derived.by(() => {
|
||||
const name = quantOrg ?? '';
|
||||
|
||||
let h = 0;
|
||||
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
|
||||
return h % 360;
|
||||
});
|
||||
</script>
|
||||
|
||||
<span class="relative mt-0.5 inline-flex shrink-0">
|
||||
{#if avatarError}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex {size} items-center justify-center rounded-md text-sm font-semibold text-white"
|
||||
style="background-color: hsl({hue} 60% 45%)"
|
||||
>
|
||||
{org.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
{:else}
|
||||
<div class="rounded-md">
|
||||
<img
|
||||
alt=""
|
||||
class="{size} rounded-md {invertAvatar ? 'dark:invert' : ''} {baseImageClass}"
|
||||
loading="lazy"
|
||||
onerror={() => (avatarError = true)}
|
||||
src={HuggingFaceService.getAvatarUrl(org)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if quantOrg && quantOrg !== org}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger
|
||||
class="absolute {quantPositionClass} h-4.25 w-4.25 overflow-hidden rounded-full border border-background bg-muted "
|
||||
>
|
||||
{#if quantError}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-full w-full items-center justify-center rounded-full text-[8px] font-semibold text-white"
|
||||
style="background-color: hsl({quantHue} 60% 45%)"
|
||||
>
|
||||
{quantOrg.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
{:else}
|
||||
<img
|
||||
alt=""
|
||||
class="{quantImageClass} rounded-full {invertQuant ? 'dark:invert' : ''}"
|
||||
loading="lazy"
|
||||
onerror={() => (quantError = true)}
|
||||
src={HuggingFaceService.getAvatarUrl(quantOrg)}
|
||||
/>
|
||||
{/if}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{quantOrg}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</span>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { Copy } from '@lucide/svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
chatTemplate: string;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
let { chatTemplate, onOpenChange, open = $bindable(false) }: Props = $props();
|
||||
|
||||
function handleOpenChange(value: boolean) {
|
||||
open = value;
|
||||
onOpenChange?.(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root onOpenChange={handleOpenChange} {open}>
|
||||
<Dialog.Content
|
||||
class="flex max-h-[calc(100vh-4rem)] flex-col gap-0 p-0 md:w-[calc(100vw-4rem)]! md:max-w-4xl!"
|
||||
>
|
||||
<Dialog.Header class="flex-row items-center justify-between border-b border-border/40 p-4">
|
||||
<Dialog.Title class="text-sm font-semibold">Chat template</Dialog.Title>
|
||||
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-colors hover:bg-muted"
|
||||
onclick={() => copyToClipboard(chatTemplate)}
|
||||
type="button"
|
||||
>
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
Copy
|
||||
</button>
|
||||
</Dialog.Header>
|
||||
|
||||
<pre
|
||||
class="flex-1 overflow-auto p-4 font-mono text-xs break-all whitespace-pre-wrap text-muted-foreground">{chatTemplate}</pre>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverDetailsDownloadOptions from './ModelsDiscoverDetailsDownloadOptions.svelte';
|
||||
import ModelsDiscoverDetailsHeader from './ModelsDiscoverDetailsHeader.svelte';
|
||||
import ModelsDiscoverDetailsReadme from './ModelsDiscoverDetailsReadme.svelte';
|
||||
import TerminalCommands from './TerminalCommands.svelte';
|
||||
import { type DraftVariant } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types/huggingface';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
/** Full HuggingFace model id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
let { modelId }: Props = $props();
|
||||
|
||||
let details = $state<HfModelDetailInfo | null>(null);
|
||||
let files = $state<HfModelSibling[]>([]);
|
||||
let readme = $state<string | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let gguf = $derived(details?.gguf);
|
||||
let baseModels = $derived(HuggingFaceService.getBaseModels(details));
|
||||
let licenseTag = $derived.by(() => {
|
||||
const tags = details?.tags ?? [];
|
||||
|
||||
return tags.find((t) => t.startsWith('license:'))?.replace('license:', '') ?? null;
|
||||
});
|
||||
|
||||
// Capabilities derived from HF metadata. Vision comes from an mmproj sidecar
|
||||
// or a multimodal pipeline tag; tool use / reasoning from the chat template.
|
||||
let hasMmproj = $derived(
|
||||
files.some((f) => HuggingFaceService.extractQuantMeta(f.path)?.variant === 'mmproj')
|
||||
);
|
||||
let hasVision = $derived(hasMmproj || details?.pipeline_tag === 'image-text-to-text');
|
||||
let hasTools = $derived(Boolean(gguf?.chat_template && /tools?[_\s}]/i.test(gguf.chat_template)));
|
||||
let hasReasoning = $derived(
|
||||
Boolean(gguf?.chat_template && /think|reasoning/i.test(gguf.chat_template))
|
||||
);
|
||||
|
||||
// Draft sidecar variants (mtp, dflash, dspark, eagle3) present in the repo,
|
||||
// e.g. speculative-decoding drafts. mmproj is excluded: it is vision.
|
||||
let draftVariants = $derived.by<DraftVariant[]>(() => {
|
||||
const set = new SvelteSet<DraftVariant>();
|
||||
|
||||
for (const file of files) {
|
||||
const variant = HuggingFaceService.extractQuantMeta(file.path)?.variant;
|
||||
|
||||
if (variant && variant !== 'mmproj') set.add(variant);
|
||||
}
|
||||
|
||||
return [...set];
|
||||
});
|
||||
|
||||
type BitDepthRow = { bitDepth: number; files: HfModelSibling[] };
|
||||
let bitDepthRows = $derived.by<BitDepthRow[]>(() => {
|
||||
const rows = new SvelteMap<number, HfModelSibling[]>();
|
||||
|
||||
for (const file of files) {
|
||||
const meta = HuggingFaceService.extractQuantMeta(file.path);
|
||||
|
||||
// mmproj sidecars are already conveyed by the Vision capability badge.
|
||||
if (meta?.variant === 'mmproj') continue;
|
||||
|
||||
const depth = meta?.quant ? HuggingFaceService.getBitDepth(meta.quant) : null;
|
||||
const bucket = depth ?? 99;
|
||||
const list = rows.get(bucket) ?? [];
|
||||
|
||||
list.push(file);
|
||||
rows.set(bucket, list);
|
||||
}
|
||||
|
||||
return Array.from(rows.entries())
|
||||
.map(([bitDepth, rowFiles]) => ({ bitDepth, files: rowFiles }))
|
||||
.sort((a, b) => a.bitDepth - b.bitDepth);
|
||||
});
|
||||
|
||||
async function load(id: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const [info, tree, readmeText] = await Promise.all([
|
||||
HuggingFaceService.getDetails(id),
|
||||
HuggingFaceService.getTree(id),
|
||||
HuggingFaceService.getReadme(id)
|
||||
]);
|
||||
|
||||
if (!info) {
|
||||
error = 'Model not found';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
details = info;
|
||||
files = HuggingFaceService.filterByExtension(
|
||||
HuggingFaceService.collapseGgufShards(tree),
|
||||
'.gguf'
|
||||
);
|
||||
readme = readmeText;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load model';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch when the dialog selects a different model (component is reused).
|
||||
$effect(() => {
|
||||
void load(modelId);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex h-full items-center justify-center py-20">
|
||||
<p class="text-sm text-muted-foreground">Loading model...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex h-full items-center justify-center py-20">
|
||||
<p class="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
{:else if details}
|
||||
<div class="space-y-6 p-6">
|
||||
<ModelsDiscoverDetailsHeader
|
||||
{baseModels}
|
||||
{details}
|
||||
{gguf}
|
||||
{hasReasoning}
|
||||
{hasTools}
|
||||
{hasVision}
|
||||
{licenseTag}
|
||||
{modelId}
|
||||
/>
|
||||
|
||||
<ModelsDiscoverDetailsDownloadOptions
|
||||
{bitDepthRows}
|
||||
{files}
|
||||
{modelId}
|
||||
nativeCtxTokens={gguf?.context_length ?? 0}
|
||||
/>
|
||||
|
||||
<TerminalCommands {draftVariants} {modelId} />
|
||||
|
||||
<ModelsDiscoverDetailsReadme {readme} />
|
||||
</div>
|
||||
{/if}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
<script lang="ts">
|
||||
import DialogModelDownload from './DialogModelDownload.svelte';
|
||||
import DownloadProgressBar from './DownloadProgressBar.svelte';
|
||||
import { Check, Cpu, Download, TriangleAlert, X } from '@lucide/svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import type { GgufVariantTagInput } from '$lib/services';
|
||||
import { HuggingFaceService, ModelsService } from '$lib/services';
|
||||
import { modelsStore, settingsStore } from '$lib/stores';
|
||||
import type { HfModelSibling } from '$lib/types/huggingface';
|
||||
import { computeFileCompatibilityTiers, detectOs, resolveDeviceMemoryGb } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
modelId: string;
|
||||
files: HfModelSibling[];
|
||||
bitDepthRows: BitDepthRow[];
|
||||
nativeCtxTokens: number;
|
||||
}
|
||||
|
||||
interface PendingDownload {
|
||||
filePath: string;
|
||||
sizeBytes: number | null;
|
||||
quant: string | null;
|
||||
variant: GgufVariantTagInput['variant'];
|
||||
}
|
||||
|
||||
type BitDepthRow = { bitDepth: number; files: HfModelSibling[] };
|
||||
|
||||
let { bitDepthRows, files, modelId, nativeCtxTokens }: Props = $props();
|
||||
|
||||
let pendingDownload = $state<PendingDownload | null>(null);
|
||||
|
||||
let deviceMemoryGb = $derived(
|
||||
resolveDeviceMemoryGb(Number(settingsStore.config.deviceMemoryGb) || 0)
|
||||
);
|
||||
let osLabel = $derived(browser ? detectOs(navigator.userAgent) : 'unknown');
|
||||
let tiers = $derived(computeFileCompatibilityTiers(files, nativeCtxTokens, deviceMemoryGb));
|
||||
|
||||
function buttonClass(parts: {
|
||||
isDownloaded: boolean;
|
||||
isFailed: boolean;
|
||||
isUnavailable: boolean;
|
||||
}): string {
|
||||
const { isDownloaded, isFailed, isUnavailable } = parts;
|
||||
const classes = [
|
||||
'relative inline-flex items-center gap-1 overflow-hidden rounded-md border bg-muted px-2 py-1 text-left font-mono text-xs transition-colors'
|
||||
];
|
||||
|
||||
// Buttons stay neutral; only the leading compatibility badge carries
|
||||
// color (green/yellow/red). Unavailable quants are greyed + disabled.
|
||||
if (isUnavailable) {
|
||||
classes.push('cursor-not-allowed opacity-50');
|
||||
} else {
|
||||
classes.push('cursor-pointer hover:border-primary/60 hover:bg-primary/5');
|
||||
}
|
||||
|
||||
if (isDownloaded && !isFailed) {
|
||||
classes.push('border-foreground bg-muted');
|
||||
} else if (isFailed) {
|
||||
classes.push('border-destructive');
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if bitDepthRows.length}
|
||||
<section class="rounded-xl border">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 px-4 pt-3 pb-1">
|
||||
<h2 class="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
|
||||
<Download class="h-4 w-4" />
|
||||
Downloadable options
|
||||
</h2>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full border bg-background px-2.5 py-1 text-xs font-medium"
|
||||
>
|
||||
<Cpu class="h-3 w-3 text-muted-foreground" />
|
||||
{osLabel}
|
||||
{#if deviceMemoryGb > 0}
|
||||
<span class="text-muted-foreground">({deviceMemoryGb} GB)</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="divide-y px-4 pb-1">
|
||||
{#each bitDepthRows as row (row.bitDepth)}
|
||||
<div class="grid grid-cols-[5rem_1fr] items-start gap-3 py-3">
|
||||
<div class="pt-1 text-sm tabular-nums text-muted-foreground">
|
||||
{#if row.bitDepth === 99}
|
||||
Other
|
||||
{:else}
|
||||
{row.bitDepth}-bit
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-1.5">
|
||||
{#each row.files as file (file.path)}
|
||||
{@const meta = HuggingFaceService.extractQuantMeta(file.path)}
|
||||
{@const basename = file.path.split('/').pop() ?? file.path}
|
||||
{@const label = meta?.quant ?? basename.replace(/\.gguf$/i, '')}
|
||||
{@const tagInput = meta?.quant
|
||||
? { quant: meta.quant, variant: meta.variant ?? null }
|
||||
: null}
|
||||
{@const hfRepoWithTag = ModelsService.buildDownloadTag(modelId, tagInput)}
|
||||
{@const progress = modelsStore.status.getDownloadProgress(hfRepoWithTag)}
|
||||
{@const isDownloading = modelsStore.status.isDownloadInProgress(hfRepoWithTag)}
|
||||
{@const isDownloaded = meta?.variant
|
||||
? modelsStore.status.isDraftDownloaded(modelId, file.path)
|
||||
: modelsStore.status.isModelDownloaded(hfRepoWithTag)}
|
||||
{@const isFailed = modelsStore.status.hasFailedDownload(hfRepoWithTag)}
|
||||
{@const tier = tiers.get(file.path)}
|
||||
{@const isUnavailable =
|
||||
tier === 'none' && !isDownloaded && !isDownloading && !isFailed}
|
||||
{@const isAvailable = tier === 'full' && !isDownloaded && !isDownloading && !isFailed}
|
||||
{@const isLimited =
|
||||
tier === 'limited' && !isDownloaded && !isDownloading && !isFailed}
|
||||
{@const tooltipText = isDownloading
|
||||
? `Downloading ${file.path}`
|
||||
: isDownloaded
|
||||
? `Already downloaded: ${file.path}`
|
||||
: isFailed
|
||||
? `Last attempt failed: ${file.path}. Click to retry.`
|
||||
: isUnavailable
|
||||
? `Does not fit this device: ${file.path}`
|
||||
: `Download ${file.path}`}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger
|
||||
aria-disabled={isUnavailable}
|
||||
class={buttonClass({
|
||||
isDownloaded,
|
||||
isFailed,
|
||||
isUnavailable
|
||||
})}
|
||||
onclick={() => {
|
||||
if (isUnavailable) return;
|
||||
|
||||
pendingDownload = {
|
||||
filePath: file.path,
|
||||
quant: meta?.quant ?? null,
|
||||
sizeBytes: file.size ?? null,
|
||||
variant: meta?.variant ?? null
|
||||
};
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{#if isAvailable}
|
||||
<span
|
||||
class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-green-600"
|
||||
>
|
||||
<Check class="h-2.5 w-2.5 text-white" />
|
||||
</span>
|
||||
{:else if isLimited}
|
||||
<span
|
||||
class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-yellow-500"
|
||||
>
|
||||
<TriangleAlert class="h-2.5 w-2.5 text-white" />
|
||||
</span>
|
||||
{:else if isUnavailable}
|
||||
<span
|
||||
class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-red-600"
|
||||
>
|
||||
<X class="h-2.5 w-2.5 text-white" />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if isFailed && !isDownloading && !isDownloaded}
|
||||
<span
|
||||
class="rounded bg-destructive px-1 py-0.5 text-[10px] font-semibold tracking-wide text-destructive-foreground uppercase"
|
||||
>
|
||||
Failed
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if meta?.variant}
|
||||
<span
|
||||
class="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase"
|
||||
>
|
||||
{meta.variant}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="font-medium {isDownloaded ? '' : 'text-muted-foreground/80'}"
|
||||
>{label}</span
|
||||
>
|
||||
|
||||
<span class="-my-1 w-px self-stretch bg-border"></span>
|
||||
|
||||
<span class={isDownloaded ? '' : 'text-muted-foreground/80'}>
|
||||
{#if isDownloading && progress && progress.totalBytes > 0}
|
||||
{Math.round((progress.downloadedBytes / progress.totalBytes) * 100)}%
|
||||
{:else}
|
||||
{HuggingFaceService.formatFileSize(file.size ?? 0)}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if isDownloading && progress}
|
||||
<DownloadProgressBar
|
||||
downloadedBytes={progress.downloadedBytes}
|
||||
overlay
|
||||
totalBytes={progress.totalBytes}
|
||||
/>
|
||||
{/if}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{tooltipText}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if pendingDownload}
|
||||
<DialogModelDownload
|
||||
bind:open={
|
||||
() => pendingDownload !== null,
|
||||
(v) => {
|
||||
if (!v) pendingDownload = null;
|
||||
}
|
||||
}
|
||||
filePath={pendingDownload.filePath}
|
||||
formattedSize={pendingDownload.sizeBytes != null
|
||||
? HuggingFaceService.formatFileSize(pendingDownload.sizeBytes)
|
||||
: undefined}
|
||||
onCancel={() => (pendingDownload = null)}
|
||||
onConfirm={() => (pendingDownload = null)}
|
||||
quant={pendingDownload.quant}
|
||||
repoId={modelId}
|
||||
variant={pendingDownload.variant}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverAvatar from './ModelsDiscoverAvatar.svelte';
|
||||
import ModelsDiscoverChatTemplateDialog from './ModelsDiscoverChatTemplateDialog.svelte';
|
||||
import ModelsDiscoverDetailsName from './ModelsDiscoverDetailsName.svelte';
|
||||
import { Download, ExternalLink, Heart, MessageSquareCode } from '@lucide/svelte';
|
||||
import { ICON_CLASS_SM } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { modelsHubStore } from '$lib/stores';
|
||||
import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';
|
||||
import { formatParameters } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
modelId: string;
|
||||
details: HfModelDetailInfo;
|
||||
gguf?: HfModelGguf;
|
||||
baseModels: string[];
|
||||
licenseTag: string | null;
|
||||
hasVision: boolean;
|
||||
hasTools: boolean;
|
||||
hasReasoning: boolean;
|
||||
}
|
||||
|
||||
let { baseModels, details, gguf, hasReasoning, hasTools, hasVision, licenseTag, modelId }: Props =
|
||||
$props();
|
||||
|
||||
// Avatar shows the base model's org (e.g. the Qwen logo for a ggml-org GGUF)
|
||||
// with the quant org as a corner badge when they differ.
|
||||
let repoOrg = $derived(details.id?.split('/')[0] ?? modelId.split('/')[0] ?? modelId);
|
||||
let baseOrg = $derived(baseModels[0]?.split('/')[0]);
|
||||
let avatarOrg = $derived(baseOrg || repoOrg);
|
||||
let quantOrg = $derived(baseOrg && baseOrg !== repoOrg ? repoOrg : undefined);
|
||||
|
||||
// Catalog family description when curated, else the HF card description.
|
||||
let description = $derived(
|
||||
modelsHubStore.descriptionFor(modelId) ?? details.cardData?.description
|
||||
);
|
||||
|
||||
let chatTemplateOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<header class="space-y-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ModelsDiscoverAvatar org={avatarOrg} {quantOrg} />
|
||||
|
||||
<ModelsDiscoverDetailsName
|
||||
{baseModels}
|
||||
{hasReasoning}
|
||||
{hasTools}
|
||||
{hasVision}
|
||||
modelId={details.id ?? modelId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
|
||||
href={HuggingFaceService.getModelUrl(modelId)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<img alt="" class="h-3.5 w-3.5" src="/recommended-mcp/huggingface.ico" />
|
||||
|
||||
View on Hugging Face
|
||||
|
||||
<ExternalLink class={ICON_CLASS_SM} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||
{#if typeof details.downloads === 'number'}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
{HuggingFaceService.formatDownloads(details.downloads)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if typeof details.likes === 'number'}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<Heart class="h-3.5 w-3.5" />
|
||||
{HuggingFaceService.formatLikes(details.likes)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if details.lastModified}
|
||||
<span>Updated {HuggingFaceService.formatRelativeTime(details.lastModified)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if description}
|
||||
<p class="text-sm text-muted-foreground">{description}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Metadata chips: label | value pairs, matching the HF model page style -->
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
{#if gguf?.total}
|
||||
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
|
||||
<span class="px-2.5 py-1 text-muted-foreground">Model size</span>
|
||||
|
||||
<span class="px-2.5 py-1 font-medium">{formatParameters(gguf.total)} params</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if gguf?.context_length}
|
||||
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
|
||||
<span class="px-2.5 py-1 text-muted-foreground">Context</span>
|
||||
|
||||
<span class="px-2.5 py-1 font-medium">{gguf.context_length.toLocaleString()}</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if gguf?.architecture}
|
||||
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
|
||||
<span class="px-2.5 py-1 text-muted-foreground">Architecture</span>
|
||||
|
||||
<span class="px-2.5 py-1 font-medium">{gguf.architecture}</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if gguf?.chat_template}
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-muted"
|
||||
onclick={() => (chatTemplateOpen = true)}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquareCode class="h-3 w-3" />
|
||||
Chat template
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if licenseTag}
|
||||
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
|
||||
<span class="px-2.5 py-1 text-muted-foreground">License</span>
|
||||
|
||||
<span class="px-2.5 py-1 font-medium">{licenseTag}</span>
|
||||
</span>
|
||||
|
||||
<span class="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if details.gated === true}
|
||||
<span
|
||||
class="rounded bg-yellow-500/10 px-2 py-0.5 text-xs font-medium text-yellow-600 dark:text-yellow-400"
|
||||
>
|
||||
gated
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if gguf?.chat_template}
|
||||
<ModelsDiscoverChatTemplateDialog
|
||||
bind:open={chatTemplateOpen}
|
||||
chatTemplate={gguf.chat_template}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { ExternalLink, Image, Lightbulb, Wrench } from '@lucide/svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
|
||||
interface Props {
|
||||
/** Full HuggingFace model id (quant org + name), e.g. `ggml-org/Qwen3.8-27B-GGUF`. */
|
||||
modelId: string;
|
||||
/** Base model ids, shown as small text under the quant name. */
|
||||
baseModels: string[];
|
||||
hasVision: boolean;
|
||||
hasTools: boolean;
|
||||
hasReasoning: boolean;
|
||||
}
|
||||
|
||||
let { baseModels, hasReasoning, hasTools, hasVision, modelId }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="truncate text-lg font-semibold">{modelId}</h1>
|
||||
|
||||
{#if hasVision || hasTools || hasReasoning}
|
||||
<div class="flex shrink-0 items-center gap-2.5 text-muted-foreground">
|
||||
{#if hasVision}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Image class="h-4 w-4" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Vision</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if hasTools}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Wrench class="h-4 w-4" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Tool use</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if hasReasoning}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Lightbulb class="h-4 w-4" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Reasoning</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if baseModels.length}
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="truncate text-xs text-muted-foreground">{baseModels.join(', ')}</span>
|
||||
|
||||
<a
|
||||
aria-label="View base model on HuggingFace"
|
||||
class="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
href={HuggingFaceService.getModelUrl(baseModels[0])}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink class="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { MarkdownContent } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
readme: string | null;
|
||||
}
|
||||
|
||||
let { readme }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if readme}
|
||||
<section class="space-y-2 bg-muted/50 p-3 rounded-xl">
|
||||
<h2 class="text-xs font-semibold tracking-wide text-muted-foreground uppercase">README</h2>
|
||||
|
||||
<MarkdownContent allowHtml class="prose-sm max-w-none" content={readme} />
|
||||
</section>
|
||||
{/if}
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import ModelId from '../ModelId.svelte';
|
||||
import { type DraftVariant } from '$lib/constants';
|
||||
import { HuggingFaceService, ModelsService } from '$lib/services';
|
||||
import { modelsHubStore } from '$lib/stores';
|
||||
import type { HfModelInfo } from '$lib/types/huggingface';
|
||||
import type { ModelModalities } from '$lib/types/models';
|
||||
import { detectToolUseSupport, formatParameters } from '$lib/utils';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
model: HfModelInfo;
|
||||
}
|
||||
|
||||
let { model }: Props = $props();
|
||||
|
||||
let contextLength = $derived(model.gguf?.context_length);
|
||||
|
||||
// Params badge fallback: the id usually carries the count (`Qwen3.8-27B`),
|
||||
// but ids like `Kimi-K3` do not. Fall back to the HF param count
|
||||
// (`gguf.total`); search results omit `gguf`, so fetch details lazily only
|
||||
// when the name has no params token.
|
||||
let fetchedParams = $state<number | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
fetchedParams = null;
|
||||
|
||||
if (model.gguf?.total || ModelsService.parseModelId(model.id).params) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void HuggingFaceService.getDetails(model.id).then((info) => {
|
||||
if (!cancelled && info?.gguf?.total) fetchedParams = info.gguf.total;
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
let hfParams = $derived(model.gguf?.total ?? fetchedParams);
|
||||
let paramsFallback = $derived(
|
||||
hfParams && !ModelsService.parseModelId(model.id).params
|
||||
? formatParameters(hfParams, 0)
|
||||
: undefined
|
||||
);
|
||||
|
||||
// Reasoning support from the chat template, matching the details view.
|
||||
let supportsThinking = $derived(
|
||||
Boolean(model.gguf?.chat_template && /think|reasoning/i.test(model.gguf.chat_template))
|
||||
);
|
||||
|
||||
// Tool use support from the chat template.
|
||||
let supportsToolUse = $derived(detectToolUseSupport(model.gguf?.chat_template ?? ''));
|
||||
|
||||
// Modalities derived from HF metadata: vision from an mmproj sidecar or a
|
||||
// multimodal pipeline tag, audio/video from their pipeline tags.
|
||||
let modalities = $derived.by<ModelModalities>(() => {
|
||||
const tag = model.pipeline_tag ?? '';
|
||||
const vision =
|
||||
['image-text-to-text', 'image-to-text', 'text-to-image', 'image-to-video'].includes(tag) ||
|
||||
Boolean(model.siblings?.some((s) => s.rfilename.toLowerCase().includes('mmproj')));
|
||||
const audio = [
|
||||
'audio-classification',
|
||||
'audio-to-audio',
|
||||
'automatic-speech-recognition',
|
||||
'text-to-speech',
|
||||
'voice-activity-detection'
|
||||
].includes(tag);
|
||||
const video = ['text-to-video', 'image-to-video', 'video-to-video'].includes(tag);
|
||||
|
||||
return { audio, video, vision };
|
||||
});
|
||||
|
||||
// Draft sidecars (mtp, dflash, dspark, eagle3) present in the repo, e.g.
|
||||
// speculative-decoding drafts. mmproj is excluded: it is vision, already
|
||||
// conveyed by the modalities.
|
||||
let draftVariants = $derived.by<DraftVariant[]>(() => {
|
||||
const set = new SvelteSet<DraftVariant>();
|
||||
|
||||
for (const sibling of model.siblings ?? []) {
|
||||
const variant = HuggingFaceService.extractQuantMeta(sibling.rfilename)?.variant;
|
||||
|
||||
if (variant && variant !== 'mmproj') set.add(variant);
|
||||
}
|
||||
|
||||
return [...set];
|
||||
});
|
||||
|
||||
// Combined min/max size: the catalog gives main-model sizes per quant, and
|
||||
// the repo file tree carries draft sidecar sizes (the detail siblings do
|
||||
// not). Min = smallest main + smallest draft, max = largest main + largest
|
||||
// draft, so the stored model fits within the range.
|
||||
let sizeRange = $state<{ min: number; max: number } | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const base = modelsHubStore.sizeRangeFor(model.id);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
if (draftVariants.length === 0) {
|
||||
sizeRange = base ?? null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
void HuggingFaceService.getTree(model.id).then((tree) => {
|
||||
if (cancelled) return;
|
||||
|
||||
const drafts = tree
|
||||
.filter((f) => {
|
||||
const variant = HuggingFaceService.extractQuantMeta(f.path)?.variant;
|
||||
|
||||
return variant && variant !== 'mmproj';
|
||||
})
|
||||
.map((f) => f.size ?? 0)
|
||||
.filter((size) => size > 0);
|
||||
|
||||
if (base && drafts.length > 0) {
|
||||
sizeRange = {
|
||||
max: base.max + Math.max(...drafts),
|
||||
min: base.min + Math.min(...drafts)
|
||||
};
|
||||
} else {
|
||||
sizeRange = base ?? null;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
<ModelId
|
||||
class="min-w-0"
|
||||
{contextLength}
|
||||
{draftVariants}
|
||||
hideOrgName
|
||||
iconsOnNewLine
|
||||
{modalities}
|
||||
modelId={model.id}
|
||||
params={paramsFallback}
|
||||
{sizeRange}
|
||||
{supportsThinking}
|
||||
{supportsToolUse}
|
||||
wrap
|
||||
/>
|
||||
</span>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverAvatar from './ModelsDiscoverAvatar.svelte';
|
||||
import ModelsDiscoverInfo from './ModelsDiscoverInfo.svelte';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { HfModelInfo } from '$lib/types/huggingface';
|
||||
|
||||
interface Props {
|
||||
model: HfModelInfo;
|
||||
active?: boolean;
|
||||
/** Show the original (base) model's org avatar instead of the repo's org. */
|
||||
showBaseModelAvatar?: boolean;
|
||||
onSelect?: (modelId: string) => void;
|
||||
}
|
||||
|
||||
let { active = false, model, onSelect, showBaseModelAvatar = false }: Props = $props();
|
||||
|
||||
let org = $derived(model.id.split('/')[0] ?? model.id);
|
||||
|
||||
// Org whose avatar is shown: the base model's org when showBaseModelAvatar
|
||||
// (e.g. the Qwen logo for ggml-org/Qwen3.8-27B-GGUF), else the repo's org.
|
||||
let avatarOrg = $derived.by(() => {
|
||||
if (!showBaseModelAvatar) return org;
|
||||
|
||||
const base = HuggingFaceService.getBaseModels(model)[0];
|
||||
|
||||
return base?.split('/')[0] || org;
|
||||
});
|
||||
</script>
|
||||
|
||||
<li>
|
||||
<button
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="flex w-full cursor-pointer items-start gap-2.5 rounded-lg p-2.5 text-left transition-colors {active
|
||||
? 'bg-primary/10 hover:bg-primary/15'
|
||||
: 'hover:bg-muted/60'}"
|
||||
onclick={() => onSelect?.(model.id)}
|
||||
type="button"
|
||||
>
|
||||
<ModelsDiscoverAvatar org={avatarOrg} quantOrg={showBaseModelAvatar ? org : undefined} />
|
||||
|
||||
<ModelsDiscoverInfo {model} />
|
||||
</button>
|
||||
</li>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverItem from './ModelsDiscoverItem.svelte';
|
||||
import type { HfModelInfo } from '$lib/types/huggingface';
|
||||
|
||||
interface Props {
|
||||
models: HfModelInfo[];
|
||||
activeId?: string | null;
|
||||
/** Show the original (base) model's org avatar instead of the repo's org. */
|
||||
showBaseModelAvatar?: boolean;
|
||||
onSelect?: (modelId: string) => void;
|
||||
}
|
||||
|
||||
let { activeId = null, models, onSelect, showBaseModelAvatar = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<ul class="space-y-0.5 p-2">
|
||||
{#each models as model (model.id)}
|
||||
<ModelsDiscoverItem active={model.id === activeId} {model} {onSelect} {showBaseModelAvatar} />
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { Check, Copy, Server, SquareTerminal } from '@lucide/svelte';
|
||||
import { type DraftVariant } from '$lib/constants';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
/** Full HuggingFace model id, The draft sidecar sits in the same repo. */
|
||||
modelId: string;
|
||||
/** Draft sidecar variants present in the repo (mtp, dflash, dspark, eagle3). */
|
||||
draftVariants?: DraftVariant[];
|
||||
}
|
||||
|
||||
let { draftVariants = [], modelId }: Props = $props();
|
||||
|
||||
// llama.cpp --spec-type value for each draft variant.
|
||||
const SPEC_TYPE: Record<DraftVariant, string> = {
|
||||
dflash: 'draft-dflash',
|
||||
dspark: 'draft-dspark',
|
||||
eagle3: 'eagle3',
|
||||
mmproj: '',
|
||||
mtp: 'draft-mtp'
|
||||
};
|
||||
|
||||
let copiedIndex = $state<number | null>(null);
|
||||
|
||||
async function handleCopy(index: number, text: string) {
|
||||
await copyToClipboard(text);
|
||||
copiedIndex = index;
|
||||
setTimeout(() => (copiedIndex = null), 1500);
|
||||
}
|
||||
|
||||
// One box per binary (serve / cli). Each box lists one command per available
|
||||
// draft sidecar, or just the base command when none is present.
|
||||
let boxes = $derived.by(() => {
|
||||
const variants = draftVariants.filter((v) => v !== 'mmproj');
|
||||
const build = (bin: string) => {
|
||||
const base = `llama ${bin} -hf ${modelId}`;
|
||||
|
||||
if (variants.length === 0) return [base];
|
||||
|
||||
return variants.map((v) => `${base} -hfd ${modelId} --spec-type ${SPEC_TYPE[v]}`);
|
||||
};
|
||||
|
||||
return [
|
||||
{ commands: build('serve'), icon: Server, title: 'Serve' },
|
||||
{ commands: build('cli'), icon: SquareTerminal, title: 'CLI' }
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each boxes as box (box.title)}
|
||||
<div
|
||||
class="overflow-hidden rounded-md"
|
||||
style="background: var(--code-background); border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-2"
|
||||
style="border-bottom: 1px solid color-mix(in oklch, var(--border) 30%, transparent);"
|
||||
>
|
||||
<box.icon class="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
|
||||
<span class="text-xs font-medium text-foreground/80">{box.title}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 p-2">
|
||||
{#each box.commands as cmd, i (cmd)}
|
||||
<div
|
||||
class="group flex items-center justify-between gap-2 rounded px-2 py-1 font-mono text-xs"
|
||||
>
|
||||
<span class="truncate text-foreground/90">{cmd}</span>
|
||||
|
||||
<button
|
||||
aria-label="Copy command"
|
||||
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
onclick={() => handleCopy(i, cmd)}
|
||||
type="button"
|
||||
>
|
||||
{#if copiedIndex === i}
|
||||
<Check class="h-3.5 w-3.5 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
*
|
||||
* MODELS HUB
|
||||
*
|
||||
* Components for the Models Hub route (`/models-hub`): a sidebar list of
|
||||
* HuggingFace GGUF models and a detail view for the selected model.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **ModelsDiscover** - Models hub explorer
|
||||
*
|
||||
* The complete discovery layout: a sidebar search + model list on the left and a
|
||||
* detail view for the selected model on the right. Used as the body of the
|
||||
* discovery dialog.
|
||||
*/
|
||||
export { default as ModelsDiscover } from './ModelsDiscover.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverList** - Sidebar model list
|
||||
*
|
||||
* Renders the hub's model list as a navigable column. Each row links to the
|
||||
* model's detail route and highlights the active one.
|
||||
*/
|
||||
export { default as ModelsDiscoverList } from './ModelsDiscoverList.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverItem** - Single sidebar row
|
||||
*
|
||||
* One model entry in the sidebar list. Links to `/models-hub/[org]/[model]`.
|
||||
*/
|
||||
export { default as ModelsDiscoverItem } from './ModelsDiscoverItem.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverAvatar** - Org avatar for a model row
|
||||
*
|
||||
* Shows the org's avatar image, falling back to a monogram on a stable hue
|
||||
* derived from the org name when the image fails to load.
|
||||
*/
|
||||
export { default as ModelsDiscoverAvatar } from './ModelsDiscoverAvatar.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverInfo** - Model name + metadata for a row
|
||||
*
|
||||
* Renders the model name via ModelId and the remaining metadata (org, last
|
||||
* modified, params, downloads, likes, vision) below it.
|
||||
*/
|
||||
export { default as ModelsDiscoverInfo } from './ModelsDiscoverInfo.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverDetails** - Model detail view
|
||||
*
|
||||
* Detail pane for the selected model. Loads its own data (details + GGUF file
|
||||
* list) from HuggingFaceService based on the `modelId` route param.
|
||||
*/
|
||||
export { default as ModelsDiscoverDetails } from './ModelsDiscoverDetails.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverDetailsHeader** - Detail view header
|
||||
*
|
||||
* Shows the model avatar (base org + quant org corner badge), name, base model
|
||||
* info, stats, metadata chips and capability badges.
|
||||
*/
|
||||
export { default as ModelsDiscoverDetailsHeader } from './ModelsDiscoverDetailsHeader.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverDetailsName** - Model name block
|
||||
*
|
||||
* Shows the quant model name with capability icons (vision, tool use,
|
||||
* reasoning) beside it, and the base model name with a smaller external-link
|
||||
* icon on the line below.
|
||||
*/
|
||||
export { default as ModelsDiscoverDetailsName } from './ModelsDiscoverDetailsName.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverDetailsDownloadOptions** - GGUF download options
|
||||
*
|
||||
* Groups GGUF files by bit depth and renders per-file download buttons with
|
||||
* progress, owned by the download confirmation dialog.
|
||||
*/
|
||||
export { default as ModelsDiscoverDetailsDownloadOptions } from './ModelsDiscoverDetailsDownloadOptions.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverChatTemplateDialog** - Chat template viewer
|
||||
*
|
||||
* Shows the model's chat template in a scrollable dialog with a copy button.
|
||||
*/
|
||||
export { default as ModelsDiscoverChatTemplateDialog } from './ModelsDiscoverChatTemplateDialog.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverDetailsReadme** - Detail view README
|
||||
*
|
||||
* Renders the model card README as markdown.
|
||||
*/
|
||||
export { default as ModelsDiscoverDetailsReadme } from './ModelsDiscoverDetailsReadme.svelte';
|
||||
|
||||
/**
|
||||
* **TerminalCommands** - Terminal command block
|
||||
*
|
||||
* Shows the `llama serve` / `llama cli` commands for a model with copy buttons.
|
||||
*/
|
||||
export { default as TerminalCommands } from './TerminalCommands.svelte';
|
||||
|
||||
/**
|
||||
* **DialogModelDownload** - Download confirmation / progress dialog
|
||||
*
|
||||
* Confirms a single GGUF download, tracks live progress over the SSE feed and
|
||||
* offers cancel / delete-&-retry flows.
|
||||
*/
|
||||
export { default as DialogModelDownload } from './DialogModelDownload.svelte';
|
||||
|
||||
/**
|
||||
* **DownloadProgressBar** - Thin download progress bar
|
||||
*
|
||||
* Normalizes bytes to a 0..100% bar; can pin to the bottom edge as an overlay.
|
||||
*/
|
||||
export { default as DownloadProgressBar } from './DownloadProgressBar.svelte';
|
||||
@@ -42,7 +42,7 @@
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte';
|
||||
export { default as ModelsSelectorDropdown } from './selector/ModelsSelectorDropdown.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsSelectorList** - Grouped model options list
|
||||
@@ -54,7 +54,7 @@ export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svel
|
||||
* Accepts an optional `renderOption` snippet to customize how each option is
|
||||
* rendered (e.g., to add keyboard navigation or highlighting).
|
||||
*/
|
||||
export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
|
||||
export { default as ModelsSelectorList } from './selector/ModelsSelectorList.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsSelectorOption** - Single model option row
|
||||
@@ -63,7 +63,7 @@ export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
|
||||
* load/unload actions, status indicators, and an info button.
|
||||
* Used inside ModelsSelectorList or directly in custom render snippets.
|
||||
*/
|
||||
export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
|
||||
export { default as ModelsSelectorOption } from './selector/ModelsSelectorOption.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsSelectorSheet** - Mobile model selection sheet
|
||||
@@ -72,7 +72,7 @@ export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
|
||||
* on mobile devices. Same functionality as ModelsSelectorDropdown but uses Sheet UI
|
||||
* instead of DropdownMenu.
|
||||
*/
|
||||
export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.svelte';
|
||||
export { default as ModelsSelectorSheet } from './selector/ModelsSelectorSheet.svelte';
|
||||
|
||||
/** * **ModelBadge** - Model name display badge
|
||||
*
|
||||
|
||||
+28
-14
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
|
||||
import type { ModelItem } from './utils';
|
||||
import { ChevronDown, Lightbulb, Loader2 } from '@lucide/svelte';
|
||||
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
|
||||
import type { ModelItem } from '../utils';
|
||||
import { ChevronDown, Lightbulb, LoaderCircle, PackageSearch } from '@lucide/svelte';
|
||||
import {
|
||||
ChatFormActionAddReasoningSubmenu,
|
||||
DialogModelInformation,
|
||||
DialogModelsDiscover,
|
||||
DropdownMenuSearchable,
|
||||
ModelId,
|
||||
ModelsSelectorList,
|
||||
@@ -39,6 +40,7 @@
|
||||
|
||||
let isOpen = $state(false);
|
||||
let highlightedId = $state<string | null>(null);
|
||||
let modelsHubOpen = $state(false);
|
||||
// The model submenu opens together with the menu so the list and its search
|
||||
// box are immediately available, as before the submenu was introduced
|
||||
let modelSubOpen = $state(false);
|
||||
@@ -96,9 +98,7 @@
|
||||
|
||||
for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
|
||||
for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
|
||||
for (const group of ms.groupedFilteredOptions.available) {
|
||||
for (const item of group.items) order.push(item.option.id);
|
||||
}
|
||||
for (const item of ms.groupedFilteredOptions.available) order.push(item.option.id);
|
||||
|
||||
return order;
|
||||
});
|
||||
@@ -169,7 +169,7 @@
|
||||
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
|
||||
{#if ms.loading && ms.options.length === 0 && ms.isRouter}
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
<LoaderCircle class="h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
Loading models…
|
||||
</div>
|
||||
@@ -212,7 +212,7 @@
|
||||
class={[
|
||||
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
? 'bg-red-400/10 text-red-400! hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
@@ -243,7 +243,7 @@
|
||||
</span>
|
||||
|
||||
{#if ms.updating || ms.isLoadingModel}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
<LoaderCircle class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{:else}
|
||||
<ChevronDown class="h-3 w-3.5 shrink-0" />
|
||||
{/if}
|
||||
@@ -264,7 +264,7 @@
|
||||
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
class="w-full md:min-w-64 md:max-w-80 max-w-[calc(100vw-2rem)]"
|
||||
class="w-full md:min-w-64 max-w-[calc(100vw-2rem)]"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenu.Sub bind:open={modelSubOpen}>
|
||||
@@ -283,7 +283,7 @@
|
||||
{/if}
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent class="w-100 max-w-[calc(100vw-2rem)] pt-0">
|
||||
<DropdownMenu.SubContent class="max-w-[calc(100vw-2rem)] md:max-w-108 pt-0">
|
||||
<DropdownMenuSearchable
|
||||
emptyMessage="No models found."
|
||||
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
|
||||
@@ -313,14 +313,15 @@
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{#snippet modelOption(item: ModelItem, _hideOrgName: boolean, compact = false)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{hideOrgName}
|
||||
{compact}
|
||||
hideOrgName={false}
|
||||
{isFav}
|
||||
{isHighlighted}
|
||||
{isSelected}
|
||||
@@ -352,6 +353,17 @@
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<ChatFormActionAddReasoningSubmenu />
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent"
|
||||
onclick={() => (modelsHubOpen = true)}
|
||||
>
|
||||
<PackageSearch class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<span>Discover models</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
@@ -392,7 +404,7 @@
|
||||
{/if}
|
||||
|
||||
{#if ms.updating}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
<LoaderCircle class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
@@ -415,3 +427,5 @@
|
||||
open={ms.showModelDialog}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<DialogModelsDiscover bind:open={modelsHubOpen} />
|
||||
+7
-14
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { GroupedModelOptions, ModelItem } from './utils';
|
||||
import type { GroupedModelOptions, ModelItem } from '../utils';
|
||||
import { ModelsSelectorOption } from '$lib/components/app';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
currentModel: string | null;
|
||||
activeId: string | null;
|
||||
sectionHeaderClass?: string;
|
||||
orgHeaderClass?: string;
|
||||
onSelect: (modelId: string) => void;
|
||||
onInfoClick: (modelName: string) => void;
|
||||
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
|
||||
renderOption?: import('svelte').Snippet<[ModelItem, boolean, boolean?]>;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -20,19 +19,19 @@
|
||||
groups,
|
||||
onInfoClick,
|
||||
onSelect,
|
||||
orgHeaderClass = 'px-2 py-2 text-[11px] font-semibold text-muted-foreground/50 select-none [&:not(:first-child)]:mt-1',
|
||||
renderOption,
|
||||
sectionHeaderClass = 'my-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none'
|
||||
sectionHeaderClass = 'my-1 px-2 py-2 text-xs font-semibold text-muted-foreground/70 select-none'
|
||||
}: Props = $props();
|
||||
let render = $derived(renderOption ?? defaultOption);
|
||||
</script>
|
||||
|
||||
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean, compact = false)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || activeId === option.id}
|
||||
{@const isFav = modelsStore.favoriteModelIds.has(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{compact}
|
||||
{hideOrgName}
|
||||
{isFav}
|
||||
isHighlighted={false}
|
||||
@@ -64,13 +63,7 @@
|
||||
{#if groups.available.length > 0}
|
||||
<p class={sectionHeaderClass}>Available models</p>
|
||||
|
||||
{#each groups.available as group (group.orgName)}
|
||||
{#if group.orgName}
|
||||
<p class={orgHeaderClass}>{group.orgName}</p>
|
||||
{/if}
|
||||
|
||||
{#each group.items as item (item.option.id)}
|
||||
{@render render(item, true)}
|
||||
{/each}
|
||||
{#each groups.available as item (`avail-${item.option.id}`)}
|
||||
{@render render(item, false)}
|
||||
{/each}
|
||||
{/if}
|
||||
+24
-8
@@ -1,16 +1,16 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
|
||||
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
|
||||
import {
|
||||
CircleAlert,
|
||||
Heart,
|
||||
HeartOff,
|
||||
Info,
|
||||
Loader2,
|
||||
LoaderCircle,
|
||||
Power,
|
||||
PowerOff,
|
||||
RotateCw
|
||||
} from '@lucide/svelte';
|
||||
import { ActionIcon, ModelId } from '$lib/components/app';
|
||||
import { ActionIcon, ModelId, ModelsDiscoverAvatar } from '$lib/components/app';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
@@ -23,6 +23,8 @@
|
||||
isHighlighted: boolean;
|
||||
isFav: boolean;
|
||||
hideOrgName?: boolean;
|
||||
/** Show only the quant/param badges, hiding the avatar and org/model name. */
|
||||
compact?: boolean;
|
||||
onSelect: (modelId: string) => void;
|
||||
onMouseEnter: () => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
@@ -30,6 +32,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
compact = false,
|
||||
hideOrgName = false,
|
||||
isFav,
|
||||
isHighlighted,
|
||||
@@ -59,9 +62,9 @@
|
||||
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
|
||||
let loadTitle = $derived(modelLoadProgressText(loadProgress));
|
||||
let modalities = $derived(option.modalities);
|
||||
let capabilities = $derived.by(() => ({
|
||||
reasoning: modelsStore.props.checkModelSupportsThinking(option.model)
|
||||
}));
|
||||
let supportsThinking = $derived(modelsStore.props.checkModelSupportsThinking(option.model));
|
||||
let quantOrg = $derived(option.parsedId?.orgName || option.model.split('/')[0] || option.model);
|
||||
let baseOrg = $derived(option.baseModel?.org || quantOrg);
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -83,14 +86,27 @@
|
||||
tabindex="0"
|
||||
title={loadTitle}
|
||||
>
|
||||
{#if !compact}
|
||||
<ModelsDiscoverAvatar
|
||||
org={baseOrg}
|
||||
quantImageClass="size-3.25"
|
||||
{quantOrg}
|
||||
quantPositionClass="-right-1.25 -bottom-1.25"
|
||||
size="size-5"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ModelId
|
||||
aliases={option.aliases}
|
||||
{capabilities}
|
||||
class="flex-1"
|
||||
hideModalities={compact}
|
||||
hideName={compact}
|
||||
{hideOrgName}
|
||||
hideParameters={compact}
|
||||
{modalities}
|
||||
modelId={option.model}
|
||||
showRawTooltip
|
||||
{supportsThinking}
|
||||
tags={option.tags}
|
||||
/>
|
||||
|
||||
@@ -133,7 +149,7 @@
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-5">
|
||||
<Loader2 class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
|
||||
<LoaderCircle class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if isFailed}
|
||||
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
|
||||
+5
-6
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
|
||||
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
|
||||
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
|
||||
import { ChevronDown, LoaderCircle, Package } from '@lucide/svelte';
|
||||
import {
|
||||
DialogModelInformation,
|
||||
ModelId,
|
||||
@@ -58,7 +58,7 @@
|
||||
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
|
||||
{#if ms.loading && ms.options.length === 0 && ms.isRouter}
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
<LoaderCircle class="h-3.5 w-3.5 animate-spin" />
|
||||
Loading models…
|
||||
</div>
|
||||
{:else if ms.options.length === 0 && ms.isRouter}
|
||||
@@ -110,7 +110,7 @@
|
||||
{/if}
|
||||
|
||||
{#if ms.updating || ms.isLoadingModel}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
<LoaderCircle class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{:else}
|
||||
<ChevronDown class="h-3 w-3.5 shrink-0" />
|
||||
{/if}
|
||||
@@ -166,7 +166,6 @@
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onSelect={ms.handleSelect}
|
||||
orgHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
|
||||
sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -194,7 +193,7 @@
|
||||
<ModelId class="font-medium" hideQuantization modelId={selectedOption?.model || ''} />
|
||||
|
||||
{#if ms.updating}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
<LoaderCircle class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -1,21 +1,15 @@
|
||||
import { ModelModality } from '$lib/enums';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
export interface ModelItem {
|
||||
option: ModelOption;
|
||||
flatIndex: number;
|
||||
}
|
||||
|
||||
export interface OrgGroup {
|
||||
orgName: string | null;
|
||||
items: ModelItem[];
|
||||
}
|
||||
|
||||
export interface GroupedModelOptions {
|
||||
loaded: ModelItem[];
|
||||
favorites: ModelItem[];
|
||||
available: OrgGroup[];
|
||||
available: ModelItem[];
|
||||
}
|
||||
|
||||
function matchesModality(option: ModelOption, term: string): boolean {
|
||||
@@ -77,24 +71,15 @@ export function groupModelOptions(
|
||||
}
|
||||
}
|
||||
|
||||
// Available models grouped by org (excluding loaded and favorites)
|
||||
const available: OrgGroup[] = [];
|
||||
const orgGroups = new SvelteMap<string, ModelItem[]>();
|
||||
// Available models (excluding loaded and favorites)
|
||||
const available: ModelItem[] = [];
|
||||
|
||||
for (let i = 0; i < filteredOptions.length; i++) {
|
||||
const option = filteredOptions[i];
|
||||
|
||||
if (loadedModelIds.has(option.model) || favoriteIds.has(option.model)) continue;
|
||||
|
||||
const key = option.parsedId?.orgName ?? '';
|
||||
|
||||
if (!orgGroups.has(key)) orgGroups.set(key, []);
|
||||
|
||||
orgGroups.get(key)!.push({ flatIndex: i, option });
|
||||
}
|
||||
|
||||
for (const [orgName, items] of orgGroups) {
|
||||
available.push({ items, orgName: orgName || null });
|
||||
available.push({ flatIndex: i, option });
|
||||
}
|
||||
|
||||
return { available, favorites, loaded };
|
||||
|
||||
+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',
|
||||
|
||||
@@ -44,6 +44,7 @@ export * from './message-export.constants';
|
||||
export * from './path-display.constants';
|
||||
export * from './model-id.constants';
|
||||
export * from './model-loading.constants';
|
||||
export * from './models-discover.constants';
|
||||
export * from './precision.constants';
|
||||
export * from './pwa.constants';
|
||||
export * from './routes.constants';
|
||||
|
||||
@@ -11,8 +11,24 @@ export const MODEL_ID = {
|
||||
|
||||
/** Matches prefix for custom quantization types, e.g. `UD-Q8_K_XL`. */
|
||||
CUSTOM_QUANTIZATION_PREFIX_RE: /^UD$/i,
|
||||
/**
|
||||
* Sidecar prefix that wraps a model id with a draft/aux variant, e.g.
|
||||
* `mtp-<name>.gguf`, `dflash-<name>.gguf`, `dspark-<name>.gguf`,
|
||||
* `eagle3-<name>.gguf`, `mmproj-<name>.gguf`. Captures the bare variant
|
||||
* token for typed lookup.
|
||||
*/
|
||||
DRAFT_VARIANT_PREFIX_RE: /^(mtp|dflash|dspark|eagle3|mmproj)-(.*)$/i,
|
||||
/**
|
||||
* Trailing `-<variant>` suffix marking a GGUF with an embedded draft in the
|
||||
* same weight file (MTP) or a sidecar download entry, e.g.
|
||||
* `Hy3-IQ1_M-mtp.gguf`, `Q4_K_M-dspark`. The captured prefix is the
|
||||
* candidate model id; the caller decides whether it looks quantized.
|
||||
*/
|
||||
DRAFT_VARIANT_SUFFIX_RE: /^(.*)-(mtp|dflash|dspark|eagle3)$/i,
|
||||
|
||||
/** Container format segments to exclude from tags (every model uses these). */
|
||||
IGNORED_SEGMENTS: new Set(['GGUF', 'GGML']),
|
||||
|
||||
/** Sentinel value returned by `indexOf` when a substring is not found. */
|
||||
NOT_FOUND: -1,
|
||||
|
||||
@@ -27,10 +43,11 @@ export const MODEL_ID = {
|
||||
PARAMS_RE: /^[Ee]?\d+(\.\d+)?[BbMmKkTt]$/,
|
||||
|
||||
/**
|
||||
* Matches a quantization/precision segment, e.g. `Q4_K_M`, `IQ4_XS`, `F16`, `BF16`, `MXFP4`.
|
||||
* Case-insensitive to handle both uppercase and lowercase inputs.
|
||||
* Matches a quantization/precision segment, e.g. `Q4_K_M`, `IQ4_XS`, `TQ1_0`,
|
||||
* `F16`, `BF16`, `MXFP4`. Case-insensitive to handle both cases.
|
||||
*/
|
||||
QUANTIZATION_SEGMENT_RE: /^(I?Q\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i,
|
||||
QUANTIZATION_SEGMENT_RE:
|
||||
/^(I?Q\d+(_[A-Z0-9]+)*|TQ\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i,
|
||||
|
||||
/** Separates the model path from the quantization tag, e.g. `model:Q4_K_M`. */
|
||||
QUANTIZATION_SEPARATOR: ':',
|
||||
@@ -41,3 +58,13 @@ export const MODEL_ID = {
|
||||
/** Matches a trailing weight file extension, e.g. `model.gguf` -> `model`. */
|
||||
WEIGHT_EXTENSION_RE: /\.(gguf|ggml)$/i
|
||||
};
|
||||
|
||||
/**
|
||||
* Auxiliary / draft variant segments that show up in GGUF filenames and HF repo IDs.
|
||||
* - `mtp` multi-token-prediction draft model
|
||||
* - `dflash` diffusion-flash draft
|
||||
* - `dspark` DSpark speculative-decoding draft
|
||||
* - `eagle3` Eagle3 speculative-decoding draft
|
||||
* - `mmproj` multimodal projector sidecar
|
||||
*/
|
||||
export type DraftVariant = 'mtp' | 'dflash' | 'dspark' | 'eagle3' | 'mmproj';
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Curated default models shown in the Discover Models sidebar, in display order.
|
||||
*/
|
||||
export const CURATED_MODEL_IDS = [
|
||||
'ggml-org/Qwen3.8-27B-GGUF',
|
||||
'ggml-org/DeepSeek-V4-Flash-0731-GGUF',
|
||||
'ggml-org/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-GGUF',
|
||||
'ggml-org/Qwen3.6-35B-A3B-GGUF',
|
||||
'ggml-org/Laguna-S-2.1-GGUF',
|
||||
'ggml-org/gemma-4-31B-it-GGUF',
|
||||
'ggml-org/gemma-4-26B-A4B-it-GGUF',
|
||||
'ggml-org/gemma-4-12B-it-GGUF',
|
||||
'ggml-org/Qwen3.5-0.8B-GGUF',
|
||||
'ggml-org/gemma-4-E2B-it-GGUF',
|
||||
'ggml-org/gemma-4-E4B-it-GGUF',
|
||||
'ggml-org/gpt-oss-120b-GGUF',
|
||||
'ggml-org/gpt-oss-20b-GGUF'
|
||||
];
|
||||
@@ -11,8 +11,14 @@ export const URL_PARAMS = {
|
||||
export const ROUTES = {
|
||||
/** Chat base — for dynamic chat URLs use RouterService. */
|
||||
CHAT: '#/chat',
|
||||
/** Model detail - for dynamic model URLs use RouterService. */
|
||||
MANAGE_MODEL: '#/models-hub/[modelId]',
|
||||
/** Model hub - browse and download HuggingFace GGUF models. */
|
||||
MANAGE_MODELS: '#/models-hub',
|
||||
/** MCP servers. */
|
||||
MCP_SERVERS: '#/mcp-servers',
|
||||
/** Model manager - installed models from /v1/models. */
|
||||
MODEL_MANAGER: '#/model-manager',
|
||||
/** Search — mobile-only full-page conversation search. */
|
||||
SEARCH: '#/search',
|
||||
/** Root — start of the app. */
|
||||
|
||||
@@ -16,6 +16,7 @@ export const SETTINGS_KEYS = {
|
||||
CUSTOM_CSS: 'customCss',
|
||||
// PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
|
||||
CUSTOM_JSON: 'customJson',
|
||||
DEVICE_MEMORY_GB: 'deviceMemoryGb',
|
||||
DISABLE_AUTO_SCROLL: 'disableAutoScroll',
|
||||
// Developer
|
||||
DISABLE_REASONING_PARSING: 'disableReasoningParsing',
|
||||
|
||||
@@ -117,6 +117,14 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
|
||||
label: 'Show microphone on empty input',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: 0,
|
||||
help: 'Total device memory (RAM) in GB, used to estimate which models can run. Set to 0 to auto-detect from the browser when possible.',
|
||||
isPositiveInteger: true,
|
||||
key: SETTINGS_KEYS.DEVICE_MEMORY_GB,
|
||||
label: 'Device memory (GB)',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Enable "Continue" button for assistant messages, including reasoning models.',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Package, Search, Settings, SquarePen } from '@lucide/svelte';
|
||||
import { ROUTES } from '$lib/constants/routes.constants';
|
||||
import { SidebarAction, ToolSource } from '$lib/enums';
|
||||
import type { DesktopIconStripItem } from '$lib/types';
|
||||
|
||||
@@ -44,6 +45,9 @@ export const STATS_UNITS = {
|
||||
|
||||
export const DEFAULT_MOBILE_BREAKPOINT = 768;
|
||||
|
||||
/** Orgs whose avatar is dark and needs inverting in dark mode. */
|
||||
export const DARK_INVERT_AVATAR_ORGS = ['openai'];
|
||||
|
||||
/** Icon used for the model selector and the `/model` slash command. */
|
||||
export const MODEL_SELECTOR_ICON = Package;
|
||||
|
||||
@@ -61,6 +65,12 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
|
||||
tooltip: 'New chat'
|
||||
},
|
||||
{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
|
||||
{
|
||||
activeRoutePrefix: '/model-manager',
|
||||
icon: Package,
|
||||
route: ROUTES.MODEL_MANAGER,
|
||||
tooltip: 'Models'
|
||||
},
|
||||
{
|
||||
action: SidebarAction.SETTINGS,
|
||||
icon: Settings,
|
||||
|
||||
@@ -13,6 +13,8 @@ export enum ServerRole {
|
||||
* Used as the `value` field in the status object from /models endpoint
|
||||
*/
|
||||
export enum ServerModelStatus {
|
||||
DOWNLOADED = 'downloaded',
|
||||
DOWNLOADING = 'downloading',
|
||||
FAILED = 'failed',
|
||||
LOADED = 'loaded',
|
||||
LOADING = 'loading',
|
||||
@@ -26,6 +28,8 @@ export enum ServerModelStatus {
|
||||
* tools/server/server-models.cpp from the C++ server.
|
||||
*/
|
||||
export enum ServerModelsSseEventType {
|
||||
DOWNLOAD_FAILED = 'download_failed',
|
||||
DOWNLOAD_FINISHED = 'download_finished',
|
||||
DOWNLOAD_PROGRESS = 'download_progress',
|
||||
MODEL_REMOVE = 'model_remove',
|
||||
MODEL_STATUS = 'model_status',
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
|
||||
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { modelsStore, serverStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
export interface UseModelsSelectorOptions {
|
||||
currentModel: () => string | null;
|
||||
@@ -76,14 +78,47 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
let searchTerm = $state('');
|
||||
let showModelDialog = $state(false);
|
||||
let infoModelId = $state<string | null>(null);
|
||||
let menuOpen = $state(false);
|
||||
|
||||
// Base model org per base repo (e.g. `Qwen` for `ggml-org/Qwen3.8-27B-GGUF`),
|
||||
// resolved lazily from the HF card while the menu is open.
|
||||
const baseOrgs = new SvelteMap<string, string>();
|
||||
const filteredOptions = $derived(filterModelOptions(options, searchTerm));
|
||||
// Augment each option with its base model; the org falls back to the quant
|
||||
// org until the HF lookup resolves.
|
||||
const optionsWithBaseModel = $derived(
|
||||
filteredOptions.map((option) => {
|
||||
const repo = option.model.split(':')[0];
|
||||
const org = baseOrgs.get(repo) ?? option.parsedId?.orgName ?? repo.split('/')[0] ?? '';
|
||||
const name = option.parsedId?.modelName ?? option.name ?? option.model;
|
||||
|
||||
return { ...option, baseModel: { name, org } };
|
||||
})
|
||||
);
|
||||
const groupedFilteredOptions = $derived(
|
||||
groupModelOptions(filteredOptions, modelsStore.favoriteModelIds, (m) =>
|
||||
groupModelOptions(optionsWithBaseModel, modelsStore.favoriteModelIds, (m) =>
|
||||
modelsStore.isModelLoaded(m)
|
||||
)
|
||||
);
|
||||
|
||||
// Fetch base model orgs for the visible repos while the menu is open. The
|
||||
// service caches per repo, so repeated opens never re-hit the HF API.
|
||||
$effect(() => {
|
||||
if (!menuOpen) return;
|
||||
|
||||
const repos = new SvelteSet<string>();
|
||||
|
||||
for (const option of options) repos.add(option.model.split(':')[0]);
|
||||
|
||||
for (const repo of repos) {
|
||||
if (baseOrgs.has(repo)) continue;
|
||||
|
||||
void HuggingFaceService.getBaseModel(repo).then((info) => {
|
||||
baseOrgs.set(repo, info?.org ?? repo.split('/')[0] ?? '');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function handleInfoClick(modelName: string) {
|
||||
infoModelId = modelName;
|
||||
showModelDialog = true;
|
||||
@@ -98,6 +133,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (loading || updating) return;
|
||||
|
||||
menuOpen = open;
|
||||
|
||||
if (isRouter) {
|
||||
searchTerm = '';
|
||||
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
import { type DraftVariant, MODEL_ID } from '$lib/constants';
|
||||
import type {
|
||||
HfCatalogEntry,
|
||||
HfModelDetailInfo,
|
||||
HfModelInfo,
|
||||
HfModelSearchParams,
|
||||
HfModelSibling,
|
||||
HfModelSort
|
||||
} from '$lib/types/huggingface';
|
||||
|
||||
/** Variant flag in a GGUF filename (e.g. draft-mtp, diffusion-flash, multimodal projector). */
|
||||
export type GgufVariant = DraftVariant;
|
||||
|
||||
/**
|
||||
* Where the `mtp` / `dflash` / `mmproj` token sits in the filename.
|
||||
* - `prefix` sidecar file that lives next to the main weights, e.g. `mtp-Q4_0.gguf`
|
||||
* - `suffix` embedded draft baked into the main weights, e.g. `Hy3-IQ1_M-mtp.gguf`
|
||||
*/
|
||||
export type GgufVariantForm = 'prefix' | 'suffix';
|
||||
|
||||
// Constants
|
||||
|
||||
export const HF_TASKS: Record<string, string> = {
|
||||
'audio-classification': 'Audio Classification',
|
||||
'audio-to-audio': 'Audio-to-Audio',
|
||||
'automatic-speech-recognition': 'Speech Recognition',
|
||||
conversational: 'Conversational',
|
||||
'depth-estimation': 'Depth Estimation',
|
||||
'feature-extraction': 'Feature Extraction',
|
||||
'fill-mask': 'Fill Mask',
|
||||
'image-classification': 'Image Classification',
|
||||
'image-feature-extraction': 'Image Feature Extraction',
|
||||
'image-segmentation': 'Image Segmentation',
|
||||
'image-text-to-text': 'Image-Text-to-Text',
|
||||
'image-to-text': 'Image-to-Text',
|
||||
'image-to-video': 'Image-to-Video',
|
||||
'object-detection': 'Object Detection',
|
||||
'question-answering': 'Question Answering',
|
||||
'reinforcement-learning': 'Reinforcement Learning',
|
||||
robotics: 'Robotics',
|
||||
'sentence-similarity': 'Sentence Similarity',
|
||||
summarization: 'Summarization',
|
||||
'text2text-generation': 'Text2Text Generation',
|
||||
'text-classification': 'Text Classification',
|
||||
'text-generation': 'Text Generation',
|
||||
'text-to-image': 'Text-to-Image',
|
||||
'text-to-speech': 'Text to Speech',
|
||||
'text-to-video': 'Text-to-Video',
|
||||
'token-classification': 'Token Classification',
|
||||
translation: 'Translation',
|
||||
'video-to-video': 'Video-to-Video',
|
||||
'voice-activity-detection': 'Voice Activity Detection',
|
||||
'zero-shot-classification': 'Zero-Shot Classification'
|
||||
};
|
||||
|
||||
/**
|
||||
* Best-effort readable label for an HF pipeline tag. Falls back to a
|
||||
* title-cased version of the kebab-case `pipeline_tag` (e.g. `image-text-to-text`
|
||||
* becomes `Image-Text-to-Text`) when we don't have an explicit entry above.
|
||||
*/
|
||||
function pipelineTagLabel(tag: string): string {
|
||||
if (HF_TASKS[tag]) return HF_TASKS[tag];
|
||||
|
||||
return tag
|
||||
.split('-')
|
||||
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
|
||||
.join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lucide icon name (string identifier, used to lazy-import the Svelte component)
|
||||
* matching the HF pipeline_tag. Used for the filter chips on the model browser.
|
||||
* Returns `null` for unknown tags so the consumer can render a generic icon.
|
||||
*/
|
||||
const HF_PIPELINE_ICONS: Record<string, string> = {
|
||||
'audio-classification': 'mic',
|
||||
'audio-to-audio': 'audio-lines',
|
||||
'automatic-speech-recognition': 'mic',
|
||||
conversational: 'message-circle',
|
||||
'depth-estimation': 'layers',
|
||||
'feature-extraction': 'hash',
|
||||
'fill-mask': 'replace',
|
||||
'image-classification': 'image',
|
||||
'image-feature-extraction': 'image',
|
||||
'image-segmentation': 'image',
|
||||
'image-text-to-text': 'image-plus',
|
||||
'image-to-text': 'image',
|
||||
'image-to-video': 'video',
|
||||
'object-detection': 'scan',
|
||||
'question-answering': 'help-circle',
|
||||
'sentence-similarity': 'equal',
|
||||
summarization: 'list-collapse',
|
||||
'text2text-generation': 'message-square-more',
|
||||
'text-generation': 'message-square',
|
||||
'text-to-image': 'image',
|
||||
'text-to-speech': 'volume-2',
|
||||
'text-to-video': 'video',
|
||||
translation: 'languages',
|
||||
'video-to-video': 'video',
|
||||
'voice-activity-detection': 'mic'
|
||||
};
|
||||
|
||||
function pipelineTagIcon(tag: string): string | null {
|
||||
return HF_PIPELINE_ICONS[tag] ?? null;
|
||||
}
|
||||
|
||||
export const HF_LIBRARIES: Record<string, string> = {
|
||||
gguf: 'GGUF',
|
||||
mlx: 'MLX',
|
||||
onnx: 'ONNX',
|
||||
safetensors: 'Safetensors',
|
||||
transformers: 'Transformers',
|
||||
vllm: 'vLLM'
|
||||
};
|
||||
|
||||
/**
|
||||
* HuggingFaceService - Service for browsing and searching GGUF models on Hugging Face Hub
|
||||
*/
|
||||
export class HuggingFaceService {
|
||||
// Configuration
|
||||
|
||||
/** Available library names with display labels */
|
||||
static readonly LIBRARIES: Record<string, string> = HF_LIBRARIES;
|
||||
/** Sort option display labels */
|
||||
static readonly SORT_LABELS: Record<HfModelSort, string> = {
|
||||
createdAt: 'Newest',
|
||||
downloads: 'Most Downloads',
|
||||
lastModified: 'Recently Updated',
|
||||
likes: 'Most Likes',
|
||||
trendingScore: 'Trending'
|
||||
};
|
||||
/** Available sort options */
|
||||
static readonly SORT_OPTIONS: HfModelSort[] = [
|
||||
'downloads',
|
||||
'likes',
|
||||
'trendingScore',
|
||||
'createdAt'
|
||||
];
|
||||
|
||||
// Available options for filtering
|
||||
|
||||
/** Available pipeline tasks with display labels */
|
||||
static readonly TASKS: Record<string, string> = HF_TASKS;
|
||||
|
||||
private static readonly BASE_URL = 'https://huggingface.co/api/models';
|
||||
|
||||
// Cached base model lookups keyed by repo id, so repeated selector opens
|
||||
// never re-hit the HF API for the same repo.
|
||||
private static baseModelCache = new Map<string, { org: string; name: string } | null>();
|
||||
|
||||
private static baseModelPending = new Map<
|
||||
string,
|
||||
Promise<{ org: string; name: string } | null>
|
||||
>();
|
||||
|
||||
private static readonly DEFAULT_LIMIT = 50;
|
||||
|
||||
private static readonly MAX_LIMIT = 100;
|
||||
|
||||
// GGUF Model Searching
|
||||
|
||||
/**
|
||||
* Map of quant token to its average bit-depth in bits-per-weight (bpw).
|
||||
*/
|
||||
private static readonly QUANT_BIT_DEPTH: Record<string, number> = {
|
||||
BF16: 16,
|
||||
F16: 16,
|
||||
IQ1_M: 1,
|
||||
IQ1_S: 1,
|
||||
IQ1_XS: 1,
|
||||
IQ1_XXS: 1,
|
||||
IQ2_M: 2,
|
||||
IQ2_S: 2,
|
||||
IQ2_XS: 2,
|
||||
IQ2_XXS: 2,
|
||||
IQ3_M: 3,
|
||||
IQ3_S: 3,
|
||||
IQ3_XS: 3,
|
||||
IQ3_XXS: 3,
|
||||
Q2_K: 2,
|
||||
Q2_K_M: 2,
|
||||
Q2_K_S: 2,
|
||||
Q3_K: 3,
|
||||
Q3_K_L: 3,
|
||||
Q3_K_M: 3,
|
||||
Q3_K_S: 3,
|
||||
Q4_0: 4,
|
||||
Q4_1: 4,
|
||||
Q4_K: 4,
|
||||
Q4_K_M: 4,
|
||||
Q4_K_S: 4,
|
||||
Q5_0: 5,
|
||||
Q5_1: 5,
|
||||
Q5_K: 5,
|
||||
Q5_K_M: 5,
|
||||
Q5_K_S: 5,
|
||||
Q6_K: 6,
|
||||
Q8_0: 8
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapse split GGUF shard sets (`-00001-of-00015.gguf`, ...) to their first
|
||||
* shard, summing every shard's size so the kept entry reflects the whole
|
||||
* quant. Non-sharded files pass through unchanged. Downloads are tag-based
|
||||
* (`repo:quant`), so the first shard is enough to represent the set.
|
||||
*/
|
||||
static collapseGgufShards(siblings: HfModelSibling[]): HfModelSibling[] {
|
||||
const sizeByPath = new Map(siblings.map((f) => [f.path, f.size ?? 0]));
|
||||
const result: HfModelSibling[] = [];
|
||||
|
||||
for (const file of siblings) {
|
||||
const match = /-(\d{5})-of-(\d{5})\.gguf$/i.exec(file.path);
|
||||
|
||||
if (!match) {
|
||||
result.push(file);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keep only the first shard; its size becomes the whole shard set's.
|
||||
if (match[1] !== '00001') continue;
|
||||
|
||||
const total = parseInt(match[2], 10);
|
||||
const stem = file.path.slice(0, file.path.length - match[0].length);
|
||||
|
||||
let size = 0;
|
||||
|
||||
for (let i = 1; i <= total; i++) {
|
||||
const shard = `${stem}-${String(i).padStart(5, '0')}-of-${String(total).padStart(5, '0')}.gguf`;
|
||||
|
||||
size += sizeByPath.get(shard) ?? 0;
|
||||
}
|
||||
|
||||
result.push({ ...file, size });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// GGUF Model Browsing
|
||||
|
||||
/**
|
||||
* Extract the GGUF quantization token (e.g. `Q4_K_M`) and any draft/aux variant
|
||||
* (`mtp`, `dflash`, `mmproj`) from a `.gguf` filename. The variant shows up
|
||||
* either as a sidecar prefix (`mtp-<name>.gguf`, `dflash-<name>.gguf`,
|
||||
* `mmproj-<name>.gguf`) or as the `-mtp` suffix when the draft model is
|
||||
* embedded in the same GGUF weight file.
|
||||
*
|
||||
* `variantForm` records which side of the filename the variant token sat
|
||||
* on so callers can render badges differently (e.g. prefix on the left of
|
||||
* the quant label, suffix appended to it).
|
||||
* `quant` is `null` for files that don't carry a bit-depth token
|
||||
* (e.g. `*-BF16.gguf`); `variant` is `null` if no draft/aux flag is present.
|
||||
* Returns `null` only when the filename doesn't end in `.gguf`.
|
||||
*/
|
||||
static extractQuantMeta(filename: string): {
|
||||
quant: string | null;
|
||||
variant: GgufVariant | null;
|
||||
variantForm: GgufVariantForm | null;
|
||||
} | null {
|
||||
if (!MODEL_ID.WEIGHT_EXTENSION_RE.test(filename)) return null;
|
||||
|
||||
let source = filename.replace(MODEL_ID.WEIGHT_EXTENSION_RE, '');
|
||||
let variant: GgufVariant | null = null;
|
||||
let variantForm: GgufVariantForm | null = null;
|
||||
|
||||
const prefixMatch = source.match(MODEL_ID.DRAFT_VARIANT_PREFIX_RE);
|
||||
|
||||
if (prefixMatch) {
|
||||
variant = prefixMatch[1].toLowerCase() as GgufVariant;
|
||||
variantForm = 'prefix';
|
||||
source = prefixMatch[2];
|
||||
} else {
|
||||
const suffixMatch = source.match(MODEL_ID.DRAFT_VARIANT_SUFFIX_RE);
|
||||
|
||||
if (suffixMatch) {
|
||||
const candidate = suffixMatch[1];
|
||||
const headSeg = candidate.split(MODEL_ID.SEGMENT_SEPARATOR).pop();
|
||||
|
||||
if (headSeg && MODEL_ID.QUANTIZATION_SEGMENT_RE.test(headSeg)) {
|
||||
variant = suffixMatch[2].toLowerCase() as GgufVariant;
|
||||
variantForm = 'suffix';
|
||||
source = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan dash-separated segments left-to-right for the first quant match.
|
||||
// - For sidecars like `mtp-Q4_0-180MB.gguf` the quant is `Q4_0`.
|
||||
// - For embedded MTP like `Hy3-IQ1_M-mtp.gguf` we have `Hy3-IQ1_M` and `IQ1_M` matches.
|
||||
// - For main files like `Llama-3-8B-Q4_K_M.gguf` we land on the trailing quant.
|
||||
const segments = source.split(MODEL_ID.SEGMENT_SEPARATOR);
|
||||
const quantIdx = segments.findIndex((seg) => MODEL_ID.QUANTIZATION_SEGMENT_RE.test(seg));
|
||||
|
||||
let quant = quantIdx >= 0 ? segments[quantIdx].toUpperCase() : null;
|
||||
|
||||
// Recombine a `UD-` (Unsloth Dynamic) prefix, e.g. `...-UD-Q4_K_XL.gguf`.
|
||||
if (quant && quantIdx > 0 && segments[quantIdx - 1].toUpperCase() === 'UD') {
|
||||
quant = `UD-${quant}`;
|
||||
}
|
||||
|
||||
return { quant, variant, variantForm };
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter raw siblings by file extension and sort by size descending.
|
||||
*/
|
||||
static filterByExtension(siblings: HfModelSibling[], ext: string): HfModelSibling[] {
|
||||
return siblings
|
||||
.filter((f) => f.path.toLowerCase().endsWith(ext.toLowerCase()) && (f.size ?? 0) > 0)
|
||||
.sort((a, b) => (b.size ?? 0) - (a.size ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format model downloads count with K/M/B suffix
|
||||
*/
|
||||
static formatDownloads(downloads: number): string {
|
||||
if (downloads >= 1_000_000) {
|
||||
return `${(downloads / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
if (downloads >= 1_000) {
|
||||
return `${(downloads / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
|
||||
return downloads.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size in bytes to human-readable string
|
||||
*/
|
||||
static formatFileSize(bytes: number): string {
|
||||
if (bytes >= 1_000_000_000) {
|
||||
return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
if (bytes >= 1_000_000) {
|
||||
return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
if (bytes >= 1_000) {
|
||||
return `${(bytes / 1_000).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format likes count with K suffix if applicable
|
||||
*/
|
||||
static formatLikes(likes: number): string {
|
||||
if (likes >= 1_000) {
|
||||
return `${(likes / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
|
||||
return likes.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to relative time
|
||||
*/
|
||||
static formatRelativeTime(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'Today';
|
||||
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
|
||||
|
||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
|
||||
|
||||
return `${Math.floor(diffDays / 365)} years ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a min-max size range with a single shared unit and no spaces
|
||||
* around the dash, e.g. `19.0-28.6 GB`.
|
||||
*/
|
||||
static formatSizeRange(min: number, max: number): string {
|
||||
const unit = max >= 1_000_000_000 ? 'GB' : max >= 1_000_000 ? 'MB' : max >= 1_000 ? 'KB' : 'B';
|
||||
const div =
|
||||
unit === 'GB' ? 1_000_000_000 : unit === 'MB' ? 1_000_000 : unit === 'KB' ? 1_000 : 1;
|
||||
const fmt = (n: number) => (div === 1 ? `${n}` : `${(n / div).toFixed(1)}`);
|
||||
|
||||
return `${fmt(min)}-${fmt(max)} ${unit}`;
|
||||
}
|
||||
|
||||
// Model Details & Files
|
||||
|
||||
/**
|
||||
* Avatar URL for an author (org or user). 404s when the author does not
|
||||
* exist, so callers should provide a fallback.
|
||||
*/
|
||||
static getAvatarUrl(author: string): string {
|
||||
return `https://huggingface.co/api/avatars/${author}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the original (non-GGUF) base model `{ org, name }` for a GGUF repo
|
||||
* from its HF card (`cardData.base_model`). Returns null when the card has no
|
||||
* base model. Results are cached per repo.
|
||||
*/
|
||||
static getBaseModel(repoId: string): Promise<{ org: string; name: string } | null> {
|
||||
const cached = this.baseModelCache.get(repoId);
|
||||
|
||||
if (cached !== undefined) return Promise.resolve(cached);
|
||||
|
||||
const pending = this.baseModelPending.get(repoId);
|
||||
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = (async () => {
|
||||
const details = await this.getDetails(repoId);
|
||||
const base = this.getBaseModels(details)[0];
|
||||
|
||||
if (!base) return null;
|
||||
|
||||
const [org, ...rest] = base.split('/');
|
||||
|
||||
return { name: rest.join('/'), org };
|
||||
})();
|
||||
|
||||
this.baseModelPending.set(repoId, promise);
|
||||
|
||||
promise
|
||||
.then((result) => this.baseModelCache.set(repoId, result))
|
||||
.finally(() => this.baseModelPending.delete(repoId));
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the original (non-GGUF) base model ids for a repo, from
|
||||
* `cardData.base_model` (string or list) and the `base_model:` tags.
|
||||
*/
|
||||
static getBaseModels(model: HfModelDetailInfo | null): string[] {
|
||||
if (!model) return [];
|
||||
|
||||
const cardBase = model.cardData?.base_model;
|
||||
const fromCard: string[] = Array.isArray(cardBase) ? cardBase : cardBase ? [cardBase] : [];
|
||||
const fromTags = (model.tags ?? [])
|
||||
.map((t) => /^base_model:(?:quantized:)?(.+)$/.exec(t)?.[1])
|
||||
.filter((v): v is string => Boolean(v));
|
||||
|
||||
return Array.from(new Set([...fromCard, ...fromTags]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the average bit-depth for a known GGUF quantization.
|
||||
* Returns `null` for unrecognized tokens.
|
||||
*/
|
||||
static getBitDepth(quant: string): number | null {
|
||||
// Strip a leading `UD-` (Unsloth Dynamic) prefix before lookup.
|
||||
const base = quant.replace(/^UD-/i, '');
|
||||
const direct = HuggingFaceService.QUANT_BIT_DEPTH[base];
|
||||
|
||||
if (direct !== undefined) return direct;
|
||||
|
||||
// Fall back to the leading precision digits for variants missing from the
|
||||
// map, e.g. `Q4_K_XL` -> 4, `IQ2_XXS` -> 2, `TQ1_0` -> 1, `BF16` -> 16.
|
||||
const match = /^(?:I?Q|TQ|BF|F|MXFP)?(\d+)/i.exec(base);
|
||||
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get GGUF models by pipeline task
|
||||
*/
|
||||
static async getByTask(
|
||||
pipelineTag: string,
|
||||
params: Omit<HfModelSearchParams, 'pipeline_tag'> = {}
|
||||
): Promise<HfModelInfo[]> {
|
||||
return this.search({
|
||||
...params,
|
||||
pipeline_tag: pipelineTag
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific GGUF model
|
||||
*/
|
||||
/**
|
||||
* Fetch the llama.app model catalog (https://llama.app/v1/catalog.json).
|
||||
* Returns an empty array on failure so callers can fall back gracefully.
|
||||
*/
|
||||
static async getCatalog(): Promise<HfCatalogEntry[]> {
|
||||
const url = 'https://llama.app/v1/catalog.json';
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to fetch catalog: ${response.status}`);
|
||||
|
||||
return (await response.json()) as HfCatalogEntry[];
|
||||
} catch (error) {
|
||||
console.error('Error fetching catalog:', error);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static async getDetails(modelId: string): Promise<HfModelDetailInfo | null> {
|
||||
// Do not encode the modelId, it contains slashes for author/name.
|
||||
// `full=true` includes cardData (description, base_model) and safetensors.
|
||||
const url = `https://huggingface.co/api/models/${modelId}?full=true`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 404) return null;
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to fetch model details: ${response.status}`);
|
||||
|
||||
const data = (await response.json()) as HfModelDetailInfo;
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching details for ${modelId}:`, error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model URL on Hugging Face Hub
|
||||
*/
|
||||
static getModelUrl(modelId: string): string {
|
||||
return `https://huggingface.co/${modelId}`;
|
||||
}
|
||||
|
||||
// Utility Methods
|
||||
|
||||
/**
|
||||
* Get most liked GGUF models
|
||||
*/
|
||||
static async getMostLiked(
|
||||
limit: number = HuggingFaceService.DEFAULT_LIMIT
|
||||
): Promise<HfModelInfo[]> {
|
||||
return this.search({ limit, sort: 'likes' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get newly released GGUF models
|
||||
*/
|
||||
static async getNew(limit: number = HuggingFaceService.DEFAULT_LIMIT): Promise<HfModelInfo[]> {
|
||||
return this.search({ limit, sort: 'createdAt' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get most popular GGUF models by downloads
|
||||
*/
|
||||
static async getPopular(
|
||||
limit: number = HuggingFaceService.DEFAULT_LIMIT
|
||||
): Promise<HfModelInfo[]> {
|
||||
return this.search({ limit, sort: 'downloads' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the raw README.md for a repo, with the YAML frontmatter stripped.
|
||||
*/
|
||||
static async getReadme(modelId: string): Promise<string | null> {
|
||||
// Do not encode the modelId, it contains slashes for author/name
|
||||
const url = `https://huggingface.co/${modelId}/raw/main/README.md`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 404) return null;
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to fetch README: ${response.status}`);
|
||||
|
||||
return HuggingFaceService.stripFrontmatter(await response.text());
|
||||
} catch (error) {
|
||||
console.error(`Error fetching README for ${modelId}:`, error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository file tree to list available GGUF variants. Recursive so
|
||||
* repos that keep quants in per-quant subdirectories (e.g. `UD-Q4_K_XL/`)
|
||||
* are included; follows cursor pagination for repos over one page.
|
||||
*/
|
||||
static async getTree(modelId: string): Promise<HfModelSibling[]> {
|
||||
const files: HfModelSibling[] = [];
|
||||
|
||||
let url: string | null =
|
||||
`https://huggingface.co/api/models/${modelId}/tree/main?recursive=true`;
|
||||
|
||||
try {
|
||||
while (url) {
|
||||
const response: Response = await fetch(url);
|
||||
|
||||
if (!response.ok) return files;
|
||||
|
||||
const data = (await response.json()) as HfModelSibling[];
|
||||
|
||||
files.push(...data.filter((f) => f.type !== 'directory'));
|
||||
|
||||
url = HuggingFaceService.parseNextPageUrl(response.headers.get('Link'));
|
||||
}
|
||||
} catch {
|
||||
// Return whatever was fetched before the failure.
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trending GGUF models
|
||||
*/
|
||||
static async getTrending(
|
||||
limit: number = HuggingFaceService.DEFAULT_LIMIT
|
||||
): Promise<HfModelInfo[]> {
|
||||
return this.search({ limit, sort: 'trendingScore' });
|
||||
}
|
||||
/**
|
||||
* Parse a local HF cache file path
|
||||
* (`.../models--<org>--<name>/snapshots/<sha>/<file>`) into its repo id and
|
||||
* repo-relative file path. Returns null when the path is not an HF cache path.
|
||||
*/
|
||||
static parseCachePath(path: string): { repo: string; file: string } | null {
|
||||
const match = /models--(.+?)\/snapshots\/[^/]+\/(.+)$/.exec(path);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
const parts = match[1].split('--');
|
||||
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
return { file: match[2], repo: `${parts[0]}/${parts.slice(1).join('--')}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort parameter count parsed from a model id/name, e.g. `27B` from
|
||||
* `Qwen3.8-27B-GGUF` or `300M` from `embeddinggemma-300M-GGUF`. Returns null
|
||||
* when no size token is present.
|
||||
*/
|
||||
static parseParamCount(name: string): string | null {
|
||||
const match = /(?:^|[^a-z0-9])(\d+(?:[._]\d+)?)\s*([bm])(?![a-z0-9])/i.exec(name);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
return `${match[1]}${match[2].toUpperCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse model tags to extract useful information
|
||||
*/
|
||||
static parseTags(tags: string[]): {
|
||||
license: string | null;
|
||||
isGated: boolean;
|
||||
isGguf: boolean;
|
||||
isSafetensors: boolean;
|
||||
tasks: string[];
|
||||
} {
|
||||
const license = tags.find((tag) => tag.startsWith('license:'))?.replace('license:', '') || null;
|
||||
const isGated = tags.includes('gated');
|
||||
const isGguf = tags.includes('gguf');
|
||||
const isSafetensors = tags.includes('safetensors');
|
||||
const tasks = tags.filter((tag) => Object.keys(HuggingFaceService.TASKS).includes(tag));
|
||||
|
||||
return { isGated, isGguf, isSafetensors, license, tasks };
|
||||
}
|
||||
|
||||
/** Resolve a pipeline_tag to a lucide icon name, or null when unknown. */
|
||||
static pipelineTagIcon(tag: string | null | undefined): string | null {
|
||||
if (!tag) return null;
|
||||
|
||||
return pipelineTagIcon(tag);
|
||||
}
|
||||
|
||||
/** Resolve a pipeline_tag to a human-readable label. */
|
||||
static pipelineTagLabel(tag: string | null | undefined): string | null {
|
||||
if (!tag) return null;
|
||||
|
||||
return pipelineTagLabel(tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search GGUF models with various filters and options
|
||||
*/
|
||||
static async search(params: HfModelSearchParams = {}): Promise<HfModelInfo[]> {
|
||||
const { limit = HuggingFaceService.DEFAULT_LIMIT, ...restParams } = params;
|
||||
const url = this.buildUrl({
|
||||
...restParams,
|
||||
filter: 'gguf',
|
||||
limit: Math.min(limit, HuggingFaceService.MAX_LIMIT)
|
||||
});
|
||||
|
||||
return this.fetchWithRetry(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search models by query string
|
||||
*/
|
||||
static async searchByQuery(
|
||||
query: string,
|
||||
params: Omit<HfModelSearchParams, 'search'> = {}
|
||||
): Promise<HfModelInfo[]> {
|
||||
return this.search({
|
||||
...params,
|
||||
search: query
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build API URL from search parameters
|
||||
*/
|
||||
private static buildUrl(params: HfModelSearchParams): string {
|
||||
const url = new URL(this.BASE_URL);
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => url.searchParams.append(key, v));
|
||||
} else {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay helper for retry logic
|
||||
*/
|
||||
private static delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Internal Methods
|
||||
|
||||
/**
|
||||
* Fetch data with retry logic for resilience
|
||||
*/
|
||||
private static async fetchWithRetry(url: string, attempt: number = 1): Promise<HfModelInfo[]> {
|
||||
const RETRY_ATTEMPTS = 3;
|
||||
const RETRY_DELAY_MS = 1000;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (response.status >= 500 && attempt < RETRY_ATTEMPTS) {
|
||||
await this.delay(RETRY_DELAY_MS * attempt);
|
||||
|
||||
return this.fetchWithRetry(url, attempt + 1);
|
||||
}
|
||||
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data as HfModelInfo[];
|
||||
}
|
||||
|
||||
if (data && Array.isArray(data.data)) {
|
||||
return data.data as HfModelInfo[];
|
||||
}
|
||||
|
||||
throw new Error('Unexpected API response format');
|
||||
} catch (error) {
|
||||
if (attempt < RETRY_ATTEMPTS) {
|
||||
await this.delay(RETRY_DELAY_MS * attempt);
|
||||
|
||||
return this.fetchWithRetry(url, attempt + 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract the `rel="next"` URL from an RFC 5988 `Link` header, if present. */
|
||||
private static parseNextPageUrl(linkHeader: string | null): string | null {
|
||||
if (!linkHeader) return null;
|
||||
|
||||
const match = /<([^>]+)>;\s*rel="next"/.exec(linkHeader);
|
||||
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/** Strip a leading YAML frontmatter block (--- ... ---) from a markdown document. */
|
||||
private static stripFrontmatter(text: string): string {
|
||||
const match = text.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
|
||||
|
||||
return match ? text.slice(match[0].length) : text;
|
||||
}
|
||||
}
|
||||
@@ -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,140 @@
|
||||
/**
|
||||
* modelsHubStore - Model Hub browse state
|
||||
*
|
||||
* Owns the HuggingFace GGUF model list shown in the hub sidebar
|
||||
* (DialogModelsDiscover). The hub has no "nothing selected" screen: it always opens
|
||||
* a model, so `firstModel` drives the initial selection. By default the list
|
||||
* shows a curated set of official ggml-org GGUF models in a fixed display order;
|
||||
* search replaces the list with matching models across all of HuggingFace.
|
||||
* Detail data is loaded by ModelsDiscoverDetails, not here.
|
||||
*/
|
||||
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { HfCatalogEntry, HfModelInfo } from '$lib/types/huggingface';
|
||||
|
||||
class ModelsHubStore {
|
||||
error = $state<string | null>(null);
|
||||
models = $state<HfModelInfo[]>([]);
|
||||
/** First model in the list - the hub auto-opens this one. */
|
||||
firstModel = $derived(this.models[0] ?? null);
|
||||
|
||||
loading = $state(false);
|
||||
|
||||
private catalog: HfCatalogEntry[] = [];
|
||||
private defaultModels: HfModelInfo[] = [];
|
||||
private fetched = false;
|
||||
private searchRequestId = 0;
|
||||
|
||||
/**
|
||||
* Catalog family description for a repo id, or undefined when the repo is
|
||||
* not part of the catalog (e.g. a search result outside the curated list).
|
||||
*/
|
||||
descriptionFor(modelId: string): string | undefined {
|
||||
return this.catalog.find((entry) =>
|
||||
entry.sizes.some((size) => size.builds.some((build) => build.repo === modelId))
|
||||
)?.description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the default list from the llama.app catalog, flattened to a flat
|
||||
* list of ggml-org repo ids in catalog order (one per size). Each repo is
|
||||
* fetched directly by ID, so the list is independent of download ranking.
|
||||
* No-op when already loaded or in flight.
|
||||
*/
|
||||
async fetch(): Promise<void> {
|
||||
if (this.loading || this.fetched) return;
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const catalog = await HuggingFaceService.getCatalog();
|
||||
|
||||
this.catalog = catalog;
|
||||
const ids = this.catalogModelIds(catalog);
|
||||
|
||||
// getDetails returns full metadata (downloads, likes, lastModified,
|
||||
// siblings, tags, gguf) for a single model.
|
||||
this.defaultModels = (
|
||||
await Promise.all(ids.map((id) => HuggingFaceService.getDetails(id)))
|
||||
).filter((m): m is HfModelInfo => m !== null);
|
||||
this.models = this.defaultModels;
|
||||
this.fetched = true;
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : 'Failed to fetch models';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the list with GGUF search results. An empty query restores the
|
||||
* default list. The current list stays visible while a search is in
|
||||
* flight; stale responses are dropped when a newer search starts.
|
||||
*/
|
||||
async search(query: string): Promise<void> {
|
||||
const trimmed = query.trim();
|
||||
|
||||
this.searchRequestId++;
|
||||
|
||||
if (!trimmed) {
|
||||
this.models = this.defaultModels;
|
||||
this.error = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = this.searchRequestId;
|
||||
|
||||
try {
|
||||
const results = await HuggingFaceService.searchByQuery(trimmed, { full: true, limit: 50 });
|
||||
|
||||
if (requestId === this.searchRequestId) {
|
||||
this.models = results;
|
||||
this.error = null;
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId === this.searchRequestId) {
|
||||
this.error = err instanceof Error ? err.message : 'Search failed';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Min/max GGUF file size (bytes) across the available quants for a repo,
|
||||
* or undefined when the repo is not part of the catalog.
|
||||
*/
|
||||
sizeRangeFor(modelId: string): { min: number; max: number } | undefined {
|
||||
for (const entry of this.catalog) {
|
||||
for (const size of entry.sizes) {
|
||||
const builds = size.builds.filter((b) => b.repo === modelId);
|
||||
|
||||
if (builds.length === 0) continue;
|
||||
|
||||
const bytes = builds.map((b) => b.sizeBytes);
|
||||
|
||||
return { max: Math.max(...bytes), min: Math.min(...bytes) };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten the catalog to a flat list of ggml-org repo ids, newest family
|
||||
* first (by release date). Returns an empty array when the catalog is empty.
|
||||
*/
|
||||
private catalogModelIds(catalog: HfCatalogEntry[]): string[] {
|
||||
return [...catalog]
|
||||
.sort((a, b) => b.released.localeCompare(a.released))
|
||||
.flatMap((entry) =>
|
||||
entry.sizes.flatMap((size) => {
|
||||
const build = size.builds.find((b) => b.repo.startsWith('ggml-org/'));
|
||||
|
||||
return build ? [build.repo] : [];
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const modelsHubStore = new ModelsHubStore();
|
||||
@@ -8,11 +8,14 @@
|
||||
*/
|
||||
|
||||
import { ServerModelsSseEventType, ServerModelStatus } from '$lib/enums';
|
||||
import { HuggingFaceService } from '$lib/services/huggingface.service';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import type { ModelPropsManager } from '$lib/stores/models/props.svelte';
|
||||
// direct imports between stores, not via the barrel, to avoid circular deps
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
// explicit type imports: the app.d.ts globals resolve to `any`, so import the real types
|
||||
import type { ApiModelsDownloadProgressData, ModelDownloadProgress } from '$lib/types';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
/**
|
||||
@@ -31,6 +34,33 @@ export interface ModelStatusHost {
|
||||
}
|
||||
|
||||
export class ModelStatusManager {
|
||||
/**
|
||||
* Draft sidecar files pulled by registered models, as `<repo>/<file>` keys.
|
||||
* Drafts are not separate /v1/models entries - the router pulls them as
|
||||
* sidecars of a main model and records them in its `--model-draft` arg.
|
||||
*/
|
||||
private downloadedDrafts = $derived.by(() => {
|
||||
const result = new SvelteSet<string>();
|
||||
|
||||
for (const m of this.host.routerModels) {
|
||||
const args = m.status?.args;
|
||||
|
||||
if (!args) continue;
|
||||
|
||||
for (let i = 0; i < args.length - 1; i++) {
|
||||
if (args[i] !== '--model-draft' && args[i] !== '-md') continue;
|
||||
|
||||
const parsed = HuggingFaceService.parseCachePath(args[i + 1]);
|
||||
|
||||
if (parsed) result.add(`${parsed.repo}/${parsed.file}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
private downloadProgress = new SvelteMap<string, ModelDownloadProgress>();
|
||||
/** `<repo>:<tag>` strings whose most recent download attempt failed (download_failed). */
|
||||
private failedDownloads = new SvelteSet<string>();
|
||||
private loadingStates = new SvelteMap<string, boolean>();
|
||||
private loadProgress = new SvelteMap<string, ModelLoadProgress>();
|
||||
// /models/sse feed state, the single source of truth for status and load progress
|
||||
@@ -41,14 +71,105 @@ export class ModelStatusManager {
|
||||
{ target: ServerModelStatus; resolve: () => void; reject: (e: Error) => void }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Cancel an in-flight download or remove a previously downloaded/failed model
|
||||
* from the server cache (ROUTER mode only). The cached row is dropped via the
|
||||
* feed's model_remove event.
|
||||
*/
|
||||
async cancelDownload(repoWithTag: string): Promise<boolean> {
|
||||
if (!serverStore.isRouterMode) {
|
||||
toast.error('Model downloads are only available in router mode');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
this.subscribe();
|
||||
|
||||
try {
|
||||
const res = await ModelsService.cancelDownload(repoWithTag);
|
||||
const ok = res.success === true;
|
||||
|
||||
if (ok) {
|
||||
this.downloadProgress.delete(repoWithTag);
|
||||
this.failedDownloads.delete(repoWithTag);
|
||||
}
|
||||
|
||||
return ok;
|
||||
} catch (error) {
|
||||
toast.error(`Failed to cancel: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an in-flight load (ROUTER mode only). The server force-kills a
|
||||
* LOADING model on unload; the feed reports the settled status, so no
|
||||
* waiter is registered here.
|
||||
*/
|
||||
async cancelLoad(modelId: string): Promise<void> {
|
||||
if (!serverStore.isRouterMode) return;
|
||||
|
||||
this.subscribe();
|
||||
|
||||
try {
|
||||
await ModelsService.unload(modelId);
|
||||
toast.info(`Load cancelled: ${this.host.toDisplayName(modelId)}`);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to cancel load: ${this.host.toDisplayName(modelId)}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
constructor(private host: ModelStatusHost) {}
|
||||
|
||||
/**
|
||||
* Trigger a model download from HuggingFace via POST /models
|
||||
* (ggml-org/llama.cpp#23976). The download runs in the background on the
|
||||
* server; the model appears in the list once the feed reports models_reload.
|
||||
*/
|
||||
async downloadModel(repoWithTag: string, displayName?: string): Promise<void> {
|
||||
if (!serverStore.isRouterMode) {
|
||||
toast.error('Model downloads are only available in router mode');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// the feed must be live so the resulting models_reload event refreshes the list
|
||||
this.subscribe();
|
||||
|
||||
const label = displayName ?? repoWithTag;
|
||||
|
||||
try {
|
||||
const res = await ModelsService.downloadModel(repoWithTag);
|
||||
|
||||
if (res.success) {
|
||||
toast.success(`Download started: ${label}`);
|
||||
} else {
|
||||
throw new Error(res.error?.message ?? 'Server rejected the download request');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Download failed: ${label}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureLoaded(modelId: string): Promise<void> {
|
||||
if (this.host.isModelLoaded(modelId)) return;
|
||||
|
||||
await this.load(modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current download progress (bytes) for a `<repo>:<tag>` identifier, or null
|
||||
* when no download is being reported by the /models/sse feed.
|
||||
*/
|
||||
getDownloadProgress(repoWithTag: string): ModelDownloadProgress | null {
|
||||
return this.downloadProgress.get(repoWithTag) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current load progress for a model, or null when not loading.
|
||||
*/
|
||||
@@ -56,6 +177,35 @@ export class ModelStatusManager {
|
||||
return this.loadProgress.get(modelId) ?? null;
|
||||
}
|
||||
|
||||
/** Whether the most recent download attempt for the given entry failed. */
|
||||
hasFailedDownload(repoWithTag: string): boolean {
|
||||
return this.failedDownloads.has(repoWithTag);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the feed reports an active download for the given `<repo>:<tag>`.
|
||||
* Cleared on download_finished / download_failed.
|
||||
*/
|
||||
isDownloadInProgress(repoWithTag: string): boolean {
|
||||
return this.downloadProgress.has(repoWithTag);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the given draft sidecar file (repo-relative path) has been pulled
|
||||
* as the `--model-draft` of some registered model.
|
||||
*/
|
||||
isDraftDownloaded(repoId: string, filePath: string): boolean {
|
||||
return this.downloadedDrafts.has(`${repoId}/${filePath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the given `<repo>:<tag>` is already a fully downloaded model
|
||||
* registered with the server (i.e. it shows up in the /v1/models list).
|
||||
*/
|
||||
isModelDownloaded(repoWithTag: string): boolean {
|
||||
return this.host.routerModels.some((m) => m.id === repoWithTag);
|
||||
}
|
||||
|
||||
isOperationInProgress(modelId: string): boolean {
|
||||
return this.loadingStates.get(modelId) ?? false;
|
||||
}
|
||||
@@ -141,6 +291,48 @@ export class ModelStatusManager {
|
||||
this.statusAbort?.abort();
|
||||
this.statusAbort = null;
|
||||
this.loadProgress.clear();
|
||||
this.downloadProgress.clear();
|
||||
this.failedDownloads.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the stored progress for the model and toast the outcome.
|
||||
* Marks failed entries so the UI can offer a delete-and-retry path.
|
||||
*/
|
||||
private applyDownloadFinished(event: ApiModelsSseEvent): void {
|
||||
this.downloadProgress.delete(event.model);
|
||||
|
||||
const ok = event.event === ServerModelsSseEventType.DOWNLOAD_FINISHED;
|
||||
|
||||
if (ok) {
|
||||
this.failedDownloads.delete(event.model);
|
||||
toast.success(`Download finished: ${this.host.toDisplayName(event.model)}`);
|
||||
} else {
|
||||
this.failedDownloads.add(event.model);
|
||||
toast.error(`Download failed: ${this.host.toDisplayName(event.model)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket the per-file byte counts from a `download_progress` envelope.
|
||||
* Total = sum of `total` across files (plan size), downloaded sum of `done`.
|
||||
*/
|
||||
private applyDownloadProgress(event: ApiModelsSseEvent): void {
|
||||
const data = event.data;
|
||||
|
||||
if (!data || !('progress' in data)) return;
|
||||
|
||||
const progress = (data as ApiModelsDownloadProgressData).progress;
|
||||
|
||||
let downloaded = 0;
|
||||
let total = 0;
|
||||
|
||||
for (const file of Object.values(progress)) {
|
||||
downloaded += file?.done ?? 0;
|
||||
total += file?.total ?? 0;
|
||||
}
|
||||
|
||||
this.downloadProgress.set(event.model, { downloadedBytes: downloaded, totalBytes: total });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,7 +343,7 @@ export class ModelStatusManager {
|
||||
const model = event.model;
|
||||
const data = event.data;
|
||||
|
||||
if (!model || !data?.status) return;
|
||||
if (!model || !data || !('status' in data) || !data.status) return;
|
||||
|
||||
const status = data.status;
|
||||
|
||||
@@ -202,6 +394,13 @@ export class ModelStatusManager {
|
||||
|
||||
break;
|
||||
case ServerModelsSseEventType.DOWNLOAD_PROGRESS:
|
||||
this.applyDownloadProgress(event);
|
||||
|
||||
break;
|
||||
case ServerModelsSseEventType.DOWNLOAD_FINISHED:
|
||||
case ServerModelsSseEventType.DOWNLOAD_FAILED:
|
||||
this.applyDownloadFinished(event);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* HuggingFace Hub Model Browsing Types
|
||||
*
|
||||
* Types for the HuggingFace REST API (/api/models)
|
||||
* Reference: https://huggingface.co/docs/huggingface_hub/package_reference/hf_api
|
||||
*/
|
||||
|
||||
// Search Options
|
||||
|
||||
export interface HfModelSearchParams {
|
||||
/** Full-text search query */
|
||||
search?: string;
|
||||
/** Filter by pipeline task (e.g., "text-generation", "image-generation") */
|
||||
pipeline_tag?: string;
|
||||
/** Filter by library (e.g., "transformers", "diffusers", "gguf") */
|
||||
library_name?: string;
|
||||
/** Filter by tag (e.g., "gguf") */
|
||||
filter?: string;
|
||||
/** Filter by author or organization */
|
||||
author?: string;
|
||||
/** Sort field */
|
||||
sort?: HfModelSort;
|
||||
/** Results per page (1-100) */
|
||||
limit?: number;
|
||||
/** Pagination offset */
|
||||
offset?: number;
|
||||
/** Filter by model config */
|
||||
config?: string;
|
||||
/** Return full model info */
|
||||
full?: boolean;
|
||||
/** Filter by visibility */
|
||||
private?: boolean;
|
||||
/** Filter by gated status */
|
||||
gated?: boolean;
|
||||
}
|
||||
|
||||
export type HfModelSort = 'downloads' | 'likes' | 'createdAt' | 'lastModified' | 'trendingScore';
|
||||
|
||||
// Model Info (from /api/models)
|
||||
|
||||
export interface HfModelInfo {
|
||||
/** Unique document ID */
|
||||
_id: string;
|
||||
/** Model ID (e.g., "meta-llama/Llama-3.1-8B-Instruct") */
|
||||
id: string;
|
||||
/** Number of likes */
|
||||
likes: number;
|
||||
/** Trending score */
|
||||
trendingScore: number;
|
||||
/** Whether the model is private */
|
||||
private: boolean;
|
||||
/** Number of downloads */
|
||||
downloads: number;
|
||||
/** Model tags */
|
||||
tags: string[];
|
||||
/** Pipeline task (e.g., "text-generation") */
|
||||
pipeline_tag: string | null;
|
||||
/** Library name (e.g., "transformers", "diffusers") */
|
||||
library_name: string | null;
|
||||
/** Creation timestamp */
|
||||
createdAt: string;
|
||||
/** Model ID (alias for id) */
|
||||
modelId: string;
|
||||
/** Author / organization (present when full=true) */
|
||||
author?: string;
|
||||
/** Last modified timestamp (present when full=true) */
|
||||
lastModified?: string;
|
||||
/** Repository file listing (present when full=true) */
|
||||
siblings?: HfModelSiblingRef[];
|
||||
/** GGUF metadata (context length, architecture, etc.) */
|
||||
gguf?: HfModelGguf;
|
||||
}
|
||||
|
||||
// Model Details (with full=true)
|
||||
|
||||
export interface HfModelCardData {
|
||||
/** License identifier */
|
||||
license?: string;
|
||||
/** License URL */
|
||||
license_link?: string;
|
||||
/** Model description */
|
||||
description?: string;
|
||||
/** Model library */
|
||||
language?: string[];
|
||||
/** Tags */
|
||||
tags?: string[];
|
||||
/** Original (non-GGUF) model(s) this repo was converted from, e.g. `Qwen/Qwen3.8-27B`. The API returns a single string or a list. */
|
||||
base_model?: string | string[];
|
||||
/** Org that produced the quant, e.g. `bartowski` */
|
||||
quantized_by?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** GGUF metadata returned by /api/models/{id}?full=true for GGUF repos. */
|
||||
export interface HfModelGguf {
|
||||
/** Total parameter count */
|
||||
total?: number;
|
||||
/** Architecture, e.g. `gemma3`, `qwen3` */
|
||||
architecture?: string;
|
||||
/** Context length */
|
||||
context_length?: number;
|
||||
/** Chat template (Jinja) */
|
||||
chat_template?: string;
|
||||
bos_token?: string;
|
||||
eos_token?: string;
|
||||
/** Total size of all GGUF files in the repo, in bytes */
|
||||
totalFileSize?: number;
|
||||
}
|
||||
|
||||
export interface HfModelDetails {
|
||||
/** Model ID */
|
||||
id?: string;
|
||||
/** SHA256 digest */
|
||||
sha?: string;
|
||||
/** Last modified timestamp */
|
||||
lastModified?: string;
|
||||
/** Downloads count */
|
||||
downloads?: number;
|
||||
/** Number of likes */
|
||||
likes?: number;
|
||||
/** Whether the model is gated */
|
||||
gated?: boolean;
|
||||
/** Model card data */
|
||||
cardData?: HfModelCardData;
|
||||
/** Tags */
|
||||
tags?: string[];
|
||||
/** Pipeline tag */
|
||||
pipeline_tag?: string | null;
|
||||
/** Library name */
|
||||
library_name?: string | null;
|
||||
/** Safe tensors info */
|
||||
safetensors?: Record<string, unknown>;
|
||||
/** Model size in bytes */
|
||||
size?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HfModelDetailInfo extends HfModelInfo {
|
||||
/** Whether the model is gated (true/false/'auto') */
|
||||
gated?: boolean | string;
|
||||
/** Repository file listing mirrors of /api/models/{id}/tree/main */
|
||||
siblings?: HfModelSiblingRef[];
|
||||
/** Author / organization */
|
||||
author?: string;
|
||||
/** Last modified timestamp */
|
||||
lastModified?: string;
|
||||
/** Model card YAML data (only present when full=true) */
|
||||
cardData?: HfModelCardData;
|
||||
/** GGUF metadata (only present when full=true for GGUF repos) */
|
||||
gguf?: HfModelGguf;
|
||||
/** Model config (only present when full=true) */
|
||||
config?: Record<string, unknown>;
|
||||
/** Total repo storage in bytes (only present when full=true) */
|
||||
usedStorage?: number;
|
||||
/** Sample widget prompts */
|
||||
widgetData?: Array<{ text?: string }>;
|
||||
/** Related spaces */
|
||||
spaces?: string[];
|
||||
}
|
||||
|
||||
/** A single entry in a model repository's file tree (`/tree` responses) */
|
||||
export interface HfModelSibling {
|
||||
/** Relative path of the file or directory within the repo */
|
||||
path: string;
|
||||
/** Size in bytes (omitted for directories) */
|
||||
size?: number;
|
||||
/** Whether this entry is a directory */
|
||||
type?: 'file' | 'directory';
|
||||
/** OID/hash for the blob */
|
||||
oid?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single file entry in a model's `siblings` list. List (`/api/models`) and
|
||||
* detail (`/api/models/{id}`) responses use `rfilename`, unlike `/tree`.
|
||||
*/
|
||||
export interface HfModelSiblingRef {
|
||||
/** Relative file name within the repo */
|
||||
rfilename: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// API Response
|
||||
|
||||
export interface HfModelApiResponse {
|
||||
/** List of models */
|
||||
data: HfModelInfo[];
|
||||
/** Total count (if available) */
|
||||
total?: number;
|
||||
}
|
||||
|
||||
// llama.app model catalog (https://llama.app/v1/catalog.json)
|
||||
|
||||
/** A single GGUF build/repo within a catalog size. */
|
||||
export interface HfCatalogBuild {
|
||||
quant: string;
|
||||
size: string;
|
||||
sizeBytes: number;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/** A size variant (e.g. `GPT-OSS 20B`) within a catalog entry. */
|
||||
export interface HfCatalogSize {
|
||||
name: string;
|
||||
params: string;
|
||||
builds: HfCatalogBuild[];
|
||||
}
|
||||
|
||||
/** A single model family in the catalog. `featured` marks the staff picks. */
|
||||
export interface HfCatalogEntry {
|
||||
name: string;
|
||||
brand: string;
|
||||
description: string;
|
||||
details: string;
|
||||
released: string;
|
||||
license: string;
|
||||
featured?: boolean;
|
||||
maxMemGb?: number;
|
||||
sizes: HfCatalogSize[];
|
||||
}
|
||||
@@ -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 { ModelOption } from './models';
|
||||
import type { DraftVariant } from '$lib/constants';
|
||||
|
||||
/**
|
||||
* A draft sidecar model attached to a quant (speculative decoding).
|
||||
*/
|
||||
export interface ModelManagerDraft {
|
||||
variant: DraftVariant;
|
||||
option: ModelOption;
|
||||
}
|
||||
|
||||
/**
|
||||
* One quantization of a GGUF repo: main weights plus attached sidecars.
|
||||
*/
|
||||
export interface ModelManagerQuant {
|
||||
/** Quantization token, e.g. `Q4_K_M`, or null when the entry has none. */
|
||||
quant: string | null;
|
||||
/** Main model entry (no draft variant). */
|
||||
main: ModelOption;
|
||||
/** Draft sidecar entries (mtp, dflash, dspark, eagle3). */
|
||||
drafts: ModelManagerDraft[];
|
||||
/** Multimodal projector sidecar, when registered separately. */
|
||||
mmproj: ModelOption | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A GGUF repo by a converter org, e.g. `ggml-org/Qwen3.8-27B-GGUF`.
|
||||
*/
|
||||
export interface ModelManagerQuantOrg {
|
||||
/** Repo id without the `:quant` tag. */
|
||||
repoId: string;
|
||||
/** Converter org, e.g. `ggml-org`. */
|
||||
orgName: string;
|
||||
quants: ModelManagerQuant[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The original (non-GGUF) model, e.g. `Qwen/Qwen3.8-27B`.
|
||||
*/
|
||||
export interface ModelManagerParent {
|
||||
/** Display id: HF base_model when resolved, else the heuristic name. */
|
||||
parentId: string;
|
||||
quantOrgs: ModelManagerQuantOrg[];
|
||||
}
|
||||
Vendored
+13
@@ -1,3 +1,4 @@
|
||||
import type { DraftVariant } from '$lib/constants/model-id.constants';
|
||||
import type { ApiModelDataEntry, ApiModelDetails, ApiModelLoadStage } from '$lib/types/api';
|
||||
|
||||
export interface ModelModalities {
|
||||
@@ -22,6 +23,8 @@ export interface ModelOption {
|
||||
parsedId?: ParsedModelId;
|
||||
aliases?: string[];
|
||||
tags?: string[];
|
||||
/** Original (non-GGUF) base model, resolved from the HF card. */
|
||||
baseModel?: { org: string; name: string };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,6 +38,15 @@ export interface ModelLoadProgress {
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-byte download progress for one in-flight model download, driven by the
|
||||
* /models/sse feed. Lives only while a download runs.
|
||||
*/
|
||||
export interface ModelDownloadProgress {
|
||||
downloadedBytes: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export interface ParsedModelId {
|
||||
raw: string;
|
||||
orgName: string | null;
|
||||
@@ -42,6 +54,7 @@ export interface ParsedModelId {
|
||||
params: string | null;
|
||||
activatedParams: string | null;
|
||||
quantization: string | null;
|
||||
variant: DraftVariant | null;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '$lib/constants';
|
||||
import type { ToolExecutionResult } from '$lib/types';
|
||||
|
||||
function detectOs(userAgent: string): string {
|
||||
export function detectOs(userAgent: string): string {
|
||||
for (const [pattern, os] of BROWSER_INFO_OS_UA_PATTERNS) {
|
||||
if (pattern.test(userAgent)) return os;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Detects whether a model's chat template supports tool calling.
|
||||
*
|
||||
* There is no server flag for tool support, so we infer it from the chat
|
||||
* template. A template that accepts a `tools` array or emits tool-call tokens
|
||||
* is treated as tool-capable.
|
||||
*/
|
||||
|
||||
/** Tool-call tokens emitted by the template for assistant tool calls. */
|
||||
const TOOL_CALL_TOKENS = [
|
||||
'tool_call',
|
||||
'tool_calls',
|
||||
'function_call',
|
||||
'tool_use',
|
||||
'<tool',
|
||||
'<|tool',
|
||||
'TOOL_CALL'
|
||||
];
|
||||
/** Jinja reference to the `tools` array passed in by the caller. */
|
||||
const JINJA_TOOLS_VAR = /\{\{[^{}]*\btools\b[^{}]*\}\}|\{%[^{}]*\btools\b[^{}]*%\}/i;
|
||||
|
||||
export function detectToolUseSupport(t: string): boolean {
|
||||
if (!t) return false;
|
||||
|
||||
if (JINJA_TOOLS_VAR.test(t)) return true;
|
||||
|
||||
return TOOL_CALL_TOKENS.some((token) => t.includes(token));
|
||||
}
|
||||
@@ -26,24 +26,29 @@ export function formatFileSize(bytes: number | unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format parameter count to human-readable format (B, M, K)
|
||||
* Format parameter count to human-readable format (T, B, M, K)
|
||||
*
|
||||
* @param params - Parameter count
|
||||
* @param decimals - Decimal places to keep (0 rounds to a full number)
|
||||
* @returns Human-readable parameter count
|
||||
*/
|
||||
export function formatParameters(params: number | unknown): string {
|
||||
export function formatParameters(params: number | unknown, decimals = 2): string {
|
||||
if (typeof params !== 'number') return 'Unknown';
|
||||
|
||||
if (params >= 1e12) {
|
||||
return `${(params / 1e12).toFixed(decimals)}T`;
|
||||
}
|
||||
|
||||
if (params >= 1e9) {
|
||||
return `${(params / 1e9).toFixed(2)}B`;
|
||||
return `${(params / 1e9).toFixed(decimals)}B`;
|
||||
}
|
||||
|
||||
if (params >= 1e6) {
|
||||
return `${(params / 1e6).toFixed(2)}M`;
|
||||
return `${(params / 1e6).toFixed(decimals)}M`;
|
||||
}
|
||||
|
||||
if (params >= 1e3) {
|
||||
return `${(params / 1e3).toFixed(2)}K`;
|
||||
return `${(params / 1e3).toFixed(decimals)}K`;
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
// API utilities
|
||||
export { getAuthHeaders, getJsonHeaders, sanitizeHeaders } from './api-headers';
|
||||
export { ApiError, apiFetch, apiFetchWithParams, apiPost } from './api-fetch';
|
||||
export { ApiError, apiDelete, apiFetch, apiFetchWithParams, apiPost } from './api-fetch';
|
||||
export { validateApiKey } from './api-key-validation';
|
||||
|
||||
// Attachment utilities
|
||||
@@ -107,6 +107,23 @@ export {
|
||||
// Model name utilities
|
||||
export { normalizeModelName, isValidModelName } from './model-names';
|
||||
|
||||
// Model manager tree utilities
|
||||
export {
|
||||
buildModelManagerTree,
|
||||
getRepoId,
|
||||
heuristicParentId,
|
||||
normalizeParentName,
|
||||
resolveBaseModel
|
||||
} from './model-manager';
|
||||
|
||||
// Model hardware-compatibility estimation
|
||||
export {
|
||||
computeFileCompatibilityTiers,
|
||||
deviceMemoryBudgetMb,
|
||||
resolveDeviceMemoryGb
|
||||
} from './model-compatibility';
|
||||
export type { CompatibilityTier } from './model-compatibility';
|
||||
|
||||
// Portal utilities
|
||||
export { portalToBody } from './portal-to-body';
|
||||
|
||||
@@ -254,6 +271,8 @@ export {
|
||||
detectThinkingSupportWithReason
|
||||
} from './chat-template-thinking-detector';
|
||||
|
||||
export { detectToolUseSupport } from './chat-template-tool-detector';
|
||||
|
||||
// Agentic content utilities (structured section derivation)
|
||||
export {
|
||||
deriveAgenticSections,
|
||||
@@ -339,7 +358,7 @@ export { buildSandboxToolDefinition, SANDBOX_TOOL_DEFINITION } from './sandbox-t
|
||||
export { executeGetDatetimeTool } from './get-datetime';
|
||||
|
||||
// Browser fallback for the server's get_info tool
|
||||
export { executeBrowserInfoTool } from './browser-info';
|
||||
export { detectOs, executeBrowserInfoTool } from './browser-info';
|
||||
|
||||
// Cryptography utilities
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Model hardware-compatibility estimation, ported from ggml-org/llama-macos
|
||||
* (`Model+Compatibility.swift`, `HFRepoResolver.swift`, `SidecarPicker.swift`).
|
||||
*
|
||||
* A quant "fits" when its estimated runtime memory stays within the device
|
||||
* budget. The budget mirrors llama.cpp's own fit target: the GPU working set
|
||||
* (approximated from RAM) minus a fit slack, clamped by an OS floor so the
|
||||
* desktop keeps enough to run. We cannot read Metal's working set from a
|
||||
* browser, so the working set is approximated as 75% of RAM (Apple's ratio on
|
||||
* the machines llama-macos targets).
|
||||
*
|
||||
* Compatibility tiers:
|
||||
* - `full` -> fits at the model's native max context (green)
|
||||
* - `limited` -> fits only at a reduced context (yellow)
|
||||
* - `none` -> does not fit even at the minimum context (red)
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { HfModelSibling } from '$lib/types/huggingface';
|
||||
|
||||
/** llama.cpp's default `--fit-target` margin, in MB. */
|
||||
const FIT_SLACK_MB = 1024;
|
||||
/** Physical RAM kept out of a model's reach for the OS and other apps, in MB. */
|
||||
const OS_FLOOR_MB = 4096;
|
||||
/** Overhead multiplier applied to the file size when estimating weight memory. */
|
||||
const WEIGHT_OVERHEAD_MULTIPLIER = 1.05;
|
||||
/** Fraction of RAM the GPU working set is approximated as (Apple's ~75%). */
|
||||
const WORKING_SET_FRACTION = 0.75;
|
||||
/** Minimum context a model must support to launch, matching llama.cpp's default. */
|
||||
const MIN_CTX_TOKENS = 4096;
|
||||
/** Standard context tiers, ascending, used to find the largest fitting one. */
|
||||
const CTX_TIERS = [4096, 8192, 16384, 32768, 65536, 131072, 262144] as const;
|
||||
|
||||
export type CompatibilityTier = 'full' | 'limited' | 'none';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
/** Hardcoded device RAM (GB) for testing the compatibility UI; 0 disables it. */
|
||||
const TEST_DEVICE_MEMORY_GB = 128;
|
||||
|
||||
/**
|
||||
* Resolve the device memory in GB: the user's settings override when set,
|
||||
* else the browser's `navigator.deviceMemory` (Chrome/Edge only, capped at 8).
|
||||
* Returns 0 when neither is available, which callers treat as "unknown".
|
||||
*/
|
||||
export function resolveDeviceMemoryGb(configuredGb: number): number {
|
||||
// Hardcoded device RAM for testing the compatibility UI; 0 disables the
|
||||
// override. TODO: remove once the device-memory source is trusted.
|
||||
if (TEST_DEVICE_MEMORY_GB > 0) return TEST_DEVICE_MEMORY_GB;
|
||||
|
||||
if (configuredGb > 0) return configuredGb;
|
||||
|
||||
if (!browser) return 0;
|
||||
|
||||
const nav = navigator as Navigator & { deviceMemory?: number };
|
||||
|
||||
return typeof nav.deviceMemory === 'number' && nav.deviceMemory > 0 ? nav.deviceMemory : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory a model may use, in MB: whichever of the GPU working set (less the
|
||||
* fit slack) or the RAM-less-OS-floor binds first. Returns 0 when unknown.
|
||||
*/
|
||||
export function deviceMemoryBudgetMb(deviceMemoryGb: number): number {
|
||||
if (deviceMemoryGb <= 0) return 0;
|
||||
|
||||
const physicalMb = deviceMemoryGb * 1024;
|
||||
const gpuLimbMb = physicalMb * WORKING_SET_FRACTION - FIT_SLACK_MB;
|
||||
const ramLimbMb = physicalMb - OS_FLOOR_MB;
|
||||
|
||||
return Math.max(Math.min(gpuLimbMb, ramLimbMb), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map every GGUF file in the repo to a compatibility tier. Main quants get
|
||||
* their own tier; their shards and sidecars (mmproj + draft head) inherit the
|
||||
* matched main quant's tier so the whole group reads consistently.
|
||||
*/
|
||||
export function computeFileCompatibilityTiers(
|
||||
files: HfModelSibling[],
|
||||
nativeCtxTokens: number,
|
||||
deviceMemoryGb: number
|
||||
): Map<string, CompatibilityTier> {
|
||||
const tiers = new Map<string, CompatibilityTier>();
|
||||
|
||||
// Unknown device memory: leave every file untiered (neutral) rather than
|
||||
// guessing a fit we cannot back up.
|
||||
if (deviceMemoryGb <= 0) return tiers;
|
||||
|
||||
const allPaths = new Set(files.map((f) => f.path));
|
||||
const sizeByPath = new Map(files.map((f) => [f.path, f.size ?? 0]));
|
||||
const budgetMb = deviceMemoryBudgetMb(deviceMemoryGb);
|
||||
// Candidate mains: skip draft heads, mmproj, imatrix, and non-first shards.
|
||||
const mains = files.filter((f) => {
|
||||
const meta = HuggingFaceService.extractQuantMeta(f.path);
|
||||
|
||||
if (!meta || meta.variant !== null) return false;
|
||||
|
||||
if ((f.path.split('/').pop() ?? f.path).toLowerCase().includes('imatrix')) return false;
|
||||
|
||||
return !isNonFirstShard(f.path);
|
||||
});
|
||||
// Track each main's quant bits + directory so draft sidecars can be matched
|
||||
// to their closest-quant main for a tier below.
|
||||
const mainInfos: MainQuantInfo[] = [];
|
||||
|
||||
for (const main of mains) {
|
||||
// Aggregate size: main + shards + mmproj + quant-matched draft.
|
||||
const picked = expandShards(main.path, allPaths);
|
||||
const mmproj = pickSidecar(main.path, files, (v) => v === 'mmproj');
|
||||
const draft = pickSidecar(main.path, files, (v) => v !== null && v !== 'mmproj');
|
||||
|
||||
if (mmproj) picked.push(mmproj.path);
|
||||
|
||||
const mainBytes = picked.reduce((sum, p) => sum + (sizeByPath.get(p) ?? 0), 0);
|
||||
const draftBytes = draft ? (sizeByPath.get(draft.path) ?? 0) : 0;
|
||||
const tier = compatibilityTier(mainBytes + draftBytes, nativeCtxTokens, budgetMb);
|
||||
|
||||
for (const p of picked) tiers.set(p, tier);
|
||||
|
||||
if (draft) tiers.set(draft.path, tier);
|
||||
|
||||
const meta = HuggingFaceService.extractQuantMeta(main.path);
|
||||
const bits = meta?.quant ? (HuggingFaceService.getBitDepth(meta.quant) ?? 0) : 0;
|
||||
|
||||
mainInfos.push({ bits, dirs: dirComponents(main.path), tier });
|
||||
}
|
||||
|
||||
// Assign every remaining draft sidecar (mtp, dflash, ...) the tier of its
|
||||
// closest-quant main, so all variant badges show a fit icon - not just the
|
||||
// single draft counted toward a main's size.
|
||||
for (const file of files) {
|
||||
if (tiers.has(file.path)) continue;
|
||||
|
||||
const meta = HuggingFaceService.extractQuantMeta(file.path);
|
||||
|
||||
if (!meta?.variant || meta.variant === 'mmproj') continue;
|
||||
|
||||
const main = bestMainForSidecar(file.path, meta.quant, mainInfos);
|
||||
|
||||
if (main) tiers.set(file.path, main.tier);
|
||||
}
|
||||
|
||||
return tiers;
|
||||
}
|
||||
|
||||
interface MainQuantInfo {
|
||||
bits: number;
|
||||
dirs: string[];
|
||||
tier: CompatibilityTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the main quant a draft sidecar pairs with: among mains in the
|
||||
* sidecar's directory or a descendant of it, the one with the closest quant
|
||||
* bit depth.
|
||||
*/
|
||||
function bestMainForSidecar(
|
||||
sidecarPath: string,
|
||||
sidecarQuant: string | null,
|
||||
mains: MainQuantInfo[]
|
||||
): MainQuantInfo | null {
|
||||
const sidecarDirs = dirComponents(sidecarPath);
|
||||
const sidecarBits = sidecarQuant ? (HuggingFaceService.getBitDepth(sidecarQuant) ?? 0) : 0;
|
||||
|
||||
let best: { diff: number; main: MainQuantInfo } | null = null;
|
||||
|
||||
for (const main of mains) {
|
||||
// The sidecar's directory must be the main's directory or an ancestor.
|
||||
if (sidecarDirs.length > main.dirs.length) continue;
|
||||
|
||||
if (!sidecarDirs.every((d, i) => main.dirs[i] === d)) continue;
|
||||
|
||||
const diff = Math.abs(main.bits - sidecarBits);
|
||||
|
||||
if (!best || diff < best.diff) best = { diff, main };
|
||||
}
|
||||
|
||||
return best?.main ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weight memory (MB) is the file size with overhead; context memory scales
|
||||
* with the requested window. A quant is `full` when it fits at the native max
|
||||
* context, `limited` when it only fits at a smaller standard tier, and `none`
|
||||
* when it does not fit even at the minimum context.
|
||||
*/
|
||||
function compatibilityTier(
|
||||
totalBytes: number,
|
||||
nativeCtxTokens: number,
|
||||
budgetMb: number
|
||||
): CompatibilityTier {
|
||||
// A known-but-tiny budget (<= 0) means nothing fits; unknown memory is
|
||||
// handled by the caller returning no tiers at all.
|
||||
const weightMb = (totalBytes / MB) * WEIGHT_OVERHEAD_MULTIPLIER;
|
||||
const ctxBytesPer1k = ctxBytesPer1kTokens(nativeCtxTokens);
|
||||
const fits = (ctxTokens: number) =>
|
||||
weightMb + (ctxBytesPer1k * (ctxTokens / 1000)) / MB <= budgetMb;
|
||||
|
||||
if (nativeCtxTokens < MIN_CTX_TOKENS) return 'none';
|
||||
|
||||
if (fits(nativeCtxTokens)) return 'full';
|
||||
|
||||
// Find the largest standard tier that still fits within the native window.
|
||||
const largestFitting = [...CTX_TIERS]
|
||||
.filter((t) => t <= nativeCtxTokens)
|
||||
.reverse()
|
||||
.find((t) => fits(t));
|
||||
|
||||
return largestFitting !== undefined ? 'limited' : 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate KV-cache bytes per 1k tokens. Without a MemProfile probe (which
|
||||
* only exists post-launch in llama-macos) we estimate from the native context
|
||||
* window; ~0.1 MB per 1k tokens is a conservative mid-range for modern models.
|
||||
*/
|
||||
function ctxBytesPer1kTokens(_nativeCtxTokens: number): number {
|
||||
return 0.1 * MB;
|
||||
}
|
||||
|
||||
/** Directory components of a repo-relative path (`Q4_K_M/a.gguf` -> `["Q4_K_M"]`). */
|
||||
function dirComponents(path: string): string[] {
|
||||
return path.split('/').slice(0, -1);
|
||||
}
|
||||
|
||||
/** True for a split-shard continuation (`-00002-of-00003.gguf`), not the first shard. */
|
||||
function isNonFirstShard(path: string): boolean {
|
||||
const match = /-(\d{5})-of-(\d{5})\.gguf$/i.exec(path);
|
||||
|
||||
return match !== null && match[1] !== '00001';
|
||||
}
|
||||
|
||||
/** Expand a main GGUF to its full shard set; non-sharded files return `[main]`. */
|
||||
function expandShards(main: string, allPaths: Set<string>): string[] {
|
||||
const match = /-(\d{5})-of-(\d{5})\.gguf$/i.exec(main);
|
||||
|
||||
if (!match) return [main];
|
||||
|
||||
const total = parseInt(match[2], 10);
|
||||
const stem = main.slice(0, main.length - match[0].length);
|
||||
const shards: string[] = [];
|
||||
|
||||
for (let i = 1; i <= total; i++) {
|
||||
const shard = `${stem}-${String(i).padStart(5, '0')}-of-${String(total).padStart(5, '0')}.gguf`;
|
||||
|
||||
if (allPaths.has(shard)) shards.push(shard);
|
||||
}
|
||||
|
||||
return shards.length > 0 ? shards : [main];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best sidecar (mmproj or draft head) for a main file, mirroring
|
||||
* llama.cpp's `find_best_sibling`: the candidate's directory must be the main
|
||||
* file's directory or an ancestor; candidates rank by deepest directory, then
|
||||
* exact quant-tag match, then closest quant-bit distance.
|
||||
*/
|
||||
function pickSidecar(
|
||||
main: string,
|
||||
files: HfModelSibling[],
|
||||
isCandidate: (variant: string | null) => boolean
|
||||
): HfModelSibling | null {
|
||||
const mainDirs = dirComponents(main);
|
||||
const mainMeta = HuggingFaceService.extractQuantMeta(main);
|
||||
const mainBits = mainMeta?.quant ? (HuggingFaceService.getBitDepth(mainMeta.quant) ?? 0) : 0;
|
||||
const mainTag = mainMeta?.quant?.toUpperCase();
|
||||
|
||||
let best: { depth: number; diff: number; exact: boolean; file: HfModelSibling } | null = null;
|
||||
|
||||
for (const file of files) {
|
||||
const meta = HuggingFaceService.extractQuantMeta(file.path);
|
||||
|
||||
if (!meta || !isCandidate(meta.variant)) continue;
|
||||
|
||||
const dirs = dirComponents(file.path);
|
||||
|
||||
if (dirs.length > mainDirs.length || !dirs.every((d, i) => mainDirs[i] === d)) continue;
|
||||
|
||||
const depth = dirs.length;
|
||||
const bits = meta.quant ? (HuggingFaceService.getBitDepth(meta.quant) ?? 0) : 0;
|
||||
const diff = Math.abs(bits - mainBits);
|
||||
const exact = mainTag ? file.path.toUpperCase().includes(`-${mainTag}.`) : false;
|
||||
|
||||
if (best) {
|
||||
const better =
|
||||
depth > best.depth ||
|
||||
(depth === best.depth && exact && !best.exact) ||
|
||||
(depth === best.depth && exact === best.exact && diff < best.diff);
|
||||
|
||||
if (!better) continue;
|
||||
}
|
||||
|
||||
best = { depth, diff, exact, file };
|
||||
}
|
||||
|
||||
return best?.file ?? null;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { ModelManagerParent, ModelManagerQuant, ModelManagerQuantOrg } from '$lib/types';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
/** Strip the `:quant` / `:quant-VARIANT` tag from a model id to get the repo id. */
|
||||
export function getRepoId(modelId: string): string {
|
||||
return modelId.split(':')[0] ?? modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a model id to a parent grouping key: last path segment with the
|
||||
* container-format suffix (`-GGUF`, `-GGML`) stripped. `Qwen/Qwen3.8-27B` and
|
||||
* `ggml-org/Qwen3.8-27B-GGUF` both normalize to `Qwen3.8-27B`.
|
||||
*/
|
||||
export function normalizeParentName(id: string): string {
|
||||
const slashIdx = id.lastIndexOf('/');
|
||||
const name = slashIdx !== -1 ? id.slice(slashIdx + 1) : id;
|
||||
|
||||
return name.replace(/-(GGUF|GGML)$/i, '');
|
||||
}
|
||||
|
||||
/** Heuristic parent id for a repo: normalized repo name (org dropped). */
|
||||
export function heuristicParentId(repoId: string): string {
|
||||
return normalizeParentName(repoId);
|
||||
}
|
||||
|
||||
// Module-level cache so base models survive route navigation.
|
||||
const baseModelCache = new Map<string, string | null>();
|
||||
|
||||
/**
|
||||
* Resolve the original (non-GGUF) model for a repo via HF metadata. Prefers
|
||||
* `cardData.base_model` (a string or a list), falling back to the
|
||||
* `base_model:<org>/<name>` tag. Cached per repo; null means "not found".
|
||||
*/
|
||||
export async function resolveBaseModel(repoId: string): Promise<string | null> {
|
||||
if (baseModelCache.has(repoId)) return baseModelCache.get(repoId) ?? null;
|
||||
|
||||
let resolved: string | null = null;
|
||||
|
||||
try {
|
||||
const details = await HuggingFaceService.getDetails(repoId);
|
||||
const base = details?.cardData?.base_model;
|
||||
const first = Array.isArray(base) ? base[0] : base;
|
||||
|
||||
if (first && first.trim()) {
|
||||
resolved = first.trim();
|
||||
} else {
|
||||
// tag fallback, e.g. `base_model:Qwen/Qwen3-8B`
|
||||
const tag = details?.tags?.find(
|
||||
(t) => t.startsWith('base_model:') && !t.startsWith('base_model:quantized:')
|
||||
);
|
||||
const fromTag = tag?.slice('base_model:'.length).trim();
|
||||
|
||||
if (fromTag) resolved = fromTag;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the heuristic
|
||||
}
|
||||
|
||||
baseModelCache.set(repoId, resolved);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the manager tree from installed models and resolved base models.
|
||||
* Repos are grouped under a parent keyed by the heuristic name; the display id
|
||||
* prefers the resolved HF base_model.
|
||||
*/
|
||||
export function buildModelManagerTree(
|
||||
models: ModelOption[],
|
||||
baseModels: ReadonlyMap<string, string | null>
|
||||
): ModelManagerParent[] {
|
||||
// repoId -> quant -> { main, drafts, mmproj }
|
||||
const repoMap = new Map<string, Map<string, ModelManagerQuant>>();
|
||||
|
||||
for (const option of models) {
|
||||
const parsed = option.parsedId;
|
||||
const repoId = getRepoId(option.model);
|
||||
const quant = parsed?.quantization ?? null;
|
||||
const variant = parsed?.variant ?? null;
|
||||
|
||||
let quantMap = repoMap.get(repoId);
|
||||
|
||||
if (!quantMap) {
|
||||
quantMap = new Map();
|
||||
repoMap.set(repoId, quantMap);
|
||||
}
|
||||
|
||||
const key = quant ?? '';
|
||||
|
||||
let entry = quantMap.get(key);
|
||||
|
||||
if (!entry) {
|
||||
entry = { drafts: [], main: option, mmproj: null, quant };
|
||||
quantMap.set(key, entry);
|
||||
}
|
||||
|
||||
if (variant === 'mmproj') {
|
||||
entry.mmproj = option;
|
||||
} else if (variant) {
|
||||
entry.drafts.push({ option, variant });
|
||||
} else {
|
||||
entry.main = option;
|
||||
}
|
||||
}
|
||||
|
||||
// Build quant orgs.
|
||||
const quantOrgs: ModelManagerQuantOrg[] = [];
|
||||
|
||||
for (const [repoId, quantMap] of repoMap) {
|
||||
const quants = Array.from(quantMap.values()).sort((a, b) =>
|
||||
(a.quant ?? '').localeCompare(b.quant ?? '')
|
||||
);
|
||||
|
||||
quants.forEach((q) => q.drafts.sort((a, b) => a.variant.localeCompare(b.variant)));
|
||||
|
||||
const slashIdx = repoId.indexOf('/');
|
||||
const orgName = slashIdx !== -1 ? repoId.slice(0, slashIdx) : repoId;
|
||||
|
||||
quantOrgs.push({ orgName, quants, repoId });
|
||||
}
|
||||
|
||||
// Group quant orgs under parents.
|
||||
const parentMap = new Map<string, ModelManagerParent>();
|
||||
|
||||
for (const org of quantOrgs) {
|
||||
const resolved = baseModels.get(org.repoId) ?? null;
|
||||
// Key by the model name so repos of the same model group together even
|
||||
// when only some of them resolved a base_model.
|
||||
const key = normalizeParentName(resolved ?? org.repoId);
|
||||
|
||||
let parent = parentMap.get(key);
|
||||
|
||||
if (!parent) {
|
||||
parent = { parentId: resolved ?? key, quantOrgs: [] };
|
||||
parentMap.set(key, parent);
|
||||
} else if (resolved && parent.parentId === key) {
|
||||
// Prefer the resolved base_model for display once known.
|
||||
parent.parentId = resolved;
|
||||
}
|
||||
|
||||
parent.quantOrgs.push(org);
|
||||
}
|
||||
|
||||
const parents = Array.from(parentMap.values());
|
||||
|
||||
parents.forEach((p) => p.quantOrgs.sort((a, b) => a.repoId.localeCompare(b.repoId)));
|
||||
parents.sort((a, b) => a.parentId.localeCompare(b.parentId));
|
||||
|
||||
return parents;
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Heart,
|
||||
HeartOff,
|
||||
LoaderCircle,
|
||||
Plus,
|
||||
Power,
|
||||
PowerOff,
|
||||
Trash2,
|
||||
X
|
||||
} from '@lucide/svelte';
|
||||
import { ActionIcon, DialogModelsDiscover, ModelId } from '$lib/components/app';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore, serverStore } from '$lib/stores';
|
||||
import type { ApiModelDataEntry } from '$lib/types';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { buildModelManagerTree, copyToClipboard, getRepoId, resolveBaseModel } from '$lib/utils';
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
let modelsHubOpen = $state(false);
|
||||
let baseModels = new SvelteMap<string, string | null>();
|
||||
|
||||
let isRouter = $derived(serverStore.isRouterMode);
|
||||
|
||||
let tree = $derived(buildModelManagerTree(modelsStore.models, baseModels));
|
||||
|
||||
onMount(() => {
|
||||
void modelsStore.fetch();
|
||||
modelsStore.status.subscribe();
|
||||
});
|
||||
|
||||
// Resolve the original model for each installed repo (cached, best-effort).
|
||||
$effect(() => {
|
||||
const repoIds = new Set(modelsStore.models.map((m) => getRepoId(m.model)));
|
||||
|
||||
for (const repoId of repoIds) {
|
||||
void resolveBaseModel(repoId).then((base) => {
|
||||
baseModels.set(repoId, base);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function getRouterEntry(modelId: string): ApiModelDataEntry | undefined {
|
||||
return modelsStore.routerModels.find((m) => m.id === modelId);
|
||||
}
|
||||
|
||||
function hfUrl(modelId: string): string | null {
|
||||
const repoId = getRepoId(modelId);
|
||||
|
||||
if (!repoId.includes('/')) return null;
|
||||
|
||||
return `https://huggingface.co/${repoId}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet modelRow(option: ModelOption, indent: boolean, hideQuantization: boolean)}
|
||||
{@const entry = getRouterEntry(option.model)}
|
||||
{@const status = entry?.status}
|
||||
{@const statusValue = status?.value}
|
||||
{@const isLoaded =
|
||||
statusValue === ServerModelStatus.LOADED || statusValue === ServerModelStatus.SLEEPING}
|
||||
{@const isLoading = statusValue === ServerModelStatus.LOADING}
|
||||
{@const isDownloading = statusValue === ServerModelStatus.DOWNLOADING}
|
||||
{@const isFailed = statusValue === ServerModelStatus.FAILED || status?.failed === true}
|
||||
{@const canRemove = entry?.can_remove === true}
|
||||
{@const isFav = modelsStore.isFavorite(option.model)}
|
||||
{@const hfLink = hfUrl(option.model)}
|
||||
{@const downloadProgress = modelsStore.status.getDownloadProgress(option.model)}
|
||||
|
||||
<tr class="border-t transition-colors hover:bg-muted/40">
|
||||
<td class="px-3 py-2 {indent ? 'pl-8' : ''}">
|
||||
<ModelId
|
||||
aliases={option.aliases}
|
||||
{hideQuantization}
|
||||
modalities={option.modalities}
|
||||
modelId={option.model}
|
||||
showRawTooltip
|
||||
supportsThinking={modelsStore.props.checkModelSupportsThinking(option.model)}
|
||||
tags={option.tags}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-2">
|
||||
<span class="inline-flex items-center gap-1.5 text-xs">
|
||||
{#if isLoading}
|
||||
<LoaderCircle class="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
|
||||
<span>Loading</span>
|
||||
{:else if isDownloading}
|
||||
<LoaderCircle class="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
|
||||
<span>Downloading</span>
|
||||
{:else if isLoaded}
|
||||
<span class="h-2 w-2 rounded-full bg-green-500"></span>
|
||||
|
||||
<span>{statusValue === ServerModelStatus.SLEEPING ? 'Sleeping' : 'Loaded'}</span>
|
||||
{:else if isFailed}
|
||||
<span class="h-2 w-2 rounded-full bg-red-500"></span>
|
||||
|
||||
<span>Failed</span>
|
||||
{:else if statusValue === ServerModelStatus.DOWNLOADED}
|
||||
<span class="h-2 w-2 rounded-full bg-muted-foreground/50"></span>
|
||||
|
||||
<span>Downloaded</span>
|
||||
{:else}
|
||||
<span class="h-2 w-2 rounded-full bg-muted-foreground/50"></span>
|
||||
|
||||
<span>Unloaded</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if isDownloading && downloadProgress}
|
||||
<div class="mt-1 h-1 w-32 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full bg-primary"
|
||||
style="width: {downloadProgress.totalBytes > 0
|
||||
? Math.round((downloadProgress.downloadedBytes / downloadProgress.totalBytes) * 100)
|
||||
: 0}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center justify-end gap-0.5">
|
||||
{#if isRouter}
|
||||
{#if isLoading}
|
||||
<ActionIcon
|
||||
icon={X}
|
||||
onclick={() => modelsStore.status.cancelLoad(option.model)}
|
||||
tooltip="Cancel load"
|
||||
/>
|
||||
{:else if isDownloading}
|
||||
<ActionIcon
|
||||
icon={X}
|
||||
onclick={() => modelsStore.status.cancelDownload(option.model)}
|
||||
tooltip="Cancel download"
|
||||
/>
|
||||
{:else if isLoaded}
|
||||
<ActionIcon
|
||||
icon={PowerOff}
|
||||
onclick={() => modelsStore.status.unload(option.model)}
|
||||
tooltip="Unload model"
|
||||
/>
|
||||
{:else}
|
||||
<ActionIcon
|
||||
icon={Power}
|
||||
onclick={() => modelsStore.status.load(option.model)}
|
||||
tooltip="Load model"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if canRemove && !isDownloading}
|
||||
<ActionIcon
|
||||
icon={Trash2}
|
||||
onclick={() => modelsStore.status.cancelDownload(option.model)}
|
||||
tooltip="Delete model"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<ActionIcon
|
||||
icon={isFav ? HeartOff : Heart}
|
||||
onclick={() => modelsStore.toggleFavorite(option.model)}
|
||||
tooltip={isFav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
/>
|
||||
|
||||
<ActionIcon
|
||||
icon={Copy}
|
||||
onclick={() => copyToClipboard(option.model)}
|
||||
tooltip="Copy model id"
|
||||
/>
|
||||
|
||||
{#if hfLink}
|
||||
<a
|
||||
aria-label="View on HuggingFace"
|
||||
class="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted-foreground/10 hover:text-foreground"
|
||||
href={hfLink}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="View on HuggingFace"
|
||||
>
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/snippet}
|
||||
|
||||
<div class="mx-auto flex h-full w-full max-w-5xl flex-col gap-4 p-4 md:p-6">
|
||||
<header class="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold">Model Manager</h1>
|
||||
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Models installed on this machine and available through /v1/models.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onclick={() => (modelsHubOpen = true)} size="sm">
|
||||
<Plus class="h-4 w-4" />
|
||||
|
||||
Add more models
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{#if modelsStore.loading}
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<p class="text-sm text-muted-foreground">Loading models...</p>
|
||||
</div>
|
||||
{:else if modelsStore.error}
|
||||
<div class="rounded-lg border border-destructive/50 bg-destructive/5 p-4 text-center">
|
||||
<p class="text-sm text-destructive">{modelsStore.error}</p>
|
||||
</div>
|
||||
{:else if modelsStore.models.length === 0}
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<p class="text-sm text-muted-foreground">No models installed.</p>
|
||||
|
||||
<Button onclick={() => (modelsHubOpen = true)} size="sm" variant="outline">
|
||||
<Plus class="h-4 w-4" />
|
||||
|
||||
Add more models
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-lg border">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/40 text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="px-3 py-2 font-medium">Model</th>
|
||||
|
||||
<th class="px-3 py-2 font-medium">Status</th>
|
||||
|
||||
<th class="px-3 py-2 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{#each tree as parent (parent.parentId)}
|
||||
<tr class="border-t bg-muted/20">
|
||||
<td class="px-3 py-1.5 text-xs font-semibold text-muted-foreground" colspan="3">
|
||||
{parent.parentId}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{#each parent.quantOrgs as org (org.repoId)}
|
||||
<tr class="border-t bg-muted/10">
|
||||
<td class="px-6 py-1.5 text-xs font-medium text-muted-foreground" colspan="3">
|
||||
{org.repoId}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{#each org.quants as quant (quant.quant ?? 'default')}
|
||||
{@render modelRow(quant.main, false, false)}
|
||||
|
||||
{#each quant.drafts as draft (draft.option.id)}
|
||||
{@render modelRow(draft.option, true, true)}
|
||||
{/each}
|
||||
{/each}
|
||||
{/each}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<DialogModelsDiscover bind:open={modelsHubOpen} />
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { ChevronRight } from '@lucide/svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: BreadcrumbItem[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className, items }: Props = $props();
|
||||
|
||||
function navigateTo(href: string | undefined) {
|
||||
if (href && browser) {
|
||||
goto(href);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav aria-label="Breadcrumb" class={className}>
|
||||
<ol class="flex list-none items-center gap-1.5 p-0 text-sm">
|
||||
{#each items as item, i (item.label)}
|
||||
<li class="flex items-center gap-1.5">
|
||||
{#if i > 0}
|
||||
<ChevronRight class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
{#if item.href && i < items.length - 1}
|
||||
<a
|
||||
class="text-muted-foreground transition-colors hover:text-foreground"
|
||||
href={item.href}
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
navigateTo(item.href);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="font-medium text-foreground">{item.label}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
import DownloadToast from './DownloadToast.svelte';
|
||||
import { Download, LoaderCircle, TriangleAlert } from '@lucide/svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import type { DraftVariant } from '$lib/constants';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import { type GgufVariantTagInput, ModelsService } from '$lib/services/models.service';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
repoId: string;
|
||||
filePath: string;
|
||||
quant: string | null;
|
||||
variant: DraftVariant | null;
|
||||
formattedSize?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
filePath,
|
||||
formattedSize,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
open = $bindable(),
|
||||
quant,
|
||||
repoId,
|
||||
variant
|
||||
}: Props = $props();
|
||||
|
||||
type Phase = 'pending' | 'starting';
|
||||
let phase = $state<Phase>('pending');
|
||||
let lastError: string | null = $state(null);
|
||||
|
||||
let tagInput = $derived<GgufVariantTagInput | null>(
|
||||
quant || variant ? { quant: quant ?? '', variant } : null
|
||||
);
|
||||
let hfRepoWithTag = $derived(ModelsService.buildDownloadTag(repoId, tagInput));
|
||||
let tagDisplay = $derived.by(() => {
|
||||
if (quant && variant) return `${quant}-${variant.toUpperCase()}`;
|
||||
|
||||
if (quant) return quant;
|
||||
|
||||
if (variant) return variant.toUpperCase();
|
||||
|
||||
return 'default';
|
||||
});
|
||||
|
||||
// True when a previous SSE `download_failed` left a recorded failure for the
|
||||
// same <repo>:<tag>. The dialog swaps Download for a delete-&-retry flow
|
||||
// because POST /models rejects already-existing partial entries.
|
||||
let previousFailure = $derived(modelsStore.status.hasFailedDownload(hfRepoWithTag));
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === KeyboardKey.ENTER && phase === 'pending') {
|
||||
event.preventDefault();
|
||||
void trigger();
|
||||
}
|
||||
}
|
||||
|
||||
// The dialog is always closable - downloads run in the background and are
|
||||
// tracked by a toast, so closing mid-flight never aborts the download.
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (newOpen) {
|
||||
lastError = null;
|
||||
phase = 'pending';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onCancel();
|
||||
}
|
||||
|
||||
async function trigger() {
|
||||
if (phase === 'starting') return;
|
||||
|
||||
phase = 'starting';
|
||||
lastError = null;
|
||||
|
||||
// A recorded failure for the same <repo>:<tag> means the server still holds
|
||||
// a partial entry that POST /models would reject; remove it before retrying.
|
||||
if (modelsStore.status.hasFailedDownload(hfRepoWithTag)) {
|
||||
await ModelsService.cancelDownload(hfRepoWithTag);
|
||||
}
|
||||
|
||||
try {
|
||||
await modelsStore.status.downloadModel(hfRepoWithTag, filePath);
|
||||
// Download runs on the server; hand progress off to a toast and close.
|
||||
showDownloadToast();
|
||||
onConfirm();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : 'Failed to start download';
|
||||
phase = 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
function showDownloadToast() {
|
||||
toast.custom(DownloadToast, {
|
||||
componentProps: {
|
||||
displayName: filePath,
|
||||
repoId,
|
||||
repoWithTag: hfRepoWithTag
|
||||
},
|
||||
dismissible: true,
|
||||
duration: Infinity
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root onOpenChange={handleOpenChange} {open}>
|
||||
<AlertDialog.Content class="max-w-md" onkeydown={handleKeydown}>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title class="flex items-center gap-2">
|
||||
<Download class="h-5 w-5 text-primary" />
|
||||
Download this model?
|
||||
</AlertDialog.Title>
|
||||
|
||||
<AlertDialog.Description>
|
||||
llama-server will download this file (and related sidecar weights such as multimodal
|
||||
projectors or draft models) from Hugging Face into your local model cache. The download runs
|
||||
in the background and its progress is shown in a notification.
|
||||
</AlertDialog.Description>
|
||||
|
||||
{#if previousFailure && phase === 'pending'}
|
||||
<div
|
||||
class="mt-2 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
|
||||
role="status"
|
||||
>
|
||||
<TriangleAlert class="mt-0.5 h-4 w-4 shrink-0" />
|
||||
|
||||
<span>
|
||||
A previous attempt for this tag failed and left partial files on disk. The server will
|
||||
reject a fresh download until those files are removed. The Retry button below deletes
|
||||
the partial files automatically.
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</AlertDialog.Header>
|
||||
|
||||
<div class="space-y-3 rounded-md border bg-muted/40 p-3 text-xs">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Request</span>
|
||||
|
||||
<code class="break-all font-mono"
|
||||
>POST /models · {`{ model: "${hfRepoWithTag}" }`}</code
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">File</span>
|
||||
|
||||
<code class="break-all font-mono">{filePath}</code>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded bg-primary/15 px-2 py-0.5 font-mono font-semibold text-primary">
|
||||
{tagDisplay}
|
||||
</span>
|
||||
|
||||
{#if formattedSize}
|
||||
<span class="text-muted-foreground">{formattedSize}</span>
|
||||
{/if}
|
||||
|
||||
{#if variant}
|
||||
<span
|
||||
class="rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground"
|
||||
>
|
||||
{variant}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if lastError}
|
||||
<p class="text-xs text-destructive">{lastError}</p>
|
||||
{/if}
|
||||
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel disabled={phase === 'starting'} onclick={onCancel}>
|
||||
Cancel
|
||||
</AlertDialog.Cancel>
|
||||
|
||||
<AlertDialog.Action disabled={phase === 'starting'} onclick={trigger}>
|
||||
{#if phase === 'starting'}
|
||||
<LoaderCircle class="mr-1.5 h-4 w-4 animate-spin" />
|
||||
Starting...
|
||||
{:else}
|
||||
<Download class="mr-1.5 h-4 w-4" />
|
||||
{previousFailure ? 'Retry download' : 'Download'}
|
||||
{/if}
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
/** Bytes downloaded so far. Caller supplies the value; we normalize 0..1. */
|
||||
downloadedBytes: number;
|
||||
/** Total bytes for the download plan. */
|
||||
totalBytes: number;
|
||||
/** Pin to the bottom edge as a thin overlay like `ModelLoadHighlight`. */
|
||||
overlay?: boolean;
|
||||
}
|
||||
|
||||
let { downloadedBytes, overlay = false, totalBytes }: Props = $props();
|
||||
|
||||
let fraction = $derived.by(() => {
|
||||
if (totalBytes <= 0) return 0;
|
||||
|
||||
return Math.min(Math.max(downloadedBytes / totalBytes, 0), 1);
|
||||
});
|
||||
let percent = $derived(Math.round(fraction * 100));
|
||||
</script>
|
||||
|
||||
{#if overlay}
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm">
|
||||
<div
|
||||
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import DownloadProgressBar from './DownloadProgressBar.svelte';
|
||||
import { ArrowUpRight, Check, TriangleAlert } from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
/** `<repo>:<tag>` identifier the server reports progress under. */
|
||||
repoWithTag: string;
|
||||
/** Repo id (no tag) used by the "Open details" CTA. */
|
||||
repoId: string;
|
||||
/** Short label for the file being downloaded. */
|
||||
displayName: string;
|
||||
/** Injected by sonner; dismisses this toast. */
|
||||
closeToast?: () => void;
|
||||
}
|
||||
|
||||
let { closeToast, displayName, repoId, repoWithTag }: Props = $props();
|
||||
|
||||
// Live progress from the /models/sse feed. The map entry is deleted on
|
||||
// download_finished / download_failed, so we latch on first sight to tell
|
||||
// "not started yet" apart from "finished".
|
||||
let progress = $derived(modelsStore.status.getDownloadProgress(repoWithTag));
|
||||
let hasSeenProgress = $state(false);
|
||||
let percent = $derived.by(() => {
|
||||
if (!progress || progress.totalBytes <= 0) return 0;
|
||||
|
||||
return Math.round((progress.downloadedBytes / progress.totalBytes) * 100);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (progress) hasSeenProgress = true;
|
||||
});
|
||||
|
||||
// A download is done when the feed drops our entry after we saw progress, or
|
||||
// when the model landed in /v1/models / a failure was recorded (covers fast
|
||||
// downloads that settle before the first progress event reaches this toast).
|
||||
let failed = $derived(modelsStore.status.hasFailedDownload(repoWithTag));
|
||||
let modelReady = $derived(modelsStore.status.isModelDownloaded(repoWithTag));
|
||||
let done = $derived(
|
||||
(hasSeenProgress && !modelsStore.status.isDownloadInProgress(repoWithTag)) ||
|
||||
failed ||
|
||||
modelReady
|
||||
);
|
||||
|
||||
// Auto-dismiss once the download settles; keep the failure visible a bit longer.
|
||||
$effect(() => {
|
||||
if (!done) return;
|
||||
|
||||
const timer = setTimeout(() => closeToast?.(), failed ? 4000 : 2500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
function openDetails() {
|
||||
closeToast?.();
|
||||
goto(`/temp/models-hub/${repoId}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="flex items-center gap-1.5 text-sm font-medium">
|
||||
{#if done && failed}
|
||||
<TriangleAlert class="h-4 w-4 text-destructive" />
|
||||
Download failed
|
||||
{:else if done}
|
||||
<Check class="h-4 w-4 text-emerald-500" />
|
||||
Download complete
|
||||
{:else}
|
||||
<span class="h-4 w-4 animate-pulse rounded-full bg-primary/30"></span>
|
||||
Downloading
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span class="font-mono text-xs tabular-nums text-muted-foreground">{percent}%</span>
|
||||
</div>
|
||||
|
||||
<p class="truncate text-xs text-muted-foreground">{displayName}</p>
|
||||
|
||||
<DownloadProgressBar
|
||||
downloadedBytes={progress?.downloadedBytes ?? 0}
|
||||
totalBytes={progress?.totalBytes ?? 0}
|
||||
/>
|
||||
|
||||
<button
|
||||
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
onclick={openDetails}
|
||||
type="button"
|
||||
>
|
||||
Open details
|
||||
<ArrowUpRight class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts" module>
|
||||
import {
|
||||
AudioLines,
|
||||
Circle,
|
||||
Equal,
|
||||
Hash,
|
||||
HelpCircle,
|
||||
Image as ImageIcon,
|
||||
ImagePlus,
|
||||
Languages,
|
||||
Layers,
|
||||
ListCollapse,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
MessageSquareMore,
|
||||
Mic,
|
||||
Replace,
|
||||
Scan,
|
||||
Video,
|
||||
Volume2
|
||||
} from '@lucide/svelte';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
/** Lucide icon name -> component; only pipeline-tag icons we use today. */
|
||||
const ICONS: Record<string, Component> = {
|
||||
'audio-lines': AudioLines,
|
||||
equal: Equal,
|
||||
hash: Hash,
|
||||
'help-circle': HelpCircle,
|
||||
image: ImageIcon,
|
||||
'image-plus': ImagePlus,
|
||||
languages: Languages,
|
||||
layers: Layers,
|
||||
'list-collapse': ListCollapse,
|
||||
'message-circle': MessageCircle,
|
||||
'message-square': MessageSquare,
|
||||
'message-square-more': MessageSquareMore,
|
||||
mic: Mic,
|
||||
replace: Replace,
|
||||
scan: Scan,
|
||||
video: Video,
|
||||
'volume-2': Volume2
|
||||
};
|
||||
|
||||
/** Default icon used when the requested name is unknown. */
|
||||
const FALLBACK: Component = Circle;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: string | null;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className = 'h-3.5 w-3.5', name }: Props = $props();
|
||||
|
||||
let Icon = $derived(name && ICONS[name] ? ICONS[name] : FALLBACK);
|
||||
</script>
|
||||
|
||||
<Icon class={className} />
|
||||
@@ -0,0 +1,474 @@
|
||||
<script lang="ts">
|
||||
import Breadcrumb from './Breadcrumb.svelte';
|
||||
import DialogModelDownload from './DialogModelDownload.svelte';
|
||||
import DownloadProgressBar from './DownloadProgressBar.svelte';
|
||||
import { ArrowLeft, Check, Copy, Cpu, Download, ExternalLink, Heart } from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { ActionIcon } from '$lib/components/app';
|
||||
import type { DraftVariant } from '$lib/constants';
|
||||
import { ROUTES } from '$lib/constants';
|
||||
import { HuggingFaceService, ModelsService } from '$lib/services';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types/huggingface';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
modelId: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className, modelId }: Props = $props();
|
||||
|
||||
let modelInfo: HfModelDetailInfo | null = $state(null);
|
||||
let siblings: HfModelSibling[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
let copied = $state(false);
|
||||
|
||||
let pendingDownload = $state<{
|
||||
filePath: string;
|
||||
sizeBytes: number | null;
|
||||
quant: string | null;
|
||||
variant: DraftVariant | null;
|
||||
} | null>(null);
|
||||
|
||||
let details = $derived.by(() => modelInfo);
|
||||
let gguf = $derived.by(() => modelInfo?.gguf);
|
||||
let ggufFiles = $derived(HuggingFaceService.filterByExtension(siblings, '.gguf'));
|
||||
let description = $derived.by(() => modelInfo?.cardData?.description ?? null);
|
||||
let licenseTag = $derived.by(() => {
|
||||
const tags = modelInfo?.tags ?? [];
|
||||
|
||||
return tags.find((t) => t.startsWith('license:'))?.replace('license:', '') ?? null;
|
||||
});
|
||||
let author = $derived.by(() => modelInfo?.author ?? modelId.split('/')[0] ?? '');
|
||||
|
||||
type BitDepthRow = { bitDepth: number; files: HfModelSibling[] };
|
||||
let bitDepthRows = $derived.by<BitDepthRow[]>(() => {
|
||||
const rows = new SvelteMap<number, HfModelSibling[]>();
|
||||
|
||||
for (const file of ggufFiles) {
|
||||
const meta = HuggingFaceService.extractQuantMeta(file.path);
|
||||
|
||||
if (meta?.variant === 'mmproj') continue;
|
||||
|
||||
const depth = meta?.quant ? HuggingFaceService.getBitDepth(meta.quant) : null;
|
||||
const bucket = depth ?? 99;
|
||||
const list = rows.get(bucket) ?? [];
|
||||
|
||||
list.push(file);
|
||||
rows.set(bucket, list);
|
||||
}
|
||||
|
||||
return Array.from(rows.entries())
|
||||
.map(([bitDepth, files]) => ({ bitDepth, files }))
|
||||
.sort((a, b) => a.bitDepth - b.bitDepth);
|
||||
});
|
||||
|
||||
function handleBack() {
|
||||
goto(ROUTES.MANAGE_MODELS);
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
await copyToClipboard(modelId);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1500);
|
||||
}
|
||||
|
||||
async function loadModel() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const [info, tree] = await Promise.all([
|
||||
HuggingFaceService.getDetails(modelId),
|
||||
HuggingFaceService.getTree(modelId)
|
||||
]);
|
||||
|
||||
if (!info) {
|
||||
error = 'Model not found on Hugging Face.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
modelInfo = info;
|
||||
siblings = tree;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load model';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Warm the router-models cache so already-downloaded quants show their
|
||||
// checkmark (otherwise isModelDownloaded(...) stays false on hard refresh).
|
||||
onMount(() => {
|
||||
void modelsStore.fetchRouterModels();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
loadModel();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col {className}">
|
||||
<!-- Pane header: back, model id + copy, View on HF -->
|
||||
<header
|
||||
class="flex shrink-0 items-center gap-2 border-b border-border/40 bg-background/90 px-4 py-3 backdrop-blur"
|
||||
>
|
||||
<div class="lg:hidden">
|
||||
<ActionIcon icon={ArrowLeft} onclick={handleBack} tooltip="Back to models" />
|
||||
</div>
|
||||
|
||||
<h1 class="min-w-0 flex-1 truncate font-semibold">{modelId}</h1>
|
||||
|
||||
<button
|
||||
aria-label="Copy model id"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:border-border hover:text-foreground"
|
||||
onclick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{#if copied}
|
||||
<Check class="h-3.5 w-3.5 text-primary" />
|
||||
{:else}
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
|
||||
<a
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
href={`https://huggingface.co/${modelId}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
|
||||
<span class="hidden sm:inline">View on HF</span>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<Breadcrumb
|
||||
class="px-4 pt-4 md:px-6"
|
||||
items={[{ href: ROUTES.MANAGE_MODELS, label: 'Models' }, { label: modelId }]}
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="mx-4 mt-4 rounded-lg border border-destructive/50 bg-destructive/5 p-4 text-center md:mx-6"
|
||||
>
|
||||
<p class="text-destructive">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-20">
|
||||
<p class="text-muted-foreground">Loading model details...</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !loading && modelInfo}
|
||||
<!-- Metadata chip row -->
|
||||
<div class="flex flex-wrap items-center gap-1.5 px-4 pt-4 md:px-6">
|
||||
{#if author}
|
||||
<span
|
||||
class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground"
|
||||
>
|
||||
{author}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if modelInfo.pipeline_tag}
|
||||
<span class="rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
{HuggingFaceService.pipelineTagLabel(modelInfo.pipeline_tag)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if modelInfo.library_name}
|
||||
<span
|
||||
class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground"
|
||||
>
|
||||
{modelInfo.library_name}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if modelInfo.gated === true}
|
||||
<span
|
||||
class="rounded bg-yellow-500/10 px-2 py-0.5 text-xs font-medium text-yellow-600 dark:text-yellow-400"
|
||||
>
|
||||
gated
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if licenseTag}
|
||||
<span class="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{licenseTag}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Two-column content -->
|
||||
<div class="grid gap-6 px-4 py-6 md:px-6 xl:grid-cols-3">
|
||||
<main class="min-w-0 space-y-6 xl:col-span-2">
|
||||
{#if description}
|
||||
<section class="rounded-lg border bg-card p-5">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
About
|
||||
</h2>
|
||||
|
||||
<p class="text-sm whitespace-pre-line text-foreground/90">{description}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if gguf}
|
||||
<section class="rounded-lg border bg-card p-5">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
GGUF Specs
|
||||
</h2>
|
||||
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 md:grid-cols-3">
|
||||
{#if gguf.architecture}
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">Architecture</dt>
|
||||
|
||||
<dd class="text-sm font-medium capitalize">
|
||||
{gguf.architecture.replace(/_/g, ' ')}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if gguf.total}
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">Total params</dt>
|
||||
|
||||
<dd class="text-sm font-medium tabular-nums">
|
||||
{HuggingFaceService.formatFileSize(gguf.total).replace(' B', '')}B
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if gguf.context_length}
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">Context length</dt>
|
||||
|
||||
<dd class="text-sm font-medium tabular-nums">
|
||||
{gguf.context_length.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<aside class="space-y-4 xl:sticky xl:top-4 xl:self-start">
|
||||
<section class="rounded-lg border bg-card p-4">
|
||||
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Stats
|
||||
</h2>
|
||||
|
||||
<dl class="space-y-2.5 text-sm">
|
||||
{#if typeof modelInfo.downloads === 'number'}
|
||||
<div class="flex items-center justify-between">
|
||||
<dt class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Download size={13} />
|
||||
Downloads
|
||||
</dt>
|
||||
|
||||
<dd class="font-medium tabular-nums">
|
||||
{HuggingFaceService.formatDownloads(modelInfo.downloads)}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if typeof modelInfo.likes === 'number'}
|
||||
<div class="flex items-center justify-between">
|
||||
<dt class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Heart size={13} />
|
||||
Likes
|
||||
</dt>
|
||||
|
||||
<dd class="font-medium tabular-nums">
|
||||
{HuggingFaceService.formatLikes(modelInfo.likes)}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if details?.lastModified}
|
||||
<div class="flex items-center justify-between">
|
||||
<dt class="text-muted-foreground">Last modified</dt>
|
||||
|
||||
<dd class="font-medium">
|
||||
{HuggingFaceService.formatRelativeTime(details.lastModified)}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if gguf?.totalFileSize}
|
||||
<div class="flex items-center justify-between">
|
||||
<dt class="text-muted-foreground">Total size</dt>
|
||||
|
||||
<dd class="font-medium tabular-nums">
|
||||
{HuggingFaceService.formatFileSize(gguf.totalFileSize)}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{#if ggufFiles.length}
|
||||
<section class="rounded-lg border bg-card p-4">
|
||||
<header class="mb-3 flex items-center gap-1.5">
|
||||
<Cpu class="text-muted-foreground" size={13} />
|
||||
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Available files
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each bitDepthRows as row (row.bitDepth)}
|
||||
<div class="grid grid-cols-[5rem_1fr] items-center gap-3">
|
||||
<div class="text-xs font-semibold tabular-nums text-muted-foreground">
|
||||
{#if row.bitDepth === 99}
|
||||
Other
|
||||
{:else}
|
||||
{row.bitDepth}-bit
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each row.files as file (file.path)}
|
||||
{@const meta = HuggingFaceService.extractQuantMeta(file.path)}
|
||||
{@const basename = file.path.split('/').pop() ?? file.path}
|
||||
{@const fallbackLabel = basename
|
||||
.replace(/\.gguf$/i, '')
|
||||
.replace(/^(?:mtp|dflash|mmproj)-/i, '')
|
||||
.replace(/-mtp$/i, '')}
|
||||
{@const label = meta?.quant ?? fallbackLabel}
|
||||
{@const downloadTagInput = meta?.quant
|
||||
? { quant: meta.quant, variant: meta.variant ?? null }
|
||||
: null}
|
||||
{@const hfRepoWithTag = ModelsService.buildDownloadTag(
|
||||
modelId,
|
||||
downloadTagInput
|
||||
)}
|
||||
{@const downloadProgress =
|
||||
modelsStore.status.getDownloadProgress(hfRepoWithTag)}
|
||||
{@const isDownloading =
|
||||
modelsStore.status.isDownloadInProgress(hfRepoWithTag)}
|
||||
{@const isFullyDownloaded =
|
||||
modelsStore.status.isModelDownloaded(hfRepoWithTag)}
|
||||
{@const isFailed = modelsStore.status.hasFailedDownload(hfRepoWithTag)}
|
||||
{@const chipState = isDownloading
|
||||
? 'downloading'
|
||||
: isFullyDownloaded
|
||||
? 'downloaded'
|
||||
: isFailed
|
||||
? 'failed'
|
||||
: 'idle'}
|
||||
<button
|
||||
class:bg-muted={isFullyDownloaded && !isDownloading && !isFailed}
|
||||
class:border-destructive={isFailed && !isDownloading}
|
||||
class:border-foreground={isFullyDownloaded && !isDownloading && !isFailed}
|
||||
class="relative inline-flex cursor-pointer items-center gap-1 overflow-hidden rounded-md border bg-background px-2 py-1 text-left font-mono text-xs transition-colors hover:border-primary/60 hover:bg-primary/5"
|
||||
onclick={() =>
|
||||
(pendingDownload = {
|
||||
filePath: file.path,
|
||||
quant: meta?.quant ?? null,
|
||||
sizeBytes: file.size ?? null,
|
||||
variant: meta?.variant ?? null
|
||||
})}
|
||||
title={chipState === 'downloading'
|
||||
? `In progress: ${file.path}. Click to view cancel options.`
|
||||
: chipState === 'downloaded'
|
||||
? `Already downloaded: ${file.path}`
|
||||
: chipState === 'failed'
|
||||
? `Last attempt failed: ${file.path}. Click to delete partial files and retry.`
|
||||
: `Download ${file.path}`}
|
||||
type="button"
|
||||
>
|
||||
{#if isFullyDownloaded && !isDownloading}
|
||||
<Check class="h-3 w-3 text-foreground/70" />
|
||||
{/if}
|
||||
|
||||
{#if isFailed && !isDownloading && !isFullyDownloaded}
|
||||
<span
|
||||
class="rounded bg-destructive px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-destructive-foreground"
|
||||
>
|
||||
Failed
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if meta?.variant && meta.variantForm === 'prefix'}
|
||||
<span
|
||||
class="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground"
|
||||
>
|
||||
{meta.variant}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="font-medium">{label}</span>
|
||||
|
||||
{#if meta?.variant && meta.variantForm === 'suffix'}
|
||||
<span
|
||||
class="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground"
|
||||
>
|
||||
{meta.variant}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="text-muted-foreground">
|
||||
{#if isDownloading && downloadProgress && downloadProgress.totalBytes > 0}
|
||||
{Math.round(
|
||||
(downloadProgress.downloadedBytes / downloadProgress.totalBytes) *
|
||||
100
|
||||
)}%
|
||||
{:else}
|
||||
{HuggingFaceService.formatFileSize(file.size ?? 0)}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if isDownloading && downloadProgress}
|
||||
<DownloadProgressBar
|
||||
downloadedBytes={downloadProgress.downloadedBytes}
|
||||
overlay
|
||||
totalBytes={downloadProgress.totalBytes}
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if pendingDownload}
|
||||
<DialogModelDownload
|
||||
bind:open={
|
||||
() => pendingDownload !== null,
|
||||
(v) => {
|
||||
if (!v) pendingDownload = null;
|
||||
}
|
||||
}
|
||||
filePath={pendingDownload.filePath}
|
||||
formattedSize={pendingDownload.sizeBytes !== null
|
||||
? HuggingFaceService.formatFileSize(pendingDownload.sizeBytes)
|
||||
: ''}
|
||||
onCancel={() => (pendingDownload = null)}
|
||||
onConfirm={() => (pendingDownload = null)}
|
||||
quant={pendingDownload.quant}
|
||||
repoId={modelId}
|
||||
variant={pendingDownload.variant}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,352 @@
|
||||
<script lang="ts">
|
||||
import IconFromName from './IconFromName.svelte';
|
||||
import ModelDetail from './ModelDetail.svelte';
|
||||
import { Download, Heart, Sparkles } from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
import { HuggingFaceService, RouterService } from '$lib/services';
|
||||
import type { HfModelInfo } from '$lib/types/huggingface';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
type SortOption = (typeof HuggingFaceService.SORT_OPTIONS)[number];
|
||||
|
||||
interface Props {
|
||||
/** Selected model id from the route (`#/temp/models-hub/<id>`), empty for the list-only view. */
|
||||
modelId?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className, modelId = '' }: Props = $props();
|
||||
|
||||
let trendingModels: HfModelInfo[] = $state([]);
|
||||
let recommendedModels: HfModelInfo[] = $state([]);
|
||||
let searchResults: HfModelInfo[] = $state([]);
|
||||
let searchQuery = $state('');
|
||||
let searchLoading = $state(false);
|
||||
let searchError: string | null = $state(null);
|
||||
let loading = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
let activeFilter = $state<string | null>(null);
|
||||
let sortBy = $state<SortOption>('downloads');
|
||||
let feed = $state<'trending' | 'recommended'>('trending');
|
||||
|
||||
let selectedModelId = $derived(modelId);
|
||||
let isSearching = $derived(searchQuery.trim().length > 0);
|
||||
|
||||
let availableFilters = $derived.by(() => {
|
||||
const counts = new SvelteMap<string, number>();
|
||||
|
||||
for (const m of trendingModels) {
|
||||
if (!m.pipeline_tag) continue;
|
||||
|
||||
counts.set(m.pipeline_tag, (counts.get(m.pipeline_tag) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return Array.from(counts.entries())
|
||||
.map(([tag, count]) => ({ count, tag }))
|
||||
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
||||
});
|
||||
|
||||
let baseModels = $derived(
|
||||
isSearching ? searchResults : feed === 'recommended' ? recommendedModels : trendingModels
|
||||
);
|
||||
|
||||
let sortedModels = $derived.by(() => {
|
||||
const list = [...baseModels];
|
||||
|
||||
list.sort((a, b) => {
|
||||
const key = sortBy === 'lastModified' ? 'createdAt' : sortBy;
|
||||
|
||||
return (
|
||||
((b as unknown as Record<string, number>)[key] ?? 0) -
|
||||
((a as unknown as Record<string, number>)[key] ?? 0)
|
||||
);
|
||||
});
|
||||
|
||||
return list;
|
||||
});
|
||||
|
||||
let filteredModels = $derived.by(() => {
|
||||
let list = sortedModels;
|
||||
|
||||
if (activeFilter) list = list.filter((m) => m.pipeline_tag === activeFilter);
|
||||
|
||||
return list;
|
||||
});
|
||||
|
||||
async function loadInitial() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const [trending, recommended] = await Promise.all([
|
||||
HuggingFaceService.getTrending(100),
|
||||
HuggingFaceService.search({ author: 'ggml-org', limit: 100, sort: 'downloads' })
|
||||
]);
|
||||
|
||||
trendingModels = trending.filter((m) => m.tags.includes('gguf'));
|
||||
recommendedModels = recommended.filter((m) => m.tags.includes('gguf')).slice(0, 12);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to fetch models';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function performSearch(query: string) {
|
||||
const trimmed = query.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
await loadInitial();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
searchLoading = true;
|
||||
searchError = null;
|
||||
|
||||
try {
|
||||
searchResults = await HuggingFaceService.searchByQuery(trimmed, { limit: 100 });
|
||||
} catch (err) {
|
||||
searchError = err instanceof Error ? err.message : 'Search failed';
|
||||
searchResults = [];
|
||||
} finally {
|
||||
searchLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleSearchInput(value: string) {
|
||||
searchQuery = value;
|
||||
|
||||
if (timeout) clearTimeout(timeout);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
performSearch(value);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function openModelDetails(model: HfModelInfo) {
|
||||
goto(RouterService.model(model.id));
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadInitial();
|
||||
|
||||
return () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 flex-col lg:h-full lg:flex-row lg:overflow-hidden {className}">
|
||||
<div
|
||||
class="{selectedModelId
|
||||
? 'hidden lg:flex'
|
||||
: 'flex'} h-full min-h-0 w-full flex-col lg:w-80 lg:shrink-0 lg:border-r lg:border-border/40"
|
||||
>
|
||||
<!-- List header: controls stay pinned while the results scroll below -->
|
||||
<div class="shrink-0 space-y-3 border-b border-border/40 bg-background/80 p-3 backdrop-blur">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h1 class="text-base font-semibold">Models</h1>
|
||||
|
||||
{#if !loading}
|
||||
<span class="text-xs text-muted-foreground">{filteredModels.length}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isSearching}
|
||||
<div class="flex rounded-md border bg-muted/40 p-0.5 text-xs font-medium" role="tablist">
|
||||
<button
|
||||
aria-selected={feed === 'trending'}
|
||||
class="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded px-2 py-1 transition-colors {feed ===
|
||||
'trending'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => (feed = 'trending')}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
Trending
|
||||
</button>
|
||||
|
||||
<button
|
||||
aria-selected={feed === 'recommended'}
|
||||
class="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded px-2 py-1 transition-colors {feed ===
|
||||
'recommended'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => (feed = 'recommended')}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<Sparkles class="h-3 w-3 text-primary" />
|
||||
ggml-org
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SearchInput
|
||||
bind:value={searchQuery}
|
||||
onInput={handleSearchInput}
|
||||
placeholder="Search models..."
|
||||
/>
|
||||
|
||||
{#if !isSearching}
|
||||
<label class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Sort</span>
|
||||
|
||||
<select
|
||||
bind:value={sortBy}
|
||||
class="flex-1 rounded-md border bg-background px-2 py-1 text-xs font-medium text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
{#each HuggingFaceService.SORT_OPTIONS as opt (opt)}
|
||||
{#if opt !== 'trendingScore'}
|
||||
<option value={opt}>{HuggingFaceService.SORT_LABELS[opt]}</option>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if !isSearching && availableFilters.length > 0}
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors {activeFilter ===
|
||||
null
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border bg-background text-muted-foreground hover:border-primary/40 hover:text-foreground'}"
|
||||
onclick={() => (activeFilter = null)}
|
||||
type="button"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
|
||||
{#each availableFilters as f (f.tag)}
|
||||
{@const isActive = activeFilter === f.tag}
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors {isActive
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border bg-background text-muted-foreground hover:border-primary/40 hover:text-foreground'}"
|
||||
onclick={() => (activeFilter = isActive ? null : f.tag)}
|
||||
type="button"
|
||||
>
|
||||
<IconFromName class="h-3 w-3" name={HuggingFaceService.pipelineTagIcon(f.tag)} />
|
||||
{HuggingFaceService.pipelineTagLabel(f.tag)}
|
||||
<span class="text-muted-foreground">{f.count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Scrollable results -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{#if error}
|
||||
<div class="mx-1 rounded-lg border border-destructive/50 bg-destructive/5 p-4 text-center">
|
||||
<p class="text-xs text-destructive">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if searchError}
|
||||
<div class="mx-1 rounded-lg border border-destructive/50 bg-destructive/5 p-4 text-center">
|
||||
<p class="text-xs text-destructive">{searchError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading || searchLoading}
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{isSearching ? 'Searching...' : 'Loading models...'}
|
||||
</p>
|
||||
</div>
|
||||
{:else if filteredModels.length === 0}
|
||||
<div class="py-16 text-center">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{#if isSearching}
|
||||
No models found matching "{searchQuery}".
|
||||
{:else}
|
||||
No models found.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
{#each filteredModels as model (model.id)}
|
||||
{@const isActive = model.id === selectedModelId}
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-start gap-2.5 rounded-lg p-2.5 text-left transition-colors {isActive
|
||||
? 'bg-primary/10 hover:bg-primary/15'
|
||||
: 'hover:bg-muted/60'}"
|
||||
onclick={() => openModelDetails(model)}
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"
|
||||
>
|
||||
<IconFromName
|
||||
class="h-4 w-4"
|
||||
name={HuggingFaceService.pipelineTagIcon(model.pipeline_tag)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate text-sm font-medium">{model.id}</span>
|
||||
|
||||
<span class="shrink-0 text-[10px] text-muted-foreground">
|
||||
{HuggingFaceService.formatRelativeTime(model.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if model.pipeline_tag}
|
||||
<p class="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{HuggingFaceService.pipelineTagLabel(model.pipeline_tag)}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span class="flex items-center gap-1">
|
||||
<Download class="h-3 w-3" />
|
||||
{HuggingFaceService.formatDownloads(model.downloads)}
|
||||
</span>
|
||||
|
||||
<span class="flex items-center gap-1">
|
||||
<Heart class="h-3 w-3" />
|
||||
{HuggingFaceService.formatLikes(model.likes)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: detail pane -->
|
||||
<main
|
||||
class="min-w-0 flex-col {selectedModelId
|
||||
? 'flex'
|
||||
: 'hidden lg:flex'} h-full flex-1 lg:border-l lg:border-border/40"
|
||||
>
|
||||
{#if selectedModelId}
|
||||
<ModelDetail class="h-full" modelId={selectedModelId} />
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-full items-center justify-center px-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted/60">
|
||||
<Download class="h-6 w-6 text-muted-foreground/60" />
|
||||
</div>
|
||||
|
||||
<p class="max-w-xs">
|
||||
Select a model from the list to see its details and download options.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import ModelsHub from '../ModelsHub.svelte';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let modelId = $derived(page.params.modelId ?? '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{modelId ? `${modelId} · llama.cpp` : 'Models · llama.cpp'}</title>
|
||||
</svelte:head>
|
||||
|
||||
<ModelsHub {modelId} />
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ModelsSelectorList from '$lib/components/app/models/ModelsSelectorList.svelte';
|
||||
import ModelsSelectorOption from '$lib/components/app/models/ModelsSelectorOption.svelte';
|
||||
import ModelsSelectorList from '$lib/components/app/models/selector/ModelsSelectorList.svelte';
|
||||
import ModelsSelectorOption from '$lib/components/app/models/selector/ModelsSelectorOption.svelte';
|
||||
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/models/utils';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores/models/index.svelte';
|
||||
@@ -82,26 +82,11 @@
|
||||
|
||||
const groupedOptions: GroupedModelOptions = {
|
||||
available: [
|
||||
{
|
||||
items: [availableModels[0]],
|
||||
orgName: 'deepseek'
|
||||
},
|
||||
{
|
||||
items: [availableModels[1]],
|
||||
orgName: 'google'
|
||||
},
|
||||
{
|
||||
items: [availableModels[2]],
|
||||
orgName: 'microsoft'
|
||||
},
|
||||
{
|
||||
items: [availableModels[3]],
|
||||
orgName: 'codellama'
|
||||
},
|
||||
{
|
||||
items: [availableModels[4]],
|
||||
orgName: 'intel'
|
||||
}
|
||||
availableModels[0],
|
||||
availableModels[1],
|
||||
availableModels[2],
|
||||
availableModels[3],
|
||||
availableModels[4]
|
||||
],
|
||||
favorites: favoriteModels,
|
||||
loaded: loadedModels
|
||||
|
||||
Reference in New Issue
Block a user