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 <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-07-19 20:25:14 +00:00
parent 8130d7ce9d
commit f7ff7b0ed3
14 changed files with 335 additions and 97 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@
"embedding": "嵌入",
"rerank": "重排",
"fitsGpu": "适合 GPU",
"hasVariants": "含变体",
"collapseVariants": "每个模型一行",
"allBackends": "所有后端",
"searchBackends": "搜索后端..."
},
@@ -72,7 +72,7 @@
"empty": {
"title": "未找到模型",
"withFilters": "没有模型与当前搜索或筛选条件匹配。",
"withVariantsFilter": "目前没有条目声明变体。关闭变体筛选即可查看完整模型库。",
"withCollapsedVariants": "符合筛选条件的模型是其他条目已经提供的替代构建。关闭“每个模型一行”即可查看。",
"noFilters": "模型库为空。"
},
"deleteDialog": {

View File

@@ -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() {
})}
<label className="filter-bar-group__toggle" style={{ marginLeft: 'auto' }}>
<Toggle
checked={variantsFilter}
onChange={(v) => { setVariantsFilter(v); setPage(1) }}
checked={collapseVariants}
onChange={(v) => { setCollapseVariants(v); setPage(1) }}
/>
<i className="fas fa-layer-group" />
<span>{t('filters.hasVariants')}</span>
<span>{t('filters.collapseVariants')}</span>
</label>
{totalGpuMemory > 0 && (
<label className="filter-bar-group__toggle">
@@ -439,14 +442,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">
{variantsFilter
? t('empty.withVariantsFilter')
{collapseVariants
? t('empty.withCollapsedVariants')
: search || filters.length > 0 || backendFilter || fitsFilter ? t('empty.withFilters') : t('empty.noFilters')}
</p>
{(search || filters.length > 0 || backendFilter || fitsFilter || variantsFilter) && (
{(search || filters.length > 0 || backendFilter || fitsFilter || collapseVariants) && (
<button
className="btn btn-secondary btn-sm"
onClick={() => { handleSearch(''); setFilters([]); setBackendFilter(''); setFitsFilter(false); setVariantsFilter(false); setPage(1) }}
onClick={() => { handleSearch(''); setFilters([]); setBackendFilter(''); setFitsFilter(false); setCollapseVariants(false); setPage(1) }}
>
<i className="fas fa-times" /> {t('search.clearFilters')}
</button>

View File

@@ -412,6 +412,11 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
"error": err.Error(),
})
}
// The filters below rebind models, so keep the unfiltered gallery for
// the questions that are about the gallery as a whole rather than about
// the current result set, such as which entries another entry already
// offers as a variant.
allModels := models
// Get all available tags
allTags := map[string]struct{}{}
@@ -480,26 +485,33 @@ 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.
// Collapse the listing to one row per model: drop the individual builds
// a parent entry already offers as variants, and keep everything else.
// What survives is every entry installable in its own right, with
// nothing shown twice, rather than one row per quantization.
//
// 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
// Off by default so the response with the parameter absent is exactly
// what it was. Adoption is a handful of entries, so this hides little
// today, but it is the view the gallery is heading towards and it costs
// nothing until someone asks for it.
//
// 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, never because of what the user searched for. A term
// matching a build but not its parent would otherwise make the build
// reappear.
//
// 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" {
if c.QueryParam("collapse_variants") == "true" {
referenced := gallery.VariantReferencedIDs(allModels)
filtered := make(gallery.GalleryElements[*gallery.GalleryModel], 0, len(models))
for _, m := range models {
if m.HasVariants() {
filtered = append(filtered, m)
if _, hidden := referenced[m.ID()]; hidden {
continue
}
filtered = append(filtered, m)
}
models = filtered
}

View File

@@ -209,10 +209,10 @@ var _ = Describe("Model gallery variants API", func() {
})
})
// 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() {
// The collapsed view is the deduplicated gallery: every entry installable
// in its own right, with nothing shown twice. Off by default, so a user who
// never touches it sees the listing exactly as it was.
Context("the collapse_variants listing filter", func() {
names := func(path string) []string {
code, body := get(path)
Expect(code).To(Equal(http.StatusOK))
@@ -237,10 +237,13 @@ var _ = Describe("Model gallery variants API", 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("hides only the builds another entry already offers", func() {
// base-entry is a parent and stays. big-entry is the build it
// references, so it drops out: a user reaches it by installing
// base-entry. plain-entry is nobody's variant, so it stays even
// though it declares none of its own.
Expect(names("/api/models?items=9999&collapse_variants=true")).
To(ConsistOf("base-entry", "plain-entry"))
})
It("leaves the response untouched for any value other than true", func() {
@@ -248,8 +251,8 @@ var _ = Describe("Model gallery variants API", func() {
// 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))
Expect(rawBody("/api/models?items=9999&collapse_variants=false")).To(Equal(base))
Expect(rawBody("/api/models?items=9999&collapse_variants=1")).To(Equal(base))
})
It("serializes a non-declaring entry exactly as it did before", func() {
@@ -262,30 +265,37 @@ var _ = Describe("Model gallery variants API", func() {
})
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"))
// base-entry and big-entry are llama-cpp; plain-entry is whisper.
// If either filter overwrote the other, the llama-cpp case would
// keep big-entry and the whisper case would lose plain-entry.
Expect(names("/api/models?items=9999&collapse_variants=true&backend=llama-cpp")).
To(ConsistOf("base-entry"))
Expect(names("/api/models?items=9999&collapse_variants=true&backend=whisper")).
To(ConsistOf("plain-entry"))
Expect(names("/api/models?items=9999&backend=llama-cpp")).
To(ConsistOf("base-entry", "big-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())
Expect(names("/api/models?items=9999&collapse_variants=true&term=plain")).
To(ConsistOf("plain-entry"))
// The term matches the referenced build but not its parent. The
// build is still hidden: it is hidden because base-entry offers it,
// which is a fact about the gallery and not about the search.
Expect(names("/api/models?items=9999&term=big")).To(ConsistOf("big-entry"))
Expect(names("/api/models?items=9999&collapse_variants=true&term=big")).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))
_, body := get("/api/models?items=9999&collapse_variants=true")
Expect(body["availableModels"]).To(BeEquivalentTo(2))
Expect(body["totalPages"]).To(BeEquivalentTo(1))
})
It("still issues no variant probes when filtering", func() {
names("/api/models?items=9999&has_variants=true")
names("/api/models?items=9999&collapse_variants=true")
Expect(probes.Load()).To(BeZero(),
"the filter must select on declared metadata, not by describing variants")
})

View File

@@ -188,6 +188,28 @@ whole page has variants.
curl http://localhost:8080/api/models | jq '.models[] | select(.has_variants) | .name'
```
### Collapsing the listing to one row per model
By default the listing returns every entry, including the individual builds a
parent entry offers as variants, so one model can occupy several rows. Pass
`collapse_variants=true` for the deduplicated view: every entry that is
installable in its own right, with nothing shown twice.
```bash
curl 'http://localhost:8080/api/models?collapse_variants=true'
```
An entry is hidden only when another entry already offers it as a variant, so
it stays reachable by installing that entry. Entries that declare variants are
always kept, and so is any entry nobody references. The filter is applied
before pagination and composes with `term`, `tag` and `backend`, so page counts
stay correct. Whether an entry is hidden depends on the gallery alone rather
than on the other filters, so a search matching a variant build but not its
parent still hides the build.
The web UI offers the same view as the "One row per model" toggle above the
gallery.
Ask for the description one entry at a time, as the web UI does when you open
a model's variant menu: