diff --git a/studio/backend/core/inference/orchestrator.py b/studio/backend/core/inference/orchestrator.py index 42de014a8d..6ff7fd2cbf 100644 --- a/studio/backend/core/inference/orchestrator.py +++ b/studio/backend/core/inference/orchestrator.py @@ -130,17 +130,17 @@ class InferenceOrchestrator: ) if resp.status_code == 200: models = resp.json() - # Top 8 GGUFs (frontend deduplicates against downloaded, - # so we fetch extra to always fill 4 slots) + # Top 40 GGUFs - frontend pages through them on-demand via + # infinite scroll, so we send a deep pool. gguf_ids = [ m["id"] for m in models if m.get("id", "").upper().endswith("-GGUF") - ][:8] - # Top 8 non-GGUF hub models + ][:40] + # Top 40 non-GGUF hub models hub_ids = [ m["id"] for m in models if not m.get("id", "").upper().endswith("-GGUF") - ][:8] + ][:40] if gguf_ids: self._top_gguf_cache = gguf_ids logger.info("Top GGUF models: %s", gguf_ids) diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 78881384b2..328cba3acd 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -35,7 +35,7 @@ import { checkVramFit, estimateLoadingVram } from "@/lib/vram"; import { Search01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Trash2Icon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { toast } from "sonner"; import type { LoraModelOption, @@ -455,21 +455,43 @@ export function HubModelPicker({ const all = dedupe([...models.map((model) => model.id), value ?? ""]) .filter((id) => !downloadedSet.has(id.toLowerCase())) .filter((id) => !chatOnly || isGgufRepo(id)); - // Cap at 4 GGUFs + 4 non-GGUFs so the list stays manageable + // Sort: GGUFs first, then hub models const gguf: string[] = []; const hub: string[] = []; for (const id of all) { - if (isGgufRepo(id) && gguf.length < 4) gguf.push(id); - else if (!isGgufRepo(id) && hub.length < 4) hub.push(id); + if (isGgufRepo(id)) gguf.push(id); + else hub.push(id); } return [...gguf, ...hub]; }, [models, value, downloadedSet, chatOnly]); + // Infinite scroll paging for the recommended section + const [recommendedPage, setRecommendedPage] = useState(1); + // Reset page when the underlying list changes + useEffect(() => { setRecommendedPage(1); }, [models, chatOnly]); + + const visibleRecommendedIds = useMemo(() => { + const hubStartIndex = recommendedIds.findIndex((id) => !isGgufRepo(id)); + const allGguf = hubStartIndex === -1 ? recommendedIds : recommendedIds.slice(0, hubStartIndex); + const allHub = hubStartIndex === -1 ? [] : recommendedIds.slice(hubStartIndex); + // Interleave in chunks of 4: [4 gguf, 4 hub, 4 gguf, 4 hub, ...] + const result: string[] = []; + for (let p = 0; p < recommendedPage; p++) { + result.push(...allGguf.slice(p * 4, (p + 1) * 4)); + result.push(...allHub.slice(p * 4, (p + 1) * 4)); + } + return result; + }, [recommendedIds, recommendedPage]); + + const hasMoreRecommended = visibleRecommendedIds.length < recommendedIds.length; + + // Fetch VRAM info for the full pool once (recommendedIds is stable across + // page increments) so we don't re-fetch on every scroll. const { paramCountById: recommendedParamCountById } = useRecommendedModelVram(recommendedIds); const showHfSection = debouncedQuery.trim().length > 0; - const recommendedSet = useMemo(() => new Set(recommendedIds), [recommendedIds]); + const recommendedSet = useMemo(() => new Set(visibleRecommendedIds), [visibleRecommendedIds]); const hfIds = useMemo(() => { if (!showHfSection) return []; @@ -519,7 +541,7 @@ export function HubModelPicker({ string, { est: number; status: VramFitStatus | null; detail: string | null } >(); - for (const id of recommendedIds) { + for (const id of visibleRecommendedIds) { const totalParams = recommendedParamCountById.get(id); if (totalParams) { const est = estimateLoadingVram(totalParams, "qlora"); @@ -531,10 +553,36 @@ export function HubModelPicker({ } } return map; - }, [recommendedIds, recommendedParamCountById, gpu]); + }, [visibleRecommendedIds, recommendedParamCountById, gpu]); const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore, results.length); + // Sentinel + IntersectionObserver for recommended infinite scroll. + // We disconnect after each fire so the observer doesn't loop while + // React re-renders; the effect re-creates it on the next page. + // Uses a callback ref for the sentinel so we detect mount/unmount reliably. + const [recommendedSentinel, setRecommendedSentinel] = useState(null); + const recommendedSentinelRef = useCallback((node: HTMLDivElement | null) => { + setRecommendedSentinel(node); + }, []); + useEffect(() => { + if (!recommendedSentinel || !hasMoreRecommended) return; + const root = scrollRef.current; + if (!root) return; + const obs = new IntersectionObserver( + ([e]) => { + if (e.isIntersecting) { + obs.disconnect(); + setRecommendedPage((p) => p + 1); + } + }, + { threshold: 0, root }, + ); + // Small delay so the browser finishes layout after the previous page render + const timer = setTimeout(() => obs.observe(recommendedSentinel), 100); + return () => { clearTimeout(timer); obs.disconnect(); }; + }, [recommendedSentinel, hasMoreRecommended, recommendedPage, scrollRef]); + /** Handle clicking a model row — GGUF repos expand, others load directly. */ const handleModelClick = useCallback( (id: string) => { @@ -622,12 +670,12 @@ export function HubModelPicker({ {!showHfSection && cachedReady ? ( <> {"\uD83E\uDDA5"} Recommended - {recommendedIds.length === 0 ? ( + {visibleRecommendedIds.length === 0 ? (
No default models.
) : ( - recommendedIds.map((id) => { + visibleRecommendedIds.map((id) => { const vram = recommendedVramMap.get(id); return (
@@ -651,6 +699,14 @@ export function HubModelPicker({ ); }) )} + {hasMoreRecommended && ( + <> +
+
+ +
+ + )} ) : null}