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 && (