From ea612b4842fff6691141ee9bd4029bab9b3e0060 Mon Sep 17 00:00:00 2001
From: Aleksander Grygier
Date: Mon, 28 Sep 2026 15:07:14 +0200
Subject: [PATCH 01/13] ui : add the models manager
A manage models dialog for the local server: a table that folds each repo's
quants into one row, groups them into families, and sorts from its column
headers. Loaded models lead, then favorites, then the local block, then the
hidden one; sections keep their open state in local storage. The toolbar
filters by capability, modality and context, rows act on favorite, delete
and hide, and the manager opens focused on a model from the sidebar or a
download row. The models store gains the recents, hidden and group-open
state, and warms the Hub records for the context and capability columns.
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
---
tools/ui/package-lock.json | 8 +-
tools/ui/package.json | 2 +-
.../app/dialogs/DialogManageModels.svelte | 37 ++
.../src/lib/components/app/dialogs/index.ts | 3 +
.../components/app/forms/SearchInput.svelte | 5 +-
.../app/misc/CollapsibleRegion.svelte | 87 ++++
.../app/misc/CollapsibleSection.svelte | 116 +++++
.../components/app/misc/GroupedList.svelte | 167 +++++++
tools/ui/src/lib/components/app/misc/index.ts | 9 +
.../components/app/models/ModelAvatar.svelte | 89 ++++
.../components/app/models/ModelBadge.svelte | 4 +-
.../app/models/ModelCapabilities.svelte | 56 +++
.../app/models/ModelCapabilityIcons.svelte | 2 +-
.../components/app/models/ModelContext.svelte | 57 +++
.../app/models/ModelDraftSidecars.svelte | 25 ++
.../lib/components/app/models/ModelId.svelte | 37 +-
.../app/models/ModelLoadControl.svelte | 108 +++++
.../app/models/ModelRowActions.svelte | 71 +++
.../models/ModelsManager/ModelsManager.svelte | 159 +++++++
.../ModelsManager/ModelsManagerFilters.svelte | 104 +++++
.../ModelsManagerModelRow.svelte | 87 ++++
.../ModelsManagerModelsTable.svelte | 419 ++++++++++++++++++
.../ModelsManagerQuantRow.svelte | 70 +++
.../ModelsManager/ModelsManagerRepoRow.svelte | 101 +++++
.../ModelsManagerStatusCell.svelte | 29 ++
.../ModelsManagerTableToolbar.svelte | 84 ++++
.../app/models/ModelsManager/row-actions.ts | 32 ++
.../app/models/ModelsManager/utils.ts | 150 +++++++
.../app/models/ModelsSection.svelte | 100 +++++
.../ModelsSelectorDownloadItem.svelte | 135 ++++++
.../app/models/ModelsSelectorDropdown.svelte | 35 +-
.../app/models/ModelsSelectorOption.svelte | 1 +
.../discover/ModelsDiscoverAvatar.svelte | 2 +-
.../ModelsDiscoverDownloadProgressBar.svelte | 32 ++
.../ui/src/lib/components/app/models/index.ts | 21 +
.../SidebarNavigation.svelte | 4 +
.../SidebarNavigationActions.svelte | 54 ++-
.../lib/components/ui/toggle-group/index.ts | 10 +
.../ui/toggle-group/toggle-group-item.svelte | 35 ++
.../ui/toggle-group/toggle-group.svelte | 75 ++++
.../ui/src/lib/components/ui/toggle/index.ts | 13 +
.../lib/components/ui/toggle/toggle.svelte | 54 +++
.../ui/src/lib/constants/backend.constants.ts | 2 +
.../lib/constants/css-classes.constants.ts | 25 ++
tools/ui/src/lib/constants/icons.constants.ts | 9 +-
tools/ui/src/lib/constants/index.ts | 1 +
.../lib/constants/settings-keys.constants.ts | 2 +
.../src/lib/constants/settings.constants.ts | 14 +
.../ui/src/lib/constants/storage.constants.ts | 12 +
tools/ui/src/lib/constants/ui.constants.ts | 21 +-
tools/ui/src/lib/enums/ui.enums.ts | 1 +
.../src/lib/services/huggingface.service.ts | 160 +++++--
.../ui/src/lib/stores/models/index.svelte.ts | 321 +++++++++++++-
.../ui/src/lib/stores/models/status.svelte.ts | 57 +++
tools/ui/src/lib/stores/ui.svelte.ts | 26 ++
tools/ui/src/lib/types/huggingface.d.ts | 7 +
tools/ui/src/lib/types/models.d.ts | 32 ++
tools/ui/src/lib/utils/index.ts | 6 +-
tools/ui/src/lib/utils/model-families.ts | 41 ++
tools/ui/src/lib/utils/near-viewport.ts | 35 ++
60 files changed, 3341 insertions(+), 120 deletions(-)
create mode 100644 tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte
create mode 100644 tools/ui/src/lib/components/app/misc/CollapsibleRegion.svelte
create mode 100644 tools/ui/src/lib/components/app/misc/CollapsibleSection.svelte
create mode 100644 tools/ui/src/lib/components/app/misc/GroupedList.svelte
create mode 100644 tools/ui/src/lib/components/app/models/ModelAvatar.svelte
create mode 100644 tools/ui/src/lib/components/app/models/ModelCapabilities.svelte
create mode 100644 tools/ui/src/lib/components/app/models/ModelContext.svelte
create mode 100644 tools/ui/src/lib/components/app/models/ModelDraftSidecars.svelte
create mode 100644 tools/ui/src/lib/components/app/models/ModelLoadControl.svelte
create mode 100644 tools/ui/src/lib/components/app/models/ModelRowActions.svelte
create mode 100644 tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte
create mode 100644 tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte
create mode 100644 tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte
create mode 100644 tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte
create mode 100644 tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerQuantRow.svelte
create mode 100644 tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerRepoRow.svelte
create mode 100644 tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte
create mode 100644 tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte
create mode 100644 tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts
create mode 100644 tools/ui/src/lib/components/app/models/ModelsManager/utils.ts
create mode 100644 tools/ui/src/lib/components/app/models/ModelsSection.svelte
create mode 100644 tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDownloadProgressBar.svelte
create mode 100644 tools/ui/src/lib/components/ui/toggle-group/index.ts
create mode 100644 tools/ui/src/lib/components/ui/toggle-group/toggle-group-item.svelte
create mode 100644 tools/ui/src/lib/components/ui/toggle-group/toggle-group.svelte
create mode 100644 tools/ui/src/lib/components/ui/toggle/index.ts
create mode 100644 tools/ui/src/lib/components/ui/toggle/toggle.svelte
create mode 100644 tools/ui/src/lib/constants/backend.constants.ts
create mode 100644 tools/ui/src/lib/utils/model-families.ts
create mode 100644 tools/ui/src/lib/utils/near-viewport.ts
diff --git a/tools/ui/package-lock.json b/tools/ui/package-lock.json
index 86d599e671c5..c38fce566dfd 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 40176047417f..5e7c05bf3983 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 000000000000..1c54fec33345
--- /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 9b0b62bd08ec..85db4e8143ff 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 99d261208fbb..674d2f6be218 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 000000000000..4a7a2ffbcc89
--- /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 000000000000..68e4e9b7cb8e
--- /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 })}
+
+
+ {@render trigger()}
+
+ {@render chevron()}
+
+ {/snippet}
+
+ {:else}
+
+ {@render trigger()}
+
+ {@render chevron()}
+
+ {/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 000000000000..5c8927a107df
--- /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 a10410ef9289..e695f242e476 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 000000000000..9dc30338b06a
--- /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 d5b723ff9039..53cdcc26a8f8 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 c9505a95eb5e..e98631bd1fc6 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 000000000000..775925ba8dfc
--- /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 000000000000..446d9650ac33
--- /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 ba3a976aef8b..27f8fbb5c5f4 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 000000000000..6218f824a53e
--- /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 000000000000..7171099e7473
--- /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 000000000000..9120ce582dd6
--- /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 000000000000..c694b581730e
--- /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 000000000000..d1f80d29b6d4
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte
@@ -0,0 +1,419 @@
+
+
+{#snippet sortHeader(key: ModelsTableSortKey, label: string)}
+ toggleSort(key)}
+ title={sortTitle(key, label)}
+ type="button"
+ >
+ {label}
+
+ {#if sortKey === key}
+ {#if sortAsc}
+
+ {:else}
+
+ {/if}
+ {/if}
+
+{/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';
+})}
+
+
+ Show {count} more {unit === 'families' ? 'families' : 'models'}
+
+
+{/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 000000000000..da8ac4821b92
--- /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 000000000000..2693c12caa09
--- /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 000000000000..9f3d2873a4f4
--- /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 000000000000..3ec0c6c4ebd6
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+ {#if hasFilters}
+
{
+ contextLimit = 0;
+ modalities = [];
+ capabilities = [];
+ }}
+ size="sm"
+ variant="ghost"
+ >
+
+
+ Clear filters
+
+ {/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 000000000000..48452d0bac49
--- /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 000000000000..d55c83f73a2b
--- /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 000000000000..2427adf1d91c
--- /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}
+ {
+ // the surrounding trigger toggles the section, the back control
+ // must not collapse the list it is leaving
+ event.stopPropagation();
+ onBack?.();
+ }}
+ type="button"
+ >
+
+
+ {/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 000000000000..6ee03f2d79b2
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte
@@ -0,0 +1,135 @@
+
+
+
+
+
{
+ if (entry.isPaused) void modelsStore.status.downloadModel(entry.repoWithTag).catch(() => {});
+ else void modelsStore.status.pauseDownload(entry.repoWithTag);
+ }}
+ type="button"
+ >
+ {#if orgName}
+
+ {/if}
+
+
+
+ {#if percent !== null}
+ {percent}%
+ {:else if entry.isPaused}
+ Paused
+ {/if}
+
+
+
+ {#if entry.isPaused}
+
+ {:else}
+
+
+
+ {/if}
+
+
+
+
uiStore.openModelsManager(entry.repoWithTag)}
+ type="button"
+ >
+
+
+
+
onRequestCancel?.(entry.repoWithTag)}
+ type="button"
+ >
+
+
+
+ {#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 30dedcbae90a..2e279e4edb37 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 cee7d0c3b57c..b97de73ca6db 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 f6a9f1f9ba6d..6eff6df29422 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 8bf12279e3c7..f2b1faa5a88a 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 @@
+
+
',
+ noscript: '">',
+ script: '',
+ 'srcset javascript': ' ',
+ 'style tag': '',
+ 'svg foreignObject smuggling':
+ ' ',
+ 'svg set-attribute animation': ' ',
+ 'unclosed tag': ' ',
+ 'video source onerror': ' ',
+ 'xlink href': ' '
+};
+
+describe('SAFE_HTML_CONFIG adversarial battery', () => {
+ it('neutralizes every payload after sanitize and after innerHTML re-parse', () => {
+ const failures: string[] = [];
+
+ for (const [name, payload] of Object.entries(PAYLOADS)) {
+ const clean = DOMPurify.sanitize(payload, SAFE_HTML_CONFIG) as string;
+ const again = roundTrip(clean);
+
+ if (HANDLER_ATTR.test(clean) || HANDLER_ATTR.test(again)) {
+ failures.push(`${name}: handler attribute survived: ${again}`);
+ }
+
+ if (JS_URL.test(clean) || JS_URL.test(again)) {
+ failures.push(`${name}: javascript: URL survived: ${again}`);
+ }
+ }
+
+ expect(failures).toEqual([]);
+ });
+
+ it('keeps benign model-card markup intact', () => {
+ const clean = DOMPurify.sanitize(
+ '
Title Text with a link , , code,
, x^2
',
+ SAFE_HTML_CONFIG
+ ) as string;
+
+ expect(clean).toContain('href="https://example.com"');
+ expect(clean).toContain(' {
+ it('renders an allowHtml payload README without executing it', async () => {
+ const evil = [
+ '# Evil card',
+ '',
+ ' ',
+ 'x ',
+ '',
+ '',
+ ' ',
+ '',
+ 'normal **markdown** continues'
+ ].join('\n');
+ const target = document.createElement('div');
+
+ document.body.appendChild(target);
+ mount(MarkdownContent, { props: { allowHtml: true, content: evil }, target });
+
+ for (let i = 0; i < 100 && !target.textContent?.includes('normal'); i++) {
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ await new Promise((r) => setTimeout(r, 200));
+
+ expect(target.textContent).toContain('normal');
+ expect(xssFired()).toBe(false);
+ expect(target.querySelector('script')).toBeNull();
+ expect(target.querySelector('iframe')).toBeNull();
+
+ target.remove();
+ });
+
+ it('escapes raw HTML to literal text in the default mode', async () => {
+ const target = document.createElement('div');
+
+ document.body.appendChild(target);
+ const component = mount(MarkdownContent, {
+ props: {
+ content: ' '
+ },
+ target
+ });
+
+ for (let i = 0; i < 100 && !target.textContent?.includes('onerror'); i++) {
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ await new Promise((r) => setTimeout(r, 200));
+
+ expect(xssFired()).toBe(false);
+ expect(target.querySelector('img[src="x"]')).toBeNull();
+ expect(target.textContent).toContain('onerror');
+
+ target.remove();
+
+ if (component) unmount(component);
+ });
+});
From 5d87573a3bdf3d4bdb09c00cd51aae9921c7017e Mon Sep 17 00:00:00 2001
From: Aleksander Grygier
Date: Mon, 28 Sep 2026 15:15:25 +0200
Subject: [PATCH 04/13] ui : add the discover models components
The Discover surface: the curated-catalog list with its search and
skeletons, the details pane with its readme, metadata, Hub stats and
download options, and the standalone download progress bar that replaces
the plain one. The quant download button sizes its select with a new xs
trigger, so the select gains that size and the muted box look. The store
stays incomplete when a repo fails, so the next mount retries it.
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
---
.../discover/DownloadProgressBar.svelte | 32 --
.../app/models/discover/ModelsDiscover.svelte | 138 +++++++++
.../ModelsDiscoverChatTemplateDialog.svelte | 67 +++++
.../ModelsDiscoverDetails.svelte | 96 ++++++
...odelsDiscoverDetailsDownloadOptions.svelte | 173 +++++++++++
...tailsDownloadOptionsDownloadCommand.svelte | 278 +++++++++++++++++
...sDownloadOptionsQuantDownloadButton.svelte | 244 +++++++++++++++
...lsDiscoverDetailsDownloadOptionsRow.svelte | 60 ++++
.../download-options.utils.ts | 28 ++
.../index.ts | 40 +++
.../ModelsDiscoverDetailsHeader.svelte | 94 ++++++
.../ModelsDiscoverDetailsHfHubStats.svelte | 31 ++
.../ModelsDiscoverDetailsMetadata.svelte | 80 +++++
.../ModelsDiscoverDetailsMetadataItem.svelte | 17 ++
.../ModelsDiscoverDetailsReadme.svelte | 17 ++
.../ModelsDiscoverDetailsSkeleton.svelte | 93 ++++++
.../discover/ModelsDiscoverDetails/index.ts | 72 +++++
.../ModelsDiscoverDownloadProgressBar.svelte | 4 +-
.../ModelsDiscoverList.svelte | 41 +++
.../ModelsDiscoverListItem.svelte | 129 ++++++++
.../ModelsDiscoverListItemSkeleton.svelte | 39 +++
.../ModelsDiscoverListSearch.svelte | 26 ++
.../discover/ModelsDiscoverList/index.ts | 38 +++
.../components/app/models/discover/index.ts | 39 +++
.../ui/select/select-content.svelte | 4 +-
.../ui/select/select-trigger.svelte | 14 +-
tools/ui/src/lib/constants/index.ts | 1 +
.../models-discover-download.constants.ts | 54 ++++
.../constants/models-discover.constants.ts | 6 +
tools/ui/src/lib/enums/index.ts | 8 +-
tools/ui/src/lib/enums/model.enums.ts | 10 +
tools/ui/src/lib/stores/index.ts | 3 +
.../stores/models-discover/index.svelte.ts | 282 ++++++++++++++++++
tools/ui/src/lib/types/index.ts | 8 +
tools/ui/src/lib/types/models-discover.d.ts | 30 ++
35 files changed, 2255 insertions(+), 41 deletions(-)
delete mode 100644 tools/ui/src/lib/components/app/models/discover/DownloadProgressBar.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscover.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverChatTemplateDialog.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetails.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptions.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsDownloadCommand.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsRow.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/download-options.utils.ts
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/index.ts
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsHeader.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsHfHubStats.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsMetadata.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsMetadataItem.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsReadme.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsSkeleton.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/index.ts
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverList.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListItem.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListItemSkeleton.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListSearch.svelte
create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/index.ts
create mode 100644 tools/ui/src/lib/components/app/models/discover/index.ts
create mode 100644 tools/ui/src/lib/constants/models-discover-download.constants.ts
create mode 100644 tools/ui/src/lib/stores/models-discover/index.svelte.ts
create mode 100644 tools/ui/src/lib/types/models-discover.d.ts
diff --git a/tools/ui/src/lib/components/app/models/discover/DownloadProgressBar.svelte b/tools/ui/src/lib/components/app/models/discover/DownloadProgressBar.svelte
deleted file mode 100644
index 6f4973c12ed1..000000000000
--- a/tools/ui/src/lib/components/app/models/discover/DownloadProgressBar.svelte
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-{#if overlay}
-
-{:else}
-
-{/if}
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscover.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscover.svelte
new file mode 100644
index 000000000000..01b4e90e0a9d
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscover.svelte
@@ -0,0 +1,138 @@
+
+
+
+
+
+ {#if selectedId}
+
+ {/if}
+
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverChatTemplateDialog.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverChatTemplateDialog.svelte
new file mode 100644
index 000000000000..45eb209a2af6
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverChatTemplateDialog.svelte
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+ Chat template
+
+ void copy()}
+ type="button"
+ >
+ {#if copied}
+
+ {:else}
+
+ {/if}
+
+ Copy
+
+
+
+
+
+
+
+ {chatTemplate}
+
+
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetails.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetails.svelte
new file mode 100644
index 000000000000..cecdda738c26
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetails.svelte
@@ -0,0 +1,96 @@
+
+
+{#if loading}
+
+{:else if error}
+
+{:else if details}
+
+
+
+
+
+
+
+{/if}
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptions.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptions.svelte
new file mode 100644
index 000000000000..75ecef320ed8
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptions.svelte
@@ -0,0 +1,173 @@
+
+
+{#if bitDepthRows.length}
+
+
+
+ {#each rows as row (row.bitDepth)}
+
+ {/each}
+
+
+
+
+
+
+
+{/if}
+
+ (confirmOpen = false)}
+ open={confirmOpen}
+ repoWithTag={pending.repoWithTag}
+/>
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsDownloadCommand.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsDownloadCommand.svelte
new file mode 100644
index 000000000000..e380505f383c
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsDownloadCommand.svelte
@@ -0,0 +1,278 @@
+
+
+
+
+
+
+
+
{SERVE_COMMAND.BIN}
+
+
{SERVE_COMMAND.SUBCOMMAND}
+
+
{SERVE_COMMAND.MODEL_FLAG}
+
+
+ {modelId}{mainQuant ? MODEL_ID.QUANTIZATION_SEPARATOR : ''}
+
+
+
+ {#if baseOption}
+
v && (basePick = v)} type="single" value={baseOption.path}>
+
+ {baseOption.label}
+
+
+
+ {#each mainOptions as option (option.path)}
+
+ {option.label}
+
+ {/each}
+
+
+ {/if}
+
+
+ {#if draftOptions.length && !withDraft}
+
(withDraft = true)}
+ type="button"
+ >
+
+
+ add draft model
+
+ {/if}
+
+
+ {#if draftOption}
+
+ {SERVE_COMMAND.DRAFT_FLAG}
+
+
+ {modelId}{draftQuant ? MODEL_ID.QUANTIZATION_SEPARATOR : ''}
+
+
+ v && (draftPick = v)}
+ type="single"
+ value={draftOption.path}
+ >
+
+ {draftOption.label}
+
+
+
+ {#each typeDraftOptions as option (option.path)}
+
+ {option.label}
+
+ {/each}
+
+
+
+ {#if draftType}
+ {SERVE_COMMAND.SPEC_TYPE_FLAG}
+
+
+ {#if specTypes.length > 1}
+ v && (draftTypePick = v as ModelSidecar)}
+ type="single"
+ value={draftType}
+ >
+
+ {SPEC_TYPE[draftType]}
+
+
+
+ {#each specTypes as type (type)}
+
+ {SPEC_TYPE[type]}
+
+ {/each}
+
+
+ {:else}
+ {SPEC_TYPE[draftType]}
+ {/if}
+ {/if}
+
+ (withDraft = false)}
+ type="button"
+ >
+
+
+
+ {/if}
+
+
+
+ {#if copied}
+
+ {:else}
+
+ {/if}
+
+
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton.svelte
new file mode 100644
index 000000000000..2908ea97a34e
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton.svelte
@@ -0,0 +1,244 @@
+
+
+{#snippet chipBody(dividerClass: string)}
+
+ {#if meta?.sidecar}
+
+ {meta.sidecar}
+
+ {/if}
+
+ {label}
+
+
+{/snippet}
+
+
+{#snippet tooltipTrigger(tip: string, button: Snippet<[Record]>)}
+
+
+ {#snippet child({ props })}
+ {@render button(props)}
+ {/snippet}
+
+
+
+ {tip}
+
+
+{/snippet}
+
+{#snippet deleteChip(props: Record)}
+
+ onRequestDelete?.(entry.repoWithTag)}
+ type="button"
+ >
+ {@render chipBody(
+ '-my-1 mx-0.75 w-px self-stretch bg-green-600/25 transition-colors duration-200 group-hover:bg-destructive/30 dark:bg-green-600/30 dark:group-hover:bg-destructive/30'
+ )}
+
+ {HuggingFaceService.formatFileSize(file.size ?? 0)}
+
+
+
+
+
+
+
+
+{/snippet}
+
+{#snippet pauseResumeChip(props: Record)}
+ {
+ if (entry.isDownloading) void modelsStore.status.pauseDownload(entry.repoWithTag);
+ else void modelsStore.status.downloadModel(entry.repoWithTag).catch(() => {});
+ }}
+ type="button"
+ >
+ {@render chipBody('-my-1 mx-0.75 w-px self-stretch bg-border')}
+
+ {#if percent !== null}
+ {percent}%
+ {:else if entry.isPaused}
+ Paused
+ {:else}
+ {HuggingFaceService.formatFileSize(file.size ?? 0)}
+ {/if}
+
+ {#if entry.isDownloading}
+
+
+
+
+
+
+ {:else}
+
+
+
+
+ {/if}
+
+{/snippet}
+
+{#snippet cancelChip(props: Record)}
+
+ onRequestCancel?.(entry.repoWithTag)}
+ type="button"
+ >
+
+
+{/snippet}
+
+{#snippet downloadChip(props: Record)}
+
+ void startDownload()}
+ type="button"
+ >
+ {#if entry.isFailed}
+
+ Failed
+
+ {/if}
+
+ {@render chipBody('-my-1 mx-0.75 w-px self-stretch bg-border')}
+
+ {HuggingFaceService.formatFileSize(file.size ?? 0)}
+
+
+ {#if entry.isFailed}
+
+ {:else}
+
+ {/if}
+
+
+{/snippet}
+
+{#if entry.isDownloaded}
+ {@render tooltipTrigger(tooltipText, deleteChip)}
+{:else if entry.isDownloading || entry.isPaused}
+
+
+ {@render tooltipTrigger(tooltipText, pauseResumeChip)}
+
+ {@render tooltipTrigger('Cancel downloading', cancelChip)}
+
+ {#if percent !== null}
+
+ {/if}
+
+{:else}
+ {@render tooltipTrigger(tooltipText, downloadChip)}
+{/if}
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsRow.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsRow.svelte
new file mode 100644
index 000000000000..11bfe45d42e9
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsRow.svelte
@@ -0,0 +1,60 @@
+
+
+
+
+ {#if bitDepth === OTHER_BIT_DEPTH}
+ {OTHER_BIT_DEPTH_LABEL}
+ {:else}
+ {bitDepth}{BIT_DEPTH_LABEL_SUFFIX}
+ {/if}
+
+ {#if mainMemGb}
+
+ needs at least {mainMemGb}{GIGABYTE_LABEL}{draftMemGb
+ ? ` + ${draftMemGb}${GIGABYTE_LABEL}`
+ : ''} memory
+
+ {/if}
+
+
+
+ {#each files as file (file.path)}
+
+ {/each}
+
+
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/download-options.utils.ts b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/download-options.utils.ts
new file mode 100644
index 000000000000..4dd8accb42bf
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/download-options.utils.ts
@@ -0,0 +1,28 @@
+import { DRAFT_FILE_LABEL, MODEL_ID, PATH_SEPARATOR } from '$lib/constants';
+import { ModelSelectableFileKind } from '$lib/enums';
+import { HuggingFaceService } from '$lib/services';
+import { isAuxSidecar, isDraftSidecar } from '$lib/utils';
+
+/** Kind of a file path: the main weights, a draft sidecar, or an aux sidecar (mmproj). */
+export function classify(path: string): ModelSelectableFileKind {
+ const sidecar = HuggingFaceService.extractQuantMeta(path)?.sidecar;
+
+ if (!sidecar) return ModelSelectableFileKind.MAIN;
+
+ return isAuxSidecar(sidecar) ? ModelSelectableFileKind.AUX : ModelSelectableFileKind.DRAFT;
+}
+
+/** Display label of a file: its quant, else the file name without the extension. */
+export function labelFor(path: string): string {
+ const meta = HuggingFaceService.extractQuantMeta(path);
+
+ if (meta?.quant) return meta.quant;
+
+ // Quantless sidecar files: the chip badge already carries the sidecar type,
+ // so the label only marks draft files; aux sidecars badge alone.
+ if (meta?.sidecar) return isDraftSidecar(meta.sidecar) ? DRAFT_FILE_LABEL : '';
+
+ const basename = path.split(PATH_SEPARATOR).pop() ?? path;
+
+ return basename.replace(MODEL_ID.WEIGHT_EXTENSION_REGEX, '');
+}
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/index.ts b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/index.ts
new file mode 100644
index 000000000000..b5c0ebef678e
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/index.ts
@@ -0,0 +1,40 @@
+/**
+ *
+ * MODELS DISCOVER - DETAILS - DOWNLOAD OPTIONS
+ *
+ * The download area of the detail pane: GGUF files grouped by bit depth, each an
+ * independent download action chip, plus the standalone terminal command preview.
+ *
+ */
+
+/**
+ * **ModelsDiscoverDetailsDownloadOptions** - GGUF download options
+ *
+ * Groups GGUF files by bit depth and renders one independent download
+ * action chip per file, plus the standalone terminal command preview.
+ */
+export { default as ModelsDiscoverDetailsDownloadOptions } from './ModelsDiscoverDetailsDownloadOptions.svelte';
+
+/**
+ * **ModelsDiscoverDetailsDownloadOptionsRow** - One bit-depth group of quants
+ *
+ * A single bit-depth row inside ModelsDiscoverDetailsDownloadOptions: the
+ * depth label with its memory hint and the quant chips of that depth.
+ */
+export { default as ModelsDiscoverDetailsDownloadOptionsRow } from './ModelsDiscoverDetailsDownloadOptionsRow.svelte';
+
+/**
+ * **ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton** - One quant chip
+ *
+ * A single GGUF file as an independent action chip: download / retry when
+ * idle, pause / resume / cancel while in flight, delete when downloaded.
+ */
+export { default as ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton } from './ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton.svelte';
+
+/**
+ * **ModelsDiscoverDetailsDownloadOptionsDownloadCommand** - Terminal command
+ *
+ * The `llama serve -hf ...` command box with inline quant selects and a copy
+ * button; owns its picks, nothing two-way binds them to the quant chips.
+ */
+export { default as ModelsDiscoverDetailsDownloadOptionsDownloadCommand } from './ModelsDiscoverDetailsDownloadOptionsDownloadCommand.svelte';
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsHeader.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsHeader.svelte
new file mode 100644
index 000000000000..a21628343652
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsHeader.svelte
@@ -0,0 +1,94 @@
+
+
+
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsHfHubStats.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsHfHubStats.svelte
new file mode 100644
index 000000000000..b848a9bfeb35
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsHfHubStats.svelte
@@ -0,0 +1,31 @@
+
+
+
+ {#if typeof details.downloads === 'number'}
+
+
+ {HuggingFaceService.formatDownloads(details.downloads)}
+
+ {/if}
+
+ {#if typeof details.likes === 'number'}
+
+
+ {HuggingFaceService.formatLikes(details.likes)}
+
+ {/if}
+
+ {#if details.lastModified}
+ Updated {HuggingFaceService.formatRelativeTime(details.lastModified)}
+ {/if}
+
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsMetadata.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsMetadata.svelte
new file mode 100644
index 000000000000..9f0184ac9fd4
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsMetadata.svelte
@@ -0,0 +1,80 @@
+
+
+{#if description}
+ {description}
+{/if}
+
+
+
+ {#if gguf?.total}
+
+ {/if}
+
+ {#if gguf?.context_length}
+
+ {/if}
+
+ {#if gguf?.architecture}
+
+ {/if}
+
+ {#if gguf?.chat_template}
+ (chatTemplateOpen = true)}
+ type="button"
+ >
+
+ Chat template
+
+ {/if}
+
+ {#if licenseTag}
+
+ {/if}
+
+ {#if details.gated === true}
+
+ gated
+
+ {/if}
+
+
+{#if gguf?.chat_template}
+
+{/if}
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsMetadataItem.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsMetadataItem.svelte
new file mode 100644
index 000000000000..90c3c4c74fbb
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsMetadataItem.svelte
@@ -0,0 +1,17 @@
+
+
+
+
+ {label}
+
+ {value}
+
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsReadme.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsReadme.svelte
new file mode 100644
index 000000000000..fbf043b79712
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsReadme.svelte
@@ -0,0 +1,17 @@
+
+
+{#if readme}
+
+{/if}
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsSkeleton.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsSkeleton.svelte
new file mode 100644
index 000000000000..030d90fa587b
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsSkeleton.svelte
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#each [0, 1, 2] as _, index (index)}
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/index.ts b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/index.ts
new file mode 100644
index 000000000000..2420bd5ef278
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/index.ts
@@ -0,0 +1,72 @@
+/**
+ *
+ * MODELS DISCOVER - DETAILS
+ *
+ * The right-hand detail pane of the discover view: header (avatar, name, stats, metadata
+ * chips, capability badges), the download options area and the model-card README.
+ *
+ */
+
+/**
+ * **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';
+
+/**
+ * **ModelsDiscoverDetailsHfHubStats** - HuggingFace Hub stats row
+ *
+ * Downloads, likes and last-updated for the viewed model, formatted the way
+ * the Hub shows them.
+ */
+export { default as ModelsDiscoverDetailsHfHubStats } from './ModelsDiscoverDetailsHfHubStats.svelte';
+
+/**
+ * **ModelsDiscoverDetailsSkeleton** - Detail view loading skeleton
+ *
+ * Static placeholder matching the detail layout: header with avatar and name,
+ * metadata chips, the download options box and readme text lines.
+ */
+export { default as ModelsDiscoverDetailsSkeleton } from './ModelsDiscoverDetailsSkeleton.svelte';
+
+/**
+ * **ModelsDiscoverDetailsReadme** - Detail view README
+ *
+ * Renders the model card README as markdown.
+ */
+export { default as ModelsDiscoverDetailsReadme } from './ModelsDiscoverDetailsReadme.svelte';
+
+/**
+ * **ModelsDiscoverDetailsMetadata** - Detail view metadata row
+ *
+ * The detail view's metadata chips (model size, context, architecture, license,
+ * chat template).
+ */
+export { default as ModelsDiscoverDetailsMetadata } from './ModelsDiscoverDetailsMetadata.svelte';
+
+/**
+ * **ModelsDiscoverDetailsMetadataItem** - Single metadata chip
+ *
+ * One label | value chip of the detail view's metadata row (model size,
+ * context, architecture, license).
+ */
+export { default as ModelsDiscoverDetailsMetadataItem } from './ModelsDiscoverDetailsMetadataItem.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';
+
+export * from './ModelsDiscoverDetailsDownloadOptions';
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDownloadProgressBar.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDownloadProgressBar.svelte
index c3a89c0ba45c..6f4973c12ed1 100644
--- a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDownloadProgressBar.svelte
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDownloadProgressBar.svelte
@@ -18,14 +18,14 @@
{#if overlay}
{:else}
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverList.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverList.svelte
new file mode 100644
index 000000000000..5ebff7230d32
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverList.svelte
@@ -0,0 +1,41 @@
+
+
+
+ {#if loading}
+ {#each Array(loadingSkeletonRowsCount) as _, index (index)}
+
+ {/each}
+ {:else}
+ {#each models as model (model.id)}
+
+ {/each}
+ {/if}
+
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListItem.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListItem.svelte
new file mode 100644
index 000000000000..3a496fd22f30
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListItem.svelte
@@ -0,0 +1,129 @@
+
+
+
+ onSelect?.(model.id)}
+ type="button"
+ >
+
+
+
+
+
+
+
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListItemSkeleton.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListItemSkeleton.svelte
new file mode 100644
index 000000000000..851abc523860
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListItemSkeleton.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+ {#each badgeWidths as width, i (i)}
+
+ {/each}
+
+
+
+
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListSearch.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListSearch.svelte
new file mode 100644
index 000000000000..dce949a0f600
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListSearch.svelte
@@ -0,0 +1,26 @@
+
+
+
+ handleInput(v)} {placeholder} size="sm" />
+
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/index.ts b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/index.ts
new file mode 100644
index 000000000000..d888343a15cb
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/index.ts
@@ -0,0 +1,38 @@
+/**
+ *
+ * MODELS DISCOVER - LIST
+ *
+ * The discover sidebar column: a debounced search field above a navigable list of
+ * HuggingFace GGUF models, with skeleton rows while loading.
+ *
+ */
+
+/**
+ * **ModelsDiscoverList** - Sidebar model list
+ *
+ * Renders the discover model list as a navigable column. Each row links to the
+ * model's detail and highlights the active one.
+ */
+export { default as ModelsDiscoverList } from './ModelsDiscoverList.svelte';
+
+/**
+ * **ModelsDiscoverListSearch** - Sidebar search input
+ *
+ * Debounced search field for the model list.
+ */
+export { default as ModelsDiscoverListSearch } from './ModelsDiscoverListSearch.svelte';
+
+/**
+ * **ModelsDiscoverListItem** - Single sidebar row
+ *
+ * One model entry in the discover sidebar list, selectable via `onSelect`.
+ */
+export { default as ModelsDiscoverListItem } from './ModelsDiscoverListItem.svelte';
+
+/**
+ * **ModelsDiscoverListItemSkeleton** - Skeleton sidebar row
+ *
+ * Pulsing placeholder matching a ModelsDiscoverListItem row, shown while the
+ * list is loading.
+ */
+export { default as ModelsDiscoverListItemSkeleton } from './ModelsDiscoverListItemSkeleton.svelte';
diff --git a/tools/ui/src/lib/components/app/models/discover/index.ts b/tools/ui/src/lib/components/app/models/discover/index.ts
new file mode 100644
index 000000000000..5926b7515362
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/index.ts
@@ -0,0 +1,39 @@
+/**
+ *
+ * MODELS DISCOVER
+ *
+ * Components for the Models Discover view: a sidebar search + list of
+ * HuggingFace GGUF models and a detail view for the selected model, used as the
+ * body of the discovery dialog. The list and detail trees live in their own
+ * subfolders; this barrel re-exports them alongside the shared leaves.
+ *
+ */
+
+/**
+ * **ModelsDiscover** - Models discover 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';
+
+/**
+ * **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. Shared by the list,
+ * the detail header and the model selector rows.
+ */
+export { default as ModelsDiscoverAvatar } from './ModelsDiscoverAvatar.svelte';
+
+/**
+ * **ModelsDiscoverDownloadProgressBar** - Thin download progress bar
+ *
+ * Normalizes bytes to a 0..100% bar; can pin to the bottom edge as an overlay.
+ * Shared by the quant chips and the model selector's download rows.
+ */
+export { default as ModelsDiscoverDownloadProgressBar } from './ModelsDiscoverDownloadProgressBar.svelte';
+
+export * from './ModelsDiscoverList';
+export * from './ModelsDiscoverDetails';
diff --git a/tools/ui/src/lib/components/ui/select/select-content.svelte b/tools/ui/src/lib/components/ui/select/select-content.svelte
index f8792f8a9d3d..a8263819a16f 100644
--- a/tools/ui/src/lib/components/ui/select/select-content.svelte
+++ b/tools/ui/src/lib/components/ui/select/select-content.svelte
@@ -88,7 +88,7 @@
{@render children?.()}
diff --git a/tools/ui/src/lib/components/ui/select/select-trigger.svelte b/tools/ui/src/lib/components/ui/select/select-trigger.svelte
index 9f642819ec56..de3abcdf2125 100644
--- a/tools/ui/src/lib/components/ui/select/select-trigger.svelte
+++ b/tools/ui/src/lib/components/ui/select/select-trigger.svelte
@@ -11,20 +11,26 @@
variant = 'default',
...restProps
}: WithoutChild & {
- size?: 'sm' | 'default';
+ size?: 'xs' | 'sm' | 'default';
variant?: 'default' | 'plain';
} = $props();
+ // Super small trigger: fits its selected value, for dense inline use.
+ const xsClasses =
+ "group flex h-6 w-fit items-center justify-between gap-1 rounded-md border border-border/30 bg-muted/60 px-2 py-0 text-xs whitespace-nowrap outline-none select-none transition-colors hover:bg-muted/80 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground dark:border-border/20 dark:bg-muted/75 dark:hover:bg-muted [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='text-'])]:text-muted-foreground";
+
const baseClasses = $derived(
variant === 'plain'
? "group inline-flex w-full items-center justify-end gap-2 whitespace-nowrap px-0 py-0 text-sm font-medium text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3 [&_svg:not([class*='text-'])]:text-muted-foreground"
- : "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground"
+ : size === 'xs'
+ ? xsClasses
+ : "flex w-fit items-center justify-between gap-2 rounded-md border border-border/30 bg-muted/60 px-3 py-2 text-sm whitespace-nowrap shadow-sm transition-colors outline-none select-none hover:bg-muted/80 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:border-border/20 dark:bg-muted/75 dark:hover:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground"
);
const chevronClasses = $derived(
- variant === 'plain'
+ variant === 'plain' || size === 'xs'
? 'size-3 opacity-60 transition-transform group-data-[state=open]:-rotate-180'
- : 'size-4 opacity-50'
+ : 'size-3.5 opacity-60 transition-transform group-data-[state=open]:-rotate-180'
);
diff --git a/tools/ui/src/lib/constants/index.ts b/tools/ui/src/lib/constants/index.ts
index 04d5ab779da8..6412511736b9 100644
--- a/tools/ui/src/lib/constants/index.ts
+++ b/tools/ui/src/lib/constants/index.ts
@@ -47,6 +47,7 @@ export * from './path-display.constants';
export * from './model-id.constants';
export * from './model-loading.constants';
export * from './models-discover.constants';
+export * from './models-discover-download.constants';
export * from './model-compatibility.constants';
export * from './huggingface.constants';
export * from './precision.constants';
diff --git a/tools/ui/src/lib/constants/models-discover-download.constants.ts b/tools/ui/src/lib/constants/models-discover-download.constants.ts
new file mode 100644
index 000000000000..e38dd1efbe18
--- /dev/null
+++ b/tools/ui/src/lib/constants/models-discover-download.constants.ts
@@ -0,0 +1,54 @@
+/**
+ * Models Discover - download options constants.
+ *
+ * Shared values backing the download-options area of the details pane: the
+ * serve `--spec-type` mapping, the bit-depth buckets, and the standalone
+ * command builder. Kept here (not component-local) so they sit with the rest
+ * of the discover domain constants.
+ */
+
+import type { ModelSidecar } from '$lib/constants';
+import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
+
+/**
+ * `--spec-type` value for each draft sidecar; aux sidecars (mmproj, imatrix)
+ * carry none and stay empty.
+ */
+export const SPEC_TYPE: Record = {
+ [ModelAuxSidecar.IMATRIX]: '',
+ [ModelAuxSidecar.MMPROJ]: '',
+ [ModelDraftSidecar.DFLASH]: 'draft-dflash',
+ [ModelDraftSidecar.DSPARK]: 'draft-dspark',
+ [ModelDraftSidecar.EAGLE3]: 'draft-eagle3',
+ [ModelDraftSidecar.MTP]: 'draft-mtp'
+};
+
+/** Bit-depth bucket for files that carry no quant token; rendered as "Other". */
+export const OTHER_BIT_DEPTH = 99;
+
+/** Label for the OTHER_BIT_DEPTH bucket. */
+export const OTHER_BIT_DEPTH_LABEL = 'Other';
+
+/** Suffix appended after a bit-depth number to label a row, e.g. `4-bit`. */
+export const BIT_DEPTH_LABEL_SUFFIX = '-bit';
+
+/**
+ * Fixed tokens of the standalone `llama serve` command preview, so the command
+ * builder and its rendered spans share one source of the CLI spelling.
+ */
+export const SERVE_COMMAND = {
+ BIN: 'llama',
+ /** Draft model repo:tag argument (download sidecar / draft weights). */
+ DRAFT_FLAG: '-hfd',
+ /** Main model repo:tag argument. */
+ MODEL_FLAG: '-hf',
+ /** Speculative-decoding type argument for the draft. */
+ SPEC_TYPE_FLAG: '--spec-type',
+ SUBCOMMAND: 'serve'
+} as const;
+
+/** Bit depth preferred for the command's default base quant. */
+export const DEFAULT_BASE_BIT_DEPTH = 4;
+
+/** Label shown for a draft-sidecar chip whose quant is unknown. */
+export const DRAFT_FILE_LABEL = 'draft';
diff --git a/tools/ui/src/lib/constants/models-discover.constants.ts b/tools/ui/src/lib/constants/models-discover.constants.ts
index f2a6a9463cf6..b0a0abc568d7 100644
--- a/tools/ui/src/lib/constants/models-discover.constants.ts
+++ b/tools/ui/src/lib/constants/models-discover.constants.ts
@@ -6,3 +6,9 @@
/** llama.app model catalog used as the default model list. Online-only source; the discover feature requires an internet connection anyway. */
export const MODELS_DISCOVER_CATALOG_URL = 'https://llama.app/v1/catalog.json';
+
+/**
+ * Catalog repos fetched in parallel per batch; small on purpose so the HF API
+ * is not hit with the whole catalog at once.
+ */
+export const MODELS_DISCOVER_CATALOG_BATCH = 4;
diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts
index 583187d9641d..6b529b66e4b9 100644
--- a/tools/ui/src/lib/enums/index.ts
+++ b/tools/ui/src/lib/enums/index.ts
@@ -69,7 +69,13 @@ export {
JsonSchemaType
} from './mcp.enums';
-export { ModelAuxSidecar, ModelCapability, ModelDraftSidecar, ModelModality } from './model.enums';
+export {
+ ModelAuxSidecar,
+ ModelCapability,
+ ModelDraftSidecar,
+ ModelModality,
+ ModelSelectableFileKind
+} from './model.enums';
export { ModelDownloadConfirmAction, ModelDownloadStopRequest } from './model.enums';
diff --git a/tools/ui/src/lib/enums/model.enums.ts b/tools/ui/src/lib/enums/model.enums.ts
index f2a0cc355241..0b54ec1631a1 100644
--- a/tools/ui/src/lib/enums/model.enums.ts
+++ b/tools/ui/src/lib/enums/model.enums.ts
@@ -36,6 +36,16 @@ export enum ModelAuxSidecar {
MMPROJ = 'mmproj'
}
+/**
+ * Role of a selectable GGUF in the download options: the main weights, a
+ * speculative-decoding draft sidecar, or an auxiliary sidecar (mmproj).
+ */
+export enum ModelSelectableFileKind {
+ AUX = 'aux',
+ DRAFT = 'draft',
+ MAIN = 'main'
+}
+
/**
* Why an in-flight download is being stopped, so the terminal `download_failed`
* feed event can be attributed: a user pause (resumable) or a user cancel
diff --git a/tools/ui/src/lib/stores/index.ts b/tools/ui/src/lib/stores/index.ts
index b571699072bb..fcab8a806431 100644
--- a/tools/ui/src/lib/stores/index.ts
+++ b/tools/ui/src/lib/stores/index.ts
@@ -40,6 +40,9 @@ export { mcpStore } from './mcp/index.svelte';
// MODELS
export { modelsStore } from './models/index.svelte';
+// MODELS DISCOVER (HuggingFace browse state for the discover dialog)
+export { modelsDiscoverStore } from './models-discover/index.svelte';
+
// SERVER
export { serverStore } from './server.svelte';
diff --git a/tools/ui/src/lib/stores/models-discover/index.svelte.ts b/tools/ui/src/lib/stores/models-discover/index.svelte.ts
new file mode 100644
index 000000000000..28cc66f00ded
--- /dev/null
+++ b/tools/ui/src/lib/stores/models-discover/index.svelte.ts
@@ -0,0 +1,282 @@
+/**
+ * modelsDiscoverStore - Models Discover browse state
+ *
+ * Owns the HuggingFace GGUF model list shown in the discover sidebar
+ * (the Discover tab of DialogManageModels). By default the list is the curated catalog set;
+ * search replaces it with matches across all of HuggingFace. Both paths fetch
+ * the same fields, so a row renders the same badges and sizes either way.
+ */
+
+import { MODELS_DISCOVER_CATALOG_BATCH } from '$lib/constants';
+import { HuggingFaceService } from '$lib/services';
+import type {
+ HfCatalogBuild,
+ HfCatalogEntry,
+ HfModelInfo,
+ HfModelSibling
+} from '$lib/types/huggingface';
+import { isAuxSidecar } from '$lib/utils';
+import { SvelteMap } from 'svelte/reactivity';
+
+/** Min/max GGUF file size (bytes) across the quants of one repo. */
+export interface ModelsDiscoverSizeRange {
+ max: number;
+ min: number;
+}
+
+class ModelsDiscoverStore {
+ error = $state(null);
+ models = $state([]);
+ /** First model in the list - discover auto-opens this one. */
+ firstModel = $derived(this.models[0] ?? null);
+
+ loading = $state(false);
+ /** True while a search is in flight; only the newest request owns this flag. */
+ searching = $state(false);
+
+ private catalog: HfCatalogEntry[] = [];
+ /** Repo id -> size range, for catalog rows and lazily measured search rows. */
+ private catalogSizeRanges = new SvelteMap();
+ private defaultModels: HfModelInfo[] = [];
+ private fetched = false;
+ private searchRequestId = 0;
+ /** In-flight `sizeRange()` lookups, keyed by repo id. */
+ private sizeRangePending = new Map>();
+
+ /**
+ * Cached size range for a repo, without measuring: the synchronous part of
+ * `sizeRange()`, for rendering a row before its measurement resolves.
+ */
+ cachedSizeRangeFor(modelId: string): ModelsDiscoverSizeRange | undefined {
+ return this.catalogSizeRanges.get(modelId);
+ }
+
+ /**
+ * 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, one repo per catalog
+ * size in display order (newest family first). Every repo is fetched by ID
+ * with its file tree, so the rows carry chat-template capabilities, context
+ * length and a real size range - the same data a search result gets.
+ * No-op when already loaded or in flight.
+ */
+ async fetch(): Promise {
+ if (this.loading || this.fetched) return;
+
+ this.loading = true;
+ this.error = null;
+
+ try {
+ const catalog = await HuggingFaceService.getCatalog();
+
+ this.catalog = catalog;
+
+ const builds = this.catalogBuilds();
+ const failedBuilds: HfCatalogBuild[] = [];
+
+ this.defaultModels = [];
+
+ // fetch in small batches: not every repo hits the HF API at once,
+ // and the rows land as they arrive instead of all at the end
+ for (let i = 0; i < builds.length; i += MODELS_DISCOVER_CATALOG_BATCH) {
+ const batch = await Promise.all(
+ builds.slice(i, i + MODELS_DISCOVER_CATALOG_BATCH).map(async (build) => {
+ const [info, tree] = await Promise.all([
+ HuggingFaceService.getDetails(build.repo),
+ HuggingFaceService.getTree(build.repo)
+ ]);
+
+ return { build, info, tree };
+ })
+ );
+
+ for (const { build, info, tree } of batch) {
+ if (!info) {
+ failedBuilds.push(build);
+
+ continue;
+ }
+
+ this.catalogSizeRanges.set(build.repo, this.sizeRangeFor(build, tree));
+
+ // the catalog repo id, not the HF response `id`, drives selection
+ this.defaultModels.push({ ...info, id: build.repo, modelId: build.repo } as HfModelInfo);
+ }
+
+ // show what has landed, unless a search owns the list right now
+ if (!this.searching) {
+ this.models = [...this.defaultModels];
+ }
+ }
+
+ // stay incomplete when some repos failed, so the next mount retries
+ // them; succeeded repos are served from the service cache for free
+ this.fetched = failedBuilds.length === 0;
+ } catch (err) {
+ this.error = err instanceof Error ? err.message : 'Failed to fetch models';
+ } finally {
+ this.loading = false;
+ }
+ }
+
+ /** Replace the list with search results; an empty query restores the default list. */
+ async search(query: string): Promise {
+ const trimmed = query.trim();
+ const requestId = ++this.searchRequestId;
+
+ if (!trimmed) {
+ this.searching = false;
+ this.models = this.defaultModels;
+ this.error = null;
+
+ return;
+ }
+
+ this.searching = true;
+
+ try {
+ const results = await HuggingFaceService.searchByQuery(trimmed, { limit: 50 });
+
+ if (requestId !== this.searchRequestId) return;
+
+ this.models = results;
+ this.error = null;
+ } catch (err) {
+ if (requestId !== this.searchRequestId) return;
+
+ this.error = err instanceof Error ? err.message : 'Search failed';
+ } finally {
+ if (requestId === this.searchRequestId) this.searching = false;
+ }
+ }
+
+ /**
+ * Size range for a repo not measured yet - a search result, or a catalog
+ * repo whose tree came back empty. Fetches the file tree once per repo and
+ * caches it, so remounting a row (scrolling, searching back) is free.
+ */
+ sizeRange(modelId: string): Promise {
+ const cached = this.catalogSizeRanges.get(modelId);
+
+ if (cached) return Promise.resolve(cached);
+
+ const pending = this.sizeRangePending.get(modelId);
+
+ if (pending) return pending;
+
+ const request = (async () => {
+ const tree = await HuggingFaceService.getTree(modelId);
+ const range = this.sizeRangeOfMainQuants(tree);
+
+ if (range) this.catalogSizeRanges.set(modelId, range);
+
+ return range;
+ })()
+ .catch(() => undefined)
+ .finally(() => this.sizeRangePending.delete(modelId));
+
+ this.sizeRangePending.set(modelId, request);
+
+ return request;
+ }
+
+ /**
+ * Bytes of every quant the catalog lists under this repo, parsed from the
+ * `size` strings when a build carries no `sizeBytes`. Never empty: an
+ * unparsable entry contributes the build's own size.
+ */
+ private buildSizeBytes(build: HfCatalogBuild): number[] {
+ const sizes = this.catalog
+ .flatMap((entry) => entry.sizes)
+ .flatMap((size) => size.builds.filter((b) => b.repo === build.repo))
+ .map((b) => b.sizeBytes ?? HuggingFaceService.parseSizeBytes(b.size))
+ .filter((bytes): bytes is number => Boolean(bytes) && bytes > 0);
+
+ return sizes.length > 0 ? sizes : [build.sizeBytes ?? 0];
+ }
+
+ /**
+ * One build per catalog size, newest family first (by release date).
+ * Prefers the official ggml-org repo, falling back to the first build so
+ * families published only by other orgs (mistralai, unsloth) still show up.
+ * Returns an empty array when the catalog is empty.
+ */
+ private catalogBuilds(): HfCatalogBuild[] {
+ return [...this.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/')) ?? size.builds[0];
+
+ return build ? [build] : [];
+ })
+ );
+ }
+
+ /** Byte sizes of every non-sidecar quant file in a tree, shards collapsed. */
+ private quantSizesOf(tree: HfModelSibling[]): number[] {
+ return HuggingFaceService.collapseGgufShards(
+ HuggingFaceService.filterByExtension(tree, '.gguf')
+ )
+ .filter((f) => {
+ const { quant, sidecar } = HuggingFaceService.extractQuantMeta(f.path) ?? {};
+
+ return Boolean(quant) && (sidecar === null || sidecar === undefined);
+ })
+ .map((f) => f.size ?? 0)
+ .filter((size) => size > 0);
+ }
+
+ /**
+ * Size range of one catalog build: every quant in the repo's file tree, plus
+ * the draft sidecars those files carry (mtp, dflash, ...) so the downloaded
+ * model fits within the range. Falls back to the catalog `size` / `sizeBytes`
+ * strings when the tree yielded nothing (partial fetch, sharded-only repo).
+ */
+ private sizeRangeFor(build: HfCatalogBuild, tree: HfModelSibling[]): ModelsDiscoverSizeRange {
+ const quantSizes = this.quantSizesOf(tree);
+
+ if (quantSizes.length === 0) {
+ const listed = this.buildSizeBytes(build);
+
+ return { max: Math.max(...listed), min: Math.min(...listed) };
+ }
+
+ const draftSizes = tree
+ .filter((f) => {
+ const sidecar = HuggingFaceService.extractQuantMeta(f.path)?.sidecar;
+
+ return sidecar !== null && sidecar !== undefined && !isAuxSidecar(sidecar);
+ })
+ .map((f) => f.size ?? 0)
+ .filter((size) => size > 0);
+ const extra = draftSizes.length > 0 ? Math.max(...draftSizes) : 0;
+
+ return {
+ max: Math.max(...quantSizes) + extra,
+ min: Math.min(...quantSizes) + (draftSizes.length > 0 ? Math.min(...draftSizes) : 0)
+ };
+ }
+
+ /**
+ * Size range across the main-model quants of a file tree, draft sidecars
+ * excluded (they only widen the range when a row advertises them).
+ */
+ private sizeRangeOfMainQuants(tree: HfModelSibling[]): ModelsDiscoverSizeRange | undefined {
+ const sizes = this.quantSizesOf(tree);
+
+ if (sizes.length === 0) return undefined;
+
+ return { max: Math.max(...sizes), min: Math.min(...sizes) };
+ }
+}
+
+export const modelsDiscoverStore = new ModelsDiscoverStore();
diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts
index 453af9bb804e..7f35a1363346 100644
--- a/tools/ui/src/lib/types/index.ts
+++ b/tools/ui/src/lib/types/index.ts
@@ -111,6 +111,14 @@ export type {
ModalityCapabilities
} from './models';
+// Models discover types
+export type {
+ ModelBitDepthRow,
+ ModelDownloadEntryState,
+ ModelQuantOption,
+ ModelSelectableFile
+} from './models-discover';
+
// Settings types
export type {
SettingsConfigValue,
diff --git a/tools/ui/src/lib/types/models-discover.d.ts b/tools/ui/src/lib/types/models-discover.d.ts
new file mode 100644
index 000000000000..f9812fea8536
--- /dev/null
+++ b/tools/ui/src/lib/types/models-discover.d.ts
@@ -0,0 +1,30 @@
+import type { ModelSelectableFileKind } from '$lib/enums';
+import type { HfModelSibling } from '$lib/types/huggingface';
+
+/**
+ * Option of a quant `` in the download-options command builder; the
+ * picks only compose the serve command and are not bound to the quant chips.
+ */
+export interface ModelQuantOption {
+ /** Quant token, or the file name when the file carries no quant (e.g. BF16). */
+ label: string;
+ /** Repo-relative file path the option stands for. */
+ path: string;
+}
+
+/** Download state of a single repo entry, injected by the integration layer. */
+export interface ModelDownloadEntryState {
+ /** Server identifier the entry's actions (pause / resume / cancel / retry) target. */
+ repoWithTag: string;
+ isDownloading: boolean;
+ progress: ModelDownloadProgress | null;
+ isDownloaded: boolean;
+ isPaused: boolean;
+ isFailed: boolean;
+}
+
+/** A group of GGUF files sharing one bit-depth bucket. */
+export type ModelBitDepthRow = { bitDepth: number; files: HfModelSibling[] };
+
+/** A selectable GGUF, tagged with its role: main weights, draft, or aux (mmproj). */
+export type ModelSelectableFile = HfModelSibling & { kind: ModelSelectableFileKind };
From 245e9cf4c1780f711dc74700f52a0a7176f3c47f Mon Sep 17 00:00:00 2001
From: Aleksander Grygier
Date: Mon, 28 Sep 2026 15:16:22 +0200
Subject: [PATCH 05/13] ui : put the discover view in the models manager
The manager dialog gains its second view: the Discover Models call to
action fades the table out, slides the title with its compass mark, and
fades the discover surface in; the arrow button returns to the table.
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
---
.../app/dialogs/DialogManageModels.svelte | 140 +++++++++++++++++-
.../lib/components/ui/button/button.svelte | 4 +-
tools/ui/src/lib/stores/ui.svelte.ts | 6 +
3 files changed, 143 insertions(+), 7 deletions(-)
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte b/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte
index 1c54fec33345..0b12310034c6 100644
--- a/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte
+++ b/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte
@@ -1,7 +1,12 @@
-{#if isRouter}
+{#if isSelectable}
{
+ onModelChange={async (modelId: string, modelName: string, backendId?: string) => {
+ // capability of the picked model's own backend, not the active one
+ const loadsOnRequest = getBackendCapabilities(getBackend(backendId)).loadUnload;
const status = modelsStore.getModelStatus(modelId);
- if (status !== ServerModelStatus.LOADED) {
+ // only a llama.cpp server loads up front; remote backends load the
+ // model with the request itself
+ if (loadsOnRequest && status !== ServerModelStatus.LOADED) {
pendingModel = modelId;
try {
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
index 6cea95d0d09a..95780596ee6f 100644
--- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
@@ -13,6 +13,7 @@
} from '$lib/components/app';
import { LANDING_SETTLE_MAX_MS, LANDING_STABLE_FRAMES, ROUTES } from '$lib/constants';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
+ import { useBackendAvailability } from '$lib/hooks/use-backend-availability.svelte';
import { useChatScreenActiveModel } from '$lib/hooks/use-chat-screen-active-model.svelte';
import { useChatScreenDragAndDrop } from '$lib/hooks/use-chat-screen-drag-and-drop.svelte';
import { useChatScreenFileUpload } from '$lib/hooks/use-chat-screen-file-upload.svelte';
@@ -44,8 +45,10 @@
showCenteredEmpty && conversationsStore.activeMessages.length === 0 && !chatStore.isLoading
);
let activeErrorDialog = $derived(chatStore.errorDialogState);
- let isServerLoading = $derived(serverStore.loading);
- let hasPropsError = $derived(!!serverStore.error);
+ // no local server in this deployment: never block on its probe
+ let isServerLoading = $derived(serverStore.loading && !serverStore.localServerMissing);
+ const availability = useBackendAvailability();
+ let hasPropsError = $derived(availability.isOffline);
let isCurrentConversationLoading = $derived(chatStore.isLoading || chatStore.isStreaming());
let chatFormBottomPosition = $derived.by(() => {
if (!deviceStore.isMobile) return '1rem';
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte
index 5af00ebb479b..b4d951392b94 100644
--- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte
@@ -17,7 +17,7 @@
Hello there
- {serverStore.props?.modalities?.audio ? 'Record audio, type a message ' : 'Type a message'} or upload
- files to get started
+ {serverStore.localProps?.modalities?.audio ? 'Record audio, type a message ' : 'Type a message'} or
+ upload files to get started
diff --git a/tools/ui/src/lib/components/app/server/ServerErrorSplash.svelte b/tools/ui/src/lib/components/app/server/ServerErrorSplash.svelte
index e8154e5ce22c..41af51129850 100644
--- a/tools/ui/src/lib/components/app/server/ServerErrorSplash.svelte
+++ b/tools/ui/src/lib/components/app/server/ServerErrorSplash.svelte
@@ -1,13 +1,13 @@
+
+
+
+
+
+ {#snippet fallback()}
+ {#if isLocal}
+
+ {:else}
+
+ {/if}
+ {/snippet}
+
+
+
+
{backend.name}
+
+
{displayUrl}
+
+
+
+
onToggle?.(value)}
+ />
+
+
+ {#if isOffline && modelsState.error}
+
+
+ {modelsState.error}
+
+
+
+ {modelsState.error}
+
+
+ {/if}
+
+
+
+ {#if isChecking}
+
+
+ Checking
+ {:else if isOnline}
+
+
+ {modelCount === 1 ? '1 model' : `${modelCount} models`}
+ {:else if isOffline}
+
+
+ Unreachable
+
+
+
+
+ {/if}
+
+ {#if hasStatus}
+ ·
+ {/if}
+
+ {metaText}
+
+
+ {#if !isLocal}
+
+
onEdit?.()}
+ size="icon"
+ variant="ghost"
+ >
+
+
+
+
(showDelete = true)}
+ size="icon"
+ variant="ghost"
+ >
+
+
+
+ {/if}
+
+
+
+ (showDelete = false)}
+ onConfirm={() => {
+ showDelete = false;
+ onDelete?.();
+ }}
+ title="Delete backend?"
+ variant="destructive"
+/>
diff --git a/tools/ui/src/lib/components/app/backends/BackendForm.svelte b/tools/ui/src/lib/components/app/backends/BackendForm.svelte
new file mode 100644
index 000000000000..28f2114e841d
--- /dev/null
+++ b/tools/ui/src/lib/components/app/backends/BackendForm.svelte
@@ -0,0 +1,190 @@
+
+
+
+
+
+ Base URL *
+
+
+
onChange({ baseUrl: e.currentTarget.value })}
+ placeholder="https://api.example.com"
+ type="url"
+ value={backend.baseUrl}
+ />
+
+ {#if urlError}
+
{urlError}
+ {:else if detectionLabel}
+
{detectionLabel}
+ {/if}
+
+
+
+
+
+ Display name
+
+
+ onChange({ name: e.currentTarget.value })}
+ placeholder="Name shown in the model selector"
+ type="text"
+ value={backend.name}
+ />
+
+
+
+
+ API key{#if apiKeyRequired} * {/if}
+
+
+
onChange({ apiKey: e.currentTarget.value || undefined })}
+ placeholder="Optional"
+ type="password"
+ value={backend.apiKey ?? ''}
+ />
+
+
+ {#if apiKeyRequired}
+ Required by this endpoint.
+ {:else}
+ Sent as a Bearer token.
+ {/if}
+
+
+
+
+
+
+ {#if showAdvanced}
+
+ {:else}
+
+ {/if}
+
+ Advanced
+
+
+
+
+
+
API format
+
+
+ onChange({ compat: undefined, protocol: value as BackendProtocol })}
+ type="single"
+ value={backend.protocol}
+ >
+ {protocolLabel}
+
+
+ {#each PROTOCOL_OPTIONS as option (option.value)}
+ {option.label}
+ {/each}
+
+
+
+
+ Detected from the endpoint. Override it when a server answers unusually.
+
+
+
+
+
+
+
+
diff --git a/tools/ui/src/lib/components/app/backends/BackendIcon.svelte b/tools/ui/src/lib/components/app/backends/BackendIcon.svelte
new file mode 100644
index 000000000000..d9ed53a518b8
--- /dev/null
+++ b/tools/ui/src/lib/components/app/backends/BackendIcon.svelte
@@ -0,0 +1,37 @@
+
+
+{#if preset}
+
+{:else if faviconUrl && !failedFavicons.has(faviconUrl)}
+ failedFavicons.add(faviconUrl)}
+ src={faviconUrl}
+ />
+{:else}
+ {@render fallback?.()}
+{/if}
diff --git a/tools/ui/src/lib/components/app/backends/BackendPresetCard.svelte b/tools/ui/src/lib/components/app/backends/BackendPresetCard.svelte
new file mode 100644
index 000000000000..bcdc400cb477
--- /dev/null
+++ b/tools/ui/src/lib/components/app/backends/BackendPresetCard.svelte
@@ -0,0 +1,41 @@
+
+
+
+ {#if added}
+
+
+ Added
+
+ {/if}
+
+
+
+
+
{preset.name}
+
+
+ {preset.description}
+
diff --git a/tools/ui/src/lib/components/app/backends/BackendPresetIcon.svelte b/tools/ui/src/lib/components/app/backends/BackendPresetIcon.svelte
new file mode 100644
index 000000000000..dccb9ff583cb
--- /dev/null
+++ b/tools/ui/src/lib/components/app/backends/BackendPresetIcon.svelte
@@ -0,0 +1,34 @@
+
+
+{#if iconUrl}
+
+{:else}
+
+{/if}
diff --git a/tools/ui/src/lib/components/app/backends/DialogBackendForm.svelte b/tools/ui/src/lib/components/app/backends/DialogBackendForm.svelte
new file mode 100644
index 000000000000..9cf2b627f3ff
--- /dev/null
+++ b/tools/ui/src/lib/components/app/backends/DialogBackendForm.svelte
@@ -0,0 +1,356 @@
+
+
+
+
+
+
+ {isEdit ? 'Edit Provider' : 'Add New Provider'}
+
+
+
+ Point at another llama-server, or connect an OpenAI-compatible endpoint.
+
+
+
+ {#if !isEdit && presetsToShow.length > 0}
+
+
+
Recommended providers
+
+ writeRecommendationsDismissed(true)}
+ size="sm"
+ variant="ghost">Dismiss
+
+
+
+ {#each presetsToShow as preset (preset.id)}
+ applyPreset(preset)}
+ {preset}
+ selected={selectedPresetId === preset.id}
+ />
+ {/each}
+
+
+ {/if}
+
+
+
+
diff --git a/tools/ui/src/lib/components/app/backends/index.ts b/tools/ui/src/lib/components/app/backends/index.ts
new file mode 100644
index 000000000000..529bc7fc3649
--- /dev/null
+++ b/tools/ui/src/lib/components/app/backends/index.ts
@@ -0,0 +1,6 @@
+export { default as BackendCard } from './BackendCard.svelte';
+export { default as BackendForm } from './BackendForm.svelte';
+export { default as BackendIcon } from './BackendIcon.svelte';
+export { default as BackendPresetCard } from './BackendPresetCard.svelte';
+export { default as BackendPresetIcon } from './BackendPresetIcon.svelte';
+export { default as DialogBackendForm } from './DialogBackendForm.svelte';
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte b/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte
index 0b12310034c6..a466ad053336 100644
--- a/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte
+++ b/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte
@@ -1,7 +1,8 @@
+
+
+
void backendsModelsStore.loadAll()}
+ />
+
+ {#if showsLocal && !serverStore.localServerMissing}
+ backendsStore.setLocalEnabled(enabled)}
+ />
+ {/if}
+
+ {#each backends as backend (backend.id)}
+ {
+ backendsStore.removeBackend(backend.id);
+ void backendsModelsStore.loadAll();
+ }}
+ onEdit={() => handleEdit(backend)}
+ onToggle={(enabled) => {
+ backendsStore.updateBackend(backend.id, { enabled });
+ void backendsModelsStore.loadAll();
+ }}
+ />
+ {/each}
+
+
+
+
+
+ {addTitle}
+
+
+
{addDescription}
+
+
diff --git a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte
index 879390075988..a12882114247 100644
--- a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte
+++ b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte
@@ -47,12 +47,17 @@
let localConfig: SettingsConfigType = $state({ ...settingsStore.config });
+ // Snapshot used to persist only the fields edited in this dialog. Config keys
+ // that other surfaces write directly (backends, MCP servers) must not be
+ // clobbered by the dialog's save.
+ let baseline: SettingsConfigType = $state({ ...settingsStore.config });
+
let mobileHeader: { updateCarousel: () => void } | undefined;
let fetchInitiated = false;
$effect(() => {
- if (serverStore.isRouterMode && currentSection.fields?.length && !fetchInitiated) {
+ if (serverStore.localIsRouter && currentSection.fields?.length && !fetchInitiated) {
fetchInitiated = true;
void modelsStore
@@ -74,6 +79,7 @@
function handleReset() {
localConfig = { ...settingsStore.config };
+ baseline = { ...settingsStore.config };
setMode(localConfig.theme as ColorMode);
mobileHeader?.updateCarousel();
}
@@ -94,7 +100,13 @@
}
}
- const processedConfig = { ...localConfig };
+ const processedConfig: SettingsConfigType = {};
+
+ for (const [key, value] of Object.entries(localConfig)) {
+ if (value !== baseline[key]) {
+ processedConfig[key] = value;
+ }
+ }
for (const field of NUMERIC_FIELDS) {
if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
@@ -126,6 +138,7 @@
export function reset() {
localConfig = { ...settingsStore.config };
+ baseline = { ...settingsStore.config };
}
diff --git a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte
index e8795d7c5204..f77b8089ccfa 100644
--- a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte
+++ b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte
@@ -25,7 +25,7 @@
let currentModelParams = $derived.by(() => {
void modelsStore.props.cacheVersion;
- if (serverStore.isRouterMode) {
+ if (serverStore.localIsRouter) {
const currentModelName = modelsStore.selectedModelName;
if (currentModelName) {
diff --git a/tools/ui/src/lib/components/app/settings/index.ts b/tools/ui/src/lib/components/app/settings/index.ts
index 7473e6af8e42..899e34392d2d 100644
--- a/tools/ui/src/lib/components/app/settings/index.ts
+++ b/tools/ui/src/lib/components/app/settings/index.ts
@@ -74,3 +74,8 @@ export { default as SettingsChatFields } from './SettingsChat/SettingsChatFields
* server favicons and permission management controls.
*/
export { default as SettingsChatToolsTab } from './SettingsChat/SettingsChatToolsTab.svelte';
+
+/**
+ * Providers panel for chat settings. Switches between the llama.cpp and
+ * OpenAI-compatible protocols and lists the endpoints configured for each.
+ */
diff --git a/tools/ui/src/lib/components/ui/tabs/index.ts b/tools/ui/src/lib/components/ui/tabs/index.ts
new file mode 100644
index 000000000000..95bbb367635a
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/tabs/index.ts
@@ -0,0 +1,18 @@
+import Root from './tabs.svelte';
+import Content from './tabs-content.svelte';
+import List, { type TabsListVariant, tabsListVariants } from './tabs-list.svelte';
+import Trigger from './tabs-trigger.svelte';
+
+export {
+ Root,
+ Content,
+ List,
+ Trigger,
+ tabsListVariants,
+ type TabsListVariant,
+ //
+ Root as Tabs,
+ Content as TabsContent,
+ List as TabsList,
+ Trigger as TabsTrigger
+};
diff --git a/tools/ui/src/lib/components/ui/tabs/tabs-content.svelte b/tools/ui/src/lib/components/ui/tabs/tabs-content.svelte
new file mode 100644
index 000000000000..fae73d919944
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/tabs/tabs-content.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/tools/ui/src/lib/components/ui/tabs/tabs-list.svelte b/tools/ui/src/lib/components/ui/tabs/tabs-list.svelte
new file mode 100644
index 000000000000..32c7f87a658c
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/tabs/tabs-list.svelte
@@ -0,0 +1,40 @@
+
+
+
+
+
diff --git a/tools/ui/src/lib/components/ui/tabs/tabs-trigger.svelte b/tools/ui/src/lib/components/ui/tabs/tabs-trigger.svelte
new file mode 100644
index 000000000000..be798b51eea2
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/tabs/tabs-trigger.svelte
@@ -0,0 +1,23 @@
+
+
+
diff --git a/tools/ui/src/lib/components/ui/tabs/tabs.svelte b/tools/ui/src/lib/components/ui/tabs/tabs.svelte
new file mode 100644
index 000000000000..fcd40003f775
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/tabs/tabs.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/tools/ui/src/lib/constants/backend.constants.ts b/tools/ui/src/lib/constants/backend.constants.ts
index 1f3ab2e394ad..100ee17dcd5d 100644
--- a/tools/ui/src/lib/constants/backend.constants.ts
+++ b/tools/ui/src/lib/constants/backend.constants.ts
@@ -1,4 +1,9 @@
-import type { BackendCapabilities, BackendCompat, BackendProtocol } from '$lib/types';
+import type {
+ BackendCapabilities,
+ BackendCompat,
+ BackendPreset,
+ BackendProtocol
+} from '$lib/types';
/** Prefix for generated ids of user-added backends. */
export const BACKEND_ID_PREFIX = 'backend';
@@ -64,3 +69,31 @@ export const MODEL_CONTEXT_LENGTH_FIELDS = [
/** Favicon extract keyed by domain, for backends with no bundled mark. */
export const FAVICON_SERVICE_URL = 'https://www.google.com/s2/favicons?domain=';
+
+/**
+ * Ready-made endpoints offered when adding a backend.
+ *
+ * TODO: pair a llama.cpp server by scanning its QR code (WebRTC transport, so a
+ * hosted PWA can reach a server on the user's network), see
+ * ggml-org/llama.cpp#24577.
+ */
+export const BACKEND_PRESETS: readonly BackendPreset[] = [
+ {
+ baseUrl: 'https://router.huggingface.co',
+ description: 'Open models from the Hub, served by inference providers.',
+ iconUrl: '/backend-presets/huggingface.svg',
+ id: 'huggingface',
+ name: 'Hugging Face',
+ protocol: 'openai'
+ },
+ {
+ baseUrl: 'https://openrouter.ai/api',
+ description: 'Models from many providers behind a single API.',
+ // v2 brand: purple on light, lime on dark
+ iconUrlDark: '/backend-presets/openrouter-dark.svg',
+ iconUrlLight: '/backend-presets/openrouter-light.svg',
+ id: 'openrouter',
+ name: 'OpenRouter',
+ protocol: 'openai'
+ }
+];
diff --git a/tools/ui/src/lib/constants/storage.constants.ts b/tools/ui/src/lib/constants/storage.constants.ts
index f66a0a011295..8c6cb3462e13 100644
--- a/tools/ui/src/lib/constants/storage.constants.ts
+++ b/tools/ui/src/lib/constants/storage.constants.ts
@@ -52,6 +52,7 @@ export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.re
export const CONVERSATION_TABS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.conversationTabs`;
export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`;
export const DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.dismissedRecommendedMcpServers`;
+export const DISMISSED_RECOMMENDED_BACKENDS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.dismissedRecommendedBackends`;
/** Key prefix for per-conversation resumable stream state, conversationId is appended */
export const STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX = `${STORAGE_APP_NAME}.streamResume.`;
diff --git a/tools/ui/src/lib/utils/backend.ts b/tools/ui/src/lib/utils/backend.ts
index 55b671bbfc03..29c68558f7a1 100644
--- a/tools/ui/src/lib/utils/backend.ts
+++ b/tools/ui/src/lib/utils/backend.ts
@@ -10,6 +10,7 @@ import {
BACKEND_CAPABILITIES,
BACKEND_COMPAT,
BACKEND_ID_PREFIX,
+ BACKEND_PRESETS,
BACKEND_PROTOCOLS,
DEFAULT_BACKEND_CHAT_PATH,
DEFAULT_BACKEND_MODELS_PATH,
@@ -17,7 +18,13 @@ import {
LOCAL_BACKEND_ID,
MODEL_CONTEXT_LENGTH_FIELDS
} from '$lib/constants';
-import type { Backend, BackendCapabilities, BackendCompat, BackendProtocol } from '$lib/types';
+import type {
+ Backend,
+ BackendCapabilities,
+ BackendCompat,
+ BackendPreset,
+ BackendProtocol
+} from '$lib/types';
/** Absolute chat completions URL for a backend. */
export function backendChatUrl(backend: Backend): string {
@@ -49,6 +56,29 @@ export function backendFaviconUrl(baseUrl: string): string | null {
}
}
+/**
+ * Preset a backend was created from, matched on origin and path so a saved
+ * backend keeps its branding. Returns undefined for edited or custom URLs.
+ */
+export function findBackendPreset(baseUrl: string): BackendPreset | undefined {
+ const target = normalizeBaseUrl(baseUrl);
+
+ if (!target) return undefined;
+
+ return BACKEND_PRESETS.find((preset) => normalizeBaseUrl(preset.baseUrl) === target);
+}
+
+/** Origin and path of a URL, without a trailing slash; null when unparsable. */
+function normalizeBaseUrl(url: string): string | null {
+ try {
+ const parsed = new URL(url);
+
+ return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`.toLowerCase();
+ } catch {
+ return null;
+ }
+}
+
/**
* Features a backend supports, derived from its protocol. A missing backend
* (unknown model, early startup) gets the plain compatible defaults.
diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts
index 3c8a3caf8c6d..ca22ac334087 100644
--- a/tools/ui/src/lib/utils/index.ts
+++ b/tools/ui/src/lib/utils/index.ts
@@ -29,6 +29,7 @@ export {
backendFaviconUrl,
backendModelsUrl,
createLocalBackend,
+ findBackendPreset,
getBackendCapabilities,
parseBackendsSettings
} from './backend';
diff --git a/tools/ui/static/backend-presets/deepseek.svg b/tools/ui/static/backend-presets/deepseek.svg
new file mode 100644
index 000000000000..185c108b458c
--- /dev/null
+++ b/tools/ui/static/backend-presets/deepseek.svg
@@ -0,0 +1 @@
+DeepSeek
\ No newline at end of file
diff --git a/tools/ui/static/backend-presets/google.svg b/tools/ui/static/backend-presets/google.svg
new file mode 100644
index 000000000000..4545c8331f9e
--- /dev/null
+++ b/tools/ui/static/backend-presets/google.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/ui/static/backend-presets/huggingface.svg b/tools/ui/static/backend-presets/huggingface.svg
new file mode 100644
index 000000000000..6b3b4f687eb3
--- /dev/null
+++ b/tools/ui/static/backend-presets/huggingface.svg
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/ui/static/backend-presets/kimi.png b/tools/ui/static/backend-presets/kimi.png
new file mode 100644
index 0000000000000000000000000000000000000000..213b9b83d6cd30fd57077aa18244dfc4e6fd0d14
GIT binary patch
literal 1835
zcmV+`2h{k9P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@
z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NIl}SWFRA>d&T1!lnR~Y^N8HTB)
zfGCjK)Jl*bY}n9ZN>fW>jKIc~3%hV*!iJU6u)t*(MnfPmftA{Xg_|al7#B?eF+h_V
z(y(b-!a_ogmVhV-$N)3*^_)B0ab{p1%nT22a^`>B*ZE%e-v8clR8=CAgM?={Pz2Nh
zKLFnXRX{PoyxmMeG(HDR0wcihz!>lr2!L-iVLn@+0Lc{sHNbDcDzE_r05;6Zdja;v
zKG`=p3m3Yrua18`ul7sq{fEOpsK2=v%U=@FK}wY<*i?LBa8S0@Bo}r306N71ZyG@g7r)g^DRaO0L6>
z-EOz!=H{B({0RgCvazut!62=fINAChj|a-N1VeTFe!pz^(S~tx>*rYF_}689>Zxo@
zkD}nVeYhz=N+~BNM^2qOCAV(f65cZ{EG$TCYpc9|{W@VABwSQfBv-Cnk+WydMhURC
zwkD4sKbHIVf0p3NtoZ)=MI4t0>$9K)X8)Ge@%s|=EkHOc*w(I?lN8XrottLUKDPlot>RV)3M}7M@O}yqC(-0nULQd0j%b4-@dKu>+4%I
z|LoZ_BOHZHRe=f0W!D1qdAdzkS638y`i^xQ#*Oj$6s@0te0D8B@<)#zHRKtf)@?|h
z!T;pR6RoVQ)GTS7^1JS{-YKYaL5j~_q28{{){p}Dy^J$dqEm^?LY-NurC^yrb&=dRmR81w~RIvfI-~U)TErj86QbTl1%c`
z7l3ZUC4qGt61R!(xzuQDYtth~j_jrt(igyzjd#UrRhO5S^}&M&T2)nLtg#E0$c_Ni
zIM+K2WD<`|9Em0-CbXfULG$wRvh7lt2|)MZ0&jA1QZHP%pjWS6)mN`xMJ@U$3v{m>zOlWbZl(Q2oV_{yr(sWhK97JrY2hf
zp1uIo>htH%wY^ZriHo!I<;$0ig(ANrtV=OwXsJ7Q?wId_Jr{s8F|{ABV+wNU&>^j_uQv<1*ae=A
zA1dPF#fx#g96H|LcHWJ&T;OPjz**^uCWDNWPY`br_S@57<$l2^pKgEsz
zpt4NBi=26eY}|*(H6Pm%)8HoekNc31!zY6Q8}J4VxF#J^QD#3Ahbo>G4*@uGU
literal 0
HcmV?d00001
diff --git a/tools/ui/static/backend-presets/openrouter-dark.svg b/tools/ui/static/backend-presets/openrouter-dark.svg
new file mode 100644
index 000000000000..5df05ac31558
--- /dev/null
+++ b/tools/ui/static/backend-presets/openrouter-dark.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/tools/ui/static/backend-presets/openrouter-light.svg b/tools/ui/static/backend-presets/openrouter-light.svg
new file mode 100644
index 000000000000..8fbbb8717851
--- /dev/null
+++ b/tools/ui/static/backend-presets/openrouter-light.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/tools/ui/static/backend-presets/qwen.png b/tools/ui/static/backend-presets/qwen.png
new file mode 100644
index 0000000000000000000000000000000000000000..87a98a9526242daec54cd5d48a37eae55c5998eb
GIT binary patch
literal 1658
zcmV-=28H>FP)qlntr@V>
z)dfm?aCph6ljCGORAVGWhPEj27u+a5m$*b?v|_-{k?zyK0*=t-axqdGwX#PvX-AfObw)nX(-
zG!LXN-1CUM^(e{>rQV7>TIYD=;E^_$Zh@3h=uiGp?_6FI#W^sn2PRm%GU?6s
z?Ue*G;B4>fV{>`UIVhwD7T9bG&OyNpm;*`acCKi}JO3RpsVkj2WGI`+`EjU*q&SZ#
zHz=@yWK;ajI&Regd&l~G??^vY(bV!sdDD}rGHg0srtt=w2$hL?2iLqq8z2w5BoDd*
z7fpSOM>Y82J&v@ge|y+0&M1w!`n1Xz-R>ow3~+_);dn})5FGd+PGWGE>5
znvMAiEz&drMnP|^G7PDqG=K+nX^dF)U`uOaVoFg{O|6;$qv!AGJkMOu)1JmqNQV$U
zeFiB^Mxl7)bLKox={)b?h}D3zde*7Ew}O7`fa2NAVk^&=?YEZR5ExwAKo
z2CO2IqdKtyr`7f0m##j=n$0W|_!KknvHHT&X^qZt5^7*vbOy&F5HG@NDwR_?Y2IaZ
zT@jzbVFW&w0skjeMN$+sIhS-=6KDpfw-p&8pSqP}RGk
zjb|wBD!tcun|QCcW_6k85^r?alGZqTpxIC1uH{Aa&es
zsEZ|iyOp#2KdX29hzM;tFBczkGGNgCMmc(6sI;X4+r*Zu2ZofS3T|4{*6?O#(MZ__
zjI!Tordm_BFl%Ebwldr=KFl(pS#hnyswf8ARKwD_v~|x3wK;5=0#}VO`%@p0AT|8g
zy5zc*JDwUQwpbphz-p7P(wKHTLCTD(&w!y)BB$bsZh2V
zpJ#YMM2pJ$t3PgvuloH&l;j_6J7=k%|4|qrC{3P
zbNd1p_RWSdA>+xFjz&evdH22f(B7}N#iB+IFHvTi3uE!>IxA^-+-=CT7dJ^)&Kwi
literal 0
HcmV?d00001
diff --git a/tools/ui/static/backend-presets/zai.png b/tools/ui/static/backend-presets/zai.png
new file mode 100644
index 0000000000000000000000000000000000000000..70c33481a4ce590c29759226284b89542de6fdd3
GIT binary patch
literal 1064
zcmV+@1lRkCP)C0001HP)t-sEiEno
z|NjvY5!Tk$%*@Qw)6)$N4JRij;Naj}TU!7C03#zKDk>@)8yg-T9tH*m?(XiSq@)%W
z7GYsw`T6-&R8;8b=$e|Ee0+S1i;Hb-ZL6!Rw6wIpzrV)D#*&hfNJvOJIyy6c3kU!J
z1AR$EK~#90?VIazqc9AGX)cDCI}}20ZFv8eTxc@c#28DFh1t$>es(nID~|0WVS67l
z%Ub{zfCWJN*DP4GE69)lSze~8Sg}i_vdo7NDnQoA*!L!qAIoEH2FPVNRrZ@gxitGjn0oW`6
z3&1Y`h=ss-)7uj615xTuhX9DrL4a}Rm!2Rj&7r{|d>h~q0;uXXk*N4I2W9Hx`2hAs
z7c0MjsH?iwu>l1ACYCfn)c0VoX9H-XZUCSHb3NQ@xd58pNXr0-@}sE$HvPX=?13nq
z-RA-LOrmFVGop*mYVZJ*{p1$h&%r4h%D4}s`hcl29AU**34`byH4}jkS`8T801`mK#
z8$ODD)`Hsg8l451OQD)jqQ080qjhOJ%FVNu?Da;A=UttP6#IesT0Bo
z;9tiQ-vycwO90S>y!FlHeEja06r5!17I{EBmhnmLIOZ`LNJYO5&*6f;-{^-wO`h4gWej@gOgKLf3bSr|a12D_Suj3JDmrAU%*UPJn|16!0eY?x;n$jOh{fT
zsaIEhbTA6ywcJr`{VARLs#mIXyj)*)Yt(XAcipd&s3X;HN(fmYuKV3o^W1TKmA=sU
iN3VP?01LqX1n>uIghvXreKlGD0000
Date: Mon, 28 Sep 2026 15:47:48 +0200
Subject: [PATCH 09/13] ui : show every provider's models in the manager
The table grows a section per enabled provider, each with its own mark,
error and loading state, and a provider filter with live repo counts.
Rows gain their capability gates back, the draft column with its
use-as-draft action, and the quant badge names the provider on its rows.
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
---
.../components/app/models/ModelAvatar.svelte | 32 ++++-
.../app/models/ModelLoadControl.svelte | 38 +++++-
.../models/ModelsManager/ModelsManager.svelte | 119 ++++++++++++++++--
.../ModelsManager/ModelsManagerFilters.svelte | 114 ++++++++++++++++-
.../ModelsManagerModelRow.svelte | 35 +++++-
.../ModelsManagerModelsTable.svelte | 52 +++++++-
.../ModelsManagerQuantRow.svelte | 41 +++++-
.../ModelsManager/ModelsManagerRepoRow.svelte | 21 +++-
.../ModelsManagerStatusCell.svelte | 3 +
.../ModelsManagerTableToolbar.svelte | 31 ++++-
.../app/models/ModelsManager/row-actions.ts | 60 ++++++---
.../app/models/ModelsManager/utils.ts | 17 ++-
.../app/models/ModelsSection.svelte | 7 ++
13 files changed, 511 insertions(+), 59 deletions(-)
diff --git a/tools/ui/src/lib/components/app/models/ModelAvatar.svelte b/tools/ui/src/lib/components/app/models/ModelAvatar.svelte
index 9dc30338b06a..29b5b82b223e 100644
--- a/tools/ui/src/lib/components/app/models/ModelAvatar.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelAvatar.svelte
@@ -1,9 +1,13 @@
-{#if orgName}
+{#if useProviderIcon}
+
+
+ {#snippet fallback()}
+ {#if isLocal}
+
+ {:else}
+
+ {/if}
+ {/snippet}
+
+
+{:else if orgName}
(isNearViewport = true)}
class={['inline-flex shrink-0', className]}
diff --git a/tools/ui/src/lib/components/app/models/ModelLoadControl.svelte b/tools/ui/src/lib/components/app/models/ModelLoadControl.svelte
index f95a2f61a4e8..8681248b6168 100644
--- a/tools/ui/src/lib/components/app/models/ModelLoadControl.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelLoadControl.svelte
@@ -1,11 +1,15 @@
- {#if isLoading}
+ {#if !canLoad}
+ {#if showBackendMark}
+
+ {:else if showRemoteMark}
+
+
+
+ {#if isBackendFailed}
+
+ {/if}
+
+ {/if}
+ {:else if isLoading}
{:else}
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte
index 7171099e7473..6112280321f8 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte
@@ -2,20 +2,27 @@
import ModelsManagerModelsTable from './ModelsManagerModelsTable.svelte';
import {
groupModelQuants,
+ loadOverrides,
type ModalityKey,
modelContextLength,
+ modelDraftsFor,
+ type ModelOverride,
type ModelQuantGroup,
MODELS_TABLE_GROUP_LABELS,
type ModelsTableGroup,
ModelsTableGroupKind,
- modelSupports
+ modelSupports,
+ saveOverrides
} from './utils';
import { LOCAL_BACKEND_ID } from '$lib/constants';
import { ModelCapability } from '$lib/enums';
- import { modelsStore, uiStore } from '$lib/stores';
+ import { backendsStore, modelsStore, uiStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
+ import { getBackend } from '$lib/utils/api-base';
+ import { getBackendCapabilities } from '$lib/utils/backend';
import { type Snippet } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
+ import { toast } from 'svelte-sonner';
interface Props {
class?: string;
@@ -26,15 +33,20 @@
let { class: className, toolbarEnd }: Props = $props();
let filter = $state('');
+ let providerFilter = $state
([]);
let contextLimit = $state(0);
let modalityFilter = $state([]);
let capabilityFilter = $state([]);
+ let draftFilter = $state(false);
let selectedId = $state(null);
+ let overrides = $state>(loadOverrides());
let allModels = $derived(modelsStore.models);
let isFavorite = $derived((option: ModelOption) =>
modelsStore.favoriteModelIds.has(option.model)
);
+ // every filter but the provider one, so a provider count does not fall to zero
+ // the moment that provider is the one being looked at
let matching = $derived.by(() => {
const term = filter.trim().toLowerCase();
@@ -50,6 +62,13 @@
return false;
}
+ if (
+ draftFilter &&
+ modelDraftsFor(option, overrides[option.id]?.load?.speculativeDecoding).length === 0
+ ) {
+ return false;
+ }
+
// a model whose modalities are unknown cannot be shown to match
if (modalityFilter.length > 0 && !modalityFilter.some((key) => option.modalities?.[key])) {
return false;
@@ -58,6 +77,24 @@
return contextLimit === 0 || modelContextLength(option) >= contextLimit;
});
});
+ let visible = $derived.by(() =>
+ providerFilter.length === 0
+ ? matching
+ : matching.filter((option) => providerFilter.includes(option.backendId ?? LOCAL_BACKEND_ID))
+ );
+ // the rail counts follow the active view and filter, so it always says how many
+ // repos each provider contributes to what the table is showing
+ let providerCounts = $derived.by(() => {
+ const counts: Record = {};
+
+ for (const entry of groupModelQuants(matching)) {
+ const backendId = entry.base.backendId ?? LOCAL_BACKEND_ID;
+
+ counts[backendId] = (counts[backendId] ?? 0) + 1;
+ }
+
+ return counts;
+ });
// recently used models lead their section, the rest keep the server's order;
// derived, so a pick made while the manager is open reorders the sections
@@ -74,12 +111,16 @@
const byRecency = (list: ModelQuantGroup[]) =>
rank.size === 0 ? list : [...list].sort((a, b) => rankOf(a) - rankOf(b));
// one entry per repo, so a model with several quants is a single table row;
- // loaded models lead the table, then favorites, then the local block
- let entries = $derived(byRecency(groupModelQuants(matching)));
+ // loaded models lead the table, then favorites, then one block per provider,
+ // and an entry lands in the first group that claims it
+ let entries = $derived(byRecency(groupModelQuants(visible)));
let groups = $derived.by(() => {
// A loaded quant is a model of its own: it moves to the loaded section, and
- // the quants of its repo that are not loaded stay behind as that repo.
- const isLoaded = (option: ModelOption) => modelsStore.isModelLoaded(option.model);
+ // the quants of its repo that are not loaded stay behind as that repo. Only
+ // llama-compat servers report a load state.
+ const isLoaded = (option: ModelOption) =>
+ getBackendCapabilities(getBackend(option.backendId)).loadUnload &&
+ modelsStore.isModelLoaded(option.model);
const loaded: ModelQuantGroup[] = [];
const rest: ModelQuantGroup[] = [];
@@ -106,23 +147,54 @@
(entry) => !claimed.has(entry.key) && entry.quants.some((q) => modelsStore.isHidden(q.id))
);
const hiddenKeys = new SvelteSet(hidden.map((entry) => entry.key));
- const local = rest.filter((entry) => !claimed.has(entry.key) && !hiddenKeys.has(entry.key));
+ const byBackend = new SvelteMap();
+
+ for (const entry of rest) {
+ if (claimed.has(entry.key) || hiddenKeys.has(entry.key)) continue;
+
+ const backendId = entry.base.backendId ?? LOCAL_BACKEND_ID;
+
+ if (!byBackend.has(backendId)) byBackend.set(backendId, []);
+
+ byBackend.get(backendId)!.push(entry);
+ }
+
const ordered: ModelsTableGroup[] = [];
- // loaded models lead the table, then favorites, then the local block
- const pushSection = (kind: ModelsTableGroupKind, items: ModelQuantGroup[]): void => {
+ // loaded models lead the table, then favorites, then one block per backend
+ const pushSection = (
+ kind: ModelsTableGroup['kind'],
+ items: ModelQuantGroup[],
+ provider?: { id: string; label: string }
+ ): void => {
if (items.length === 0) return;
+ const isLocal = kind === ModelsTableGroupKind.LOCAL;
+
ordered.push({
+ backendId: provider?.id ?? (isLocal ? LOCAL_BACKEND_ID : null),
+ isLocal,
items,
- key: kind === ModelsTableGroupKind.LOCAL ? LOCAL_BACKEND_ID : kind,
+ key: provider?.id ?? (isLocal ? LOCAL_BACKEND_ID : kind),
kind,
- label: MODELS_TABLE_GROUP_LABELS[kind]
+ label: provider?.label ?? MODELS_TABLE_GROUP_LABELS[kind as ModelsTableGroupKind]
});
};
pushSection(ModelsTableGroupKind.LOADED, loaded);
pushSection(ModelsTableGroupKind.FAVORITES, favorites);
- pushSection(ModelsTableGroupKind.LOCAL, local);
+
+ const localItems = byBackend.get(LOCAL_BACKEND_ID);
+
+ if (localItems?.length) pushSection(ModelsTableGroupKind.LOCAL, localItems);
+
+ for (const backend of backendsStore.enabled) {
+ if (backend.id === LOCAL_BACKEND_ID) continue;
+
+ const items = byBackend.get(backend.id);
+
+ if (items?.length) pushSection('provider', items, { id: backend.id, label: backend.name });
+ }
+
pushSection(ModelsTableGroupKind.HIDDEN, hidden);
return ordered;
@@ -140,6 +212,24 @@
uiStore.manageModelFocus = null;
});
+
+ /** Point the selected model's load settings at another model as its draft. */
+ function useAsDraft(draft: ModelOption, targetId: string): void {
+ const target = modelsStore.models.find((option) => option.id === targetId);
+
+ if (!target) return;
+
+ saveOverride(target, {
+ ...overrides[target.id],
+ load: { ...overrides[target.id]?.load, speculativeDecoding: draft.id }
+ });
+ }
+
+ function saveOverride(option: ModelOption, override: ModelOverride): void {
+ overrides = { ...overrides, [option.id]: override };
+ saveOverrides(overrides);
+ toast.success(`Saved settings for ${option.name}`);
+ }
@@ -147,11 +237,16 @@
(selectedId = option.id)}
+ onUseAsDraft={useAsDraft}
+ {overrides}
+ {providerCounts}
{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
index 9120ce582dd6..9559d936c380 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte
@@ -1,25 +1,40 @@
+{#snippet providerMark(backend: Backend)}
+ {#if backend.id === LOCAL_BACKEND_ID}
+
+ {#snippet fallback()}
+
+ {/snippet}
+
+ {:else}
+
+ {/if}
+{/snippet}
+
+ {#if backends.length > 1}
+
+
+ {#snippet child({ props })}
+
+
+
+ {providerLabel}
+
+
+
+ {/snippet}
+
+
+
+
+ Providers
+
+ {#each backends as backend (backend.id)}
+ toggleProvider(backend.id, checked)}
+ >
+ {@render providerMark(backend)}
+
+ {backend.name}
+
+ {providerCounts[backend.id] ?? 0}
+
+ {/each}
+
+
+ {#if providers.length > 0}
+
+
+ (providers = [])}
+ >Show every provider
+ {/if}
+
+
+ {/if}
+
(contextLimit = Number(value))}
type="single"
@@ -83,6 +169,26 @@
+
+
+
+ {#if draft}
+
+ {/if}
+
+
+ Has draft sidecar
+
+
boolean;
+ /** Stored per-model overrides, for the drafts and context the row reports. */
+ overrides?: Record;
/** Model this row stands for. */
option: ModelOption;
onDelete: (option: ModelOption) => void;
onSelect: (option: ModelOption) => void;
+ onUseAsDraft?: (draft: ModelOption, targetId: string) => void;
selected: boolean;
/** Left padding in px, from the nesting depth. */
indent?: number;
}
- let { indent = 0, isFavorite, onDelete, onSelect, option, selected }: Props = $props();
+ let {
+ draftTarget = null,
+ indent = 0,
+ isFavorite,
+ onDelete,
+ onSelect,
+ onUseAsDraft,
+ option,
+ overrides,
+ selected
+ }: Props = $props();
let favorite = $derived(isFavorite(option));
let isHidden = $derived(modelsStore.isHidden(option.id));
+ let drafts = $derived(modelDraftsFor(option, overrides?.[option.id]?.load?.speculativeDecoding));
function handleKeydown(event: KeyboardEvent): void {
if (event.key === ' ') event.preventDefault();
@@ -60,6 +76,7 @@
aliases={option.aliases}
class="min-w-0 flex-1"
draftSidecars={option.draftSidecars}
+ {drafts}
hideCapabilities
hideModalities
modalities={option.modalities}
@@ -72,13 +89,21 @@
-
+
boolean;
onSelect: (option: ModelOption) => void;
/** Modalities a model must support at least one of. */
modalities?: ModalityKey[];
+ /** Per-model load and inference overrides, keyed by backend-qualified id. */
+ overrides: Record;
+ /** Backend ids to keep; empty keeps every provider. */
+ providers?: string[];
+ /** Repos each provider contributes to the current search, for the filter menu. */
+ providerCounts?: Record;
+ /** Called when a row is set as the draft of the selected model. */
+ onUseAsDraft?: (draft: ModelOption, targetId: string) => void;
selectedId: string | null;
/** Rendered at the toolbar's right end, past the filters. */
toolbarEnd?: Snippet;
@@ -58,17 +70,35 @@
let {
capabilities = $bindable([]),
contextLimit = $bindable(0),
+ draft = $bindable(false),
filter = $bindable(''),
groups,
isFavorite,
modalities = $bindable([]),
onSelect,
+ onUseAsDraft,
+ overrides,
+ providerCounts = {},
+ providers = $bindable([]),
selectedId,
toolbarEnd
}: Props = $props();
let isEmpty = $derived(groups.every((group) => group.items.length === 0));
- let hasFilters = $derived(contextLimit > 0 || modalities.length > 0 || capabilities.length > 0);
+ let hasFilters = $derived(
+ providers.length > 0 ||
+ contextLimit > 0 ||
+ modalities.length > 0 ||
+ capabilities.length > 0 ||
+ draft
+ );
+
+ /** Model the configuration pane has open, when a row can be set as its draft. */
+ let draftTarget = $derived.by(() => {
+ const selected = modelsStore.models.find((option) => option.id === selectedId);
+
+ return selected ? { id: selected.id, label: selected.name } : null;
+ });
/** In-flight and paused downloads, tracked by the status feed. */
let downloadEntries = $derived(modelsStore.status.getDownloadEntries());
@@ -216,27 +246,35 @@
expanded={!collapsedQuants.has(entry.key)}
{indent}
onToggle={() => toggleQuants(entry.key)}
+ {overrides}
/>
{#if !collapsedQuants.has(entry.key)}
{#each entry.quants as quant (quant.id)}
{/each}
{/if}
{:else}
{/if}
@@ -333,8 +371,11 @@
@@ -381,12 +422,17 @@
{/if}
{/snippet}
+ {@const backendState = group.backendId ? backendsModelsStore.get(group.backendId) : null}
+
boolean;
+ /** Stored per-model overrides, for the drafts and context the row reports. */
+ overrides?: Record;
/** Quant this row stands for. */
option: ModelOption;
onDelete: (option: ModelOption) => void;
onSelect: (option: ModelOption) => void;
+ onUseAsDraft?: (draft: ModelOption, targetId: string) => void;
selected: boolean;
+ /** Badge the backend name instead of the quant, for a provider group. */
+ showProvider?: boolean;
/** Left padding in px, from the nesting depth. */
indent?: number;
}
- let { indent = 0, isFavorite, onDelete, onSelect, option, selected }: Props = $props();
+ let {
+ draftTarget = null,
+ indent = 0,
+ isFavorite,
+ onDelete,
+ onSelect,
+ onUseAsDraft,
+ option,
+ overrides,
+ selected,
+ showProvider = false
+ }: Props = $props();
let favorite = $derived(isFavorite(option));
let isHidden = $derived(modelsStore.isHidden(option.id));
@@ -48,20 +67,30 @@
tabindex="0"
>
- {quant}
+
+ {showProvider ? (getBackend(option.backendId)?.name ?? quant) : quant}
+
{option.model}
-
+
void;
+ /** Stored per-model overrides, for the drafts and context the row reports. */
+ overrides?: Record;
/** Left padding in px, from the nesting depth. */
indent?: number;
}
- let { entry, expanded, indent = 0, onToggle }: Props = $props();
+ let { entry, expanded, indent = 0, onToggle, overrides }: Props = $props();
+ let providerCount = $derived(new Set(entry.quants.map((option) => option.backendId ?? '')).size);
let groupLabel = $derived(
- entry.kind === 'variants'
- ? `${entry.quants.length} variants`
- : `${entry.quants.length} quants available`
+ entry.kind === 'providers'
+ ? `${providerCount} provider${providerCount === 1 ? '' : 's'}`
+ : entry.kind === 'variants'
+ ? `${entry.quants.length} variants`
+ : `${entry.quants.length} quants available`
);
let anyLoaded = $derived(entry.quants.some((quant) => isModelRunning(quant)));
// a repo row stands for its quants, so it reports what they agree on
let contextSource = $derived(entry.quants.find((quant) => quant.contextLength) ?? entry.base);
let mediaSource = $derived(entry.quants.find((quant) => quant.modalities) ?? entry.base);
+ let drafts = $derived(
+ modelDraftsFor(entry.base, overrides?.[entry.base.id]?.load?.speculativeDecoding)
+ );
function handleKeydown(event: KeyboardEvent): void {
if (event.key === ' ') event.preventDefault();
@@ -61,6 +69,7 @@
aliases={entry.base.aliases}
class="min-w-0"
draftSidecars={entry.base.draftSidecars}
+ {drafts}
hideCapabilities
hideModalities
hideQuantization
@@ -79,7 +88,7 @@
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte
index 9f3d2873a4f4..004e4950c172 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte
@@ -1,5 +1,6 @@
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte
index 3ec0c6c4ebd6..050de33f8144 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte
@@ -5,7 +5,7 @@
import { SearchInput } from '$lib/components/app/forms';
import { Button } from '$lib/components/ui/button';
import { ModelCapability } from '$lib/enums';
- import { uiStore } from '$lib/stores';
+ import { backendsStore, uiStore } from '$lib/stores';
import type { Snippet } from 'svelte';
interface Props {
@@ -13,9 +13,15 @@
capabilities?: ModelCapability[];
/** Smallest context a model must support; 0 keeps every model. */
contextLimit?: number;
+ /** Keep only models that have a draft sidecar to speculate with. */
+ draft?: boolean;
filter?: string;
/** Modalities a model must support at least one of. */
modalities?: ModalityKey[];
+ /** Repos each provider contributes to the current search, for the filter menu. */
+ providerCounts?: Record;
+ /** Backend ids to keep; empty keeps every provider. */
+ providers?: string[];
/** Rendered at the toolbar's right end, past the filters. */
toolbarEnd?: Snippet;
}
@@ -23,12 +29,21 @@
let {
capabilities = $bindable([]),
contextLimit = $bindable(0),
+ draft = $bindable(false),
filter = $bindable(''),
modalities = $bindable([]),
+ providerCounts = {},
+ providers = $bindable([]),
toolbarEnd
}: Props = $props();
- let hasFilters = $derived(contextLimit > 0 || modalities.length > 0 || capabilities.length > 0);
+ let hasFilters = $derived(
+ providers.length > 0 ||
+ contextLimit > 0 ||
+ modalities.length > 0 ||
+ capabilities.length > 0 ||
+ draft
+ );
let filterInput = $state(null);
// the dialog hands focus to its first control, so the filter takes it instead
@@ -59,15 +74,25 @@
size="sm"
/>
-
+
{#if hasFilters}
{
+ providers = [];
contextLimit = 0;
modalities = [];
capabilities = [];
+ draft = false;
}}
size="sm"
variant="ghost"
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
index 48452d0bac49..6edf25ba97ab 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts
@@ -1,27 +1,59 @@
-import { Eye, EyeOff, Heart, HeartOff, Trash2 } from '@lucide/svelte';
+import { Eye, EyeOff, Heart, HeartOff, Trash2, Zap } from '@lucide/svelte';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
+/** Model the configuration pane has open, which a row can be set as the draft of. */
+export interface ModelRowDraftTarget {
+ id: string;
+ label: string;
+}
+
+interface RowState {
+ /** Backend can load and unload the model. */
+ canLoad: boolean;
+ /** Model the pane has open, when one is selected. */
+ draftTarget?: ModelRowDraftTarget | null;
+ favorite: boolean;
+ isHidden: boolean;
+}
+
+interface RowHandlers {
+ onDelete: (option: ModelOption) => void;
+ onUseAsDraft?: (draft: ModelOption, targetId: string) => void;
+}
+
/** 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
-) {
+export function modelRowActions(option: ModelOption, state: RowState, handlers: RowHandlers) {
+ const { canLoad, draftTarget, favorite, isHidden } = state;
+ const canBeDraft = canLoad && !!draftTarget && draftTarget.id !== option.id;
+
return [
+ ...(canBeDraft
+ ? [
+ {
+ icon: Zap,
+ label: `Use as draft for ${draftTarget.label}`,
+ onclick: () => handlers.onUseAsDraft?.(option, draftTarget.id),
+ separator: true
+ }
+ ]
+ : []),
{
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
- },
+ ...(canLoad
+ ? [
+ {
+ icon: Trash2,
+ label: 'Delete from disk',
+ onclick: () => handlers.onDelete(option),
+ separator: true,
+ variant: 'destructive' as const
+ }
+ ]
+ : []),
{
icon: isHidden ? Eye : EyeOff,
label: isHidden ? 'Unhide model' : 'Hide model',
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts b/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts
index 1af65db1502a..8de442cfc972 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts
@@ -312,10 +312,25 @@ export function isModelRunning(option: ModelOption): boolean {
}
/** Context the model runs with: what a loaded model reports. */
-export function configuredContext(option: ModelOption): number | null {
+/** Context the model runs with: the stored override, else what a loaded local model reports. */
+export function configuredContext(
+ option: ModelOption,
+ overrides?: Record
+): number | null {
+ const override = overrides?.[option.id]?.load?.contextLength;
+
+ if (override) return override;
+
+ if (!isLocalOption(option)) return null;
+
return isModelRunning(option) ? modelsStore.props.getModelContextSize(option.model) : null;
}
+/** True when the backend that serves the model can load and unload it. */
+export function canLoadOption(option: ModelOption): boolean {
+ return getBackendCapabilities(getBackend(option.backendId)).loadUnload;
+}
+
export function modelSupports(option: ModelOption, capability: ModelCapability): boolean {
if (option.capabilities.includes(capability)) return true;
diff --git a/tools/ui/src/lib/components/app/models/ModelsSection.svelte b/tools/ui/src/lib/components/app/models/ModelsSection.svelte
index 2427adf1d91c..854b0ae7b2bc 100644
--- a/tools/ui/src/lib/components/app/models/ModelsSection.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsSection.svelte
@@ -1,12 +1,16 @@
- {#if ms.loading && ms.options.length === 0 && ms.isRouter}
+ {#if ms.loading && ms.options.length === 0 && ms.isMultiModel}
Loading models…
- {:else if ms.options.length === 0 && ms.isRouter}
+ {:else if ms.options.length === 0 && ms.isMultiModel}
{#if currentModel}
-
+
{:else}
-
No models available.
+
+ No models yet. Add a backend to get started.
+
{/if}
{:else}
- {@const selectedOption = ms.getDisplayOption()}
- {@const triggerModel = selectedOption?.model}
{@const triggerStatus = triggerModel
? modelsStore.routerModels.find((m) => m.id === triggerModel)?.status?.value
: undefined}
@@ -224,7 +252,7 @@
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
: 0}
- {#if ms.isRouter}
+ {#if ms.isMultiModel}
@@ -234,35 +262,36 @@
{...props}
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'
- : forceForegroundText
- ? 'text-foreground'
- : ms.isHighlightedCurrentModelActive
+ error
+ ? 'border-destructive/40 bg-destructive/10 !text-destructive hover:bg-destructive/20'
+ : !ms.isCurrentModelInCache
+ ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
+ : forceForegroundText
? 'text-foreground'
- : 'text-foreground',
+ : ms.isHighlightedCurrentModelActive
+ ? 'text-foreground'
+ : 'text-foreground',
isOpen && 'text-foreground',
'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]'
]}
disabled={disabled || ms.updating}
>
-
+
-
+
{#if selectedOption}
{:else}
Select model
{/if}
-
- {#if reasoning.isReasoningActive}
-
- {/if}
{#if ms.updating || ms.isLoadingModel}
@@ -287,94 +316,98 @@
event.preventDefault()}
>
-
-
-
+ ms.setSearchTerm(v)}
+ onSearchKeyDown={handleSearchKeyDown}
+ placeholder="Search models..."
+ searchClass="bg-transparent"
+ searchValue={ms.searchTerm}
+ >
+
+
+ {#if !ms.isCurrentModelInCache && currentModel}
+
+
+
- {#if selectedOption}
-
- {:else}
- No model
+ (not available)
+
{/if}
-
-
-
- ms.setSearchTerm(v)}
- onSearchKeyDown={handleSearchKeyDown}
- placeholder="Search models..."
- searchValue={ms.searchTerm}
- >
-
- {#if !ms.isCurrentModelInCache && currentModel}
-
-
-
-
- (not available)
-
- {/if}
-
- {#if ms.filteredOptions.length === 0}
-
No models found.
- {/if}
- {#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
- {@const { option } = item}
- {@const isSelected = currentModel === option.model || ms.activeId === option.id}
- {@const isHighlighted = option.id === highlightedId}
- {@const isFav = ms.isFavorite(option.model)}
-
-
{
- if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
- event.preventDefault();
- void handleModelKeyAction(option.id, event.altKey);
- }
- }}
- onMouseEnter={() => (highlightedId = option.id)}
- onSelect={ms.handleSelect}
- {option}
- />
- {/snippet}
-
-
-
-
-
-
+ {#if ms.isEmpty}
+
{ms.emptyMessage}
+ {/if}
-
+ {#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
+ {@const { option } = item}
+ {@const isSelected = currentModel === option.model || ms.activeId === option.id}
+ {@const isHighlighted = option.id === highlightedId}
+ {@const isFav = ms.isFavorite(option.model)}
+
+
{
+ if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
+ event.preventDefault();
+ void handleModelKeyAction(option.id, event.altKey);
+ }
+ }}
+ onMouseEnter={() => (highlightedId = option.id)}
+ onSelect={ms.handleSelect}
+ {option}
+ showBaseModelAvatar={!settingsStore.config.groupModelsByFamily}
+ showRepoOrgAvatar={settingsStore.config.groupModelsByFamily}
+ />
+ {/snippet}
+
+
+
+
+ {#snippet footer()}
+
+
+
+
+
+ Manage models
+
+
+ {/snippet}
+
{:else}
@@ -387,7 +420,7 @@
class={[
`inline-flex cursor-pointer items-center gap-1.5 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
@@ -399,21 +432,20 @@
onclick={() => ms.handleOpenChange(true)}
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
>
-
+
{#if selectedOption}
{/if}
- {#if reasoning.isReasoningActive}
-
- {/if}
-
{#if ms.updating}
{/if}
@@ -423,18 +455,15 @@
{#if selectedOption}
- {triggerTooltipLabel(selectedOption)}
-
+
{triggerTooltipLabel(selectedOption)}
+
{/if}
{/if}
{/if}
-{#if ms.showModelDialog}
- ms.setShowModelDialog(v)}
- open={ms.showModelDialog}
- />
-{/if}
+ void ms.showBackendModels(backend.id)}
+/>
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte
new file mode 100644
index 000000000000..4d58b89b7e55
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte
@@ -0,0 +1,268 @@
+
+
+{#snippet familyHeading({ group: family }: { group: ModelFamilyGroup })}
+
+
+
+
+ {family.label}
+
+
+
+ {family.entries.length} model{family.entries.length === 1 ? '' : 's'}
+
+
+{/snippet}
+
+{#snippet listItem({ depth, entry }: { depth: number; entry: ModelItem })}
+ {@render render(entry, !showOrgName)}
+{/snippet}
+
+{#snippet listRows(items: ModelItem[], prefix: string)}
+ {#if settingsStore.config.groupModelsByFamily}
+ row.option.model).map((family) => ({
+ entries: family.entries,
+ group: family,
+ key: `${prefix}-${family.key}`
+ }))}
+ item={listItem}
+ keyOf={(row) => `${prefix}-${row.option.id}`}
+ stickyStyle="top: calc(var(--dropdown-sticky-height, 0px) + 2.25rem - 1px)"
+ />
+ {:else}
+ `${prefix}-${row.option.id}`} />
+ {/if}
+{/snippet}
+
+{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
+ {@const { option } = item}
+ {@const isSelected = currentModel === option.model || activeId === option.id}
+ {@const isFav = modelsStore.favoriteModelIds.has(option.model)}
+
+ {}}
+ onMouseEnter={() => {}}
+ {onSelect}
+ {option}
+ showBaseModelAvatar={!settingsStore.config.groupModelsByFamily}
+ showRepoOrgAvatar={settingsStore.config.groupModelsByFamily}
+ />
+{/snippet}
+
+{#if loaded.length > 0}
+
+ {#snippet icon()}
+
+ {/snippet}
+
+ {#each loaded as item (item.option.id)}
+ {@render render(item, !showOrgName)}
+ {/each}
+
+{/if}
+
+{#if favorites.length > 0}
+
+
+ {#snippet icon()}
+
+ {/snippet}
+
+ {#each favorites as item (`fav-${item.option.id}`)}
+ {@render render(item, !showOrgName)}
+ {/each}
+
+{/if}
+
+{#if getDownloadEntries.length > 0}
+
+
+ {#each getDownloadEntries as entry (entry.repoWithTag)}
+
+ {/each}
+{/if}
+
+{#snippet localRows()}
+
+ {#each localGroups.loaded as item (`loaded-${item.option.id}`)}
+ {@render render(item, !showOrgName)}
+ {/each}
+
+ {@render listRows(
+ localGroups.available.flatMap((group) => group.items),
+ 'local'
+ )}
+
+ {#if hasMoreLocal}
+
+ {/if}
+{/snippet}
+
+{#if groups.loaded.length > 0 || groups.available.length > 0}
+
+ {#snippet icon()}
+
+ {/snippet}
+
+ {@render localRows()}
+
+{/if}
+
+
+{#each groups.providers as provider (provider.backendId)}
+
+ {#if provider.items.length > 0}
+ {@render listRows(provider.items, provider.backendId)}
+
+ {#if onProviderOpen && provider.matched > provider.items.length}
+
+ onProviderOpen(provider.backendId)}
+ type="button"
+ >
+ + {provider.matched - provider.items.length} more
+
+ {/if}
+ {:else if provider.catalog === 0}
+
+ {provider.error ?? (provider.loading ? 'Loading models...' : 'No models')}
+
+ {/if}
+
+{/each}
+
+ (cancelOpen = false)}
+ open={cancelOpen}
+ repoWithTag={pendingCancel}
+/>
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte
new file mode 100644
index 000000000000..071dc145aec3
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte
@@ -0,0 +1,136 @@
+
+
+ onSelect(option.id)}
+ onkeydown={onKeyDown}
+ onmouseenter={onMouseEnter}
+ role="option"
+ tabindex="0"
+ title={loadTitle}
+>
+
+
+
+
+ {#if showCapabilities}
+
+ {/if}
+
+
+
+
+
+
+
+ {#if isLoading}
+
+ {/if}
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorSheet.svelte
similarity index 62%
rename from tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte
rename to tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorSheet.svelte
index c89ca186e3bb..74a8c42f504c 100644
--- a/tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorSheet.svelte
@@ -1,24 +1,32 @@
- {#if ms.loading && ms.options.length === 0 && ms.isRouter}
+ {#if ms.loading && ms.options.length === 0 && ms.isMultiModel}
Loading models…
- {:else if ms.options.length === 0 && ms.isRouter}
-
No models available.
+ {:else if ms.options.length === 0 && ms.isMultiModel}
+
+ No models yet. Add a backend to get started.
+
{:else}
{@const selectedOption = ms.getDisplayOption()}
{@const triggerModel = selectedOption?.model}
@@ -77,17 +110,19 @@
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
: 0}
- {#if ms.isRouter}
+ {#if ms.isMultiModel}
-
+
{#if !selectedOption}
Select model
@@ -156,20 +191,35 @@
{/if}
- {#if ms.filteredOptions.length === 0}
- No models found.
+ {#if ms.isEmpty}
+ {ms.emptyMessage}
{/if}
+
+
+
+
+
+ Manage models
+
+
@@ -177,21 +227,28 @@
ms.handleOpenChange(true)}
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
>
-
+
-
+
{#if ms.updating}
@@ -201,10 +258,7 @@
{/if}
-{#if ms.showModelDialog}
- ms.setShowModelDialog(v)}
- open={ms.showModelDialog}
- />
-{/if}
+ void ms.showBackendModels(backend.id)}
+/>
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorTriggerIcon.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorTriggerIcon.svelte
new file mode 100644
index 000000000000..6c9621cddcb2
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorTriggerIcon.svelte
@@ -0,0 +1,23 @@
+
+
+{#if option}
+
+ {#snippet fallback()}
+
+ {/snippet}
+
+{:else}
+
+{/if}
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/index.ts b/tools/ui/src/lib/components/app/models/ModelsSelector/index.ts
new file mode 100644
index 000000000000..42ff366a1068
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/index.ts
@@ -0,0 +1,109 @@
+/**
+ *
+ * MODELS SELECTOR
+ *
+ * Model selection UI for the chat form: a desktop dropdown and a mobile sheet,
+ * each backed by a shared grouped options list, option rows, in-flight download
+ * rows and an inline reasoning-effort picker. Supports two server modes:
+ * - **Single model mode**: Server runs with one model, selector shows model info
+ * - **Router mode**: Server runs with multiple models, selector enables switching
+ *
+ * Integrates with modelsStore for model data and serverStore for mode detection.
+ *
+ */
+
+/**
+ * **ModelsSelectorDropdown** - Model selection dropdown (desktop)
+ *
+ * Dropdown for selecting AI models with status indicators,
+ * search, and model information display. Adapts UI based on server mode.
+ *
+ * **Architecture:**
+ * - Uses DropdownMenuSearchable for model list
+ * - Integrates with modelsStore for model options and selection
+ * - Detects router vs single mode from serverStore
+ * - Opens DialogModelInformation for model details
+ *
+ * **Features:**
+ * - Searchable model list with keyboard navigation
+ * - Model status indicators (loading/ready/error/updating)
+ * - Model capabilities badges (vision, tools, etc.)
+ * - Current/active model highlighting
+ * - Model information dialog on info button click
+ * - Router mode: shows all available models with status
+ * - Single mode: shows current model name only
+ * - Loading/updating skeleton states
+ * - Global selection support for form integration
+ *
+ * @example
+ * ```svelte
+ * updateModel(id)}
+ * useGlobalSelection
+ * />
+ * ```
+ */
+export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte';
+
+/**
+ *
+ * Switches the rendered view: the favorites of every backend, the local server's
+ * models, or the remote backends'. Purely a display concern - the backend that
+ * serves requests follows the selected model.
+ */
+
+/**
+ * **ModelsSelectorList** - Grouped model options list
+ *
+ * Renders grouped model options (loaded, favorites, available) with section
+ * headers and org subgroups. Shared between ModelsSelectorDropdown and ModelsSelectorSheet
+ * to avoid template duplication.
+ *
+ * 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';
+
+/**
+ * **ModelsSelectorDownloadItem** - Single in-flight download row
+ *
+ * One "Download in progress" row for the models selector: live progress bar,
+ * pause / resume on click and a hover-revealed cancel, mirroring the discover
+ * quant chips.
+ */
+export { default as ModelsSelectorDownloadItem } from './ModelsSelectorDownloadItem.svelte';
+
+/**
+ * **ModelsSelectorOption** - Single model option row
+ *
+ * Renders a single model option with selection state, favorite toggle,
+ * 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';
+
+/**
+ *
+ * Collapsible row that expands in place to reveal the reasoning effort levels.
+ * Used in the sticky footer of ModelsSelectorDropdown, where a flyout submenu
+ * would float over the model list it belongs to.
+ */
+
+/**
+ * **ModelsSelectorSheet** - Mobile model selection sheet
+ *
+ * Bottom sheet variant of ModelsSelectorDropdown optimized for touch interaction
+ * on mobile devices. Same functionality as ModelsSelectorDropdown but uses Sheet UI
+ * instead of DropdownMenu.
+ */
+export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.svelte';
+
+/**
+ * **ModelsSelectorTriggerIcon** - Icon for the selector trigger button
+ *
+ * Shows the selected model's own org avatar (the same mark the option rows
+ * use) and falls back to the generic model icon when the model has neither an
+ * org nor a provider mark.
+ */
+export { default as ModelsSelectorTriggerIcon } from './ModelsSelectorTriggerIcon.svelte';
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte
deleted file mode 100644
index e40e33d0c087..000000000000
--- a/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte
+++ /dev/null
@@ -1,76 +0,0 @@
-
-
-{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
- {@const { option } = item}
- {@const isSelected = currentModel === option.model || activeId === option.id}
- {@const isFav = modelsStore.favoriteModelIds.has(option.model)}
-
- {}}
- onMouseEnter={() => {}}
- {onSelect}
- {option}
- />
-{/snippet}
-
-{#if groups.loaded.length > 0}
-
-
- {#each groups.loaded as item (`loaded-${item.option.id}`)}
- {@render render(item, false)}
- {/each}
-{/if}
-
-{#if groups.favorites.length > 0}
-
-
- {#each groups.favorites as item (`fav-${item.option.id}`)}
- {@render render(item, true)}
- {/each}
-{/if}
-
-{#if groups.available.length > 0}
-
-
- {#each groups.available as group (group.orgName)}
- {#if group.orgName}
-
- {/if}
-
- {#each group.items as item (item.option.id)}
- {@render render(item, true)}
- {/each}
- {/each}
-{/if}
diff --git a/tools/ui/src/lib/components/app/models/index.ts b/tools/ui/src/lib/components/app/models/index.ts
index 6eff6df29422..ae86e98fc38e 100644
--- a/tools/ui/src/lib/components/app/models/index.ts
+++ b/tools/ui/src/lib/components/app/models/index.ts
@@ -8,71 +8,10 @@
*
* Integrates with modelsStore for model data and serverStore for mode detection.
*
- */
-
-/**
- * **ModelsSelectorDropdown** - Model selection dropdown (desktop)
- *
- * Dropdown for selecting AI models with status indicators,
- * search, and model information display. Adapts UI based on server mode.
- *
- * **Architecture:**
- * - Uses DropdownMenuSearchable for model list
- * - Integrates with modelsStore for model options and selection
- * - Detects router vs single mode from serverStore
- * - Opens DialogModelInformation for model details
- *
- * **Features:**
- * - Searchable model list with keyboard navigation
- * - Model status indicators (loading/ready/error/updating)
- * - Model capabilities badges (vision, tools, etc.)
- * - Current/active model highlighting
- * - Model information dialog on info button click
- * - Router mode: shows all available models with status
- * - Single mode: shows current model name only
- * - Loading/updating skeleton states
- * - Global selection support for form integration
- *
- * @example
- * ```svelte
- * updateModel(id)}
- * useGlobalSelection
- * />
- * ```
- */
-export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte';
-
-/**
- * **ModelsSelectorList** - Grouped model options list
+ * The selection UI lives in the ModelsSelector subfolder; the shared model
+ * display primitives (id / badge rendering) stay here.
*
- * Renders grouped model options (loaded, favorites, available) with section
- * headers and org subgroups. Shared between ModelsSelectorDropdown and ModelsSelectorSheet
- * to avoid template duplication.
- *
- * 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';
-
-/**
- * **ModelsSelectorOption** - Single model option row
- *
- * Renders a single model option with selection state, favorite toggle,
- * 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';
-
-/**
- * **ModelsSelectorSheet** - Mobile model selection sheet
- *
- * Bottom sheet variant of ModelsSelectorDropdown optimized for touch interaction
- * on mobile devices. Same functionality as ModelsSelectorDropdown but uses Sheet UI
- * instead of DropdownMenu.
*/
-export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.svelte';
/** * **ModelBadge** - Model name display badge
*
@@ -108,7 +47,13 @@ export { default as ModelBadge } from './ModelBadge.svelte';
* 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 ModelId } from './ModelId.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';
/**
* **ModelCapabilityIcons** - Capability and modality icon row
@@ -139,3 +84,5 @@ 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';
+
+export * from './ModelsSelector';
diff --git a/tools/ui/src/lib/components/app/models/utils.ts b/tools/ui/src/lib/components/app/models/utils.ts
deleted file mode 100644
index 35bfb58a2571..000000000000
--- a/tools/ui/src/lib/components/app/models/utils.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-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[];
-}
-
-function matchesModality(option: ModelOption, term: string): boolean {
- const modalities = option.modalities;
-
- if (!modalities) return false;
-
- switch (term) {
- case ModelModality.VISION.toLowerCase():
- return modalities.vision;
- case ModelModality.AUDIO.toLowerCase():
- return modalities.audio;
- case ModelModality.VIDEO.toLowerCase():
- return modalities.video;
- default:
- return false;
- }
-}
-
-export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] {
- const term = searchTerm.trim().toLowerCase();
-
- if (!term) return options;
-
- return options.filter(
- (option) =>
- option.model.toLowerCase().includes(term) ||
- option.name?.toLowerCase().includes(term) ||
- option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
- option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) ||
- matchesModality(option, term)
- );
-}
-
-export function groupModelOptions(
- filteredOptions: ModelOption[],
- favoriteIds: Set,
- isModelLoaded: (model: string) => boolean
-): GroupedModelOptions {
- // Loaded models
- const loaded: ModelItem[] = [];
-
- for (let i = 0; i < filteredOptions.length; i++) {
- if (isModelLoaded(filteredOptions[i].model)) {
- loaded.push({ flatIndex: i, option: filteredOptions[i] });
- }
- }
-
- // Favorites (excluding loaded)
- const loadedModelIds = new Set(loaded.map((item) => item.option.model));
- const favorites: ModelItem[] = [];
-
- for (let i = 0; i < filteredOptions.length; i++) {
- if (
- favoriteIds.has(filteredOptions[i].model) &&
- !loadedModelIds.has(filteredOptions[i].model)
- ) {
- favorites.push({ flatIndex: i, option: filteredOptions[i] });
- }
- }
-
- // Available models grouped by org (excluding loaded and favorites)
- const available: OrgGroup[] = [];
- const orgGroups = new SvelteMap();
-
- 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 });
- }
-
- return { available, favorites, loaded };
-}
diff --git a/tools/ui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte b/tools/ui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte
index f14db5f23c0f..897bb847dce4 100644
--- a/tools/ui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte
+++ b/tools/ui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte
@@ -1,6 +1,5 @@
-
+
-
- {@render children()}
+
+
+
+ {@render children()}
- {#if isEmpty}
-
{emptyMessage}
- {/if}
+ {#if isEmpty}
+
{emptyMessage}
+ {/if}
+
{#if footer}
-
+
+
- {@render footer()}
+ {@render footer()}
+
{/if}
diff --git a/tools/ui/src/lib/components/app/navigation/utils.ts b/tools/ui/src/lib/components/app/navigation/utils.ts
new file mode 100644
index 000000000000..964d6b4f96f5
--- /dev/null
+++ b/tools/ui/src/lib/components/app/navigation/utils.ts
@@ -0,0 +1,197 @@
+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[];
+}
+
+/** One remote backend's section on the remote view. */
+export interface ProviderGroup {
+ backendId: string;
+ /** Models the provider lists without any search filtering. */
+ catalog: number;
+ error: string | null;
+ /** Rows to render, capped by the display limit. */
+ items: ModelItem[];
+ loading: boolean;
+ /** Rows left after the search filter, before the cap. */
+ matched: number;
+ name: string;
+}
+
+export interface GroupedModelOptions {
+ available: OrgGroup[];
+ loaded: ModelItem[];
+ /** Remote backends, one section each. */
+ providers: ProviderGroup[];
+}
+
+function matchesModality(option: ModelOption, term: string): boolean {
+ const modalities = option.modalities;
+
+ if (!modalities) return false;
+
+ switch (term) {
+ case ModelModality.VISION.toLowerCase():
+ return modalities.vision;
+ case ModelModality.AUDIO.toLowerCase():
+ return modalities.audio;
+ case ModelModality.VIDEO.toLowerCase():
+ return modalities.video;
+ default:
+ return false;
+ }
+}
+
+export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] {
+ const term = searchTerm.trim().toLowerCase();
+
+ if (!term) return options;
+
+ return options.filter(
+ (option) =>
+ option.model.toLowerCase().includes(term) ||
+ option.name?.toLowerCase().includes(term) ||
+ option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
+ option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) ||
+ matchesModality(option, term)
+ );
+}
+
+/**
+ * Favorite models across every backend, in list order. The favorites tab spans
+ * all backends, so they are collected from the full option list.
+ */
+export function groupFavoriteOptions(
+ options: ModelOption[],
+ favoriteIds: Set
+): ModelItem[] {
+ const favorites: ModelItem[] = [];
+
+ for (let i = 0; i < options.length; i++) {
+ if (favoriteIds.has(options[i].model)) {
+ favorites.push({ flatIndex: i, option: options[i] });
+ }
+ }
+
+ return favorites;
+}
+
+/**
+ * Cut the local groups down to a window of rows. Loaded models come first, then
+ * the org groups, so the caller can grow the window as the list is scrolled.
+ */
+export function windowLocalGroups(
+ groups: GroupedModelOptions,
+ limit: number
+): { available: GroupedModelOptions['available']; loaded: ModelItem[]; shown: number } {
+ const loaded = groups.loaded.slice(0, Math.max(0, limit));
+
+ let budget = limit - loaded.length;
+
+ const available: GroupedModelOptions['available'] = [];
+
+ let shown = loaded.length;
+
+ for (const group of groups.available) {
+ if (budget <= 0) break;
+
+ const items = group.items.slice(0, budget);
+
+ budget -= items.length;
+ shown += items.length;
+
+ if (items.length > 0) available.push({ ...group, items });
+ }
+
+ return { available, loaded, shown };
+}
+
+export function groupModelOptions(
+ filteredOptions: ModelOption[],
+ isModelLoaded: (model: string) => boolean
+): GroupedModelOptions {
+ // Loaded models
+ const loaded: ModelItem[] = [];
+
+ for (let i = 0; i < filteredOptions.length; i++) {
+ const option = filteredOptions[i];
+
+ if (isModelLoaded(option.model)) {
+ loaded.push({ flatIndex: i, option });
+ }
+ }
+
+ const loadedModelIds = new Set(loaded.map((item) => item.option.model));
+ // Available models grouped by org (excluding loaded)
+ const available: OrgGroup[] = [];
+ const orgGroups = new SvelteMap();
+
+ for (let i = 0; i < filteredOptions.length; i++) {
+ const option = filteredOptions[i];
+
+ if (loadedModelIds.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 });
+ }
+
+ return { available, loaded, providers: [] };
+}
+
+/**
+ * Remote backends as sections, one per backend, in the given order. Each section
+ * keeps at most `limit` rows; `matched` carries the full count so the caller can
+ * offer the rest.
+ */
+export function groupProviderOptions(
+ options: ModelOption[],
+ providers: {
+ backendId: string;
+ catalog: number;
+ error: string | null;
+ loading: boolean;
+ name: string;
+ }[],
+ limit = Infinity,
+ recentIds: readonly string[] = []
+): ProviderGroup[] {
+ const byBackend = new SvelteMap();
+ const rank = new SvelteMap();
+
+ recentIds.forEach((id, index) => rank.set(id, index));
+
+ const rankOf = (id: string) => rank.get(id) ?? Number.MAX_SAFE_INTEGER;
+ // recently used models lead their section, the rest keep the backend's order
+ const byRecency = (items: ModelItem[]) =>
+ rank.size === 0 ? items : [...items].sort((a, b) => rankOf(a.option.id) - rankOf(b.option.id));
+
+ for (let i = 0; i < options.length; i++) {
+ const option = options[i];
+ const backendId = option.backendId ?? '';
+
+ if (!byBackend.has(backendId)) byBackend.set(backendId, []);
+
+ byBackend.get(backendId)!.push({ flatIndex: i, option });
+ }
+
+ return providers.map((provider) => {
+ const items = byRecency(byBackend.get(provider.backendId) ?? []);
+
+ return { ...provider, items: items.slice(0, limit), matched: items.length };
+ });
+}
diff --git a/tools/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/tools/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte
index 014e85b58b45..83f54958f62c 100644
--- a/tools/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte
+++ b/tools/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte
@@ -17,7 +17,7 @@
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 type { GroupedModelOptions, ModelItem } from '$lib/components/app/models/utils';
+ import ModelsSelectorList from '$lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte';
+ import ModelsSelectorOption from '$lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte';
+ import type { GroupedModelOptions, ModelItem } from '$lib/components/app/navigation/utils';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores/models/index.svelte';
@@ -103,8 +103,8 @@
orgName: 'intel'
}
],
- favorites: favoriteModels,
- loaded: loadedModels
+ loaded: loadedModels,
+ providers: []
};
function handleSelect(modelId: string) {
@@ -124,8 +124,8 @@
console.log('Info clicked:', modelName)}
onSelect={handleSelect}
/>
@@ -138,10 +138,9 @@
currentModel={null}
groups={{
available: [],
- favorites: [],
- loaded: [loadedModels[0]]
+ loaded: [loadedModels[0]],
+ providers: []
}}
- onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
onSelect={handleSelect}
/>
@@ -152,12 +151,12 @@
console.log('Info clicked:', modelName)}
onSelect={handleSelect}
/>
From 83ac43f54de312bc7e6cb4fbab66ad9f2eea01df Mon Sep 17 00:00:00 2001
From: Aleksander Grygier
Date: Mon, 28 Sep 2026 15:59:57 +0200
Subject: [PATCH 11/13] ui : rework the selector around its providers
The selector keeps working when a provider is down: the trigger shows the
provider's mark and the org avatar, the banner only appears when no
backend is enabled, and the model ids read through their settings. The
model mark becomes MODEL_ICON everywhere.
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
---
.../ChatFormActionModels.svelte | 35 ++-
.../ChatFormPickerCommand.svelte | 4 +-
.../ChatScreen/ChatScreenServerError.svelte | 13 +-
tools/ui/src/lib/constants/icons.constants.ts | 3 -
.../lib/constants/settings-keys.constants.ts | 4 +-
.../src/lib/constants/settings.constants.ts | 30 +-
.../lib/hooks/use-models-selector.svelte.ts | 262 ++++++++++++++----
.../lib/hooks/use-reasoning-menu.svelte.ts | 4 +-
.../src/lib/stores/settings/index.svelte.ts | 27 +-
.../a11y/ChatScreenForm.a11y.stories.svelte | 21 +-
10 files changed, 306 insertions(+), 97 deletions(-)
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte
index a4baa40bf994..434db0cf4f6b 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte
@@ -1,6 +1,14 @@
-{#if hasError}
+{#if hasError || isLoadingModel}
{#if isLoadingModel}
diff --git a/tools/ui/src/lib/constants/icons.constants.ts b/tools/ui/src/lib/constants/icons.constants.ts
index 71741c695724..38de4e0cdcb8 100644
--- a/tools/ui/src/lib/constants/icons.constants.ts
+++ b/tools/ui/src/lib/constants/icons.constants.ts
@@ -31,9 +31,6 @@ export const DEFAULT_FILE_ICON = FileIcon;
/** The mark for a model, wherever one is listed. */
export const MODEL_ICON = ModelIcon;
-/** Alias kept for the selector until it adopts MODEL_ICON. */
-export const MODEL_SELECTOR_ICON = ModelIcon;
-
export const MODALITY_ICONS = {
[ModelModality.AUDIO]: AudioIcon,
[ModelModality.VIDEO]: VideoIcon,
diff --git a/tools/ui/src/lib/constants/settings-keys.constants.ts b/tools/ui/src/lib/constants/settings-keys.constants.ts
index 7727370bde07..574c906de2e1 100644
--- a/tools/ui/src/lib/constants/settings-keys.constants.ts
+++ b/tools/ui/src/lib/constants/settings-keys.constants.ts
@@ -41,6 +41,7 @@ export const SETTINGS_KEYS = {
MCP_SERVERS: 'mcpServers',
MENTION_SEARCH_MAX_DEPTH: 'mentionSearchMaxDepth',
MIN_P: 'min_p',
+ // Display
PASTE_LONG_TEXT_TO_FILE_LEN: 'pasteLongTextToFileLen',
PDF_AS_IMAGE: 'pdfAsImage',
// Performance
@@ -58,7 +59,8 @@ export const SETTINGS_KEYS = {
SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
// Display
SHOW_MESSAGE_STATS: 'showMessageStats',
- SHOW_MODEL_ORG_NAME_IN_TRIGGER: 'showModelOrgNameInTrigger',
+ SHOW_MODEL_CAPABILITIES_IN_SELECTOR: 'showModelCapabilitiesInSelector',
+ SHOW_MODEL_ORG_NAME: 'showModelOrgName',
SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
SHOW_MODEL_TAGS: 'showModelTags',
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
diff --git a/tools/ui/src/lib/constants/settings.constants.ts b/tools/ui/src/lib/constants/settings.constants.ts
index 27d20c924372..2d5cb4afec17 100644
--- a/tools/ui/src/lib/constants/settings.constants.ts
+++ b/tools/ui/src/lib/constants/settings.constants.ts
@@ -28,6 +28,7 @@ import type {
/** Settings sections — slug is the routing identity, title is the display label. */
export const SETTINGS_SECTIONS = {
AGENTIC: { slug: 'agentic', title: 'Agentic' },
+ BACKENDS: { slug: 'backends', title: 'Providers' },
DEVELOPER: { slug: 'developer', title: 'Developer' },
DISPLAY: { slug: 'display', title: 'Display' },
GENERAL: { slug: 'general', title: 'General' },
@@ -38,6 +39,7 @@ export const SETTINGS_SECTIONS = {
export const SETTINGS_SECTION_SLUGS = {
AGENTIC: SETTINGS_SECTIONS.AGENTIC.slug,
+ BACKENDS: SETTINGS_SECTIONS.BACKENDS.slug,
DEVELOPER: SETTINGS_SECTIONS.DEVELOPER.slug,
DISPLAY: SETTINGS_SECTIONS.DISPLAY.slug,
GENERAL: SETTINGS_SECTIONS.GENERAL.slug,
@@ -48,6 +50,7 @@ export const SETTINGS_SECTION_SLUGS = {
export const SETTINGS_SECTION_TITLES = {
AGENTIC: SETTINGS_SECTIONS.AGENTIC.title,
+ BACKENDS: SETTINGS_SECTIONS.BACKENDS.title,
DEVELOPER: SETTINGS_SECTIONS.DEVELOPER.title,
DISPLAY: SETTINGS_SECTIONS.DISPLAY.title,
GENERAL: SETTINGS_SECTIONS.GENERAL.title,
@@ -291,9 +294,16 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
},
{
defaultValue: false,
- help: 'Display the organization name in the model selector trigger button.',
- key: SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER,
- label: 'Show organization name in model selector trigger',
+ help: 'Show the modality and capability icons (vision, audio, reasoning, tools) on the models selector rows.',
+ key: SETTINGS_KEYS.SHOW_MODEL_CAPABILITIES_IN_SELECTOR,
+ label: 'Show model modalities & capabilities in models selector',
+ type: SettingsFieldType.CHECKBOX
+ },
+ {
+ defaultValue: true,
+ help: 'Display the organization name alongside model names in the model selector, in the trigger and in the model rows.',
+ key: SETTINGS_KEYS.SHOW_MODEL_ORG_NAME,
+ label: 'Show organization name in model selector',
type: SettingsFieldType.CHECKBOX
},
{
@@ -303,19 +313,19 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
label: 'Show build version information',
type: SettingsFieldType.CHECKBOX
},
- {
- defaultValue: true,
- help: 'Group models into families (e.g. "Qwen") in the model picker and the models manager. Turn it off to list every model on its own.',
- key: SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY,
- label: 'Group models by family',
- type: SettingsFieldType.CHECKBOX
- },
{
defaultValue: false,
help: 'Display the full file system path inside file and folder @-mention badges instead of just the file or folder name.',
key: SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS,
label: 'Show full path in mentions',
type: SettingsFieldType.CHECKBOX
+ },
+ {
+ defaultValue: true,
+ help: 'Group models into families (e.g. "Qwen") in the model picker and the models manager. Turn it off to list every model on its own.',
+ key: SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY,
+ label: 'Group models by family',
+ type: SettingsFieldType.CHECKBOX
}
],
slug: SETTINGS_SECTION_SLUGS.DISPLAY,
diff --git a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts
index 7d2770a261ad..38c5f3b42b7a 100644
--- a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts
@@ -1,14 +1,35 @@
-import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
-import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
-import { modelsStore, serverStore } from '$lib/stores';
+import type { ModelItem } from '$lib/components/app/navigation/utils';
+import {
+ filterModelOptions,
+ groupFavoriteOptions,
+ groupModelOptions,
+ groupProviderOptions
+} from '$lib/components/app/navigation/utils';
+import {
+ CHAT_INPUT_FOCUS_SELECTOR,
+ LOCAL_BACKEND_ID,
+ REMOTE_PROVIDER_MODEL_LIMIT
+} from '$lib/constants';
+import { backendsModelsStore, backendsStore, modelsStore, serverStore, uiStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
+import { getBackend } from '$lib/utils/api-base';
+import { getBackendCapabilities } from '$lib/utils/backend';
+import { rawModelId } from '$lib/utils/model-option-id';
import { onMount } from 'svelte';
+import { SvelteSet } from 'svelte/reactivity';
+
+/** Groups of the favorites tab, which lists favorites only. */
+const EMPTY_GROUPS = { available: [], loaded: [], providers: [] };
export interface UseModelsSelectorOptions {
currentModel: () => string | null;
useGlobalSelection?: () => boolean;
onModelChange?: () =>
- | ((modelId: string, modelName: string) => Promise | boolean | void)
+ | ((
+ modelId: string,
+ modelName: string,
+ backendId?: string
+ ) => Promise | boolean | void)
| undefined;
onOpenChange?: (open: boolean) => void;
}
@@ -18,19 +39,24 @@ export interface UseModelsSelectorReturn {
readonly loading: boolean;
readonly updating: boolean;
readonly activeId: string | null;
+ readonly emptyMessage: string;
+ readonly isMultiModel: boolean;
readonly isRouter: boolean;
readonly serverModel: string | null;
readonly isHighlightedCurrentModelActive: boolean;
readonly isCurrentModelInCache: boolean;
+ readonly favoriteItems: ModelItem[];
+ readonly loadedItems: ModelItem[];
readonly filteredOptions: ModelOption[];
+ readonly isEmpty: boolean;
+ readonly isProviderView: boolean;
readonly groupedFilteredOptions: ReturnType;
readonly isLoadingModel: boolean;
readonly searchTerm: string;
- readonly showModelDialog: boolean;
- readonly infoModelId: string | null;
+ closeProvider(): void;
+ openProvider(backendId: string): void;
setSearchTerm(value: string): void;
- setShowModelDialog(value: boolean): void;
- handleInfoClick(modelName: string): void;
+ showBackendModels(backendId: string): Promise;
handleSelect(modelId: string): Promise;
handleOpenChange(open: boolean): void;
isFavorite(model: string): boolean;
@@ -45,16 +71,36 @@ export interface UseModelsSelectorReturn {
* duplicating store derivations, selection handling, and model loading.
*/
export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSelectorReturn {
- const options = $derived(
+ /**
+ * Current view: the favorites of every backend, the local server's models, or
+ * the remote backends'. Favorites are the default while there is at least one.
+ */
+ /** Remote backend drilled into from its section; null while browsing. */
+ let providerViewId = $state(null);
+
+ const isProviderView = $derived(providerViewId !== null);
+ const isLocalOption = (option: ModelOption) => option.backendId === LOCAL_BACKEND_ID;
+ // every enabled backend's models are one list: favorites, then the local
+ // server, then one section per remote provider
+ const allOptions = $derived(
modelsStore.models.filter((option) => {
const modelProps = modelsStore.props.getModelProps(option.model);
return modelProps?.ui !== false;
})
);
+ const options = $derived(
+ providerViewId ? allOptions.filter((option) => option.backendId === providerViewId) : allOptions
+ );
const loading = $derived(modelsStore.loading);
const updating = $derived(modelsStore.updating);
const activeId = $derived(modelsStore.selectedModelId);
+ // Router mode and external backends both expose a selectable model list; only
+ // a lone llama.cpp server without a router has nothing to choose from.
+ const isMultiModel = $derived(
+ serverStore.isRouterMode ||
+ backendsStore.enabled.some((backend) => backend.id !== LOCAL_BACKEND_ID)
+ );
const isRouter = $derived(serverStore.isRouterMode);
const serverModel = $derived(modelsStore.singleModelName);
const currentModel = $derived(opts.currentModel());
@@ -62,32 +108,95 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
const isHighlightedCurrentModelActive = $derived.by(() => {
if (!isRouter || !currentModel) return false;
- const currentOption = options.find((option) => option.model === currentModel);
+ const currentOption = allOptions.find((option) => option.model === currentModel);
return currentOption ? currentOption.id === activeId : false;
});
const isCurrentModelInCache = $derived.by(() => {
if (!isRouter || !currentModel) return true;
- return options.some((option) => option.model === currentModel);
+ return allOptions.some((option) => option.model === currentModel);
});
let isLoadingModel = $state(false);
let searchTerm = $state('');
- let showModelDialog = $state(false);
- let infoModelId = $state(null);
+ const visibleOptions = $derived(allOptions.filter((option) => !modelsStore.isHidden(option.id)));
const filteredOptions = $derived(filterModelOptions(options, searchTerm));
- const groupedFilteredOptions = $derived(
- groupModelOptions(filteredOptions, modelsStore.favoriteModelIds, (m) =>
- modelsStore.isModelLoaded(m)
+ // favorites span every backend, so they come from the full option list
+ const favoriteItems = $derived(
+ groupFavoriteOptions(
+ filterModelOptions(visibleOptions, searchTerm),
+ modelsStore.favoriteModelIds
)
);
+ const remoteProviders = $derived(
+ backendsStore.enabled
+ .filter((backend) => backend.id !== LOCAL_BACKEND_ID)
+ .map((backend) => {
+ const state = backendsModelsStore.get(backend.id);
- function handleInfoClick(modelName: string) {
- infoModelId = modelName;
- showModelDialog = true;
- }
+ return {
+ backendId: backend.id,
+ catalog: state.models.length,
+ error: state.error,
+ loading: state.loading,
+ name: backend.name
+ };
+ })
+ );
+ // loaded models lead the list, from any llama-compat backend
+ const isLoadedLlamaCompat = (option: ModelOption) =>
+ modelsStore.isModelLoaded(option.model) &&
+ getBackendCapabilities(getBackend(option.backendId)).loadUnload;
+ const loadedItems = $derived.by(() => {
+ if (isProviderView) return [];
+
+ return filterModelOptions(visibleOptions, searchTerm)
+ .map((option, flatIndex) => ({ flatIndex, option }))
+ .filter(({ option }) => isLoadedLlamaCompat(option));
+ });
+ const loadedIds = $derived(new SvelteSet(loadedItems.map((item) => item.option.id)));
+ // loaded models and favorites are listed once, at the top: the sections skip both
+ const sectionOptions = $derived(
+ filteredOptions.filter(
+ (option) => !modelsStore.favoriteModelIds.has(option.model) && !loadedIds.has(option.id)
+ )
+ );
+ const providerSections = $derived(
+ groupProviderOptions(
+ sectionOptions,
+ remoteProviders,
+ // a drill-in or a search reaches every model, the sections stay short
+ providerViewId || searchTerm ? Infinity : REMOTE_PROVIDER_MODEL_LIMIT,
+ modelsStore.recentModelIds
+ )
+ );
+ const groupedFilteredOptions = $derived.by(() => {
+ if (isProviderView) {
+ const sections = providerSections.filter((section) => section.backendId === providerViewId);
+
+ return { ...EMPTY_GROUPS, providers: sections };
+ }
+
+ const local = groupModelOptions(sectionOptions.filter(isLocalOption), (m) =>
+ modelsStore.isModelLoaded(m)
+ );
+
+ return { ...local, providers: providerSections };
+ });
+ const isEmpty = $derived(
+ filteredOptions.length === 0 && favoriteItems.length === 0 && loadedItems.length === 0
+ );
+ const emptyMessage = $derived(searchTerm ? 'No models found.' : 'No models yet.');
+
+ // a row action can hand over to the models manager, which takes the focus;
+ // the selector closes so it does not sit open behind the dialog
+ $effect(() => {
+ if (!uiStore.manageModelsOpen) return;
+
+ opts.onOpenChange?.(false);
+ });
onMount(() => {
modelsStore.fetch().catch((error) => {
@@ -98,36 +207,61 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
function handleOpenChange(open: boolean) {
if (loading || updating) return;
- if (isRouter) {
- searchTerm = '';
+ // a single-model llama.cpp server with no other backend has no list to show,
+ // so the trigger opens the manager, which holds the one model's configuration
+ if (!isMultiModel) {
+ if (open) uiStore.openModelsManager();
- if (open) {
- modelsStore.fetchRouterModels().then(() => {
- modelsStore.props.fetchModalitiesForLoadedModels();
- });
- }
+ return;
+ }
- opts.onOpenChange?.(open);
- } else {
- showModelDialog = open;
+ searchTerm = '';
+ providerViewId = null;
+
+ if (open && isRouter) {
+ modelsStore.props.fetchModalitiesForLoadedModels();
}
+
+ opts.onOpenChange?.(open);
+ }
+
+ /**
+ * Switch the rendered view. Views are display only: the backend that serves
+ * requests follows the selected model, not the view.
+ */
+ /** Drill into one remote backend's full model list. */
+ function openProvider(backendId: string) {
+ providerViewId = backendId;
+ searchTerm = '';
+ }
+
+ function closeProvider() {
+ providerViewId = null;
+ searchTerm = '';
+ }
+
+ /** Refresh a backend's models, e.g. right after it was added. */
+ async function showBackendModels(backendId: string): Promise {
+ await backendsModelsStore.ensureLoaded(backendId);
}
async function handleSelect(modelId: string) {
- const option = options.find((opt) => opt.id === modelId);
+ // favorites live above the tabs and may belong to another backend, so the
+ // lookup spans every enabled backend
+ const option = allOptions.find((opt) => opt.id === modelId);
if (!option) return;
let shouldCloseMenu = true;
if (onModelChange) {
- const result = await onModelChange(option.id, option.model);
+ const result = await onModelChange(rawModelId(option.id), option.model, option.backendId);
if (result === false) {
shouldCloseMenu = false;
}
} else {
- await modelsStore.selectModelById(option.id);
+ await modelsStore.selectModelById(option.id, { recordRecent: true });
}
if (shouldCloseMenu) {
@@ -140,7 +274,10 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
});
}
- if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
+ // only the built-in server loads on request, and only in router mode
+ const canLoadHere = option.backendId === LOCAL_BACKEND_ID && isRouter;
+
+ if (!onModelChange && canLoadHere && !modelsStore.isModelLoaded(option.model)) {
isLoadingModel = true;
modelsStore.status
@@ -152,6 +289,18 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
function getDisplayOption(): ModelOption | undefined {
if (!isRouter) {
+ // External backend: the selection is backend-scoped, so it wins over
+ // the conversation's model, which may belong to another backend.
+ if (!serverStore.capabilities.props) {
+ const selected = activeId ? allOptions.find((option) => option.id === activeId) : undefined;
+
+ if (selected) return selected;
+
+ return currentModel
+ ? allOptions.find((option) => option.model === currentModel)
+ : undefined;
+ }
+
const displayModel = serverModel || currentModel;
if (displayModel) {
@@ -176,11 +325,11 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
};
}
- return options.find((option) => option.model === currentModel);
+ return allOptions.find((option) => option.model === currentModel);
}
if (activeId) {
- return options.find((option) => option.id === activeId);
+ return allOptions.find((option) => option.id === activeId);
}
return undefined;
@@ -191,6 +340,15 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return activeId;
},
+ closeProvider,
+
+ get emptyMessage() {
+ return emptyMessage;
+ },
+
+ get favoriteItems() {
+ return favoriteItems;
+ },
get filteredOptions() {
return filteredOptions;
},
@@ -201,20 +359,18 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return groupedFilteredOptions;
},
- handleInfoClick,
-
handleOpenChange,
handleSelect,
- get infoModelId() {
- return infoModelId;
- },
-
get isCurrentModelInCache() {
return isCurrentModelInCache;
},
+ get isEmpty() {
+ return isEmpty;
+ },
+
isFavorite(model: string) {
return modelsStore.favoriteModelIds.has(model);
},
@@ -227,14 +383,28 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return isLoadingModel;
},
+ get isMultiModel() {
+ return isMultiModel;
+ },
+
+ get isProviderView() {
+ return isProviderView;
+ },
+
get isRouter() {
return isRouter;
},
+ get loadedItems() {
+ return loadedItems;
+ },
+
get loading() {
return loading;
},
+ openProvider,
+
get options() {
return options;
},
@@ -251,13 +421,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
searchTerm = value;
},
- setShowModelDialog(value: boolean) {
- showModelDialog = value;
- },
-
- get showModelDialog() {
- return showModelDialog;
- },
+ showBackendModels,
get updating() {
return updating;
diff --git a/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts b/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts
index 3c52d7ab2768..ab3012d9c66a 100644
--- a/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts
@@ -20,8 +20,8 @@ export interface UseReasoningMenuReturn {
/**
* Shared reactive state and helpers for the reasoning effort menu.
*
- * Used by both the desktop dropdown (`ChatFormActionAddReasoningSubmenu`)
- * and the mobile sheet (`ChatFormActionAddSheet`) to avoid duplicating the
+ * Used by the models dropdown footer (`ModelsSelectorReasoningPanel`) and the
+ * mobile sheet (`ChatFormActionAddSheet`) to avoid duplicating the
* thinking-support derivation and the effort selection logic.
*/
export function useReasoningMenu(): UseReasoningMenuReturn {
diff --git a/tools/ui/src/lib/stores/settings/index.svelte.ts b/tools/ui/src/lib/stores/settings/index.svelte.ts
index a583a1423fc3..fb98b03dcc67 100644
--- a/tools/ui/src/lib/stores/settings/index.svelte.ts
+++ b/tools/ui/src/lib/stores/settings/index.svelte.ts
@@ -212,7 +212,7 @@ class SettingsStore {
/**
* Initialize the settings store by loading from localStorage.
- * Called by initStores() after migrations have run.
+ * Called by hydrateStores() after migrations have run.
*/
initialize() {
if (!browser) return;
@@ -220,6 +220,7 @@ class SettingsStore {
try {
this.loadConfig();
this.migrateLegacyTheme();
+ this.migrateModelOrgNameKey();
// Apply the persisted theme from config on initial load
setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode);
this.isInitialized = true;
@@ -475,6 +476,30 @@ class SettingsStore {
}
}
+ /**
+ * Move the trigger-only org name key onto the selector wide one. The setting
+ * grew from the trigger to every model id, so an explicit choice has to
+ * survive the rename.
+ */
+ private migrateModelOrgNameKey() {
+ if (!browser) return;
+
+ const legacyKey = 'showModelOrgNameInTrigger';
+ const { config: saved } = SettingsService.loadConfig();
+
+ if (!(legacyKey in saved)) return;
+
+ this.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME] = Boolean(saved[legacyKey]);
+
+ if (this.userOverrides.delete(legacyKey)) {
+ this.userOverrides.add(SETTINGS_KEYS.SHOW_MODEL_ORG_NAME);
+ }
+
+ delete (this.config as Record)[legacyKey];
+
+ this.saveConfig();
+ }
+
/**
* Save the current configuration to localStorage via the persistence service.
*/
diff --git a/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte b/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte
index 826e0d4aa1eb..c56bfd7678bd 100644
--- a/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte
+++ b/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte
@@ -2,25 +2,8 @@
import { defineMeta } from '@storybook/addon-svelte-csf';
import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
import { ATTACHMENT_TOOLTIP_TEXT } from '$lib/constants';
- import { ServerRole } from '$lib/enums';
- import { serverStore } from '$lib/stores';
- import type { ApiLlamaCppServerProps } from '$lib/types';
import { expect, screen, waitFor } from 'storybook/test';
- /**
- * The add menu mounts the reasoning submenu only outside router mode, and the
- * dev server proxies /props to whichever server happens to be running, so pin
- * the mode this story asserts instead of inheriting it from the environment.
- */
- function pinSingleModelMode(): void {
- serverStore.props = {
- ...(serverStore.props ?? {}),
- role: ServerRole.MODEL
- } as ApiLlamaCppServerProps;
-
- serverStore.role = ServerRole.MODEL;
- }
-
const { Story } = defineMeta({
component: ChatScreenForm,
parameters: {
@@ -55,8 +38,6 @@
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
name="AddDropdownFocusesFirstEnabled"
play={async ({ canvas, userEvent }) => {
- pinSingleModelMode();
-
const trigger = await canvas.findByRole('button', { name: ATTACHMENT_TOOLTIP_TEXT });
trigger.focus();
@@ -64,7 +45,7 @@
await screen.findByRole('menu');
await waitFor(() => {
- expect(document.activeElement).toHaveTextContent('Reasoning');
+ expect(document.activeElement).toHaveTextContent('Add files');
});
}}
/>
From f847272314930fc7457966100d01edb4bb356975 Mon Sep 17 00:00:00 2001
From: Aleksander Grygier
Date: Mon, 28 Sep 2026 16:00:58 +0200
Subject: [PATCH 12/13] ui : move reasoning effort beside the model selector
The reasoning level becomes its own control next to the selector, and
the add menu drops its reasoning submenu.
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
---
.../ChatFormActionAddDropdown.svelte | 29 ++-----
.../ChatFormActionAddReasoningSubmenu.svelte | 75 -------------------
.../ChatFormActionAddSheet.svelte | 24 +++---
.../ChatFormActionReasoning.svelte | 73 ++++++++++++++++++
.../ChatFormActions/ChatFormActions.svelte | 6 +-
tools/ui/src/lib/components/app/chat/index.ts | 9 +--
6 files changed, 96 insertions(+), 120 deletions(-)
delete mode 100644 tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte
create mode 100644 tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionReasoning.svelte
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte
index 1ce731845fe7..7d0b3ac1e804 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte
@@ -1,10 +1,6 @@
-
-
-
- {#if reasoning.isReasoningActive}
-
- {:else if reasoning.isOff}
-
- {:else}
-
- {/if}
-
-
- Reasoning
-
-
- {reasoning.currentEffort}
-
-
-
-
-
- {#each reasoning.levels as level (level.value)}
- {@const tokenLabel = reasoning.tokenLabel(level)}
- reasoning.select(level)}
- >
- {#if reasoning.isSelected(level)}
-
- {:else}
-
- {/if}
-
- {level.label}
-
- {#if tokenLabel}
-
- {tokenLabel}
-
- {/if}
-
- {#if level.hasInfo}
-
-
-
-
-
-
- Maximum reasoning effort with extended context usage
-
-
- {/if}
-
- {/each}
-
-
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte
index acd0f4d21161..56e8434e86c9 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte
@@ -226,20 +226,20 @@
- {/if}
- {
- sheetOpen = false;
- chatFormActions.onMcpSettingsClick?.();
- }}
- type="button"
- >
-
+ {
+ sheetOpen = false;
+ chatFormActions.onMcpSettingsClick?.();
+ }}
+ type="button"
+ >
+
- MCP Servers
-
+ MCP Servers
+
+ {/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionReasoning.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionReasoning.svelte
new file mode 100644
index 000000000000..dc40ecbc6af1
--- /dev/null
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionReasoning.svelte
@@ -0,0 +1,73 @@
+
+
+
+
+
+ {#snippet child({ props })}
+
+
+ {#if reasoning.isOff}
+
+ {:else}
+
+ {/if}
+
+ {#if !isDefault}
+ {reasoning.currentEffort}
+ {/if}
+
+
+ {/snippet}
+
+
+
+ {#each reasoning.levels as level (level.value)}
+ {@const tokenLabel = reasoning.tokenLabel(level)}
+
+ reasoning.select(level)}>
+ {#if reasoning.isSelected(level)}
+
+ {:else}
+
+ {/if}
+
+ {level.label}
+
+ {#if tokenLabel}
+ {tokenLabel}
+ {/if}
+
+ {#if level.hasInfo}
+
+
+
+
+
+
+ Maximum reasoning effort with extended context usage
+
+
+ {/if}
+
+ {/each}
+
+
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
index 395f2cfbe177..748f062a6579 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
@@ -3,6 +3,7 @@
import { page } from '$app/state';
import {
ChatFormActionModels,
+ ChatFormActionReasoning,
ChatFormActionRecord,
ChatFormActionsAdd,
ChatFormActionSubmit,
@@ -29,9 +30,9 @@
uploadedFiles?: ChatUploadedFile[];
onFileUpload?: () => void;
onMicClick?: () => void;
+ onMcpSettingsClick?: () => void;
onStop?: () => void;
onSystemPromptClick?: () => void;
- onMcpSettingsClick?: () => void;
}
let {
@@ -135,6 +136,7 @@
get onFileUpload() {
return onFileUpload;
},
+
get onMcpSettingsClick() {
return onMcpSettingsClick;
},
@@ -160,6 +162,8 @@
{/if}
{#if showModelSelector}
+
+
Date: Tue, 29 Sep 2026 13:53:05 +0200
Subject: [PATCH 13/13] ui : drop an orphaned doc comment left by a moved
method
Assisted-by: pi:zai-org/GLM-5.3-Flash
---
tools/ui/src/lib/components/app/chat/index.ts | 1 -
.../ModelsSelector/ModelsSelectorDropdown.svelte | 6 +++---
tools/ui/src/lib/components/app/models/index.ts | 15 +--------------
.../ui/src/lib/constants/css-classes.constants.ts | 6 ++++++
tools/ui/src/lib/constants/ui.constants.ts | 3 +++
tools/ui/src/lib/services/models.service.ts | 5 -----
6 files changed, 13 insertions(+), 23 deletions(-)
diff --git a/tools/ui/src/lib/components/app/chat/index.ts b/tools/ui/src/lib/components/app/chat/index.ts
index 243856bd5539..2f7d417b4e7b 100644
--- a/tools/ui/src/lib/components/app/chat/index.ts
+++ b/tools/ui/src/lib/components/app/chat/index.ts
@@ -221,7 +221,6 @@ export { default as ChatFormActionReasoning } from './ChatForm/ChatFormActions/C
*/
export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte';
-
/**
* Compact context-usage gauge with per-turn and cumulative breakdown in the tooltip.
*/
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte
index 1a142eca9c39..df1987828c5d 100644
--- a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte
@@ -14,8 +14,8 @@
import * as Tooltip from '$lib/components/ui/tooltip';
import {
DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR,
- MODEL_ID,
MODEL_ICON,
+ MODEL_ID,
SETTINGS_KEYS
} from '$lib/constants';
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
@@ -455,8 +455,8 @@
{#if selectedOption}
- {triggerTooltipLabel(selectedOption)}
-
+ {triggerTooltipLabel(selectedOption)}
+
{/if}
{/if}
diff --git a/tools/ui/src/lib/components/app/models/index.ts b/tools/ui/src/lib/components/app/models/index.ts
index ae86e98fc38e..4189bc0e9316 100644
--- a/tools/ui/src/lib/components/app/models/index.ts
+++ b/tools/ui/src/lib/components/app/models/index.ts
@@ -63,6 +63,7 @@ export { default as ModelsSection } from './ModelsSection.svelte';
* styling stay consistent across every model-id surface.
*/
export { default as ModelCapabilityIcons } from './ModelCapabilityIcons.svelte';
+
/**
* **ModelDraftSidecars** - draft sidecar badges
*
@@ -71,18 +72,4 @@ export { default as ModelCapabilityIcons } from './ModelCapabilityIcons.svelte';
* 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';
-
export * from './ModelsSelector';
diff --git a/tools/ui/src/lib/constants/css-classes.constants.ts b/tools/ui/src/lib/constants/css-classes.constants.ts
index edd33c1e923c..40ea4d745852 100644
--- a/tools/ui/src/lib/constants/css-classes.constants.ts
+++ b/tools/ui/src/lib/constants/css-classes.constants.ts
@@ -23,6 +23,12 @@ export const DIALOG_SUBMENU_CONTENT = 'w-60';
export const CHAT_INPUT_FOCUS_SELECTOR =
'[data-slot="input-area"] textarea, [data-slot="input-area"] [contenteditable="true"]';
+/**
+ * Selects the search input inside an open dropdown-menu's content, to focus it
+ * after the menu mounts (bits-ui's default auto-focus can yank page scroll).
+ */
+export const DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR = '[data-slot="dropdown-menu-content"] input';
+
/** Filter controls above the model table: one box, one height, one fill. */
export const FILTER_TRIGGER_CLASS = `
h-8
diff --git a/tools/ui/src/lib/constants/ui.constants.ts b/tools/ui/src/lib/constants/ui.constants.ts
index 661e3dcc7e9e..f974da43e4fd 100644
--- a/tools/ui/src/lib/constants/ui.constants.ts
+++ b/tools/ui/src/lib/constants/ui.constants.ts
@@ -45,6 +45,9 @@ export const STATS_UNITS = {
export const DEFAULT_MOBILE_BREAKPOINT = 768;
+/** Models listed per remote provider before the "+ X more" line; search covers the rest. */
+export const REMOTE_PROVIDER_MODEL_LIMIT = 12;
+
/** Orgs whose avatar is dark and needs inverting in dark mode. */
export const DARK_INVERT_AVATAR_ORGS = ['openai'];
diff --git a/tools/ui/src/lib/services/models.service.ts b/tools/ui/src/lib/services/models.service.ts
index 1431c620d3d2..989ed75002df 100644
--- a/tools/ui/src/lib/services/models.service.ts
+++ b/tools/ui/src/lib/services/models.service.ts
@@ -90,11 +90,6 @@ export class ModelsService {
return apiPost(API_MODELS.DOWNLOAD, payload);
}
- /**
- * True when a router entry id is a sidecar-only entry, e.g. `org/model:Q4_0-mtp`
- * or `org/model:mmproj`. Such entries mark a downloaded sidecar file, not a
- * loadable model, so the selector skips them.
- */
/**
* Draft sidecars a listing carries as their own entries, keyed by the repo they
* belong to. The router lists a downloaded sidecar as a model of its own, so this