diff --git a/core/http/react-ui/e2e/models-gallery.spec.js b/core/http/react-ui/e2e/models-gallery.spec.js index f543f4987..40ac56acb 100644 --- a/core/http/react-ui/e2e/models-gallery.spec.js +++ b/core/http/react-ui/e2e/models-gallery.spec.js @@ -750,20 +750,24 @@ test.describe("Models Gallery - Collapse Variants Filter", () => { 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")) { + // 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 + // narrowed bodies below. + const isListing = + url.pathname.endsWith("/api/models") && + url.searchParams.get("items") === "9"; + if (isListing) { listingUrls.push(url); } const collapsed = url.searchParams.get("collapse_variants") === "true"; const tag = url.searchParams.get("tag"); - let body = MOCK_MODELS_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; - } + // 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. + let body = collapsed ? COLLAPSED_RESPONSE : MOCK_MODELS_RESPONSE; + if (isListing && tag) body = EMPTY_FILTERED_RESPONSE; route.fulfill({ contentType: "application/json", body: JSON.stringify(body), @@ -780,30 +784,10 @@ test.describe("Models Gallery - Collapse Variants Filter", () => { await expect(page.getByText("One row per model")).toBeVisible(); }); - test("defaults to off, showing everything and sending no filter param", async ({ + test("defaults to on, sending collapse_variants=true and collapsing the list", async ({ page, }) => { - 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" }), - ).toBeVisible(); - await expect( - page.locator("tr", { hasText: "stablediffusion-model" }), - ).toBeVisible(); - - // Asserted positively over every listing request, so a param that leaked - // in as collapse_variants=false would still fail this. - expect(listingUrls.length).toBeGreaterThan(0); - for (const url of listingUrls) { - expect(url.searchParams.has("collapse_variants")).toBe(false); - } - }); - - test("toggling on sends collapse_variants=true and narrows the list", async ({ - page, - }) => { - await collapseToggle(page).click(); + 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 @@ -816,18 +800,34 @@ test.describe("Models Gallery - Collapse Variants Filter", () => { page.locator("tr", { hasText: "stablediffusion-model" }), ).toBeVisible(); - const last = listingUrls[listingUrls.length - 1]; - expect(last.searchParams.get("collapse_variants")).toBe("true"); + // Asserted over every listing request, so a first paint that fetched the + // uncollapsed listing before settling on the default would still fail. + expect(listingUrls.length).toBeGreaterThan(0); + for (const url of listingUrls) { + expect(url.searchParams.get("collapse_variants")).toBe("true"); + } }); - test("toggling off restores the full list and drops the param", async ({ + test("a stored preference from before the default flipped is not honoured", async ({ page, }) => { - await collapseToggle(page).click(); + // 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(); + + await expect(page.getByLabel("One row per model")).toBeChecked(); await expect(page.locator("tr", { hasText: "whisper-model" })).toHaveCount( 0, ); + }); + test("turning it off drops the param and restores the full list", async ({ + page, + }) => { await collapseToggle(page).click(); await expect( @@ -837,56 +837,92 @@ test.describe("Models Gallery - Collapse Variants Filter", () => { 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(); + + await expect(page.locator("tr", { hasText: "whisper-model" })).toHaveCount( + 0, + ); + const last = listingUrls[listingUrls.length - 1]; + 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 narrowed the rows while holding page 3 would strand the - // user on a page the filtered set no longer has. + // 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("state persists after reload, like the other filters", async ({ + 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")).toBeChecked(); - await expect(page.locator("tr", { hasText: "whisper-model" })).toHaveCount( - 0, - ); + + 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("names the collapsed view in the empty state when it is the cause", async ({ + test("mentions the collapsed view in the empty state as a hint, not a cause", async ({ page, }) => { - await collapseToggle(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. + // 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( - 'The models matching your filters are alternative builds that another entry already offers. Turn off "One row per model" to see them.', + "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("clear filters turns the toggle back off", async ({ page }) => { + 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. + await expect(page.locator(".empty-state-hint")).toHaveCount(0); + }); + + test("clear filters restores the collapsed default", async ({ page }) => { 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("One row per model")).not.toBeChecked(); - await expect( - page.locator("tr", { hasText: "whisper-model" }), - ).toBeVisible(); + // 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 409b4f7b2..8047c187f 100644 --- a/core/http/react-ui/public/locales/de/models.json +++ b/core/http/react-ui/public/locales/de/models.json @@ -72,7 +72,7 @@ "empty": { "title": "Keine Modelle gefunden", "withFilters": "Keine Modelle entsprechen den aktuellen Such- oder Filterkriterien.", - "withCollapsedVariants": "Die passenden Modelle sind alternative Builds, die ein anderer Eintrag bereits anbietet. Schalte \"Eine Zeile pro Modell\" aus, um sie zu sehen.", + "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 671eac0fb..ea86cc6aa 100644 --- a/core/http/react-ui/public/locales/en/models.json +++ b/core/http/react-ui/public/locales/en/models.json @@ -91,7 +91,7 @@ "empty": { "title": "No models found", "withFilters": "No models match your current search or filters.", - "withCollapsedVariants": "The models matching your filters are alternative builds that another entry already offers. Turn off \"One row per model\" to see them.", + "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 225867076..13e8428c7 100644 --- a/core/http/react-ui/public/locales/es/models.json +++ b/core/http/react-ui/public/locales/es/models.json @@ -72,7 +72,7 @@ "empty": { "title": "No se encontraron modelos", "withFilters": "Ningún modelo coincide con la búsqueda o filtros actuales.", - "withCollapsedVariants": "Los modelos que coinciden con los filtros son compilaciones alternativas que otra entrada ya ofrece. Desactiva \"Una fila por modelo\" para verlas.", + "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 b70ea6724..32356075c 100644 --- a/core/http/react-ui/public/locales/id/models.json +++ b/core/http/react-ui/public/locales/id/models.json @@ -79,7 +79,7 @@ "empty": { "title": "Model tidak ditemukan", "withFilters": "Tidak ada model yang cocok dengan pencarian atau filter Anda.", - "withCollapsedVariants": "Model yang cocok dengan filter adalah build alternatif yang sudah ditawarkan entri lain. Matikan \"Satu baris per model\" untuk melihatnya.", + "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 060fd3aa9..564eeaa73 100644 --- a/core/http/react-ui/public/locales/it/models.json +++ b/core/http/react-ui/public/locales/it/models.json @@ -72,7 +72,7 @@ "empty": { "title": "Nessun modello trovato", "withFilters": "Nessun modello corrisponde ai filtri attuali.", - "withCollapsedVariants": "I modelli che corrispondono ai filtri sono build alternative gia offerte da un'altra voce. Disattiva \"Una riga per modello\" per vederle.", + "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 d546d71d2..3d9c2a3ca 100644 --- a/core/http/react-ui/public/locales/zh-CN/models.json +++ b/core/http/react-ui/public/locales/zh-CN/models.json @@ -72,7 +72,7 @@ "empty": { "title": "未找到模型", "withFilters": "没有模型与当前搜索或筛选条件匹配。", - "withCollapsedVariants": "符合筛选条件的模型是其他条目已经提供的替代构建。关闭“每个模型一行”即可查看。", + "collapsedVariantsHint": "其他条目已经提供的替代构建已被隐藏。关闭“每个模型一行”即可将其包含在内。", "noFilters": "模型库为空。" }, "deleteDialog": { diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 5ce200b59..1b4273a21 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -2890,6 +2890,16 @@ button.collapsible-header:focus-visible { margin: 0; } +/* Secondary to .empty-state-text: a hint about a default that may be narrowing + the result, so it must not compete with the reason stated above it. */ +.empty-state-hint { + color: var(--color-text-muted); + font-size: var(--text-sm); + line-height: var(--leading-normal); + max-width: 52ch; + margin: 0; +} + /* Opt-in editorial sub-parts — pages can adopt these class names over time */ .empty-state__eyebrow { display: inline-flex; diff --git a/core/http/react-ui/src/pages/Models.jsx b/core/http/react-ui/src/pages/Models.jsx index c8f0e712e..feac21a52 100644 --- a/core/http/react-ui/src/pages/Models.jsx +++ b/core/http/react-ui/src/pages/Models.jsx @@ -26,6 +26,27 @@ 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 = [ @@ -94,13 +115,7 @@ export default function Models() { // 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(COLLAPSE_VARIANTS_STORAGE_KEY) === '1' - } catch { - return false - } - }) + const [collapseVariants, setCollapseVariants] = useState(readCollapseVariantsPreference) // Total GPU memory for "fits" check const totalGpuMemory = resources?.aggregate?.total_memory || 0 @@ -120,8 +135,8 @@ 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. + // 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 @@ -311,7 +326,7 @@ export default function Models() { useEffect(() => { try { - localStorage.setItem(COLLAPSE_VARIANTS_STORAGE_KEY, collapseVariants ? '1' : '0') + localStorage.setItem(COLLAPSE_VARIANTS_STORAGE_KEY, collapseVariants ? 'on' : 'off') } catch { // Ignore storage errors (e.g., private browsing restrictions). } @@ -442,14 +457,19 @@ export default function Models() {

{t('empty.title')}

- {collapseVariants - ? t('empty.withCollapsedVariants') - : search || filters.length > 0 || backendFilter || fitsFilter ? t('empty.withFilters') : t('empty.noFilters')} + {search || filters.length > 0 || backendFilter || fitsFilter ? t('empty.withFilters') : t('empty.noFilters')}

- {(search || filters.length > 0 || backendFilter || fitsFilter || collapseVariants) && ( + {/* Collapsing is the default now, so it can no longer be named as the + cause of an empty result the way an opted-into filter could. It is + still worth mentioning as something the filters may be hiding + behind, but only once the user has filters narrowing the set. */} + {collapseVariants && (search || filters.length > 0 || backendFilter || fitsFilter) && ( +

{t('empty.collapsedVariantsHint')}

+ )} + {(search || filters.length > 0 || backendFilter || fitsFilter) && (