diff --git a/README.md b/README.md index 6330201..da69b81 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/model-manager/app/main.py b/model-manager/app/main.py index de6f3cf..7a51e05 100644 --- a/model-manager/app/main.py +++ b/model-manager/app/main.py @@ -189,7 +189,8 @@ async def start_download(body: DownloadIn) -> dict: @app.get("/api/civitai/search") async def civitai_search( 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", 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}") diff --git a/model-manager/app/registries.py b/model-manager/app/registries.py index b83abf5..f8acb0f 100644 --- a/model-manager/app/registries.py +++ b/model-manager/app/registries.py @@ -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: diff --git a/model-manager/app/static/app.js b/model-manager/app/static/app.js index 3e0fd91..eb209ed 100644 --- a/model-manager/app/static/app.js +++ b/model-manager/app/static/app.js @@ -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"); diff --git a/model-manager/app/static/index.html b/model-manager/app/static/index.html index e5970f8..23a5413 100644 --- a/model-manager/app/static/index.html +++ b/model-manager/app/static/index.html @@ -104,6 +104,15 @@ +
+ + +
diff --git a/model-manager/app/static/style.css b/model-manager/app/static/style.css index 6dd1dca..c3882e6 100644 --- a/model-manager/app/static/style.css +++ b/model-manager/app/static/style.css @@ -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));