From f7ff7b0ed38bd00f9ba583d3240a0d051f4586d5 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sun, 19 Jul 2026 20:25:14 +0000 Subject: [PATCH] feat(gallery): collapse the listing to one row per model The listing supported has_variants=true, which narrowed to entries that DECLARE variants. With adoption at three entries that showed three rows, which is useless; it was always a placeholder. Replace it with the view that is actually useful: the deduplicated gallery. Show every entry installable in its own right and nothing twice, which means the parents plus every entry nobody references, and hide only the builds another entry already offers as a variant, since those are reachable through their parent. The parameter is renamed to collapse_variants accordingly: the filter is no longer a predicate on a row's own metadata but a view over the whole gallery. Default stays off, so the response with the parameter absent is unchanged. VariantReferencedIDs never reports an entry that declares variants of its own, so parents are always visible. That guarantees every hidden entry has a visible entry offering it, and no chain can strand a row. Variant resolution already refuses to install such a reference, but the listing has to stay coherent in the presence of a gallery that has one rather than silently swallowing entries. Self-references and dangling references hide nothing. The referenced set is computed over the whole gallery rather than over what the other filters left, so an entry is hidden because a parent offers it and never because of what the user searched for. The pass is over metadata already in memory: it resolves nothing over the network and triggers no variant description or size probe, so the listing's zero-probe contract still holds. The UI toggle keeps its behaviour (persistence, page reset, clear filters) and becomes "One row per model", which says what the user gets. Its localStorage key moves too, since the stored value meant a different filter. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto --- .agents/adding-gallery-models.md | 4 + core/gallery/collapse_variants.go | 87 +++++++++++++++++ core/gallery/collapse_variants_test.go | 95 +++++++++++++++++++ core/http/react-ui/e2e/models-gallery.spec.js | 71 +++++++------- .../react-ui/public/locales/de/models.json | 4 +- .../react-ui/public/locales/en/models.json | 4 +- .../react-ui/public/locales/es/models.json | 4 +- .../react-ui/public/locales/id/models.json | 4 +- .../react-ui/public/locales/it/models.json | 4 +- .../react-ui/public/locales/zh-CN/models.json | 4 +- core/http/react-ui/src/pages/Models.jsx | 41 ++++---- core/http/routes/ui_api.go | 34 ++++--- core/http/routes/ui_api_variants_test.go | 54 ++++++----- docs/content/features/model-gallery.md | 22 +++++ 14 files changed, 335 insertions(+), 97 deletions(-) create mode 100644 core/gallery/collapse_variants.go create mode 100644 core/gallery/collapse_variants_test.go diff --git a/.agents/adding-gallery-models.md b/.agents/adding-gallery-models.md index aef70e060..00c99ea18 100644 --- a/.agents/adding-gallery-models.md +++ b/.agents/adding-gallery-models.md @@ -112,6 +112,10 @@ Rules: LocalAI release, which simply ignore `variants`. - A variant references another gallery entry **by name**. That entry must exist and must not declare `variants` of its own. +- **A referenced entry keeps its own gallery row by default.** It is hidden only + in the collapsed listing (`collapse_variants=true`, the UI's "One row per + model" toggle), where the declaring entry stands in for it, so referencing an + entry never makes it unreachable. - **Order carries no meaning.** Do not try to encode a preference; write the list in whatever order reads best. - **A variant may be smaller than the declaring entry.** Offering a downgrade diff --git a/core/gallery/collapse_variants.go b/core/gallery/collapse_variants.go new file mode 100644 index 000000000..14c794b1b --- /dev/null +++ b/core/gallery/collapse_variants.go @@ -0,0 +1,87 @@ +package gallery + +import ( + "os" + "strings" +) + +// VariantReferencedIDs reports the IDs of entries that another entry already +// offers as one of its variants, and which therefore need no row of their own +// in a deduplicated listing: they are reachable by installing the entry that +// references them. +// +// The returned keys are GalleryModel.ID() values, so an entry is identified by +// gallery and name rather than by name alone. Two galleries may legitimately +// ship an entry of the same name, and only the one actually referenced should +// disappear. +// +// An entry that declares variants of its own is never reported, even when +// something else references it. That guard is what keeps a chain from hiding a +// row the user cannot otherwise reach: parents are always visible, so every +// hidden entry is guaranteed to have a visible entry that offers it. Such a +// reference is an authoring error anyway, and variant resolution already +// refuses to install it, but the listing must stay coherent in the presence of +// a gallery that has one rather than silently swallowing entries. +// +// This is a pure pass over metadata already in memory. It resolves nothing over +// the network and probes no weight files: whether an entry is referenced is +// answerable from the declared names alone. +func VariantReferencedIDs(models []*GalleryModel) map[string]struct{} { + // FindGalleryElement resolves a reference by scanning every entry, which + // would make this quadratic over the whole gallery. The index below is the + // same lookup precomputed once, so the matching rules have to agree with + // it: case-insensitive, path separators folded to "__", and a reference + // carrying an "@" addressed against "gallery@name" instead of the bare name. + byName := make(map[string]*GalleryModel, len(models)) + byQualified := make(map[string]*GalleryModel, len(models)) + for _, m := range models { + if m == nil { + continue + } + // First match wins, mirroring FindGalleryElement's scan order, so a + // name colliding across galleries resolves the same way here as it + // does at install time. + name := variantLookupKey(m.Name) + if _, seen := byName[name]; !seen { + byName[name] = m + } + qualified := variantLookupKey(m.ID()) + if _, seen := byQualified[qualified]; !seen { + byQualified[qualified] = m + } + } + + referenced := map[string]struct{}{} + for _, m := range models { + if m == nil { + continue + } + for _, v := range m.Variants { + key := variantLookupKey(v.Model) + target, ok := byQualified[key] + if !strings.Contains(key, "@") { + target, ok = byName[key] + } + // A dangling reference names nothing to hide. Reporting it is the + // linter's job, not the listing's. + if !ok || target == nil { + continue + } + if target.HasVariants() { + continue + } + // An entry naming itself would otherwise erase itself from the + // gallery entirely. + if target.ID() == m.ID() { + continue + } + referenced[target.ID()] = struct{}{} + } + } + + return referenced +} + +func variantLookupKey(name string) string { + return strings.ToLower(strings.ReplaceAll(name, string(os.PathSeparator), "__")) +} diff --git a/core/gallery/collapse_variants_test.go b/core/gallery/collapse_variants_test.go new file mode 100644 index 000000000..c46dcd452 --- /dev/null +++ b/core/gallery/collapse_variants_test.go @@ -0,0 +1,95 @@ +package gallery_test + +import ( + "github.com/mudler/LocalAI/core/config" + . "github.com/mudler/LocalAI/core/gallery" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// VariantReferencedIDs answers the only question the deduplicated listing asks: +// which entries does another entry already offer, and so need no row of their +// own. Everything else is shown. +var _ = Describe("VariantReferencedIDs", func() { + entry := func(gal, name string, variants ...string) *GalleryModel { + m := &GalleryModel{} + m.Name = name + m.Gallery = config.Gallery{Name: gal} + for _, v := range variants { + m.Variants = append(m.Variants, Variant{Model: v}) + } + return m + } + + It("reports the builds a parent references and nothing else", func() { + parent := entry("test", "parent", "build-a", "build-b") + models := []*GalleryModel{ + parent, + entry("test", "build-a"), + entry("test", "build-b"), + entry("test", "standalone"), + } + + Expect(VariantReferencedIDs(models)).To(HaveLen(2)) + Expect(VariantReferencedIDs(models)).To(HaveKey("test@build-a")) + Expect(VariantReferencedIDs(models)).To(HaveKey("test@build-b")) + // The parent is installable in its own right, and a standalone entry + // is nobody's variant. + Expect(VariantReferencedIDs(models)).NotTo(HaveKey("test@parent")) + Expect(VariantReferencedIDs(models)).NotTo(HaveKey("test@standalone")) + }) + + It("never hides an entry that declares variants of its own", func() { + // A parent referencing another parent is an authoring error that + // variant resolution refuses to install. The listing still has to stay + // coherent: if the chain hid the middle entry, the build it offers + // would be unreachable, since nothing visible would mention it. + top := entry("test", "top", "middle") + middle := entry("test", "middle", "leaf") + models := []*GalleryModel{top, middle, entry("test", "leaf")} + + referenced := VariantReferencedIDs(models) + Expect(referenced).NotTo(HaveKey("test@middle"), + "hiding a parent would strand the builds only it offers") + Expect(referenced).To(HaveKey("test@leaf")) + }) + + It("ignores an entry that references itself", func() { + // Self-reference would otherwise erase the entry from the gallery. + models := []*GalleryModel{entry("test", "loop", "loop", "build")} + models = append(models, entry("test", "build")) + + Expect(VariantReferencedIDs(models)).NotTo(HaveKey("test@loop")) + Expect(VariantReferencedIDs(models)).To(HaveKey("test@build")) + }) + + It("ignores a reference that names no existing entry", func() { + // A dangling reference hides nothing; reporting it is the linter's job. + models := []*GalleryModel{entry("test", "parent", "does-not-exist")} + Expect(VariantReferencedIDs(models)).To(BeEmpty()) + }) + + It("resolves references the way install-time lookup does", func() { + // Matching has to agree with FindGalleryElement, otherwise a reference + // that installs fine would fail to hide its target. Case-insensitive, + // and an "@" addresses gallery and name together. + models := []*GalleryModel{ + entry("test", "parent", "BUILD-A", "other@build-b"), + entry("test", "build-a"), + entry("other", "build-b"), + entry("test", "build-b"), + } + + referenced := VariantReferencedIDs(models) + Expect(referenced).To(HaveKey("test@build-a")) + Expect(referenced).To(HaveKey("other@build-b")) + // The qualified reference names the other gallery's entry, so the + // same-named local entry keeps its row. + Expect(referenced).NotTo(HaveKey("test@build-b")) + }) + + It("returns an empty set for a gallery declaring no variants at all", func() { + models := []*GalleryModel{entry("test", "a"), entry("test", "b")} + Expect(VariantReferencedIDs(models)).To(BeEmpty()) + }) +}) diff --git a/core/http/react-ui/e2e/models-gallery.spec.js b/core/http/react-ui/e2e/models-gallery.spec.js index 7f3aec4d7..f543f4987 100644 --- a/core/http/react-ui/e2e/models-gallery.spec.js +++ b/core/http/react-ui/e2e/models-gallery.spec.js @@ -723,25 +723,26 @@ test.describe("Models Gallery - Variant picker", () => { }); }); -// 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 +// The collapsed view is the deduplicated gallery: every entry installable in +// its own right, with nothing shown twice. Here whisper-model stands in for a +// build llama-model already offers as a variant, so it is the only row that +// drops; stablediffusion-model is nobody's variant and stays. 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 = { +const COLLAPSED_RESPONSE = { ...MOCK_MODELS_RESPONSE, - models: MOCK_MODELS_RESPONSE.models.filter((m) => m.has_variants), - availableModels: 1, + models: MOCK_MODELS_RESPONSE.models.filter((m) => m.name !== "whisper-model"), + availableModels: 2, totalPages: 1, currentPage: 1, }; -test.describe("Models Gallery - Has Variants Filter", () => { +test.describe("Models Gallery - Collapse Variants Filter", () => { let listingUrls; - const variantsToggle = (page) => + const collapseToggle = (page) => page - .locator("label.filter-bar-group__toggle", { hasText: "Has variants" }) + .locator("label.filter-bar-group__toggle", { hasText: "One row per model" }) .locator(".toggle__track"); test.beforeEach(async ({ page }) => { @@ -754,13 +755,14 @@ test.describe("Models Gallery - Has Variants Filter", () => { if (url.pathname.endsWith("/api/models")) { listingUrls.push(url); } - const onlyVariants = url.searchParams.get("has_variants") === "true"; + const collapsed = url.searchParams.get("collapse_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; + if (collapsed) { + // Stacking the toggle with a usecase filter is the case that yields + // nothing but a collapsed view, which is what the empty state has to + // explain. + body = tag ? EMPTY_FILTERED_RESPONSE : COLLAPSED_RESPONSE; } route.fulfill({ contentType: "application/json", @@ -775,13 +777,13 @@ test.describe("Models Gallery - Has Variants Filter", () => { }); test("the toggle is visible", async ({ page }) => { - await expect(page.getByText("Has variants")).toBeVisible(); + await expect(page.getByText("One row per model")).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.getByLabel("One row per model")).not.toBeChecked(); await expect(page.locator("tr", { hasText: "llama-model" })).toBeVisible(); await expect( page.locator("tr", { hasText: "whisper-model" }), @@ -791,39 +793,42 @@ test.describe("Models Gallery - Has Variants Filter", () => { ).toBeVisible(); // Asserted positively over every listing request, so a param that leaked - // in as has_variants=false would still fail this. + // in as collapse_variants=false would still fail this. expect(listingUrls.length).toBeGreaterThan(0); for (const url of listingUrls) { - expect(url.searchParams.has("has_variants")).toBe(false); + expect(url.searchParams.has("collapse_variants")).toBe(false); } }); - test("toggling on sends has_variants=true and narrows the list", async ({ + test("toggling on sends collapse_variants=true and narrows the list", async ({ page, }) => { - await variantsToggle(page).click(); + await collapseToggle(page).click(); + // The parent keeps its row, the build it already offers drops out, and an + // entry that is nobody's variant is untouched. A filter that kept only the + // entries declaring variants would wrongly drop stablediffusion-model too. 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); + ).toBeVisible(); const last = listingUrls[listingUrls.length - 1]; - expect(last.searchParams.get("has_variants")).toBe("true"); + expect(last.searchParams.get("collapse_variants")).toBe("true"); }); test("toggling off restores the full list and drops the param", async ({ page, }) => { - await variantsToggle(page).click(); + await collapseToggle(page).click(); await expect(page.locator("tr", { hasText: "whisper-model" })).toHaveCount( 0, ); - await variantsToggle(page).click(); + await collapseToggle(page).click(); await expect( page.locator("tr", { hasText: "whisper-model" }), @@ -833,11 +838,11 @@ test.describe("Models Gallery - Has Variants Filter", () => { ).toBeVisible(); const last = listingUrls[listingUrls.length - 1]; - expect(last.searchParams.has("has_variants")).toBe(false); + expect(last.searchParams.has("collapse_variants")).toBe(false); }); test("resets to page 1 when toggled", async ({ page }) => { - await variantsToggle(page).click(); + await collapseToggle(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. @@ -847,18 +852,18 @@ test.describe("Models Gallery - Has Variants Filter", () => { test("state persists after reload, like the other filters", async ({ page, }) => { - await variantsToggle(page).click(); + await collapseToggle(page).click(); await page.reload(); - await expect(page.getByLabel("Has variants")).toBeChecked(); + await expect(page.getByLabel("One row per model")).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 ({ + test("names the collapsed view in the empty state when it is the cause", async ({ page, }) => { - await variantsToggle(page).click(); + await collapseToggle(page).click(); await page.locator(".filter-btn", { hasText: "Chat" }).click(); await expect(page.locator(".empty-state-title")).toHaveText( @@ -867,18 +872,18 @@ test.describe("Models Gallery - Has Variants Filter", () => { // 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.", + 'The models matching your filters are alternative builds that another entry already offers. Turn off "One row per model" to see them.', ); }); test("clear filters turns the toggle back off", async ({ page }) => { - await variantsToggle(page).click(); + await collapseToggle(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.getByLabel("One row per model")).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 b1f73320b..409b4f7b2 100644 --- a/core/http/react-ui/public/locales/de/models.json +++ b/core/http/react-ui/public/locales/de/models.json @@ -24,7 +24,7 @@ "embedding": "Embedding", "rerank": "Rerank", "fitsGpu": "Passt in die GPU", - "hasVariants": "Mit Varianten", + "collapseVariants": "Eine Zeile pro Modell", "allBackends": "Alle Backends", "searchBackends": "Backends suchen..." }, @@ -72,7 +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.", + "withCollapsedVariants": "Die passenden Modelle sind alternative Builds, die ein anderer Eintrag bereits anbietet. Schalte \"Eine Zeile pro Modell\" aus, um sie 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 37d8edfa5..671eac0fb 100644 --- a/core/http/react-ui/public/locales/en/models.json +++ b/core/http/react-ui/public/locales/en/models.json @@ -43,7 +43,7 @@ "vad": "VAD", "ner": "NER", "fitsGpu": "Fits in GPU", - "hasVariants": "Has variants", + "collapseVariants": "One row per model", "allBackends": "All Backends", "searchBackends": "Search backends..." }, @@ -91,7 +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.", + "withCollapsedVariants": "The models matching your filters are alternative builds that another entry already offers. Turn off \"One row per model\" to see them.", "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 e0005b6f1..225867076 100644 --- a/core/http/react-ui/public/locales/es/models.json +++ b/core/http/react-ui/public/locales/es/models.json @@ -24,7 +24,7 @@ "embedding": "Embedding", "rerank": "Rerank", "fitsGpu": "Cabe en la GPU", - "hasVariants": "Con variantes", + "collapseVariants": "Una fila por modelo", "allBackends": "Todos los backends", "searchBackends": "Buscar backends..." }, @@ -72,7 +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.", + "withCollapsedVariants": "Los modelos que coinciden con los filtros son compilaciones alternativas que otra entrada ya ofrece. Desactiva \"Una fila por modelo\" para verlas.", "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 4c0b582fa..b70ea6724 100644 --- a/core/http/react-ui/public/locales/id/models.json +++ b/core/http/react-ui/public/locales/id/models.json @@ -31,7 +31,7 @@ "detection": "Deteksi", "vad": "VAD", "fitsGpu": "Muat di GPU", - "hasVariants": "Punya varian", + "collapseVariants": "Satu baris per model", "allBackends": "Semua Backend", "searchBackends": "Cari backends..." }, @@ -79,7 +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.", + "withCollapsedVariants": "Model yang cocok dengan filter adalah build alternatif yang sudah ditawarkan entri lain. Matikan \"Satu baris per model\" untuk melihatnya.", "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 dbfea4f82..060fd3aa9 100644 --- a/core/http/react-ui/public/locales/it/models.json +++ b/core/http/react-ui/public/locales/it/models.json @@ -24,7 +24,7 @@ "embedding": "Embedding", "rerank": "Rerank", "fitsGpu": "Entra nella GPU", - "hasVariants": "Con varianti", + "collapseVariants": "Una riga per modello", "allBackends": "Tutti i backend", "searchBackends": "Cerca backend..." }, @@ -72,7 +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.", + "withCollapsedVariants": "I modelli che corrispondono ai filtri sono build alternative gia offerte da un'altra voce. Disattiva \"Una riga per modello\" per vederle.", "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 a3d064e5e..d546d71d2 100644 --- a/core/http/react-ui/public/locales/zh-CN/models.json +++ b/core/http/react-ui/public/locales/zh-CN/models.json @@ -24,7 +24,7 @@ "embedding": "嵌入", "rerank": "重排", "fitsGpu": "适合 GPU", - "hasVariants": "含变体", + "collapseVariants": "每个模型一行", "allBackends": "所有后端", "searchBackends": "搜索后端..." }, @@ -72,7 +72,7 @@ "empty": { "title": "未找到模型", "withFilters": "没有模型与当前搜索或筛选条件匹配。", - "withVariantsFilter": "目前没有条目声明变体。关闭变体筛选即可查看完整模型库。", + "withCollapsedVariants": "符合筛选条件的模型是其他条目已经提供的替代构建。关闭“每个模型一行”即可查看。", "noFilters": "模型库为空。" }, "deleteDialog": { diff --git a/core/http/react-ui/src/pages/Models.jsx b/core/http/react-ui/src/pages/Models.jsx index 664067dbe..c8f0e712e 100644 --- a/core/http/react-ui/src/pages/Models.jsx +++ b/core/http/react-ui/src/pages/Models.jsx @@ -23,7 +23,9 @@ 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' +// Renamed alongside the filter it persists: the old key's stored value meant +// "show only entries declaring variants", which is not what this filter does. +const COLLAPSE_VARIANTS_STORAGE_KEY = 'localai-models-collapse-variants-filter' const FILTERS = [ @@ -88,12 +90,13 @@ 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(() => { + // Collapses the listing to one row per model by hiding the individual builds + // another entry already offers as variants. Server-side, unlike fitsFilter, + // because the listing paginates and a client-side narrowing would leave the + // page count describing the unfiltered set. + const [collapseVariants, setCollapseVariants] = useState(() => { try { - return localStorage.getItem(VARIANTS_FILTER_STORAGE_KEY) === '1' + return localStorage.getItem(COLLAPSE_VARIANTS_STORAGE_KEY) === '1' } catch { return false } @@ -109,7 +112,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 collapseVal = params.collapseVariants !== undefined ? params.collapseVariants : collapseVariants const queryParams = { page: params.page || page, items: 9, @@ -119,7 +122,7 @@ export default function Models() { 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 (collapseVal) queryParams.collapse_variants = 'true' if (sortVal) { queryParams.sort = sortVal queryParams.order = params.order || order @@ -137,11 +140,11 @@ export default function Models() { } finally { setLoading(false) } - }, [page, search, filters, sort, order, backendFilter, variantsFilter, addToast, t]) + }, [page, search, filters, sort, order, backendFilter, collapseVariants, addToast, t]) useEffect(() => { fetchModels() - }, [page, filters, sort, order, backendFilter, variantsFilter]) + }, [page, filters, sort, order, backendFilter, collapseVariants]) // Fetch backend→usecase mapping once on mount useEffect(() => { @@ -308,11 +311,11 @@ export default function Models() { useEffect(() => { try { - localStorage.setItem(VARIANTS_FILTER_STORAGE_KEY, variantsFilter ? '1' : '0') + localStorage.setItem(COLLAPSE_VARIANTS_STORAGE_KEY, collapseVariants ? '1' : '0') } catch { // Ignore storage errors (e.g., private browsing restrictions). } - }, [variantsFilter]) + }, [collapseVariants]) const visibleModels = models.filter((model) => { if (!fitsFilter) return true @@ -387,11 +390,11 @@ export default function Models() { })} {totalGpuMemory > 0 && (