From 462583f3838ab967a8da1826db0ac2c1a5d6fc0d Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Mon, 20 Jul 2026 10:17:22 +0000 Subject: [PATCH] ui(models): let search bypass the variant collapse, drop the toggle The models page collapsed the gallery to one row per model by default and offered a toggle to see every individual build. Because the collapse composed with the search term, a build another entry offers as a variant could not be found by typing its name, so the toggle was the only way to reach those builds in the UI. A user who typed a name they knew existed got "no models found", which reads as "that model does not exist". Collapse is for browsing; search is for finding. An explicit search term now bypasses the collapse in the listing handler, so a name lookup returns matching entries whether or not a parent offers them. The term is trimmed once at the top of the handler, so whitespace is neither a search nor a bypass; previously an untrimmed blank term also narrowed the listing to whatever contained a space. Tag and backend deliberately do not bypass: they refine a listing the user is still reading rather than name an entry already known to exist. That makes the toggle redundant, so it goes, along with its i18n strings in all six locales, its localStorage persistence, its participation in "Clear filters" and the empty-state hint telling users to turn it off. The hint was doubly stale: it pointed at a control that no longer exists, and it was untrue exactly when a user has a search term, since searching now sees every build. The page always requests the collapsed listing. The stored preference key is left inert rather than cleaned up: nothing reads it, so a user who had the toggle off simply gets the collapsed view. collapse_variants stays on the API, off by default, because other clients want either view and the UI dropping its control is no reason to remove a working parameter. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto --- .agents/adding-gallery-models.md | 6 +- core/http/react-ui/e2e/models-gallery.spec.js | 184 +++++++++--------- .../react-ui/public/locales/de/models.json | 2 - .../react-ui/public/locales/en/models.json | 2 - .../react-ui/public/locales/es/models.json | 2 - .../react-ui/public/locales/id/models.json | 2 - .../react-ui/public/locales/it/models.json | 2 - .../react-ui/public/locales/zh-CN/models.json | 2 - core/http/react-ui/src/pages/Models.jsx | 71 +------ core/http/routes/ui_api.go | 24 ++- core/http/routes/ui_api_variants_test.go | 40 +++- docs/content/features/model-gallery.md | 27 ++- 12 files changed, 173 insertions(+), 191 deletions(-) diff --git a/.agents/adding-gallery-models.md b/.agents/adding-gallery-models.md index 155495e62..a52daddbb 100644 --- a/.agents/adding-gallery-models.md +++ b/.agents/adding-gallery-models.md @@ -113,9 +113,9 @@ Rules: - 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. + in the collapsed listing (`collapse_variants=true`, which the web UI always + requests), where the declaring entry stands in for it. A search term bypasses + the collapse, so referencing an entry never makes it unfindable by name. - **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/http/react-ui/e2e/models-gallery.spec.js b/core/http/react-ui/e2e/models-gallery.spec.js index 40ac56acb..5556fa239 100644 --- a/core/http/react-ui/e2e/models-gallery.spec.js +++ b/core/http/react-ui/e2e/models-gallery.spec.js @@ -737,13 +737,18 @@ const COLLAPSED_RESPONSE = { currentPage: 1, }; -test.describe("Models Gallery - Collapse Variants Filter", () => { - let listingUrls; +// What a search for the hidden build gets back: the build itself, even though +// browsing would have collapsed it away behind its parent. +const SEARCH_HIT_RESPONSE = { + ...MOCK_MODELS_RESPONSE, + models: MOCK_MODELS_RESPONSE.models.filter((m) => m.name === "whisper-model"), + availableModels: 1, + totalPages: 1, + currentPage: 1, +}; - const collapseToggle = (page) => - page - .locator("label.filter-bar-group__toggle", { hasText: "One row per model" }) - .locator(".toggle__track"); +test.describe("Models Gallery - Collapsed Listing", () => { + let listingUrls; test.beforeEach(async ({ page }) => { listingUrls = []; @@ -753,7 +758,7 @@ test.describe("Models Gallery - Collapse Variants Filter", () => { // Only the gallery's own listing. Sibling routes like // /api/models/estimate share the prefix, and the recommended-models // panel queries /api/models itself with its own page size, so neither - // must pollute the record of what the filter sent, nor pick up the + // must pollute the record of what the page sent, nor pick up the // narrowed bodies below. const isListing = url.pathname.endsWith("/api/models") && @@ -761,12 +766,17 @@ test.describe("Models Gallery - Collapse Variants Filter", () => { if (isListing) { listingUrls.push(url); } - const collapsed = url.searchParams.get("collapse_variants") === "true"; + const term = (url.searchParams.get("term") || "").trim(); + const collapsed = + url.searchParams.get("collapse_variants") === "true" && term === ""; const tag = url.searchParams.get("tag"); - // A usecase filter matches nothing in this fixture either way, so the - // empty state is reachable in both toggle states and the specs can pin - // down what it says about each. + // Stands in for the server: an explicit search term bypasses the + // collapse, so a build a parent already offers is still findable by + // name. Anything else browsing-shaped stays collapsed. let body = collapsed ? COLLAPSED_RESPONSE : MOCK_MODELS_RESPONSE; + if (isListing && term === "whisper-model") body = SEARCH_HIT_RESPONSE; + // A usecase filter matches nothing in this fixture, so the empty state + // stays reachable and the specs can pin down what it says. if (isListing && tag) body = EMPTY_FILTERED_RESPONSE; route.fulfill({ contentType: "application/json", @@ -780,18 +790,23 @@ test.describe("Models Gallery - Collapse Variants Filter", () => { }); }); - test("the toggle is visible", async ({ page }) => { - await expect(page.getByText("One row per model")).toBeVisible(); + test("there is no collapse toggle to find", async ({ page }) => { + // The control is redundant once searching bypasses the collapse, and a + // toggle whose only job is "let me find things" is a worse answer than + // the search box already being able to find them. + await expect(page.getByText("One row per model")).toHaveCount(0); + await expect( + page.locator("label.filter-bar-group__toggle", { + hasText: "One row per model", + }), + ).toHaveCount(0); }); - test("defaults to on, sending collapse_variants=true and collapsing the list", async ({ + test("browsing collapses: the parent stays, the build it offers drops", async ({ page, }) => { - await expect(page.getByLabel("One row per model")).toBeChecked(); - - // 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. + // 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, @@ -801,55 +816,71 @@ test.describe("Models Gallery - Collapse Variants Filter", () => { ).toBeVisible(); // Asserted over every listing request, so a first paint that fetched the - // uncollapsed listing before settling on the default would still fail. + // uncollapsed listing before settling would still fail. expect(listingUrls.length).toBeGreaterThan(0); for (const url of listingUrls) { expect(url.searchParams.get("collapse_variants")).toBe("true"); } }); - test("a stored preference from before the default flipped is not honoured", async ({ + test("searching a build the collapse hides still finds it", async ({ page, }) => { - // The previous build wrote '0' from an effect that runs on mount, so it - // marks a visit rather than a choice. Reading it as one would leave every - // earlier visitor on the old default forever. - await page.evaluate(() => { - localStorage.setItem("localai-models-collapse-variants-filter", "0"); - }); - await page.reload(); + // The regression this whole change exists to prevent: typing the name of + // an entry the gallery does hold must never answer "no models found", + // which reads as "that model does not exist". + await page.locator(".search-bar input").fill("whisper-model"); + + await expect( + page.locator("tr", { hasText: "whisper-model" }), + ).toBeVisible(); + await expect(page.locator(".empty-state")).toHaveCount(0); + }); + + test("the search term is sent alongside the collapse, not instead of it", async ({ + page, + }) => { + // The server decides what an active search means. The page keeps asking + // for the collapsed listing so that decision lives in one place, and so + // clearing the box goes straight back to the browsing view. + await page.locator(".search-bar input").fill("whisper-model"); + await expect.poll( + () => listingUrls[listingUrls.length - 1].searchParams.get("term"), + ).toBe("whisper-model"); + + const searched = listingUrls[listingUrls.length - 1]; + expect(searched.searchParams.get("collapse_variants")).toBe("true"); + }); + + test("clearing the search box returns to the collapsed listing", async ({ + page, + }) => { + await page.locator(".search-bar input").fill("whisper-model"); + await expect( + page.locator("tr", { hasText: "whisper-model" }), + ).toBeVisible(); + + await page.locator(".search-bar input").fill(""); - await expect(page.getByLabel("One row per model")).toBeChecked(); await expect(page.locator("tr", { hasText: "whisper-model" })).toHaveCount( 0, ); + await expect(page.locator("tr", { hasText: "llama-model" })).toBeVisible(); }); - test("turning it off drops the param and restores the full list", async ({ + test("a stored preference from the removed toggle is inert", async ({ page, }) => { - await collapseToggle(page).click(); - - await expect( - page.locator("tr", { hasText: "whisper-model" }), - ).toBeVisible(); - await expect( - page.locator("tr", { hasText: "stablediffusion-model" }), - ).toBeVisible(); - - // Omitted rather than sent as false, so opting out asks for exactly the - // listing every other API client gets. - const last = listingUrls[listingUrls.length - 1]; - expect(last.searchParams.has("collapse_variants")).toBe(false); - }); - - test("turning it back on collapses the list again", async ({ page }) => { - await collapseToggle(page).click(); - await expect( - page.locator("tr", { hasText: "whisper-model" }), - ).toBeVisible(); - - await collapseToggle(page).click(); + // The key outlives the control it belonged to. A user who left the toggle + // off gets the collapsed view like everyone else rather than a listing + // shaped by a setting they can no longer see or change. + await page.evaluate(() => { + localStorage.setItem("localai-models-collapse-variants-filter", "off"); + }); + await page.reload(); + await expect(page.locator("th", { hasText: "Backend" })).toBeVisible({ + timeout: 10_000, + }); await expect(page.locator("tr", { hasText: "whisper-model" })).toHaveCount( 0, @@ -858,29 +889,7 @@ test.describe("Models Gallery - Collapse Variants Filter", () => { expect(last.searchParams.get("collapse_variants")).toBe("true"); }); - test("resets to page 1 when toggled", async ({ page }) => { - await collapseToggle(page).click(); - const last = listingUrls[listingUrls.length - 1]; - // A filter that widened or narrowed the rows while holding page 3 would - // strand the user on a page the new set may not have. - expect(last.searchParams.get("page") || "1").toBe("1"); - }); - - test("turning it off persists after reload, like the other filters", async ({ - page, - }) => { - await collapseToggle(page).click(); - await page.reload(); - - await expect(page.getByLabel("One row per model")).not.toBeChecked(); - await expect( - page.locator("tr", { hasText: "whisper-model" }), - ).toBeVisible(); - const last = listingUrls[listingUrls.length - 1]; - expect(last.searchParams.has("collapse_variants")).toBe(false); - }); - - test("mentions the collapsed view in the empty state as a hint, not a cause", async ({ + test("the empty state no longer blames a toggle nobody can reach", async ({ page, }) => { await page.locator(".filter-btn", { hasText: "Chat" }).click(); @@ -888,37 +897,22 @@ test.describe("Models Gallery - Collapse Variants Filter", () => { await expect(page.locator(".empty-state-title")).toHaveText( "No models found", ); - // Collapsing is the default, so it cannot be blamed for the empty result - // the way an opted-into filter could; the filters get the top line and the - // toggle only gets a hint underneath. await expect(page.locator(".empty-state-text")).toHaveText( "No models match your current search or filters.", ); - await expect(page.locator(".empty-state-hint")).toHaveText( - 'Alternative builds that another entry already offers are hidden. Turn off "One row per model" to include them.', - ); - }); - - test("no collapsed-view hint once the toggle is off", async ({ page }) => { - await collapseToggle(page).click(); - await page.locator(".filter-btn", { hasText: "Chat" }).click(); - await expect(page.locator(".empty-state")).toBeVisible(); - - // Nothing is hidden any more, so the hint would be pointing at a toggle - // that is already doing what it suggests. + // The old hint told the user to turn off a control that is gone, and it + // is no longer even true for a search: searching sees every build. await expect(page.locator(".empty-state-hint")).toHaveCount(0); }); - test("clear filters restores the collapsed default", async ({ page }) => { - await collapseToggle(page).click(); + test("clear filters returns to the collapsed browsing view", async ({ + page, + }) => { await page.locator(".filter-btn", { hasText: "Chat" }).click(); await expect(page.locator(".empty-state")).toBeVisible(); await page.getByRole("button", { name: "Clear filters" }).click(); - // Clearing filters means going back to the view a fresh visit gets, which - // is now the collapsed one. - await expect(page.getByLabel("One row per model")).toBeChecked(); await expect(page.locator("tr", { hasText: "llama-model" })).toBeVisible(); await expect(page.locator("tr", { hasText: "whisper-model" })).toHaveCount( 0, diff --git a/core/http/react-ui/public/locales/de/models.json b/core/http/react-ui/public/locales/de/models.json index 8047c187f..9142a1b8b 100644 --- a/core/http/react-ui/public/locales/de/models.json +++ b/core/http/react-ui/public/locales/de/models.json @@ -24,7 +24,6 @@ "embedding": "Embedding", "rerank": "Rerank", "fitsGpu": "Passt in die GPU", - "collapseVariants": "Eine Zeile pro Modell", "allBackends": "Alle Backends", "searchBackends": "Backends suchen..." }, @@ -72,7 +71,6 @@ "empty": { "title": "Keine Modelle gefunden", "withFilters": "Keine Modelle entsprechen den aktuellen Such- oder Filterkriterien.", - "collapsedVariantsHint": "Alternative Builds, die ein anderer Eintrag bereits anbietet, sind ausgeblendet. Schalte \"Eine Zeile pro Modell\" aus, um sie einzubeziehen.", "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 ea86cc6aa..ec5180f3b 100644 --- a/core/http/react-ui/public/locales/en/models.json +++ b/core/http/react-ui/public/locales/en/models.json @@ -43,7 +43,6 @@ "vad": "VAD", "ner": "NER", "fitsGpu": "Fits in GPU", - "collapseVariants": "One row per model", "allBackends": "All Backends", "searchBackends": "Search backends..." }, @@ -91,7 +90,6 @@ "empty": { "title": "No models found", "withFilters": "No models match your current search or filters.", - "collapsedVariantsHint": "Alternative builds that another entry already offers are hidden. Turn off \"One row per model\" to include 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 13e8428c7..69ca19dae 100644 --- a/core/http/react-ui/public/locales/es/models.json +++ b/core/http/react-ui/public/locales/es/models.json @@ -24,7 +24,6 @@ "embedding": "Embedding", "rerank": "Rerank", "fitsGpu": "Cabe en la GPU", - "collapseVariants": "Una fila por modelo", "allBackends": "Todos los backends", "searchBackends": "Buscar backends..." }, @@ -72,7 +71,6 @@ "empty": { "title": "No se encontraron modelos", "withFilters": "Ningún modelo coincide con la búsqueda o filtros actuales.", - "collapsedVariantsHint": "Las compilaciones alternativas que otra entrada ya ofrece están ocultas. Desactiva \"Una fila por modelo\" para incluirlas.", "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 32356075c..3acff3afb 100644 --- a/core/http/react-ui/public/locales/id/models.json +++ b/core/http/react-ui/public/locales/id/models.json @@ -31,7 +31,6 @@ "detection": "Deteksi", "vad": "VAD", "fitsGpu": "Muat di GPU", - "collapseVariants": "Satu baris per model", "allBackends": "Semua Backend", "searchBackends": "Cari backends..." }, @@ -79,7 +78,6 @@ "empty": { "title": "Model tidak ditemukan", "withFilters": "Tidak ada model yang cocok dengan pencarian atau filter Anda.", - "collapsedVariantsHint": "Build alternatif yang sudah ditawarkan entri lain disembunyikan. Matikan \"Satu baris per model\" untuk menyertakannya.", "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 564eeaa73..15eca936f 100644 --- a/core/http/react-ui/public/locales/it/models.json +++ b/core/http/react-ui/public/locales/it/models.json @@ -24,7 +24,6 @@ "embedding": "Embedding", "rerank": "Rerank", "fitsGpu": "Entra nella GPU", - "collapseVariants": "Una riga per modello", "allBackends": "Tutti i backend", "searchBackends": "Cerca backend..." }, @@ -72,7 +71,6 @@ "empty": { "title": "Nessun modello trovato", "withFilters": "Nessun modello corrisponde ai filtri attuali.", - "collapsedVariantsHint": "Le build alternative gia offerte da un'altra voce sono nascoste. Disattiva \"Una riga per modello\" per includerle.", "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 3d9c2a3ca..88cd49d7d 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,6 @@ "embedding": "嵌入", "rerank": "重排", "fitsGpu": "适合 GPU", - "collapseVariants": "每个模型一行", "allBackends": "所有后端", "searchBackends": "搜索后端..." }, @@ -72,7 +71,6 @@ "empty": { "title": "未找到模型", "withFilters": "没有模型与当前搜索或筛选条件匹配。", - "collapsedVariantsHint": "其他条目已经提供的替代构建已被隐藏。关闭“每个模型一行”即可将其包含在内。", "noFilters": "模型库为空。" }, "deleteDialog": { diff --git a/core/http/react-ui/src/pages/Models.jsx b/core/http/react-ui/src/pages/Models.jsx index feac21a52..e5665b44e 100644 --- a/core/http/react-ui/src/pages/Models.jsx +++ b/core/http/react-ui/src/pages/Models.jsx @@ -23,31 +23,6 @@ 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' -// 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' -// The deduplicated gallery is what a user asking "what can I install" wants, so -// the UI asks for it unless told otherwise. Only the UI decides this: the server -// still returns the full listing when the parameter is absent, because other API -// clients depend on that response. -const COLLAPSE_VARIANTS_DEFAULT = true - -// The stored vocabulary changed with the default. The previous build wrote -// '1'/'0' from an effect that runs on mount, so a stored '0' recorded that the -// page had been opened, not that anyone chose the expanded view. Honouring it -// would pin every earlier visitor to a default they never picked, so only the -// 'on'/'off' written since counts as a choice. -const readCollapseVariantsPreference = () => { - try { - const stored = localStorage.getItem(COLLAPSE_VARIANTS_STORAGE_KEY) - if (stored === 'on') return true - if (stored === 'off') return false - return COLLAPSE_VARIANTS_DEFAULT - } catch { - return COLLAPSE_VARIANTS_DEFAULT - } -} - const FILTERS = [ { key: '', labelKey: 'filters.all', icon: 'fa-layer-group' }, @@ -111,12 +86,6 @@ export default function Models() { return false } }) - // 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(readCollapseVariantsPreference) - // Total GPU memory for "fits" check const totalGpuMemory = resources?.aggregate?.total_memory || 0 @@ -127,17 +96,20 @@ 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 collapseVal = params.collapseVariants !== undefined ? params.collapseVariants : collapseVariants const queryParams = { page: params.page || page, items: 9, + // The deduplicated gallery is what a user asking "what can I install" + // wants, so the UI always asks for it. There is no control for this + // because a search term already bypasses the collapse server-side, so + // a build hidden behind a parent is still findable by name. The + // parameter stays optional on the API: other clients want either view, + // and an absent parameter still returns the full listing. + collapse_variants: 'true', } if (filtersVal.length > 0) queryParams.tag = filtersVal.join(',') if (searchVal) queryParams.term = searchVal if (backendVal) queryParams.backend = backendVal - // Omitted entirely when off rather than sent as false, so opting out asks - // for exactly the listing every other API client gets. - if (collapseVal) queryParams.collapse_variants = 'true' if (sortVal) { queryParams.sort = sortVal queryParams.order = params.order || order @@ -155,11 +127,11 @@ export default function Models() { } finally { setLoading(false) } - }, [page, search, filters, sort, order, backendFilter, collapseVariants, addToast, t]) + }, [page, search, filters, sort, order, backendFilter, addToast, t]) useEffect(() => { fetchModels() - }, [page, filters, sort, order, backendFilter, collapseVariants]) + }, [page, filters, sort, order, backendFilter]) // Fetch backend→usecase mapping once on mount useEffect(() => { @@ -324,14 +296,6 @@ export default function Models() { } }, [fitsFilter]) - useEffect(() => { - try { - localStorage.setItem(COLLAPSE_VARIANTS_STORAGE_KEY, collapseVariants ? 'on' : 'off') - } catch { - // Ignore storage errors (e.g., private browsing restrictions). - } - }, [collapseVariants]) - const visibleModels = models.filter((model) => { if (!fitsFilter) return true const name = model.name || model.id @@ -403,14 +367,6 @@ export default function Models() { ) })} - {totalGpuMemory > 0 && (