From ea612b4842fff6691141ee9bd4029bab9b3e0060 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Mon, 28 Sep 2026 15:07:14 +0200 Subject: [PATCH] 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 | 341 ++++++++++++-- .../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, 3351 insertions(+), 130 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 86d599e671..c38fce566d 100644 --- a/tools/ui/package-lock.json +++ b/tools/ui/package-lock.json @@ -12,7 +12,7 @@ "@eslint/compat": "1.4.1", "@eslint/js": "9.39.2", "@internationalized/date": "3.12.2", - "@lucide/svelte": "1.25.0", + "@lucide/svelte": "1.46.0", "@modelcontextprotocol/sdk": "1.30.0", "@playwright/test": "1.56.1", "@storybook/addon-a11y": "10.5.6", @@ -3225,9 +3225,9 @@ } }, "node_modules/@lucide/svelte": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz", - "integrity": "sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==", + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.46.0.tgz", + "integrity": "sha512-Ru8jDjoCVLJ7J7pPRUdY+odSBV2BI6rj68JTmT+ENq/ZwYOPGPAiKVvKz+XLyAoK4HAwlRTzZHpsMvrHfsetqg==", "dev": true, "license": "ISC", "peerDependencies": { diff --git a/tools/ui/package.json b/tools/ui/package.json index 4017604741..5e7c05bf39 100644 --- a/tools/ui/package.json +++ b/tools/ui/package.json @@ -32,7 +32,7 @@ "@eslint/compat": "1.4.1", "@eslint/js": "9.39.2", "@internationalized/date": "3.12.2", - "@lucide/svelte": "1.25.0", + "@lucide/svelte": "1.46.0", "@modelcontextprotocol/sdk": "1.30.0", "@playwright/test": "1.56.1", "@storybook/addon-a11y": "10.5.6", diff --git a/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte b/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte new file mode 100644 index 0000000000..1c54fec333 --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte @@ -0,0 +1,37 @@ + + + + event.preventDefault()} + onOpenAutoFocus={(event) => event.preventDefault()} + > + + + + + Models + + + +
+ +
+
+
diff --git a/tools/ui/src/lib/components/app/dialogs/index.ts b/tools/ui/src/lib/components/app/dialogs/index.ts index 9b0b62bd08..85db4e8143 100644 --- a/tools/ui/src/lib/components/app/dialogs/index.ts +++ b/tools/ui/src/lib/components/app/dialogs/index.ts @@ -537,3 +537,6 @@ export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview. * ``` */ export { default as DialogMermaidPreview } from './DialogMermaidPreview.svelte'; + +/** **DialogManageModels** - shell for the models manager. */ +export { default as DialogManageModels } from './DialogManageModels.svelte'; diff --git a/tools/ui/src/lib/components/app/forms/SearchInput.svelte b/tools/ui/src/lib/components/app/forms/SearchInput.svelte index 99d261208f..674d2f6be2 100644 --- a/tools/ui/src/lib/components/app/forms/SearchInput.svelte +++ b/tools/ui/src/lib/components/app/forms/SearchInput.svelte @@ -11,6 +11,8 @@ onClose?: () => void; onKeyDown?: (event: KeyboardEvent) => void; class?: string; + /** Input height, matching the small size of the other controls. */ + size?: 'sm' | 'default'; id?: string; ref?: HTMLInputElement | null; isCancelAlwaysVisible?: boolean; @@ -26,6 +28,7 @@ onKeyDown, placeholder = 'Search...', ref = $bindable(null), + size = 'default', value = $bindable('') }: Props = $props(); @@ -58,7 +61,7 @@ bind:ref bind:value {autofocus} - class="pl-9 {showClearButton ? 'pr-9' : ''}" + class="pl-9 {showClearButton ? 'pr-9' : ''} {size === 'sm' ? 'h-8' : ''}" {id} oninput={handleInput} onkeydown={onKeyDown} diff --git a/tools/ui/src/lib/components/app/misc/CollapsibleRegion.svelte b/tools/ui/src/lib/components/app/misc/CollapsibleRegion.svelte new file mode 100644 index 0000000000..4a7a2ffbcc --- /dev/null +++ b/tools/ui/src/lib/components/app/misc/CollapsibleRegion.svelte @@ -0,0 +1,87 @@ + + +
+ {#if contentMounted} +
+ {@render children()} +
+ {/if} +
+ + diff --git a/tools/ui/src/lib/components/app/misc/CollapsibleSection.svelte b/tools/ui/src/lib/components/app/misc/CollapsibleSection.svelte new file mode 100644 index 0000000000..68e4e9b7cb --- /dev/null +++ b/tools/ui/src/lib/components/app/misc/CollapsibleSection.svelte @@ -0,0 +1,116 @@ + + +{#snippet chevron()} + + {#if open} + + {:else} + + {/if} + +{/snippet} + +{#snippet triggerButton()} + {#if inMenu} + + + {#snippet child({ props })} + + + {/snippet} + + {:else} + + {/if} +{/snippet} + +{#snippet region()} + + {#snippet body()} + {@render children()} + {/snippet} + + {@render body()} +{/snippet} + +{#if triggerPosition === 'top'} + {@render triggerButton()} + + {@render region()} +{:else} + {@render region()} + + {@render triggerButton()} +{/if} diff --git a/tools/ui/src/lib/components/app/misc/GroupedList.svelte b/tools/ui/src/lib/components/app/misc/GroupedList.svelte new file mode 100644 index 0000000000..5c8927a107 --- /dev/null +++ b/tools/ui/src/lib/components/app/misc/GroupedList.svelte @@ -0,0 +1,167 @@ + + + + +{#if groups} + {#each windowed as entry (entry.key)} + {@const expanded = !collapsed.has(entry.key)} + + {#if group} +
+ {@render group({ + depth: 0, + expanded, + group: entry.group, + key: entry.key, + toggle: () => toggleGroup(entry.key) + })} +
+ {/if} + + + {#each entry.rows as row (keyOf(row))} + {@render item({ depth: 1, entry: row })} + {/each} + + {#if entry.hidden > 0 && more} + {@render more({ + count: Math.min(groupWindow, entry.hidden), + onMore: () => growGroup(entry.key), + unit: 'entries' + })} + {/if} + + {/each} + + {#if windowed.length < (groups?.length ?? 0) && more} + {@render more({ + count: Math.min(sectionWindow, (groups?.length ?? 0) - windowed.length), + onMore: () => (sectionSteps += 1), + unit: 'families' + })} + {/if} +{:else} + {#each flatRows as row (keyOf(row))} + {@render item({ depth: 0, entry: row })} + {/each} + + {#if flatRows.length < items.length && more} + {@render more({ + count: Math.min(sectionWindow, items.length - flatRows.length), + onMore: () => (sectionSteps += 1), + unit: 'entries' + })} + {/if} +{/if} diff --git a/tools/ui/src/lib/components/app/misc/index.ts b/tools/ui/src/lib/components/app/misc/index.ts index a10410ef92..e695f242e4 100644 --- a/tools/ui/src/lib/components/app/misc/index.ts +++ b/tools/ui/src/lib/components/app/misc/index.ts @@ -59,3 +59,12 @@ export { default as CodeBlockActions } from './CodeBlockActions.svelte'; * `class` and `style` props and inherits color via `currentColor`. */ export { default as Logo } from './Logo.svelte'; +/** + * **CollapsibleSection** - Section with a trigger that expands in place + * + * The trigger can sit above or below the content, and the chevron can stay out + * of sight while the section is open. Collapsed content unmounts after the + * transition, so hidden rows never reach menu keyboard navigation. + */ +export { default as CollapsibleSection } from './CollapsibleSection.svelte'; +export { default as GroupedList, type GroupedListGroup } from './GroupedList.svelte'; diff --git a/tools/ui/src/lib/components/app/models/ModelAvatar.svelte b/tools/ui/src/lib/components/app/models/ModelAvatar.svelte new file mode 100644 index 0000000000..9dc30338b0 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelAvatar.svelte @@ -0,0 +1,89 @@ + + +{#if orgName} + (isNearViewport = true)} + class={['inline-flex shrink-0', className]} + > + + +{:else} + {@render fallback?.()} +{/if} diff --git a/tools/ui/src/lib/components/app/models/ModelBadge.svelte b/tools/ui/src/lib/components/app/models/ModelBadge.svelte index d5b723ff90..53cdcc26a8 100644 --- a/tools/ui/src/lib/components/app/models/ModelBadge.svelte +++ b/tools/ui/src/lib/components/app/models/ModelBadge.svelte @@ -1,8 +1,8 @@ + + (isNearViewport = true)} class={className}> + + diff --git a/tools/ui/src/lib/components/app/models/ModelCapabilityIcons.svelte b/tools/ui/src/lib/components/app/models/ModelCapabilityIcons.svelte index c9505a95eb..e98631bd1f 100644 --- a/tools/ui/src/lib/components/app/models/ModelCapabilityIcons.svelte +++ b/tools/ui/src/lib/components/app/models/ModelCapabilityIcons.svelte @@ -52,7 +52,7 @@ {/if} {#if hasModalityIcons && !hideModalities} - + {#if modalities?.vision} diff --git a/tools/ui/src/lib/components/app/models/ModelContext.svelte b/tools/ui/src/lib/components/app/models/ModelContext.svelte new file mode 100644 index 0000000000..775925ba8d --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelContext.svelte @@ -0,0 +1,57 @@ + + + (isNearViewport = true)} + class={['text-sm text-muted-foreground', className]} +> + {values.length ? `${values.map((value) => formatParameters(value)).join(' / ')} tokens` : '-'} + diff --git a/tools/ui/src/lib/components/app/models/ModelDraftSidecars.svelte b/tools/ui/src/lib/components/app/models/ModelDraftSidecars.svelte new file mode 100644 index 0000000000..446d9650ac --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelDraftSidecars.svelte @@ -0,0 +1,25 @@ + + +{#each kinds as kind (kind)} + + {kind} + +{/each} diff --git a/tools/ui/src/lib/components/app/models/ModelId.svelte b/tools/ui/src/lib/components/app/models/ModelId.svelte index ba3a976aef..27f8fbb5c5 100644 --- a/tools/ui/src/lib/components/app/models/ModelId.svelte +++ b/tools/ui/src/lib/components/app/models/ModelId.svelte @@ -1,13 +1,15 @@ + +
+ {#if isLoading} + + {:else} + + {#if showAction && revealOnHover} + {#if isFailed} + + {:else} + + {/if} + {:else} + {#if isFailed} + + {:else} + + {/if} + {/if} + + +
+ {#if isFailed} + modelsStore.status.load(option.model)} + stopPropagationOnClick + tooltip="Retry loading model" + tooltipAsTitle + /> + {:else if isLoaded || isSleeping} + modelsStore.status.unload(option.model)} + stopPropagationOnClick + tooltip="Unload model" + tooltipAsTitle + /> + {:else} + modelsStore.status.load(option.model)} + stopPropagationOnClick + tooltip="Load model" + tooltipAsTitle + /> + {/if} +
+ {/if} +
diff --git a/tools/ui/src/lib/components/app/models/ModelRowActions.svelte b/tools/ui/src/lib/components/app/models/ModelRowActions.svelte new file mode 100644 index 0000000000..6218f824a5 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelRowActions.svelte @@ -0,0 +1,71 @@ + + +
event.stopPropagation()} + onkeydown={(event) => event.stopPropagation()} + role="presentation" +> + uiStore.openModelsManager(option.id)} + tooltip="Manage model" + tooltipAsTitle + /> + + {#if isFav} + + + modelsStore.toggleFavorite(option.model)} + tooltip="Remove from favorites" + tooltipAsTitle + /> + + + + + {:else} + modelsStore.toggleFavorite(option.model)} + tooltip="Add to favorites" + tooltipAsTitle + /> + {/if} +
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte new file mode 100644 index 0000000000..7171099e74 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte @@ -0,0 +1,159 @@ + + +
+
+ (selectedId = option.id)} + {selectedId} + {toolbarEnd} + /> +
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte new file mode 100644 index 0000000000..9120ce582d --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte @@ -0,0 +1,104 @@ + + + + (contextLimit = Number(value))} + type="single" + value={String(contextLimit)} + > + + Context: + + {contextLabel} + + + + {#each CONTEXT_STEPS as step (step.value)} + {step.label} + {/each} + + + + + {#each TOGGLES as toggle (toggle.value)} + + + + {/each} + + diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte new file mode 100644 index 0000000000..c694b58173 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte @@ -0,0 +1,87 @@ + + +
onSelect(option)} + onkeydown={handleKeydown} + role="button" + tabindex="0" +> + + + + + + + + + + + + + + +
+ +
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte new file mode 100644 index 0000000000..d1f80d29b6 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte @@ -0,0 +1,419 @@ + + +{#snippet sortHeader(key: ModelsTableSortKey, label: string)} + +{/snippet} + +{#snippet entryTree(entry: ModelQuantGroup, indent = 0)} + {#if entry.quants.length > 1} + toggleQuants(entry.key)} + /> + + {#if !collapsedQuants.has(entry.key)} + {#each entry.quants as quant (quant.id)} + + {/each} + {/if} + {:else} + + {/if} +{/snippet} + +{#snippet familyRow({ + expanded, + group: family, + toggle +}: { + expanded: boolean; + group: ModelFamilyGroup; + toggle: () => void; +})} + {@const countLabel = `${family.entries.length} model${family.entries.length === 1 ? '' : 's'}`} + +
handleFamilyKeydown(event, toggle)} + role="button" + tabindex="0" + > + + + + {family.label} + + {countLabel} + + + + + + + + {#if expanded} + + {:else} + + {/if} + +
+{/snippet} + +{#snippet listItem({ depth, entry }: { depth: number; entry: ModelQuantGroup })} + {@render entryTree(entry, depth > 0 ? 16 : 0)} +{/snippet} + +{#snippet showMore({ + count, + onMore, + unit +}: { + count: number; + onMore: () => void; + unit: 'entries' | 'families'; +})} +
+ +
+{/snippet} + + (cancelOpen = false)} + open={cancelOpen} + repoWithTag={pendingCancel} +/> + + (deleteOpen = false)} + open={deleteOpen} + repoWithTag={pendingDelete} +/> + +
+ + +
+ {@render sortHeader(ModelsTableSortKey.NAME, 'Model')} + + + {@render sortHeader(ModelsTableSortKey.CONTEXT, 'Context')} + + + + {@render sortHeader(ModelsTableSortKey.STATUS, 'Status')} + + + Actions +
+ +
+ {#if downloadEntries.length > 0} + + {#snippet icon()} + + {/snippet} + + {#each downloadEntries as entry (entry.repoWithTag)} + + {/each} + + {/if} + + {#each sections as group (group.key)} + {#if group.items.length > 0} + {#snippet groupIcon()} + {#if group.kind === ModelsTableGroupKind.FAVORITES} + + {:else if group.kind === ModelsTableGroupKind.LOADED} + + {:else if group.kind === ModelsTableGroupKind.HIDDEN} + + {:else if group.kind === ModelsTableGroupKind.LOCAL} + + {/if} + {/snippet} + + + entry.key} + more={showMore} + sectionWindow={MODEL_ROW_WINDOW} + stickyClass="bg-muted/30 backdrop-blur-lg" + stickyStyle="top: calc(2.25rem - 1px)" + weightOf={(entry) => entry.quants.length} + /> + + {/if} + {/each} + + {#if isEmpty} +

+ {hasFilters ? 'No models match these filters.' : 'No models found.'} +

+ {/if} +
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerQuantRow.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerQuantRow.svelte new file mode 100644 index 0000000000..da8ac4821b --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerQuantRow.svelte @@ -0,0 +1,70 @@ + + +
onSelect(option)} + onkeydown={handleKeydown} + role="button" + tabindex="0" +> + + {quant} + + + + {option.model} + + + + + + +
+ +
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerRepoRow.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerRepoRow.svelte new file mode 100644 index 0000000000..2693c12caa --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerRepoRow.svelte @@ -0,0 +1,101 @@ + + +
+ + + + + + + + + + + {groupLabel} + + + + + + + + + + + {#if expanded} + + {:else} + + {/if} + +
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte new file mode 100644 index 0000000000..9f3d2873a4 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte @@ -0,0 +1,29 @@ + + + diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte new file mode 100644 index 0000000000..3ec0c6c4eb --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte @@ -0,0 +1,84 @@ + + +
+ + + + + {#if hasFilters} + + {/if} + +
+ {@render toolbarEnd?.()} +
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts b/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts new file mode 100644 index 0000000000..48452d0bac --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts @@ -0,0 +1,32 @@ +import { Eye, EyeOff, Heart, HeartOff, Trash2 } from '@lucide/svelte'; +import { modelsStore } from '$lib/stores'; +import type { ModelOption } from '$lib/types/models'; + +/** Row actions follow the app's dropdown pattern: icon, label, separators, variants. */ +export function modelRowActions( + option: ModelOption, + favorite: boolean, + isHidden: boolean, + onDelete: (option: ModelOption) => void +) { + return [ + { + icon: favorite ? HeartOff : Heart, + label: favorite ? 'Remove from favorites' : 'Add to favorites', + onclick: () => modelsStore.toggleFavorite(option.model) + }, + { + icon: Trash2, + label: 'Delete from disk', + onclick: () => onDelete(option), + separator: true, + variant: 'destructive' as const + }, + { + icon: isHidden ? Eye : EyeOff, + label: isHidden ? 'Unhide model' : 'Hide model', + onclick: () => modelsStore.toggleHidden(option.id), + separator: true + } + ]; +} diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts b/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts new file mode 100644 index 0000000000..d55c83f73a --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts @@ -0,0 +1,150 @@ +import { ModelCapability, ServerModelStatus } from '$lib/enums'; +import { HuggingFaceService, ModelsService } from '$lib/services'; +import { modelsStore } from '$lib/stores'; +import type { ModelModalities, ModelOption } from '$lib/types/models'; +import { detectThinkingSupport, detectToolUseSupport } from '$lib/utils'; + +/** What a group folds, which decides its label. */ +export type ModelGroupKind = 'quants' | 'variants'; + +/** One repo of the table, with the rows it ships as. */ +export interface ModelQuantGroup { + base: ModelOption; + key: string; + kind: ModelGroupKind; + quants: ModelOption[]; +} + +/** + * Kind of one collapsible block of the manager's table. A plain constant map, so + * provider blocks can extend the union with their own string kinds. + */ +export const ModelsTableGroupKind = { + FAVORITES: 'favorites', + HIDDEN: 'hidden', + LOADED: 'loaded', + LOCAL: 'local' +} as const; + +export type ModelsTableGroupKind = (typeof ModelsTableGroupKind)[keyof typeof ModelsTableGroupKind]; + +/** Column the manager's table can be ordered by. */ +export const ModelsTableSortKey = { + CONTEXT: 'context', + NAME: 'name', + STATUS: 'status' +} as const; + +export type ModelsTableSortKey = (typeof ModelsTableSortKey)[keyof typeof ModelsTableSortKey]; + +/** Header label of each manager section. */ +export const MODELS_TABLE_GROUP_LABELS: Record = { + [ModelsTableGroupKind.FAVORITES]: 'Favorites', + [ModelsTableGroupKind.HIDDEN]: 'Hidden models', + [ModelsTableGroupKind.LOADED]: 'Loaded models', + [ModelsTableGroupKind.LOCAL]: 'Local models' +}; + +/** One collapsible block of the manager's table. */ +export interface ModelsTableGroup { + /** One entry per repo, its quants hanging off it. */ + items: ModelQuantGroup[]; + key: string; + kind: ModelsTableGroupKind; + label: string; +} + +/** Modalities a model can accept, as the manager filter offers them. */ +export type ModalityKey = keyof ModelModalities; + +/** Modalities the manager filter offers, in display order. */ +export const MODALITY_KEYS: ModalityKey[] = ['vision', 'video', 'audio']; + +/** True when the model is loaded (or sleeping) and not mid-operation. */ +export function isModelRunning(option: ModelOption): boolean { + const status = modelsStore.getModelStatus(option.model); + + return ( + (status === ServerModelStatus.LOADED || status === ServerModelStatus.SLEEPING) && + !modelsStore.status.isOperationInProgress(option.model) + ); +} + +/** Context the model runs with: what a loaded model reports. */ +export function configuredContext(option: ModelOption): number | null { + return isModelRunning(option) ? modelsStore.props.getModelContextSize(option.model) : null; +} + +export function modelSupports(option: ModelOption, capability: ModelCapability): boolean { + if (option.capabilities.includes(capability)) return true; + + const template = HuggingFaceService.cachedDetails(option.model)?.gguf?.chat_template ?? ''; + + return capability === ModelCapability.TOOL_USE + ? detectToolUseSupport(template) + : detectThinkingSupport(template); +} + +/** + * Context a model reports: the provider listing first, then whatever the Hub has + * cached for it. 0 means nothing is known yet. + */ +export function modelContextLength(option: ModelOption): number { + return ( + option.contextLength ?? + HuggingFaceService.cachedDetails(option.model)?.gguf?.context_length ?? + 0 + ); +} + +/** Repo a `repo:quant` id belongs to, the id itself when it carries no quant. */ +export function modelRepoKey(model: string): string { + const quant = ModelsService.parseModelId(model).quantization; + + return quant ? model.slice(0, model.lastIndexOf(':')) : model; +} + +/** Fold the quants of one repo into a single entry, so the table shows one row per model. */ +export function groupModelQuants(models: ModelOption[]): ModelQuantGroup[] { + const groups = new Map(); + + for (const option of models) { + const key = modelRepoKey(option.model); + const group = groups.get(key); + + if (group) { + group.quants.push(option); + + continue; + } + + groups.set(key, { base: option, key, kind: 'quants', quants: [option] }); + } + + return Array.from(groups.values()).flatMap((group) => { + const kind = groupKind(group.quants); + const modelIds = group.quants.map((option) => option.model); + + // the very same id twice is not a quant set; keep those rows apart + if (modelIds.length > 1 && modelIds.every((model) => model === modelIds[0])) { + return group.quants.map((option) => ({ + ...group, + base: option, + key: `${group.key}::${option.id}`, + kind: 'variants' as const, + quants: [option] + })); + } + + return [{ ...group, kind }]; + }); +} + +/** What a group folds: quants or variants of one repo. */ +function groupKind(quants: ModelOption[]): ModelGroupKind { + const isQuant = quants.every( + (option) => (option.parsedId ?? ModelsService.parseModelId(option.model)).quantization + ); + + return isQuant ? 'quants' : 'variants'; +} diff --git a/tools/ui/src/lib/components/app/models/ModelsSection.svelte b/tools/ui/src/lib/components/app/models/ModelsSection.svelte new file mode 100644 index 0000000000..2427adf1d9 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsSection.svelte @@ -0,0 +1,100 @@ + + + modelsStore.setGroupOpen(persistKey, open) : null} + {revealChevronOnHover} + {triggerClass} + {triggerStyle} +> + {#snippet trigger()} + {#if onBack} + + {/if} + + {#if icon} + {@render icon()} + {/if} + + {label} + + {#if loading} + + {:else if error} + + {/if} + + {#if count !== undefined} + {count} + {/if} + {/snippet} + + {@render children()} + diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte new file mode 100644 index 0000000000..6ee03f2d79 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte @@ -0,0 +1,135 @@ + + + +
+ + + + + + + {#if entry.progress && entry.progress.totalBytes > 0} + + {/if} +
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte index 30dedcbae9..2e279e4edb 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte @@ -12,11 +12,13 @@ } from '$lib/components/app'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as Tooltip from '$lib/components/ui/tooltip'; - import { MODEL_SELECTOR_ICON, SETTINGS_KEYS } from '$lib/constants'; + import { MODEL_ID, MODEL_SELECTOR_ICON, SETTINGS_KEYS } from '$lib/constants'; import { KeyboardKey, ServerModelStatus } from '$lib/enums'; import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte'; import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte'; + import { ModelsService } from '$lib/services/models.service'; import { modelsStore, settingsStore } from '$lib/stores'; + import type { ModelOption, ModelSidecarBadge } from '$lib/types/models'; import { modelLoadFraction } from '$lib/utils'; interface Props { @@ -66,6 +68,27 @@ const reasoning = useReasoningMenu(); + /** Draft sidecar as it reads in the trigger tooltip, with its own quant. */ + function draftSidecarLabel(baseModel: string, badge: ModelSidecarBadge): string { + const baseRepo = baseModel.split(MODEL_ID.QUANTIZATION_SEPARATOR)[0]; + + // a sidecar of the model's own repo reads as a bare tag, a foreign one keeps its id + if (badge.repo === baseRepo) { + return `${badge.kind.toUpperCase()}${badge.quant ? `:${badge.quant}` : ''}`; + } + + return ModelsService.buildDownloadTag(badge.repo, badge.quant, badge.kind); + } + + /** Raw id of the selected model, plus every draft sidecar it pulls. */ + function triggerTooltipLabel(option: ModelOption): string { + const drafts = (option.draftSidecars ?? []).map((badge) => + draftSidecarLabel(option.model, badge) + ); + + return [option.model, ...drafts].join(' + '); + } + const showOrgNameInTrigger = $derived( settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER] ?? false ); @@ -229,8 +252,8 @@ {#if selectedOption} {:else} @@ -257,7 +280,7 @@ {#if selectedOption} -

{selectedOption.model}

+

{triggerTooltipLabel(selectedOption)}

{/if}
@@ -274,8 +297,8 @@ {#if selectedOption} {:else} @@ -381,8 +404,8 @@ {#if selectedOption} {/if} @@ -400,7 +423,7 @@ {#if selectedOption} -

{selectedOption.model}

+

{triggerTooltipLabel(selectedOption)}

{/if} diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte index cee7d0c3b5..b97de73ca6 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte @@ -87,6 +87,7 @@ - + {#if orgAvatarFailed}