feat(model-manager): base-model multi-select filter on CivitAI browse

Add a checkbox dropdown to filter the CivitAI catalog by one or more base
models (SD 1.5, SDXL, Pony, Illustrious, Flux, etc.), mapped to the API's
`baseModels` array param. Backend search accepts comma-separated
base_models; button shows the selected count with a Clear action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 16:37:33 -04:00
parent 505f212c4d
commit 15e0dc3772
6 changed files with 86 additions and 4 deletions
+2 -1
View File
@@ -239,7 +239,8 @@ SparkyUI includes a **StabilityMatrix-style Model Manager** - a lightweight Fast
click for a full-size lightbox view, and **permanently delete** photos one at a time or
all at once (with confirm).
- **Browse CivitAI** - search the CivitAI catalog in a thumbnail grid (filter by type,
sort, period, NSFW toggle) and **click a model to download it** - no URL pasting needed.
base model (multi-select), sort, period, NSFW toggle) and **click a model to download
it** - no URL pasting needed.
Multi-version models get a version picker on the card. **Early Access** versions are
flagged (they require purchased access on CivitAI and otherwise fail with 401).
- **Installed Models** - browse what's on disk, grouped by type, with size and delete actions
+4 -2
View File
@@ -190,6 +190,7 @@ async def start_download(body: DownloadIn) -> dict:
async def civitai_search(
query: Optional[str] = None,
types: Optional[str] = None, # comma-separated CivitAI types
base_models: Optional[str] = None, # comma-separated CivitAI base models
sort: Optional[str] = "Most Downloaded",
period: Optional[str] = "AllTime",
nsfw: bool = False,
@@ -199,10 +200,11 @@ async def civitai_search(
if not db.get_setting("civitai_api_key"):
raise HTTPException(400, "Set your CivitAI API key in Settings first.")
type_list = [t for t in (types or "").split(",") if t] or None
base_model_list = [b for b in (base_models or "").split(",") if b] or None
try:
return await registries.civitai_search(
query=query, types=type_list, sort=sort, period=period,
nsfw=nsfw, page=page, cursor=cursor)
query=query, types=type_list, base_models=base_model_list,
sort=sort, period=period, nsfw=nsfw, page=page, cursor=cursor)
except Exception as exc: # noqa: BLE001 - surface API errors to the UI
raise HTTPException(502, f"CivitAI search failed: {exc}")
+3
View File
@@ -212,6 +212,7 @@ def _to_card(item: dict) -> dict:
async def civitai_search(query: Optional[str] = None,
types: Optional[list[str]] = None,
base_models: Optional[list[str]] = None,
sort: Optional[str] = None,
period: Optional[str] = None,
nsfw: Optional[bool] = None,
@@ -228,6 +229,8 @@ async def civitai_search(query: Optional[str] = None,
params["query"] = query
if types:
params["types"] = types # httpx serializes lists as repeated params
if base_models:
params["baseModels"] = base_models
if sort:
params["sort"] = sort
if period:
+46
View File
@@ -404,11 +404,56 @@ $("#galleryMore").addEventListener("click", () => loadGallery(false));
const browseState = { loaded: false, page: null, cursor: null, busy: false };
// CivitAI base-model values (exact strings the API expects).
const BASE_MODELS = [
"SD 1.5", "SD 2.1", "SDXL 1.0", "SDXL Lightning", "SDXL Hyper", "SDXL Turbo",
"Pony", "Illustrious", "NoobAI", "Flux.1 S", "Flux.1 D", "Flux.1 Kontext",
"SD 3", "SD 3.5", "SD 3.5 Medium", "SD 3.5 Large", "Stable Cascade",
"Wan Video", "Hunyuan Video", "LTXV", "Qwen", "Chroma", "HiDream", "Other",
];
function initBaseModelDropdown() {
const panel = $("#bBasePanel");
for (const bm of BASE_MODELS) {
const lab = document.createElement("label");
lab.className = "multi-opt";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.value = bm;
cb.className = "bm-check";
cb.addEventListener("change", updateBaseModelLabel);
lab.append(cb, document.createTextNode(" " + bm));
panel.appendChild(lab);
}
$("#bBaseBtn").addEventListener("click", (e) => {
e.stopPropagation();
panel.hidden = !panel.hidden;
});
$("#bBaseClear").addEventListener("click", () => {
$$(".bm-check").forEach((c) => { c.checked = false; });
updateBaseModelLabel();
});
document.addEventListener("click", (e) => {
if (!$("#bBaseDD").contains(e.target)) panel.hidden = true;
});
}
function selectedBaseModels() {
return $$(".bm-check").filter((c) => c.checked).map((c) => c.value);
}
function updateBaseModelLabel() {
const n = selectedBaseModels().length;
$("#bBaseBtn").textContent = n ? `Base model (${n}) ▾` : "Base model ▾";
}
function browseParams() {
const p = new URLSearchParams();
const q = $("#bSearch").value.trim();
if (q) p.set("query", q);
if ($("#bType").value) p.set("types", $("#bType").value);
const bms = selectedBaseModels();
if (bms.length) p.set("base_models", bms.join(","));
p.set("sort", $("#bSort").value);
p.set("period", $("#bPeriod").value);
p.set("nsfw", $("#bNsfw").checked ? "true" : "false");
@@ -622,6 +667,7 @@ $("#saveSettings").addEventListener("click", async () => {
(async function init() {
await loadModelTypes();
initBaseModelDropdown();
const wanted = new URLSearchParams(location.search).get("view");
const valid = ["installed", "gallery", "browse", "download", "settings"];
showView(valid.includes(wanted) ? wanted : "installed");
+9
View File
@@ -104,6 +104,15 @@
<option>Week</option>
<option>Day</option>
</select>
<div class="multi-dd" id="bBaseDD">
<button type="button" class="btn multi-btn" id="bBaseBtn">Base model ▾</button>
<div class="multi-panel" id="bBasePanel" hidden>
<div class="multi-panel-actions">
<button type="button" class="link-btn" id="bBaseClear">Clear</button>
</div>
<!-- checkboxes injected by app.js -->
</div>
</div>
<label class="check"><input type="checkbox" id="bNsfw" /> NSFW</label>
<button class="btn primary" id="bGo">Search</button>
</div>
+21
View File
@@ -219,6 +219,27 @@ code { background: var(--bg-3); padding: 1px 5px; border-radius: 4px; font-size:
.check { display: flex; align-items: center; gap: 6px; margin: 0; color: var(--text); white-space: nowrap; }
.check input { width: auto; }
/* multi-select dropdown (base model) */
.multi-dd { position: relative; }
.multi-btn { white-space: nowrap; }
.multi-panel {
position: absolute; top: calc(100% + 6px); left: 0; z-index: 50;
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; padding: 8px; min-width: 200px;
max-height: 320px; overflow-y: auto;
display: grid; grid-template-columns: 1fr 1fr; gap: 2px 10px;
box-shadow: 0 10px 30px rgba(0,0,0,.45);
}
.multi-panel[hidden] { display: none; }
.multi-panel-actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; border-bottom: 1px solid var(--border); padding-bottom: 4px; margin-bottom: 2px; }
.link-btn { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 12px; padding: 2px 4px; }
.multi-opt {
display: flex; align-items: center; gap: 6px; margin: 0;
color: var(--text); font-size: 13px; padding: 4px 6px; border-radius: 6px; cursor: pointer;
}
.multi-opt:hover { background: var(--bg-3); }
.multi-opt input { width: auto; }
.browse-grid {
display: grid; gap: 14px;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));