diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte index 679c2522b1..1f2c682204 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte @@ -1,11 +1,6 @@ +{#snippet familyRow(family: ModelFamilyGroup, prefix: string)} + {@const key = familyKey(prefix, family.key)} + {@const isExpanded = !collapsedFamilies.has(key)} + + +{/snippet} + +{#snippet familyRows(items: ModelItem[], prefix: string)} + {#each groupModelFamilies(items, (item) => item.option.model) as family (familyKey(prefix, family.key))} + {@render familyRow(family, prefix)} + + {#if !collapsedFamilies.has(familyKey(prefix, family.key))} + {#each family.entries as item (`family-${prefix}-${item.option.id}`)} +
{@render render(item, !showOrgName)}
+ {/each} + {/if} + {/each} +{/snippet} + {#snippet defaultOption(item: ModelItem, hideOrgName: boolean)} {@const { option } = item} {@const isSelected = currentModel === option.model || activeId === option.id} @@ -165,11 +227,10 @@ {@render render(item, !showOrgName)} {/each} - {#each localGroups.available as group (group.orgName)} - {#each group.items as item (item.option.id)} - {@render render(item, !showOrgName)} - {/each} - {/each} + {@render familyRows( + localGroups.available.flatMap((group) => group.items), + 'local' + )} {#if hasMoreLocal} @@ -202,9 +263,7 @@ sticky > {#if provider.items.length > 0} - {#each provider.items as item (`${provider.backendId}-${item.option.id}`)} - {@render render(item, !showOrgName)} - {/each} + {@render familyRows(provider.items, provider.backendId)} {#if onProviderOpen && provider.matched > provider.items.length} diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts index 79c2c67fa9..f43596a469 100644 --- a/tools/ui/src/lib/utils/index.ts +++ b/tools/ui/src/lib/utils/index.ts @@ -131,6 +131,7 @@ export { isValidModelName, normalizeModelName, orgOf } from './model-names'; // Backend-qualified model option ids export { backendIdFromModelId, qualifyModelId, rawModelId } from './model-option-id'; +export { groupModelFamilies, modelFamilyKey, type ModelFamilyGroup } from './model-families'; // Sidecar token utilities export { isAuxSidecar, isDraftSidecar, sidecarFromFileToken } from './sidecars'; diff --git a/tools/ui/src/lib/utils/model-families.ts b/tools/ui/src/lib/utils/model-families.ts new file mode 100644 index 0000000000..342a91440d --- /dev/null +++ b/tools/ui/src/lib/utils/model-families.ts @@ -0,0 +1,41 @@ +/** One family of a model list, and the entries it covers. */ +export interface ModelFamilyGroup { + entries: T[]; + key: string; + label: string; +} + +/** + * Family a repo belongs to, from its name. The version is dropped, so `Qwen3.5`, + * `Qwen3.8-27B` and `Qwen3.8-Flash-Next` all read as `Qwen`. A name that does not + * start with letters keeps its first segment. + */ +export function modelFamilyKey(repo: string): string { + const name = repo.split('/').pop() ?? repo; + const letters = name.match(/^[A-Za-z]+/); + + return letters ? letters[0] : (name.split(/[-_.]/)[0] ?? name); +} + +/** Fold entries into families, so `Qwen` collects its sizes and variants. */ +export function groupModelFamilies( + entries: T[], + modelOf: (entry: T) => string +): ModelFamilyGroup[] { + const families = new Map>(); + + for (const entry of entries) { + const key = modelFamilyKey(modelOf(entry)); + const family = families.get(key); + + if (family) { + family.entries.push(entry); + + continue; + } + + families.set(key, { entries: [entry], key, label: key }); + } + + return Array.from(families.values()); +}