mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"embedding": "嵌入",
|
||||
"rerank": "重排",
|
||||
"fitsGpu": "适合 GPU",
|
||||
"hasVariants": "含变体",
|
||||
"allBackends": "所有后端",
|
||||
"searchBackends": "搜索后端..."
|
||||
},
|
||||
@@ -71,6 +72,7 @@
|
||||
"empty": {
|
||||
"title": "未找到模型",
|
||||
"withFilters": "没有模型与当前搜索或筛选条件匹配。",
|
||||
"withVariantsFilter": "目前没有条目声明变体。关闭变体筛选即可查看完整模型库。",
|
||||
"noFilters": "模型库为空。"
|
||||
},
|
||||
"deleteDialog": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user