diff --git a/tools/ui/package-lock.json b/tools/ui/package-lock.json
index 86d599e671..c38fce566d 100644
--- a/tools/ui/package-lock.json
+++ b/tools/ui/package-lock.json
@@ -12,7 +12,7 @@
"@eslint/compat": "1.4.1",
"@eslint/js": "9.39.2",
"@internationalized/date": "3.12.2",
- "@lucide/svelte": "1.25.0",
+ "@lucide/svelte": "1.46.0",
"@modelcontextprotocol/sdk": "1.30.0",
"@playwright/test": "1.56.1",
"@storybook/addon-a11y": "10.5.6",
@@ -3225,9 +3225,9 @@
}
},
"node_modules/@lucide/svelte": {
- "version": "1.25.0",
- "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz",
- "integrity": "sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==",
+ "version": "1.46.0",
+ "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.46.0.tgz",
+ "integrity": "sha512-Ru8jDjoCVLJ7J7pPRUdY+odSBV2BI6rj68JTmT+ENq/ZwYOPGPAiKVvKz+XLyAoK4HAwlRTzZHpsMvrHfsetqg==",
"dev": true,
"license": "ISC",
"peerDependencies": {
diff --git a/tools/ui/package.json b/tools/ui/package.json
index 4017604741..5e7c05bf39 100644
--- a/tools/ui/package.json
+++ b/tools/ui/package.json
@@ -32,7 +32,7 @@
"@eslint/compat": "1.4.1",
"@eslint/js": "9.39.2",
"@internationalized/date": "3.12.2",
- "@lucide/svelte": "1.25.0",
+ "@lucide/svelte": "1.46.0",
"@modelcontextprotocol/sdk": "1.30.0",
"@playwright/test": "1.56.1",
"@storybook/addon-a11y": "10.5.6",
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte b/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte
new file mode 100644
index 0000000000..1c54fec333
--- /dev/null
+++ b/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte
@@ -0,0 +1,37 @@
+
+
+
+ event.preventDefault()}
+ onOpenAutoFocus={(event) => event.preventDefault()}
+ >
+
+
+
+
+ Models
+
+
+
+
+
+
+
+
diff --git a/tools/ui/src/lib/components/app/dialogs/index.ts b/tools/ui/src/lib/components/app/dialogs/index.ts
index 9b0b62bd08..85db4e8143 100644
--- a/tools/ui/src/lib/components/app/dialogs/index.ts
+++ b/tools/ui/src/lib/components/app/dialogs/index.ts
@@ -537,3 +537,6 @@ export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.
* ```
*/
export { default as DialogMermaidPreview } from './DialogMermaidPreview.svelte';
+
+/** **DialogManageModels** - shell for the models manager. */
+export { default as DialogManageModels } from './DialogManageModels.svelte';
diff --git a/tools/ui/src/lib/components/app/forms/SearchInput.svelte b/tools/ui/src/lib/components/app/forms/SearchInput.svelte
index 99d261208f..674d2f6be2 100644
--- a/tools/ui/src/lib/components/app/forms/SearchInput.svelte
+++ b/tools/ui/src/lib/components/app/forms/SearchInput.svelte
@@ -11,6 +11,8 @@
onClose?: () => void;
onKeyDown?: (event: KeyboardEvent) => void;
class?: string;
+ /** Input height, matching the small size of the other controls. */
+ size?: 'sm' | 'default';
id?: string;
ref?: HTMLInputElement | null;
isCancelAlwaysVisible?: boolean;
@@ -26,6 +28,7 @@
onKeyDown,
placeholder = 'Search...',
ref = $bindable(null),
+ size = 'default',
value = $bindable('')
}: Props = $props();
@@ -58,7 +61,7 @@
bind:ref
bind:value
{autofocus}
- class="pl-9 {showClearButton ? 'pr-9' : ''}"
+ class="pl-9 {showClearButton ? 'pr-9' : ''} {size === 'sm' ? 'h-8' : ''}"
{id}
oninput={handleInput}
onkeydown={onKeyDown}
diff --git a/tools/ui/src/lib/components/app/misc/CollapsibleRegion.svelte b/tools/ui/src/lib/components/app/misc/CollapsibleRegion.svelte
new file mode 100644
index 0000000000..4a7a2ffbcc
--- /dev/null
+++ b/tools/ui/src/lib/components/app/misc/CollapsibleRegion.svelte
@@ -0,0 +1,87 @@
+
+
+
+ {#if contentMounted}
+
+ {@render children()}
+
+ {/if}
+
+
+
diff --git a/tools/ui/src/lib/components/app/misc/CollapsibleSection.svelte b/tools/ui/src/lib/components/app/misc/CollapsibleSection.svelte
new file mode 100644
index 0000000000..68e4e9b7cb
--- /dev/null
+++ b/tools/ui/src/lib/components/app/misc/CollapsibleSection.svelte
@@ -0,0 +1,116 @@
+
+
+{#snippet chevron()}
+
+ {#if open}
+
+ {:else}
+
+ {/if}
+
+{/snippet}
+
+{#snippet triggerButton()}
+ {#if inMenu}
+
+
+ {#snippet child({ props })}
+
+
+ {/snippet}
+
+ {:else}
+
+ {/if}
+{/snippet}
+
+{#snippet region()}
+
+ {#snippet body()}
+ {@render children()}
+ {/snippet}
+
+ {@render body()}
+{/snippet}
+
+{#if triggerPosition === 'top'}
+ {@render triggerButton()}
+
+ {@render region()}
+{:else}
+ {@render region()}
+
+ {@render triggerButton()}
+{/if}
diff --git a/tools/ui/src/lib/components/app/misc/GroupedList.svelte b/tools/ui/src/lib/components/app/misc/GroupedList.svelte
new file mode 100644
index 0000000000..5c8927a107
--- /dev/null
+++ b/tools/ui/src/lib/components/app/misc/GroupedList.svelte
@@ -0,0 +1,167 @@
+
+
+
+
+{#if groups}
+ {#each windowed as entry (entry.key)}
+ {@const expanded = !collapsed.has(entry.key)}
+
+ {#if group}
+
+ {@render group({
+ depth: 0,
+ expanded,
+ group: entry.group,
+ key: entry.key,
+ toggle: () => toggleGroup(entry.key)
+ })}
+
+ {/if}
+
+
+ {#each entry.rows as row (keyOf(row))}
+ {@render item({ depth: 1, entry: row })}
+ {/each}
+
+ {#if entry.hidden > 0 && more}
+ {@render more({
+ count: Math.min(groupWindow, entry.hidden),
+ onMore: () => growGroup(entry.key),
+ unit: 'entries'
+ })}
+ {/if}
+
+ {/each}
+
+ {#if windowed.length < (groups?.length ?? 0) && more}
+ {@render more({
+ count: Math.min(sectionWindow, (groups?.length ?? 0) - windowed.length),
+ onMore: () => (sectionSteps += 1),
+ unit: 'families'
+ })}
+ {/if}
+{:else}
+ {#each flatRows as row (keyOf(row))}
+ {@render item({ depth: 0, entry: row })}
+ {/each}
+
+ {#if flatRows.length < items.length && more}
+ {@render more({
+ count: Math.min(sectionWindow, items.length - flatRows.length),
+ onMore: () => (sectionSteps += 1),
+ unit: 'entries'
+ })}
+ {/if}
+{/if}
diff --git a/tools/ui/src/lib/components/app/misc/index.ts b/tools/ui/src/lib/components/app/misc/index.ts
index a10410ef92..e695f242e4 100644
--- a/tools/ui/src/lib/components/app/misc/index.ts
+++ b/tools/ui/src/lib/components/app/misc/index.ts
@@ -59,3 +59,12 @@ export { default as CodeBlockActions } from './CodeBlockActions.svelte';
* `class` and `style` props and inherits color via `currentColor`.
*/
export { default as Logo } from './Logo.svelte';
+/**
+ * **CollapsibleSection** - Section with a trigger that expands in place
+ *
+ * The trigger can sit above or below the content, and the chevron can stay out
+ * of sight while the section is open. Collapsed content unmounts after the
+ * transition, so hidden rows never reach menu keyboard navigation.
+ */
+export { default as CollapsibleSection } from './CollapsibleSection.svelte';
+export { default as GroupedList, type GroupedListGroup } from './GroupedList.svelte';
diff --git a/tools/ui/src/lib/components/app/models/ModelAvatar.svelte b/tools/ui/src/lib/components/app/models/ModelAvatar.svelte
new file mode 100644
index 0000000000..9dc30338b0
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelAvatar.svelte
@@ -0,0 +1,89 @@
+
+
+{#if orgName}
+ (isNearViewport = true)}
+ class={['inline-flex shrink-0', className]}
+ >
+
+
+{:else}
+ {@render fallback?.()}
+{/if}
diff --git a/tools/ui/src/lib/components/app/models/ModelBadge.svelte b/tools/ui/src/lib/components/app/models/ModelBadge.svelte
index d5b723ff90..53cdcc26a8 100644
--- a/tools/ui/src/lib/components/app/models/ModelBadge.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelBadge.svelte
@@ -1,8 +1,8 @@
+
+ (isNearViewport = true)} class={className}>
+
+
diff --git a/tools/ui/src/lib/components/app/models/ModelCapabilityIcons.svelte b/tools/ui/src/lib/components/app/models/ModelCapabilityIcons.svelte
index c9505a95eb..e98631bd1f 100644
--- a/tools/ui/src/lib/components/app/models/ModelCapabilityIcons.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelCapabilityIcons.svelte
@@ -52,7 +52,7 @@
{/if}
{#if hasModalityIcons && !hideModalities}
-
+
{#if modalities?.vision}
diff --git a/tools/ui/src/lib/components/app/models/ModelContext.svelte b/tools/ui/src/lib/components/app/models/ModelContext.svelte
new file mode 100644
index 0000000000..775925ba8d
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelContext.svelte
@@ -0,0 +1,57 @@
+
+
+ (isNearViewport = true)}
+ class={['text-sm text-muted-foreground', className]}
+>
+ {values.length ? `${values.map((value) => formatParameters(value)).join(' / ')} tokens` : '-'}
+
diff --git a/tools/ui/src/lib/components/app/models/ModelDraftSidecars.svelte b/tools/ui/src/lib/components/app/models/ModelDraftSidecars.svelte
new file mode 100644
index 0000000000..446d9650ac
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelDraftSidecars.svelte
@@ -0,0 +1,25 @@
+
+
+{#each kinds as kind (kind)}
+
+ {kind}
+
+{/each}
diff --git a/tools/ui/src/lib/components/app/models/ModelId.svelte b/tools/ui/src/lib/components/app/models/ModelId.svelte
index ba3a976aef..27f8fbb5c5 100644
--- a/tools/ui/src/lib/components/app/models/ModelId.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelId.svelte
@@ -1,13 +1,15 @@
+
+
+ {#if isLoading}
+
+ {:else}
+
+ {#if showAction && revealOnHover}
+ {#if isFailed}
+
+ {:else}
+
+ {/if}
+ {:else}
+ {#if isFailed}
+
+ {:else}
+
+ {/if}
+ {/if}
+
+
+
+ {#if isFailed}
+
modelsStore.status.load(option.model)}
+ stopPropagationOnClick
+ tooltip="Retry loading model"
+ tooltipAsTitle
+ />
+ {:else if isLoaded || isSleeping}
+ modelsStore.status.unload(option.model)}
+ stopPropagationOnClick
+ tooltip="Unload model"
+ tooltipAsTitle
+ />
+ {:else}
+ modelsStore.status.load(option.model)}
+ stopPropagationOnClick
+ tooltip="Load model"
+ tooltipAsTitle
+ />
+ {/if}
+
+ {/if}
+
diff --git a/tools/ui/src/lib/components/app/models/ModelRowActions.svelte b/tools/ui/src/lib/components/app/models/ModelRowActions.svelte
new file mode 100644
index 0000000000..6218f824a5
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelRowActions.svelte
@@ -0,0 +1,71 @@
+
+
+ event.stopPropagation()}
+ onkeydown={(event) => event.stopPropagation()}
+ role="presentation"
+>
+
uiStore.openModelsManager(option.id)}
+ tooltip="Manage model"
+ tooltipAsTitle
+ />
+
+ {#if isFav}
+
+
+ modelsStore.toggleFavorite(option.model)}
+ tooltip="Remove from favorites"
+ tooltipAsTitle
+ />
+
+
+
+ modelsStore.toggleFavorite(option.model)}
+ tooltip="Remove from favorites"
+ tooltipAsTitle
+ />
+
+
+ {:else}
+ modelsStore.toggleFavorite(option.model)}
+ tooltip="Add to favorites"
+ tooltipAsTitle
+ />
+ {/if}
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte
new file mode 100644
index 0000000000..7171099e74
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte
@@ -0,0 +1,159 @@
+
+
+
+
+ (selectedId = option.id)}
+ {selectedId}
+ {toolbarEnd}
+ />
+
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte
new file mode 100644
index 0000000000..9120ce582d
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte
@@ -0,0 +1,104 @@
+
+
+
+ (contextLimit = Number(value))}
+ type="single"
+ value={String(contextLimit)}
+ >
+
+ Context:
+
+ {contextLabel}
+
+
+
+ {#each CONTEXT_STEPS as step (step.value)}
+ {step.label}
+ {/each}
+
+
+
+
+ {#each TOGGLES as toggle (toggle.value)}
+
+
+
+ {/each}
+
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte
new file mode 100644
index 0000000000..c694b58173
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte
@@ -0,0 +1,87 @@
+
+
+ onSelect(option)}
+ onkeydown={handleKeydown}
+ role="button"
+ tabindex="0"
+>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte
new file mode 100644
index 0000000000..d1f80d29b6
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte
@@ -0,0 +1,419 @@
+
+
+{#snippet sortHeader(key: ModelsTableSortKey, label: string)}
+
+{/snippet}
+
+{#snippet entryTree(entry: ModelQuantGroup, indent = 0)}
+ {#if entry.quants.length > 1}
+ toggleQuants(entry.key)}
+ />
+
+ {#if !collapsedQuants.has(entry.key)}
+ {#each entry.quants as quant (quant.id)}
+
+ {/each}
+ {/if}
+ {:else}
+
+ {/if}
+{/snippet}
+
+{#snippet familyRow({
+ expanded,
+ group: family,
+ toggle
+}: {
+ expanded: boolean;
+ group: ModelFamilyGroup;
+ toggle: () => void;
+})}
+ {@const countLabel = `${family.entries.length} model${family.entries.length === 1 ? '' : 's'}`}
+
+ handleFamilyKeydown(event, toggle)}
+ role="button"
+ tabindex="0"
+ >
+
+
+
+ {family.label}
+
+ {countLabel}
+
+
+
+
+
+
+
+ {#if expanded}
+
+ {:else}
+
+ {/if}
+
+
+{/snippet}
+
+{#snippet listItem({ depth, entry }: { depth: number; entry: ModelQuantGroup })}
+ {@render entryTree(entry, depth > 0 ? 16 : 0)}
+{/snippet}
+
+{#snippet showMore({
+ count,
+ onMore,
+ unit
+}: {
+ count: number;
+ onMore: () => void;
+ unit: 'entries' | 'families';
+})}
+
+
+
+{/snippet}
+
+ (cancelOpen = false)}
+ open={cancelOpen}
+ repoWithTag={pendingCancel}
+/>
+
+ (deleteOpen = false)}
+ open={deleteOpen}
+ repoWithTag={pendingDelete}
+/>
+
+
+
+
+
+ {@render sortHeader(ModelsTableSortKey.NAME, 'Model')}
+
+
+ {@render sortHeader(ModelsTableSortKey.CONTEXT, 'Context')}
+
+
+
+ {@render sortHeader(ModelsTableSortKey.STATUS, 'Status')}
+
+
+ Actions
+
+
+
+ {#if downloadEntries.length > 0}
+
+ {#snippet icon()}
+
+ {/snippet}
+
+ {#each downloadEntries as entry (entry.repoWithTag)}
+
+ {/each}
+
+ {/if}
+
+ {#each sections as group (group.key)}
+ {#if group.items.length > 0}
+ {#snippet groupIcon()}
+ {#if group.kind === ModelsTableGroupKind.FAVORITES}
+
+ {:else if group.kind === ModelsTableGroupKind.LOADED}
+
+ {:else if group.kind === ModelsTableGroupKind.HIDDEN}
+
+ {:else if group.kind === ModelsTableGroupKind.LOCAL}
+
+ {/if}
+ {/snippet}
+
+
+ entry.key}
+ more={showMore}
+ sectionWindow={MODEL_ROW_WINDOW}
+ stickyClass="bg-muted/30 backdrop-blur-lg"
+ stickyStyle="top: calc(2.25rem - 1px)"
+ weightOf={(entry) => entry.quants.length}
+ />
+
+ {/if}
+ {/each}
+
+ {#if isEmpty}
+
+ {hasFilters ? 'No models match these filters.' : 'No models found.'}
+
+ {/if}
+
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerQuantRow.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerQuantRow.svelte
new file mode 100644
index 0000000000..da8ac4821b
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerQuantRow.svelte
@@ -0,0 +1,70 @@
+
+
+ onSelect(option)}
+ onkeydown={handleKeydown}
+ role="button"
+ tabindex="0"
+>
+
+ {quant}
+
+
+
+ {option.model}
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerRepoRow.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerRepoRow.svelte
new file mode 100644
index 0000000000..2693c12caa
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerRepoRow.svelte
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {groupLabel}
+
+
+
+
+
+
+
+
+
+
+ {#if expanded}
+
+ {:else}
+
+ {/if}
+
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte
new file mode 100644
index 0000000000..9f3d2873a4
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte
@@ -0,0 +1,29 @@
+
+
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte
new file mode 100644
index 0000000000..3ec0c6c4eb
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+ {#if hasFilters}
+
+ {/if}
+
+
+ {@render toolbarEnd?.()}
+
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts b/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts
new file mode 100644
index 0000000000..48452d0bac
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts
@@ -0,0 +1,32 @@
+import { Eye, EyeOff, Heart, HeartOff, Trash2 } from '@lucide/svelte';
+import { modelsStore } from '$lib/stores';
+import type { ModelOption } from '$lib/types/models';
+
+/** Row actions follow the app's dropdown pattern: icon, label, separators, variants. */
+export function modelRowActions(
+ option: ModelOption,
+ favorite: boolean,
+ isHidden: boolean,
+ onDelete: (option: ModelOption) => void
+) {
+ return [
+ {
+ icon: favorite ? HeartOff : Heart,
+ label: favorite ? 'Remove from favorites' : 'Add to favorites',
+ onclick: () => modelsStore.toggleFavorite(option.model)
+ },
+ {
+ icon: Trash2,
+ label: 'Delete from disk',
+ onclick: () => onDelete(option),
+ separator: true,
+ variant: 'destructive' as const
+ },
+ {
+ icon: isHidden ? Eye : EyeOff,
+ label: isHidden ? 'Unhide model' : 'Hide model',
+ onclick: () => modelsStore.toggleHidden(option.id),
+ separator: true
+ }
+ ];
+}
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts b/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts
new file mode 100644
index 0000000000..d55c83f73a
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts
@@ -0,0 +1,150 @@
+import { ModelCapability, ServerModelStatus } from '$lib/enums';
+import { HuggingFaceService, ModelsService } from '$lib/services';
+import { modelsStore } from '$lib/stores';
+import type { ModelModalities, ModelOption } from '$lib/types/models';
+import { detectThinkingSupport, detectToolUseSupport } from '$lib/utils';
+
+/** What a group folds, which decides its label. */
+export type ModelGroupKind = 'quants' | 'variants';
+
+/** One repo of the table, with the rows it ships as. */
+export interface ModelQuantGroup {
+ base: ModelOption;
+ key: string;
+ kind: ModelGroupKind;
+ quants: ModelOption[];
+}
+
+/**
+ * Kind of one collapsible block of the manager's table. A plain constant map, so
+ * provider blocks can extend the union with their own string kinds.
+ */
+export const ModelsTableGroupKind = {
+ FAVORITES: 'favorites',
+ HIDDEN: 'hidden',
+ LOADED: 'loaded',
+ LOCAL: 'local'
+} as const;
+
+export type ModelsTableGroupKind = (typeof ModelsTableGroupKind)[keyof typeof ModelsTableGroupKind];
+
+/** Column the manager's table can be ordered by. */
+export const ModelsTableSortKey = {
+ CONTEXT: 'context',
+ NAME: 'name',
+ STATUS: 'status'
+} as const;
+
+export type ModelsTableSortKey = (typeof ModelsTableSortKey)[keyof typeof ModelsTableSortKey];
+
+/** Header label of each manager section. */
+export const MODELS_TABLE_GROUP_LABELS: Record = {
+ [ModelsTableGroupKind.FAVORITES]: 'Favorites',
+ [ModelsTableGroupKind.HIDDEN]: 'Hidden models',
+ [ModelsTableGroupKind.LOADED]: 'Loaded models',
+ [ModelsTableGroupKind.LOCAL]: 'Local models'
+};
+
+/** One collapsible block of the manager's table. */
+export interface ModelsTableGroup {
+ /** One entry per repo, its quants hanging off it. */
+ items: ModelQuantGroup[];
+ key: string;
+ kind: ModelsTableGroupKind;
+ label: string;
+}
+
+/** Modalities a model can accept, as the manager filter offers them. */
+export type ModalityKey = keyof ModelModalities;
+
+/** Modalities the manager filter offers, in display order. */
+export const MODALITY_KEYS: ModalityKey[] = ['vision', 'video', 'audio'];
+
+/** True when the model is loaded (or sleeping) and not mid-operation. */
+export function isModelRunning(option: ModelOption): boolean {
+ const status = modelsStore.getModelStatus(option.model);
+
+ return (
+ (status === ServerModelStatus.LOADED || status === ServerModelStatus.SLEEPING) &&
+ !modelsStore.status.isOperationInProgress(option.model)
+ );
+}
+
+/** Context the model runs with: what a loaded model reports. */
+export function configuredContext(option: ModelOption): number | null {
+ return isModelRunning(option) ? modelsStore.props.getModelContextSize(option.model) : null;
+}
+
+export function modelSupports(option: ModelOption, capability: ModelCapability): boolean {
+ if (option.capabilities.includes(capability)) return true;
+
+ const template = HuggingFaceService.cachedDetails(option.model)?.gguf?.chat_template ?? '';
+
+ return capability === ModelCapability.TOOL_USE
+ ? detectToolUseSupport(template)
+ : detectThinkingSupport(template);
+}
+
+/**
+ * Context a model reports: the provider listing first, then whatever the Hub has
+ * cached for it. 0 means nothing is known yet.
+ */
+export function modelContextLength(option: ModelOption): number {
+ return (
+ option.contextLength ??
+ HuggingFaceService.cachedDetails(option.model)?.gguf?.context_length ??
+ 0
+ );
+}
+
+/** Repo a `repo:quant` id belongs to, the id itself when it carries no quant. */
+export function modelRepoKey(model: string): string {
+ const quant = ModelsService.parseModelId(model).quantization;
+
+ return quant ? model.slice(0, model.lastIndexOf(':')) : model;
+}
+
+/** Fold the quants of one repo into a single entry, so the table shows one row per model. */
+export function groupModelQuants(models: ModelOption[]): ModelQuantGroup[] {
+ const groups = new Map();
+
+ for (const option of models) {
+ const key = modelRepoKey(option.model);
+ const group = groups.get(key);
+
+ if (group) {
+ group.quants.push(option);
+
+ continue;
+ }
+
+ groups.set(key, { base: option, key, kind: 'quants', quants: [option] });
+ }
+
+ return Array.from(groups.values()).flatMap((group) => {
+ const kind = groupKind(group.quants);
+ const modelIds = group.quants.map((option) => option.model);
+
+ // the very same id twice is not a quant set; keep those rows apart
+ if (modelIds.length > 1 && modelIds.every((model) => model === modelIds[0])) {
+ return group.quants.map((option) => ({
+ ...group,
+ base: option,
+ key: `${group.key}::${option.id}`,
+ kind: 'variants' as const,
+ quants: [option]
+ }));
+ }
+
+ return [{ ...group, kind }];
+ });
+}
+
+/** What a group folds: quants or variants of one repo. */
+function groupKind(quants: ModelOption[]): ModelGroupKind {
+ const isQuant = quants.every(
+ (option) => (option.parsedId ?? ModelsService.parseModelId(option.model)).quantization
+ );
+
+ return isQuant ? 'quants' : 'variants';
+}
diff --git a/tools/ui/src/lib/components/app/models/ModelsSection.svelte b/tools/ui/src/lib/components/app/models/ModelsSection.svelte
new file mode 100644
index 0000000000..2427adf1d9
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsSection.svelte
@@ -0,0 +1,100 @@
+
+
+ modelsStore.setGroupOpen(persistKey, open) : null}
+ {revealChevronOnHover}
+ {triggerClass}
+ {triggerStyle}
+>
+ {#snippet trigger()}
+ {#if onBack}
+
+ {/if}
+
+ {#if icon}
+ {@render icon()}
+ {/if}
+
+ {label}
+
+ {#if loading}
+
+ {:else if error}
+
+ {/if}
+
+ {#if count !== undefined}
+ {count}
+ {/if}
+ {/snippet}
+
+ {@render children()}
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte
new file mode 100644
index 0000000000..6ee03f2d79
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
+
+ {#if entry.progress && entry.progress.totalBytes > 0}
+
+ {/if}
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte
index 30dedcbae9..2e279e4edb 100644
--- a/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte
@@ -12,11 +12,13 @@
} from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
- import { MODEL_SELECTOR_ICON, SETTINGS_KEYS } from '$lib/constants';
+ import { MODEL_ID, MODEL_SELECTOR_ICON, SETTINGS_KEYS } from '$lib/constants';
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
+ import { ModelsService } from '$lib/services/models.service';
import { modelsStore, settingsStore } from '$lib/stores';
+ import type { ModelOption, ModelSidecarBadge } from '$lib/types/models';
import { modelLoadFraction } from '$lib/utils';
interface Props {
@@ -66,6 +68,27 @@
const reasoning = useReasoningMenu();
+ /** Draft sidecar as it reads in the trigger tooltip, with its own quant. */
+ function draftSidecarLabel(baseModel: string, badge: ModelSidecarBadge): string {
+ const baseRepo = baseModel.split(MODEL_ID.QUANTIZATION_SEPARATOR)[0];
+
+ // a sidecar of the model's own repo reads as a bare tag, a foreign one keeps its id
+ if (badge.repo === baseRepo) {
+ return `${badge.kind.toUpperCase()}${badge.quant ? `:${badge.quant}` : ''}`;
+ }
+
+ return ModelsService.buildDownloadTag(badge.repo, badge.quant, badge.kind);
+ }
+
+ /** Raw id of the selected model, plus every draft sidecar it pulls. */
+ function triggerTooltipLabel(option: ModelOption): string {
+ const drafts = (option.draftSidecars ?? []).map((badge) =>
+ draftSidecarLabel(option.model, badge)
+ );
+
+ return [option.model, ...drafts].join(' + ');
+ }
+
const showOrgNameInTrigger = $derived(
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER] ?? false
);
@@ -229,8 +252,8 @@
{#if selectedOption}
{:else}
@@ -257,7 +280,7 @@
{#if selectedOption}
- {selectedOption.model}
+ {triggerTooltipLabel(selectedOption)}
{/if}
@@ -274,8 +297,8 @@
{#if selectedOption}
{:else}
@@ -381,8 +404,8 @@
{#if selectedOption}
{/if}
@@ -400,7 +423,7 @@
{#if selectedOption}
- {selectedOption.model}
+ {triggerTooltipLabel(selectedOption)}
{/if}
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte
index cee7d0c3b5..b97de73ca6 100644
--- a/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte
@@ -87,6 +87,7 @@
-
+
{#if orgAvatarFailed}
+ interface Props {
+ downloadedBytes: number;
+ totalBytes: number;
+ 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));
+
+
+{#if overlay}
+
+{:else}
+
+{/if}
diff --git a/tools/ui/src/lib/components/app/models/index.ts b/tools/ui/src/lib/components/app/models/index.ts
index f6a9f1f9ba..6eff6df294 100644
--- a/tools/ui/src/lib/components/app/models/index.ts
+++ b/tools/ui/src/lib/components/app/models/index.ts
@@ -118,3 +118,24 @@ export { default as ModelId } from './ModelId.svelte';
* styling stay consistent across every model-id surface.
*/
export { default as ModelCapabilityIcons } from './ModelCapabilityIcons.svelte';
+/**
+ * **ModelDraftSidecars** - draft sidecar badges
+ *
+ * The `mtp|Q8_0` style badges for the draft sidecars a model pulls with it,
+ * shared by ModelId and the manager's quant rows so they render identically
+ * everywhere.
+ */
+export { default as ModelDraftSidecars } from './ModelDraftSidecars.svelte';
+/**
+ * **ModelId** - Parsed model identifier display
+ *
+ * Displays a model ID with optional org name, parameter badges, quantization,
+ * aliases, and tags. Supports raw mode to show the unprocessed model name.
+ * Respects the user's `showRawModelNames` setting.
+ */
+export { default as ModelCapabilities } from './ModelCapabilities.svelte';
+export { default as ModelContext } from './ModelContext.svelte';
+export { default as ModelAvatar } from './ModelAvatar.svelte';
+export { default as ModelLoadControl } from './ModelLoadControl.svelte';
+export { default as ModelRowActions } from './ModelRowActions.svelte';
+export { default as ModelsSection } from './ModelsSection.svelte';
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
index 8bf12279e3..f2b1faa5a8 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
@@ -5,6 +5,7 @@
import {
ActionIcon,
DialogConversationRename,
+ DialogManageModels,
DialogSettingsChat,
Logo,
SidebarNavigationActions,
@@ -389,6 +390,7 @@
bind:searchQuery
class="px-2"
isExpandedMode={innerWidth > 768 ? uiStore.isSidebarExpanded : true}
+ onManageModelsClick={() => uiStore.openModelsManager()}
onNewChat={() => {
if (deviceStore.isMobile) {
scheduleMobileCollapse();
@@ -452,6 +454,8 @@
+
+