feat(ui): filter the model gallery to entries that declare variants

The gallery is heading towards showing parent entries and hiding the
individual builds they reference, so a user sees one row per model
rather than six quantizations of it.

Adoption is a single entry today, so defaulting to that would leave a
one-row gallery. This ships the migration-phase inverse instead: the
default is untouched, and a toggle narrows the list to only the entries
that declare variants. It previews the end state and changes nothing
until someone asks for it.

The filter is server-side, next to term/tag/backend/capability and above
the pagination arithmetic. The listing paginates at 9 items, so
narrowing on the client would leave totalPages and availableModels
describing the unfiltered set and hand the user empty pages. It selects
on HasVariants(), which reads already-loaded metadata, so it issues no
variant probes.

The parameter is named has_variants after the listing field it selects
on, and is compared against "true" like the other boolean query params
(all_users, save_checkpoint), so has_variants=false reads as absent.
With it omitted the response is byte-for-byte what it was before.

The control is the shared Toggle component, matching the fitsFilter
toggle already on this page: same wrapper class, same icon and label
shape, same localStorage persistence. Unlike fitsFilter it resets to
page 1 on change, which a server-side filter has to do.

Stacking the toggle with a tag or backend filter easily yields nothing
while one entry declares variants, so the empty state now names the
variants filter as the cause rather than leaving a user to conclude the
gallery is broken.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-07-19 07:52:22 +00:00
parent 4f52c737ad
commit 831b127ded
10 changed files with 319 additions and 7 deletions

View File

@@ -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();
});
});

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -24,6 +24,7 @@
"embedding": "嵌入",
"rerank": "重排",
"fitsGpu": "适合 GPU",
"hasVariants": "含变体",
"allBackends": "所有后端",
"searchBackends": "搜索后端..."
},
@@ -71,6 +72,7 @@
"empty": {
"title": "未找到模型",
"withFilters": "没有模型与当前搜索或筛选条件匹配。",
"withVariantsFilter": "目前没有条目声明变体。关闭变体筛选即可查看完整模型库。",
"noFilters": "模型库为空。"
},
"deleteDialog": {

View File

@@ -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() {
</button>
)
})}
<label className="filter-bar-group__toggle" style={{ marginLeft: 'auto' }}>
<Toggle
checked={variantsFilter}
onChange={(v) => { setVariantsFilter(v); setPage(1) }}
/>
<i className="fas fa-layer-group" />
<span>{t('filters.hasVariants')}</span>
</label>
{totalGpuMemory > 0 && (
<label className="filter-bar-group__toggle" style={{ marginLeft: 'auto' }}>
<label className="filter-bar-group__toggle">
<Toggle checked={fitsFilter} onChange={setFitsFilter} />
<i className="fas fa-microchip" />
<span>{t('filters.fitsGpu')}</span>
@@ -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' }}
/>
)}
</div>
@@ -408,12 +438,14 @@ export default function Models() {
<div className="empty-state-icon"><i className="fas fa-search" /></div>
<h2 className="empty-state-title">{t('empty.title')}</h2>
<p className="empty-state-text">
{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')}
</p>
{(search || filters.length > 0 || backendFilter || fitsFilter) && (
{(search || filters.length > 0 || backendFilter || fitsFilter || variantsFilter) && (
<button
className="btn btn-secondary btn-sm"
onClick={() => { handleSearch(''); setFilters([]); setBackendFilter(''); setFitsFilter(false); setPage(1) }}
onClick={() => { handleSearch(''); setFilters([]); setBackendFilter(''); setFitsFilter(false); setVariantsFilter(false); setPage(1) }}
>
<i className="fas fa-times" /> {t('search.clearFilters')}
</button>

View File

@@ -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

View File

@@ -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")
})
})
})