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:
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user