diff --git a/core/http/react-ui/e2e/models-gallery.spec.js b/core/http/react-ui/e2e/models-gallery.spec.js index 5d3934710..8e0d58f00 100644 --- a/core/http/react-ui/e2e/models-gallery.spec.js +++ b/core/http/react-ui/e2e/models-gallery.spec.js @@ -664,3 +664,165 @@ test.describe("Models Gallery - Variant picker", () => { expect(variantUrls).toHaveLength(1); }); }); + +// The gallery is heading towards hiding the individual builds a parent entry +// references. Adoption is one entry, so this toggle is the inverse of that end +// state: off by default and changing nothing, on to preview it. The filter is +// server-side because the listing paginates, so these specs assert on the +// request the page actually sends, not just on the rows it renders. +const VARIANTS_ONLY_RESPONSE = { + ...MOCK_MODELS_RESPONSE, + models: MOCK_MODELS_RESPONSE.models.filter((m) => m.has_variants), + availableModels: 1, + totalPages: 1, + currentPage: 1, +}; + +test.describe("Models Gallery - Has Variants Filter", () => { + let listingUrls; + + const variantsToggle = (page) => + page + .locator("label.filter-bar-group__toggle", { hasText: "Has variants" }) + .locator(".toggle__track"); + + test.beforeEach(async ({ page }) => { + listingUrls = []; + + await page.route("**/api/models*", (route) => { + const url = new URL(route.request().url()); + // Only the listing itself; sibling routes like /api/models/estimate + // must not pollute the record of what the filter sent. + if (url.pathname.endsWith("/api/models")) { + listingUrls.push(url); + } + const onlyVariants = url.searchParams.get("has_variants") === "true"; + const tag = url.searchParams.get("tag"); + let body = MOCK_MODELS_RESPONSE; + if (onlyVariants) { + // Stacking the toggle with a usecase filter is the case that easily + // yields nothing while a single entry declares variants. + body = tag ? EMPTY_FILTERED_RESPONSE : VARIANTS_ONLY_RESPONSE; + } + route.fulfill({ + contentType: "application/json", + body: JSON.stringify(body), + }); + }); + + await page.goto("/app/models"); + await expect(page.locator("th", { hasText: "Backend" })).toBeVisible({ + timeout: 10_000, + }); + }); + + test("the toggle is visible", async ({ page }) => { + await expect(page.getByText("Has variants")).toBeVisible(); + }); + + test("defaults to off, showing everything and sending no filter param", async ({ + page, + }) => { + await expect(page.getByLabel("Has variants")).not.toBeChecked(); + await expect(page.locator("tr", { hasText: "llama-model" })).toBeVisible(); + await expect( + page.locator("tr", { hasText: "whisper-model" }), + ).toBeVisible(); + await expect( + page.locator("tr", { hasText: "stablediffusion-model" }), + ).toBeVisible(); + + // Asserted positively over every listing request, so a param that leaked + // in as has_variants=false would still fail this. + expect(listingUrls.length).toBeGreaterThan(0); + for (const url of listingUrls) { + expect(url.searchParams.has("has_variants")).toBe(false); + } + }); + + test("toggling on sends has_variants=true and narrows the list", async ({ + page, + }) => { + await variantsToggle(page).click(); + + await expect(page.locator("tr", { hasText: "llama-model" })).toBeVisible(); + await expect(page.locator("tr", { hasText: "whisper-model" })).toHaveCount( + 0, + ); + await expect( + page.locator("tr", { hasText: "stablediffusion-model" }), + ).toHaveCount(0); + + const last = listingUrls[listingUrls.length - 1]; + expect(last.searchParams.get("has_variants")).toBe("true"); + }); + + test("toggling off restores the full list and drops the param", async ({ + page, + }) => { + await variantsToggle(page).click(); + await expect(page.locator("tr", { hasText: "whisper-model" })).toHaveCount( + 0, + ); + + await variantsToggle(page).click(); + + await expect( + page.locator("tr", { hasText: "whisper-model" }), + ).toBeVisible(); + await expect( + page.locator("tr", { hasText: "stablediffusion-model" }), + ).toBeVisible(); + + const last = listingUrls[listingUrls.length - 1]; + expect(last.searchParams.has("has_variants")).toBe(false); + }); + + test("resets to page 1 when toggled", async ({ page }) => { + await variantsToggle(page).click(); + const last = listingUrls[listingUrls.length - 1]; + // A filter that narrowed the rows while holding page 3 would strand the + // user on a page the filtered set no longer has. + expect(last.searchParams.get("page") || "1").toBe("1"); + }); + + test("state persists after reload, like the other filters", async ({ + page, + }) => { + await variantsToggle(page).click(); + await page.reload(); + await expect(page.getByLabel("Has variants")).toBeChecked(); + await expect(page.locator("tr", { hasText: "whisper-model" })).toHaveCount( + 0, + ); + }); + + test("names the variants filter in the empty state when it is the cause", async ({ + page, + }) => { + await variantsToggle(page).click(); + await page.locator(".filter-btn", { hasText: "Chat" }).click(); + + await expect(page.locator(".empty-state-title")).toHaveText( + "No models found", + ); + // The generic copy would leave a user thinking the gallery is broken + // rather than that this toggle is doing exactly what it says. + await expect(page.locator(".empty-state-text")).toHaveText( + "No entries declare variants yet. Turn off the variants filter to see the full gallery.", + ); + }); + + test("clear filters turns the toggle back off", async ({ page }) => { + await variantsToggle(page).click(); + await page.locator(".filter-btn", { hasText: "Chat" }).click(); + await expect(page.locator(".empty-state")).toBeVisible(); + + await page.getByRole("button", { name: "Clear filters" }).click(); + + await expect(page.getByLabel("Has variants")).not.toBeChecked(); + await expect( + page.locator("tr", { hasText: "whisper-model" }), + ).toBeVisible(); + }); +}); diff --git a/core/http/react-ui/public/locales/de/models.json b/core/http/react-ui/public/locales/de/models.json index 9142a1b8b..b1f73320b 100644 --- a/core/http/react-ui/public/locales/de/models.json +++ b/core/http/react-ui/public/locales/de/models.json @@ -24,6 +24,7 @@ "embedding": "Embedding", "rerank": "Rerank", "fitsGpu": "Passt in die GPU", + "hasVariants": "Mit Varianten", "allBackends": "Alle Backends", "searchBackends": "Backends suchen..." }, @@ -71,6 +72,7 @@ "empty": { "title": "Keine Modelle gefunden", "withFilters": "Keine Modelle entsprechen den aktuellen Such- oder Filterkriterien.", + "withVariantsFilter": "Noch keine Eintraege deklarieren Varianten. Schalte den Varianten-Filter aus, um die vollstaendige Galerie zu sehen.", "noFilters": "Die Modellgalerie ist leer." }, "deleteDialog": { diff --git a/core/http/react-ui/public/locales/en/models.json b/core/http/react-ui/public/locales/en/models.json index 9c750774f..5bcb7db14 100644 --- a/core/http/react-ui/public/locales/en/models.json +++ b/core/http/react-ui/public/locales/en/models.json @@ -43,6 +43,7 @@ "vad": "VAD", "ner": "NER", "fitsGpu": "Fits in GPU", + "hasVariants": "Has variants", "allBackends": "All Backends", "searchBackends": "Search backends..." }, @@ -90,6 +91,7 @@ "empty": { "title": "No models found", "withFilters": "No models match your current search or filters.", + "withVariantsFilter": "No entries declare variants yet. Turn off the variants filter to see the full gallery.", "noFilters": "The model gallery is empty." }, "deleteDialog": { diff --git a/core/http/react-ui/public/locales/es/models.json b/core/http/react-ui/public/locales/es/models.json index 69ca19dae..e0005b6f1 100644 --- a/core/http/react-ui/public/locales/es/models.json +++ b/core/http/react-ui/public/locales/es/models.json @@ -24,6 +24,7 @@ "embedding": "Embedding", "rerank": "Rerank", "fitsGpu": "Cabe en la GPU", + "hasVariants": "Con variantes", "allBackends": "Todos los backends", "searchBackends": "Buscar backends..." }, @@ -71,6 +72,7 @@ "empty": { "title": "No se encontraron modelos", "withFilters": "Ningún modelo coincide con la búsqueda o filtros actuales.", + "withVariantsFilter": "Ninguna entrada declara variantes todavia. Desactiva el filtro de variantes para ver la galeria completa.", "noFilters": "La galería de modelos está vacía." }, "deleteDialog": { diff --git a/core/http/react-ui/public/locales/id/models.json b/core/http/react-ui/public/locales/id/models.json index 3acff3afb..4c0b582fa 100644 --- a/core/http/react-ui/public/locales/id/models.json +++ b/core/http/react-ui/public/locales/id/models.json @@ -31,6 +31,7 @@ "detection": "Deteksi", "vad": "VAD", "fitsGpu": "Muat di GPU", + "hasVariants": "Punya varian", "allBackends": "Semua Backend", "searchBackends": "Cari backends..." }, @@ -78,6 +79,7 @@ "empty": { "title": "Model tidak ditemukan", "withFilters": "Tidak ada model yang cocok dengan pencarian atau filter Anda.", + "withVariantsFilter": "Belum ada entri yang mendeklarasikan varian. Matikan filter varian untuk melihat seluruh galeri.", "noFilters": "Galeri model kosong." }, "deleteDialog": { diff --git a/core/http/react-ui/public/locales/it/models.json b/core/http/react-ui/public/locales/it/models.json index 15eca936f..dbfea4f82 100644 --- a/core/http/react-ui/public/locales/it/models.json +++ b/core/http/react-ui/public/locales/it/models.json @@ -24,6 +24,7 @@ "embedding": "Embedding", "rerank": "Rerank", "fitsGpu": "Entra nella GPU", + "hasVariants": "Con varianti", "allBackends": "Tutti i backend", "searchBackends": "Cerca backend..." }, @@ -71,6 +72,7 @@ "empty": { "title": "Nessun modello trovato", "withFilters": "Nessun modello corrisponde ai filtri attuali.", + "withVariantsFilter": "Nessuna voce dichiara varianti al momento. Disattiva il filtro varianti per vedere l'intera galleria.", "noFilters": "La galleria modelli è vuota." }, "deleteDialog": { diff --git a/core/http/react-ui/public/locales/zh-CN/models.json b/core/http/react-ui/public/locales/zh-CN/models.json index 88cd49d7d..a3d064e5e 100644 --- a/core/http/react-ui/public/locales/zh-CN/models.json +++ b/core/http/react-ui/public/locales/zh-CN/models.json @@ -24,6 +24,7 @@ "embedding": "嵌入", "rerank": "重排", "fitsGpu": "适合 GPU", + "hasVariants": "含变体", "allBackends": "所有后端", "searchBackends": "搜索后端..." }, @@ -71,6 +72,7 @@ "empty": { "title": "未找到模型", "withFilters": "没有模型与当前搜索或筛选条件匹配。", + "withVariantsFilter": "目前没有条目声明变体。关闭变体筛选即可查看完整模型库。", "noFilters": "模型库为空。" }, "deleteDialog": { diff --git a/core/http/react-ui/src/pages/Models.jsx b/core/http/react-ui/src/pages/Models.jsx index 75375632b..7e211622d 100644 --- a/core/http/react-ui/src/pages/Models.jsx +++ b/core/http/react-ui/src/pages/Models.jsx @@ -22,6 +22,7 @@ import React from 'react' const CONTEXT_SIZES = [8192, 16384, 32768, 65536, 131072, 262144] const CONTEXT_LABELS = ['8K', '16K', '32K', '64K', '128K', '256K'] const FITS_FILTER_STORAGE_KEY = 'localai-models-fits-filter' +const VARIANTS_FILTER_STORAGE_KEY = 'localai-models-has-variants-filter' const FILTERS = [ @@ -86,6 +87,16 @@ export default function Models() { return false } }) + // Narrows the listing to entries that declare variants. Server-side, unlike + // fitsFilter, because the listing paginates and a client-side narrowing + // would leave the page count describing the unfiltered set. + const [variantsFilter, setVariantsFilter] = useState(() => { + try { + return localStorage.getItem(VARIANTS_FILTER_STORAGE_KEY) === '1' + } catch { + return false + } + }) // Total GPU memory for "fits" check const totalGpuMemory = resources?.aggregate?.total_memory || 0 @@ -97,6 +108,7 @@ export default function Models() { const filtersVal = params.filters !== undefined ? params.filters : filters const sortVal = params.sort !== undefined ? params.sort : sort const backendVal = params.backendFilter !== undefined ? params.backendFilter : backendFilter + const variantsVal = params.variantsFilter !== undefined ? params.variantsFilter : variantsFilter const queryParams = { page: params.page || page, items: 9, @@ -104,6 +116,9 @@ export default function Models() { if (filtersVal.length > 0) queryParams.tag = filtersVal.join(',') if (searchVal) queryParams.term = searchVal if (backendVal) queryParams.backend = backendVal + // Omitted entirely when off, so the default request is byte-for-byte + // what it was before the toggle existed. + if (variantsVal) queryParams.has_variants = 'true' if (sortVal) { queryParams.sort = sortVal queryParams.order = params.order || order @@ -121,11 +136,11 @@ export default function Models() { } finally { setLoading(false) } - }, [page, search, filters, sort, order, backendFilter, addToast, t]) + }, [page, search, filters, sort, order, backendFilter, variantsFilter, addToast, t]) useEffect(() => { fetchModels() - }, [page, filters, sort, order, backendFilter]) + }, [page, filters, sort, order, backendFilter, variantsFilter]) // Fetch backend→usecase mapping once on mount useEffect(() => { @@ -290,6 +305,14 @@ export default function Models() { } }, [fitsFilter]) + useEffect(() => { + try { + localStorage.setItem(VARIANTS_FILTER_STORAGE_KEY, variantsFilter ? '1' : '0') + } catch { + // Ignore storage errors (e.g., private browsing restrictions). + } + }, [variantsFilter]) + const visibleModels = models.filter((model) => { if (!fitsFilter) return true const name = model.name || model.id @@ -361,8 +384,16 @@ export default function Models() { ) })} + + { setVariantsFilter(v); setPage(1) }} + /> + + {t('filters.hasVariants')} + {totalGpuMemory > 0 && ( - + {t('filters.fitsGpu')} @@ -376,7 +407,6 @@ export default function Models() { placeholder={t('filters.allBackends')} allOption={t('filters.allBackends')} searchPlaceholder={t('filters.searchBackends')} - style={totalGpuMemory > 0 ? undefined : { marginLeft: 'auto' }} /> )} @@ -408,12 +438,14 @@ export default function Models() { {t('empty.title')} - {search || filters.length > 0 || backendFilter || fitsFilter ? t('empty.withFilters') : t('empty.noFilters')} + {variantsFilter + ? t('empty.withVariantsFilter') + : search || filters.length > 0 || backendFilter || fitsFilter ? t('empty.withFilters') : t('empty.noFilters')} - {(search || filters.length > 0 || backendFilter || fitsFilter) && ( + {(search || filters.length > 0 || backendFilter || fitsFilter || variantsFilter) && ( { handleSearch(''); setFilters([]); setBackendFilter(''); setFitsFilter(false); setPage(1) }} + onClick={() => { handleSearch(''); setFilters([]); setBackendFilter(''); setFitsFilter(false); setVariantsFilter(false); setPage(1) }} > {t('search.clearFilters')} diff --git a/core/http/routes/ui_api.go b/core/http/routes/ui_api.go index ec5f4258e..b3dcc8ce1 100644 --- a/core/http/routes/ui_api.go +++ b/core/http/routes/ui_api.go @@ -480,6 +480,30 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model models = filtered } + // Narrow to entries that declare variants, i.e. the ones the listing + // marks with has_variants. + // + // This is the inverse of where the gallery is heading. The end state + // hides the individual builds a parent entry references, so a user sees + // one row per model rather than one per quantization. Adoption is still + // a single entry, so defaulting to that today would empty the gallery. + // Opt-in instead: with the parameter absent the response is exactly + // what it was, and the toggle is a preview of the end state that costs + // nothing until someone asks for it. + // + // Server-side because the listing paginates at 9 items; filtering the + // current page in the client would leave the page count describing the + // unfiltered set and hand the user empty pages. + if c.QueryParam("has_variants") == "true" { + filtered := make(gallery.GalleryElements[*gallery.GalleryModel], 0, len(models)) + for _, m := range models { + if m.HasVariants() { + filtered = append(filtered, m) + } + } + models = filtered + } + // Capability filters are derived from the effective gallery model // configuration. In particular, voice cloning is variant-sensitive, so // filtering by the TTS usecase or backend name alone would advertise diff --git a/core/http/routes/ui_api_variants_test.go b/core/http/routes/ui_api_variants_test.go index 508a45223..994caee07 100644 --- a/core/http/routes/ui_api_variants_test.go +++ b/core/http/routes/ui_api_variants_test.go @@ -208,4 +208,86 @@ var _ = Describe("Model gallery variants API", func() { Expect(code).To(Equal(http.StatusNotFound)) }) }) + + // The gallery is heading towards hiding the individual builds a parent + // entry references. Adoption is one entry, so the toggle is the inverse of + // that end state: off by default and changing nothing, on to preview it. + Context("the has_variants listing filter", func() { + names := func(path string) []string { + code, body := get(path) + Expect(code).To(Equal(http.StatusOK)) + raw, ok := body["models"].([]any) + Expect(ok).To(BeTrue(), "listing must return a models array") + out := make([]string, 0, len(raw)) + for _, m := range raw { + out = append(out, m.(map[string]any)["name"].(string)) + } + return out + } + + rawBody := func(path string) []byte { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + app.ServeHTTP(rec, req) + Expect(rec.Code).To(Equal(http.StatusOK)) + return rec.Body.Bytes() + } + + It("returns every entry when off", func() { + Expect(names("/api/models?items=9999")).To(ConsistOf("base-entry", "big-entry", "plain-entry")) + }) + + It("returns only entries that declare variants when on", func() { + // big-entry is the build base-entry references, so it must drop + // out even though it is a perfectly valid entry on its own. + Expect(names("/api/models?items=9999&has_variants=true")).To(ConsistOf("base-entry")) + }) + + It("leaves the response untouched for any value other than true", func() { + // Default off has to mean off, so an explicit false and an + // unparseable value must both behave as absent rather than as a + // truthy presence check. + base := rawBody("/api/models?items=9999") + Expect(rawBody("/api/models?items=9999&has_variants=false")).To(Equal(base)) + Expect(rawBody("/api/models?items=9999&has_variants=1")).To(Equal(base)) + }) + + It("serializes a non-declaring entry exactly as it did before", func() { + // The whole promise of the migration phase: a user who never + // touches the toggle sees byte-for-byte what they saw before. + entry := find(listing(), "plain-entry") + Expect(entry).NotTo(HaveKey("has_variants")) + Expect(entry).NotTo(HaveKey("variants")) + Expect(entry).NotTo(HaveKey("auto_variant")) + }) + + It("composes with the backend filter rather than replacing it", func() { + // base-entry declares variants and is llama-cpp; plain-entry is + // whisper and declares none. If either filter overwrote the other, + // the whisper case would return base-entry or plain-entry instead + // of nothing. + Expect(names("/api/models?items=9999&has_variants=true&backend=llama-cpp")).To(ConsistOf("base-entry")) + Expect(names("/api/models?items=9999&has_variants=true&backend=whisper")).To(BeEmpty()) + Expect(names("/api/models?items=9999&backend=whisper")).To(ConsistOf("plain-entry")) + }) + + It("composes with the search term", func() { + Expect(names("/api/models?items=9999&has_variants=true&term=base")).To(ConsistOf("base-entry")) + Expect(names("/api/models?items=9999&has_variants=true&term=plain")).To(BeEmpty()) + }) + + It("reports the filtered total so pagination stays honest", func() { + // The listing paginates at 9, so a filter that narrowed the rows + // without narrowing the count would hand the user empty pages. + _, body := get("/api/models?items=9999&has_variants=true") + Expect(body["availableModels"]).To(BeEquivalentTo(1)) + Expect(body["totalPages"]).To(BeEquivalentTo(1)) + }) + + It("still issues no variant probes when filtering", func() { + names("/api/models?items=9999&has_variants=true") + Expect(probes.Load()).To(BeZero(), + "the filter must select on declared metadata, not by describing variants") + }) + }) })
- {search || filters.length > 0 || backendFilter || fitsFilter ? t('empty.withFilters') : t('empty.noFilters')} + {variantsFilter + ? t('empty.withVariantsFilter') + : search || filters.length > 0 || backendFilter || fitsFilter ? t('empty.withFilters') : t('empty.noFilters')}