diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte index 4ffdd01f67..50c07dbf16 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte @@ -6,7 +6,12 @@ import { BackendIcon } from '$lib/components/app/backends'; import { DialogConfirmDownload } from '$lib/components/app/dialogs'; import Logo from '$lib/components/app/misc/Logo.svelte'; - import type { GroupedModelOptions, ModelItem } from '$lib/components/app/navigation/utils'; + import { + type GroupedModelOptions, + type ModelItem, + windowLocalGroups + } from '$lib/components/app/navigation/utils'; + import { MODEL_ROW_WINDOW } from '$lib/constants'; import { ModelDownloadConfirmAction } from '$lib/enums'; import { modelsStore } from '$lib/stores'; import { getBackend } from '$lib/utils/api-base'; @@ -43,6 +48,38 @@ showOrgName = true }: Props = $props(); let render = $derived(renderOption ?? defaultOption); + // A large local catalog is mounted a window at a time; the sentinel at the end + // of the list grows the window when it scrolls into view. + let visibleCount = $state(MODEL_ROW_WINDOW); + let sentinelEl = $state(null); + const localGroups = $derived(windowLocalGroups(groups, visibleCount)); + const localRowCount = $derived( + groups.loaded.length + groups.available.reduce((count, group) => count + group.items.length, 0) + ); + const hasMoreLocal = $derived(localGroups.shown < localRowCount); + + $effect(() => { + const sentinel = sentinelEl; + + if (!sentinel || !hasMoreLocal) return; + + // scroll does not bubble, so listen in the capture phase and ask the + // sentinel where it is instead of guessing which ancestor scrolls + const onScroll = () => { + const rect = sentinel.getBoundingClientRect(); + + // grow only when the end of the list is actually on screen, otherwise + // every scroll event of the page would mount the whole catalog + if (rect.top > window.innerHeight || rect.bottom < 0) return; + + visibleCount += MODEL_ROW_WINDOW; + }; + + document.addEventListener('scroll', onScroll, { capture: true, passive: true }); + onScroll(); + + return () => document.removeEventListener('scroll', onScroll, { capture: true }); + }); // a local-only install is one list: favorites first, then the rest, no headings let localOnly = $derived(groups.providers.length === 0); // section headers stick right below the search/tabs block of the dropdown @@ -120,15 +157,19 @@ {#snippet localRows()} - {#each groups.loaded as item (`loaded-${item.option.id}`)} + {#each localGroups.loaded as item (`loaded-${item.option.id}`)} {@render render(item, !showOrgName)} {/each} - {#each groups.available as group (group.orgName)} + {#each localGroups.available as group (group.orgName)} {#each group.items as item (item.option.id)} {@render render(item, !showOrgName)} {/each} {/each} + + {#if hasMoreLocal} + + {/if} {/snippet} {#if groups.loaded.length > 0 || groups.available.length > 0} diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte index 92c4c2394c..8feea5c983 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte @@ -85,10 +85,40 @@ let fetchedBaseModelOrg = $state(null); let baseModelOrg = $derived(orgOf(tagBaseModel) || fetchedBaseModelOrg); + // Long local lists mount hundreds of rows at once; resolving every base model + // up front means one Hugging Face request per row, so wait until a row is + // actually near the viewport. + let rowEl = $state(null); + let isNearViewport = $state(false); + + $effect(() => { + if (isNearViewport || !rowEl) return; + + if (typeof IntersectionObserver === 'undefined') { + isNearViewport = true; + + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + isNearViewport = true; + observer.disconnect(); + } + }, + { rootMargin: '200px' } + ); + + observer.observe(rowEl); + + return () => observer.disconnect(); + }); + $effect(() => { fetchedBaseModelOrg = null; - if (!showBaseModelAvatar || !orgName || tagBaseModel) return; + if (!isNearViewport || !showBaseModelAvatar || !orgName || tagBaseModel) return; // external provider ids (`~openai/gpt-...`, `deepseek/deepseek-chat`) are // not HF repos; their org is already the provider slug, so the base model @@ -117,6 +147,7 @@
0) available.push({ ...group, items }); + } + + return { available, loaded, shown }; +} + export function groupModelOptions( filteredOptions: ModelOption[], isModelLoaded: (model: string) => boolean diff --git a/tools/ui/src/lib/constants/ui.constants.ts b/tools/ui/src/lib/constants/ui.constants.ts index d140521f1f..8199f187eb 100644 --- a/tools/ui/src/lib/constants/ui.constants.ts +++ b/tools/ui/src/lib/constants/ui.constants.ts @@ -53,6 +53,12 @@ export const MODEL_SELECTOR_ICON = Package; /** Models listed per remote provider before the "+ X more" line; search covers the rest. */ export const REMOTE_PROVIDER_MODEL_LIMIT = 12; +/** + * Model rows mounted before the list is scrolled. A local catalog can hold + * hundreds of models and mounting them all costs seconds on open. + */ +export const MODEL_ROW_WINDOW = 50; + /** Recently used models kept per browser, most recent first. */ export const RECENT_MODEL_LIMIT = 20;