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