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
|
click for a full-size lightbox view, and **permanently delete** photos one at a time or
|
||||||
all at once (with confirm).
|
all at once (with confirm).
|
||||||
- **Browse CivitAI** - search the CivitAI catalog in a thumbnail grid (filter by type,
|
- **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
|
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).
|
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
|
- **Installed Models** - browse what's on disk, grouped by type, with size and delete actions
|
||||||
|
|||||||
@@ -189,7 +189,8 @@ async def start_download(body: DownloadIn) -> dict:
|
|||||||
@app.get("/api/civitai/search")
|
@app.get("/api/civitai/search")
|
||||||
async def civitai_search(
|
async def civitai_search(
|
||||||
query: Optional[str] = None,
|
query: Optional[str] = None,
|
||||||
types: Optional[str] = None, # comma-separated CivitAI types
|
types: Optional[str] = None, # comma-separated CivitAI types
|
||||||
|
base_models: Optional[str] = None, # comma-separated CivitAI base models
|
||||||
sort: Optional[str] = "Most Downloaded",
|
sort: Optional[str] = "Most Downloaded",
|
||||||
period: Optional[str] = "AllTime",
|
period: Optional[str] = "AllTime",
|
||||||
nsfw: bool = False,
|
nsfw: bool = False,
|
||||||
@@ -199,10 +200,11 @@ async def civitai_search(
|
|||||||
if not db.get_setting("civitai_api_key"):
|
if not db.get_setting("civitai_api_key"):
|
||||||
raise HTTPException(400, "Set your CivitAI API key in Settings first.")
|
raise HTTPException(400, "Set your CivitAI API key in Settings first.")
|
||||||
type_list = [t for t in (types or "").split(",") if t] or None
|
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:
|
try:
|
||||||
return await registries.civitai_search(
|
return await registries.civitai_search(
|
||||||
query=query, types=type_list, sort=sort, period=period,
|
query=query, types=type_list, base_models=base_model_list,
|
||||||
nsfw=nsfw, page=page, cursor=cursor)
|
sort=sort, period=period, nsfw=nsfw, page=page, cursor=cursor)
|
||||||
except Exception as exc: # noqa: BLE001 - surface API errors to the UI
|
except Exception as exc: # noqa: BLE001 - surface API errors to the UI
|
||||||
raise HTTPException(502, f"CivitAI search failed: {exc}")
|
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,
|
async def civitai_search(query: Optional[str] = None,
|
||||||
types: Optional[list[str]] = None,
|
types: Optional[list[str]] = None,
|
||||||
|
base_models: Optional[list[str]] = None,
|
||||||
sort: Optional[str] = None,
|
sort: Optional[str] = None,
|
||||||
period: Optional[str] = None,
|
period: Optional[str] = None,
|
||||||
nsfw: Optional[bool] = None,
|
nsfw: Optional[bool] = None,
|
||||||
@@ -228,6 +229,8 @@ async def civitai_search(query: Optional[str] = None,
|
|||||||
params["query"] = query
|
params["query"] = query
|
||||||
if types:
|
if types:
|
||||||
params["types"] = types # httpx serializes lists as repeated params
|
params["types"] = types # httpx serializes lists as repeated params
|
||||||
|
if base_models:
|
||||||
|
params["baseModels"] = base_models
|
||||||
if sort:
|
if sort:
|
||||||
params["sort"] = sort
|
params["sort"] = sort
|
||||||
if period:
|
if period:
|
||||||
|
|||||||
@@ -404,11 +404,56 @@ $("#galleryMore").addEventListener("click", () => loadGallery(false));
|
|||||||
|
|
||||||
const browseState = { loaded: false, page: null, cursor: null, busy: 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() {
|
function browseParams() {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
const q = $("#bSearch").value.trim();
|
const q = $("#bSearch").value.trim();
|
||||||
if (q) p.set("query", q);
|
if (q) p.set("query", q);
|
||||||
if ($("#bType").value) p.set("types", $("#bType").value);
|
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("sort", $("#bSort").value);
|
||||||
p.set("period", $("#bPeriod").value);
|
p.set("period", $("#bPeriod").value);
|
||||||
p.set("nsfw", $("#bNsfw").checked ? "true" : "false");
|
p.set("nsfw", $("#bNsfw").checked ? "true" : "false");
|
||||||
@@ -622,6 +667,7 @@ $("#saveSettings").addEventListener("click", async () => {
|
|||||||
|
|
||||||
(async function init() {
|
(async function init() {
|
||||||
await loadModelTypes();
|
await loadModelTypes();
|
||||||
|
initBaseModelDropdown();
|
||||||
const wanted = new URLSearchParams(location.search).get("view");
|
const wanted = new URLSearchParams(location.search).get("view");
|
||||||
const valid = ["installed", "gallery", "browse", "download", "settings"];
|
const valid = ["installed", "gallery", "browse", "download", "settings"];
|
||||||
showView(valid.includes(wanted) ? wanted : "installed");
|
showView(valid.includes(wanted) ? wanted : "installed");
|
||||||
|
|||||||
@@ -104,6 +104,15 @@
|
|||||||
<option>Week</option>
|
<option>Week</option>
|
||||||
<option>Day</option>
|
<option>Day</option>
|
||||||
</select>
|
</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>
|
<label class="check"><input type="checkbox" id="bNsfw" /> NSFW</label>
|
||||||
<button class="btn primary" id="bGo">Search</button>
|
<button class="btn primary" id="bGo">Search</button>
|
||||||
</div>
|
</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 { display: flex; align-items: center; gap: 6px; margin: 0; color: var(--text); white-space: nowrap; }
|
||||||
.check input { width: auto; }
|
.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 {
|
.browse-grid {
|
||||||
display: grid; gap: 14px;
|
display: grid; gap: 14px;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||||
|
|||||||
Reference in New Issue
Block a user