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}