mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 01:48:06 -04:00
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 <mudler@localai.io>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"embedding": "嵌入",
|
||||
"rerank": "重排",
|
||||
"fitsGpu": "适合 GPU",
|
||||
"collapseVariants": "每个模型一行",
|
||||
"allBackends": "所有后端",
|
||||
"searchBackends": "搜索后端..."
|
||||
},
|
||||
@@ -72,7 +71,6 @@
|
||||
"empty": {
|
||||
"title": "未找到模型",
|
||||
"withFilters": "没有模型与当前搜索或筛选条件匹配。",
|
||||
"collapsedVariantsHint": "其他条目已经提供的替代构建已被隐藏。关闭“每个模型一行”即可将其包含在内。",
|
||||
"noFilters": "模型库为空。"
|
||||
},
|
||||
"deleteDialog": {
|
||||
|
||||
@@ -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() {
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<label className="filter-bar-group__toggle" style={{ marginLeft: 'auto' }}>
|
||||
<Toggle
|
||||
checked={collapseVariants}
|
||||
onChange={(v) => { setCollapseVariants(v); setPage(1) }}
|
||||
/>
|
||||
<i className="fas fa-layer-group" />
|
||||
<span>{t('filters.collapseVariants')}</span>
|
||||
</label>
|
||||
{totalGpuMemory > 0 && (
|
||||
<label className="filter-bar-group__toggle">
|
||||
<Toggle checked={fitsFilter} onChange={setFitsFilter} />
|
||||
@@ -459,17 +415,10 @@ export default function Models() {
|
||||
<p className="empty-state-text">
|
||||
{search || filters.length > 0 || backendFilter || fitsFilter ? t('empty.withFilters') : t('empty.noFilters')}
|
||||
</p>
|
||||
{/* 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) && (
|
||||
<p className="empty-state-hint">{t('empty.collapsedVariantsHint')}</p>
|
||||
)}
|
||||
{(search || filters.length > 0 || backendFilter || fitsFilter) && (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => { handleSearch(''); setFilters([]); setBackendFilter(''); setFitsFilter(false); setCollapseVariants(COLLAPSE_VARIANTS_DEFAULT); setPage(1) }}
|
||||
onClick={() => { handleSearch(''); setFilters([]); setBackendFilter(''); setFitsFilter(false); setPage(1) }}
|
||||
>
|
||||
<i className="fas fa-times" /> {t('search.clearFilters')}
|
||||
</button>
|
||||
|
||||
@@ -394,7 +394,11 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||
|
||||
// Model Gallery APIs (admin only)
|
||||
app.GET("/api/models", func(c echo.Context) error {
|
||||
term := c.QueryParam("term")
|
||||
// Trimmed once, here, so "is the user searching?" has a single answer
|
||||
// for both the search itself and the variant collapse below.
|
||||
// Whitespace is not a lookup: an untrimmed blank term used to narrow
|
||||
// the listing to whatever happened to contain a space.
|
||||
term := strings.TrimSpace(c.QueryParam("term"))
|
||||
tag := c.QueryParam("tag")
|
||||
page := c.QueryParam("page")
|
||||
if page == "" {
|
||||
@@ -497,14 +501,24 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||
//
|
||||
// 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.
|
||||
// parent offers it, never because of which tag or backend the user
|
||||
// picked while browsing.
|
||||
//
|
||||
// A search term is the exception: collapsing serves browsing, but a user
|
||||
// who types a name is looking up a specific entry and must get it even
|
||||
// when a parent offers it as a variant. Otherwise the only answer the
|
||||
// gallery can give for a build it does hold is "no models found", which
|
||||
// reads as "that model does not exist". Tag and backend do not bypass
|
||||
// the collapse because they refine a listing the user is still reading
|
||||
// rather than name something they already know exists.
|
||||
//
|
||||
// term is already trimmed, so a stray space in the search box does not
|
||||
// count as a search and cannot silently expand the listing.
|
||||
//
|
||||
// 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("collapse_variants") == "true" {
|
||||
if term == "" && c.QueryParam("collapse_variants") == "true" {
|
||||
referenced := gallery.VariantReferencedIDs(allModels)
|
||||
filtered := make(gallery.GalleryElements[*gallery.GalleryModel], 0, len(models))
|
||||
for _, m := range models {
|
||||
|
||||
@@ -210,8 +210,9 @@ var _ = Describe("Model gallery variants API", 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.
|
||||
// in its own right, with nothing shown twice. Off by default, so a client
|
||||
// that never asks for it sees the listing exactly as it was. The web UI
|
||||
// always asks for it; the parameter stays for every other client.
|
||||
Context("the collapse_variants listing filter", func() {
|
||||
names := func(path string) []string {
|
||||
code, body := get(path)
|
||||
@@ -256,8 +257,8 @@ var _ = Describe("Model gallery variants API", func() {
|
||||
})
|
||||
|
||||
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.
|
||||
// The whole promise of the migration phase: a client that never
|
||||
// sends the parameter gets byte-for-byte what it got before.
|
||||
entry := find(listing(), "plain-entry")
|
||||
Expect(entry).NotTo(HaveKey("has_variants"))
|
||||
Expect(entry).NotTo(HaveKey("variants"))
|
||||
@@ -276,14 +277,35 @@ var _ = Describe("Model gallery variants API", func() {
|
||||
To(ConsistOf("base-entry", "big-entry"))
|
||||
})
|
||||
|
||||
It("composes with the search term", func() {
|
||||
It("still applies the search term when nothing is collapsed away", func() {
|
||||
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.
|
||||
})
|
||||
|
||||
It("lets a search term find a build the collapse would hide", func() {
|
||||
// The term matches the referenced build but not its parent.
|
||||
// Collapsing is a browsing aid; a user who types a name is looking
|
||||
// something up, and answering "no models found" for an entry the
|
||||
// gallery does hold reads as "that model does not exist".
|
||||
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())
|
||||
Expect(names("/api/models?items=9999&collapse_variants=true&term=big")).
|
||||
To(ConsistOf("big-entry"))
|
||||
})
|
||||
|
||||
It("does not treat an empty or whitespace-only term as a search", func() {
|
||||
// Otherwise a cleared or fat-fingered search box would silently
|
||||
// stop collapsing and the browsing view would grow duplicate rows.
|
||||
Expect(names("/api/models?items=9999&collapse_variants=true&term=")).
|
||||
To(ConsistOf("base-entry", "plain-entry"))
|
||||
Expect(names("/api/models?items=9999&collapse_variants=true&term=%20%20")).
|
||||
To(ConsistOf("base-entry", "plain-entry"))
|
||||
})
|
||||
|
||||
It("does not let tag or backend filters bypass the collapse", func() {
|
||||
// They refine a listing the user is still reading rather than name
|
||||
// an entry they already know exists, so collapsing still helps.
|
||||
Expect(names("/api/models?items=9999&collapse_variants=true&backend=llama-cpp")).
|
||||
NotTo(ContainElement("big-entry"))
|
||||
})
|
||||
|
||||
It("reports the filtered total so pagination stays honest", func() {
|
||||
|
||||
@@ -207,13 +207,28 @@ 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.
|
||||
before pagination, so page counts stay correct.
|
||||
|
||||
The web UI offers the same view as the "One row per model" toggle above the
|
||||
gallery.
|
||||
**A search term bypasses the collapse.** Collapsing is a browsing aid, so when
|
||||
`term` is set the listing returns every match, including builds another entry
|
||||
offers as variants. Looking a model up by name must not answer "not found" for
|
||||
an entry the gallery holds. A term that is empty or only whitespace does not
|
||||
count as a search, so browsing still collapses:
|
||||
|
||||
```bash
|
||||
# Collapsed: the parent stands in for the build it offers.
|
||||
curl 'http://localhost:8080/api/models?collapse_variants=true'
|
||||
# Searched: the build comes back by name even though it is collapsed away above.
|
||||
curl 'http://localhost:8080/api/models?collapse_variants=true&term=nanbeige4.1-3b-q8'
|
||||
```
|
||||
|
||||
`tag` and `backend` do not bypass the collapse. They refine a listing the user
|
||||
is still reading rather than name an entry already known to exist, so the
|
||||
deduplicated view is still the more useful one under them.
|
||||
|
||||
The web UI always requests the collapsed view. It has no control for this,
|
||||
because searching already reaches every build; the parameter stays on the API,
|
||||
off by default, for clients that want either view.
|
||||
|
||||
Ask for the description one entry at a time, as the web UI does when you open
|
||||
a model's variant menu:
|
||||
|
||||
Reference in New Issue
Block a user