feat(model-manager): add "Delete all" gallery button + clearer 401 errors

- Gallery: DELETE /api/gallery/all removes every image under output/;
  "Delete all" button with in-app confirm and a deleted/failed count.
- Downloads: surface a clear, actionable message when CivitAI/HuggingFace
  returns 401/403 (model requires login/early-access, or the key/token
  lacks access) instead of a bare error, both at resolve time and during
  the download stream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 16:19:48 -04:00
parent b919e55206
commit 6871a6d460
5 changed files with 55 additions and 3 deletions
+2 -1
View File
@@ -236,7 +236,8 @@ SparkyUI includes a **StabilityMatrix-style Model Manager** - a lightweight Fast
**Features:** **Features:**
- **Gallery** - browse generated photos from ComfyUI's `output/` in a large desktop grid, - **Gallery** - browse generated photos from ComfyUI's `output/` in a large desktop grid,
click for a full-size lightbox view, and **permanently delete** photos (with confirm). 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, - **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. 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. Multi-version models get a version picker on the card.
+5 -2
View File
@@ -111,8 +111,11 @@ async def run_download(download_id: int, url: str, headers: dict[str, str],
db.update_download(download_id, status="canceled", error="Canceled by user") db.update_download(download_id, status="canceled", error="Canceled by user")
_cleanup(part_path) _cleanup(part_path)
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
db.update_download(download_id, status="failed", code = exc.response.status_code
error=f"HTTP {exc.response.status_code}") msg = f"HTTP {code}"
if code in (401, 403):
msg += " — requires login/early-access or invalid API key"
db.update_download(download_id, status="failed", error=msg)
_cleanup(part_path) _cleanup(part_path)
except Exception as exc: # noqa: BLE001 - surface any failure to the UI except Exception as exc: # noqa: BLE001 - surface any failure to the UI
db.update_download(download_id, status="failed", error=str(exc)) db.update_download(download_id, status="failed", error=str(exc))
+26
View File
@@ -6,6 +6,7 @@ import os
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import httpx
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -141,6 +142,15 @@ async def start_download(body: DownloadIn) -> dict:
try: try:
resolved = await registries.resolve(url) resolved = await registries.resolve(url)
except httpx.HTTPStatusError as exc:
code = exc.response.status_code
if code in (401, 403):
raise HTTPException(
400,
f"The source returned {code} for this model. It likely requires "
"being logged in / early access on that site, or your API key/token "
"doesn't have access to it. (Public models download fine.)")
raise HTTPException(400, f"Could not resolve URL (HTTP {code})")
except Exception as exc: # noqa: BLE001 - report resolution failure to the user except Exception as exc: # noqa: BLE001 - report resolution failure to the user
raise HTTPException(400, f"Could not resolve URL: {exc}") raise HTTPException(400, f"Could not resolve URL: {exc}")
@@ -246,6 +256,22 @@ def list_gallery(limit: int = 60, offset: int = 0) -> dict:
"offset": offset, "returned": len(page)} "offset": offset, "returned": len(page)}
@app.delete("/api/gallery/all")
def delete_all_photos() -> dict:
"""Permanently delete every image under OUTPUT_DIR."""
deleted = 0
failed = 0
if OUTPUT_DIR.is_dir():
for path in OUTPUT_DIR.rglob("*"):
if path.is_file() and path.suffix.lower() in IMAGE_EXTS:
try:
path.unlink()
deleted += 1
except OSError:
failed += 1
return {"deleted": deleted, "failed": failed}
@app.get("/gallery/file") @app.get("/gallery/file")
def gallery_file(path: str): def gallery_file(path: str):
try: try:
+21
View File
@@ -376,7 +376,28 @@ document.addEventListener("keydown", (e) => {
$("#lbDelete").addEventListener("click", () => { $("#lbDelete").addEventListener("click", () => {
if (lbCurrent) deletePhoto(lbCurrent.p, lbCurrent.card); if (lbCurrent) deletePhoto(lbCurrent.p, lbCurrent.card);
}); });
async function deleteAllPhotos() {
if (!galleryState.total) {
toast($("#galleryToast"), "No photos to delete", false);
return;
}
const ok = await confirmDialog(
`Permanently delete ALL ${galleryState.total} photo(s)? This cannot be undone.`,
"Delete all");
if (!ok) return;
try {
const res = await api("/api/gallery/all", { method: "DELETE" });
let msg = `Deleted ${res.deleted} photo(s)`;
if (res.failed) msg += ` (${res.failed} could not be removed — permissions)`;
toast($("#galleryToast"), msg, res.failed > 0);
loadGallery(true);
} catch (err) {
toast($("#galleryToast"), err.message, true);
}
}
$("#refreshGallery").addEventListener("click", () => loadGallery(true)); $("#refreshGallery").addEventListener("click", () => loadGallery(true));
$("#deleteAllGallery").addEventListener("click", deleteAllPhotos);
$("#galleryMore").addEventListener("click", () => loadGallery(false)); $("#galleryMore").addEventListener("click", () => loadGallery(false));
// ---- browse civitai ------------------------------------------------------- // ---- browse civitai -------------------------------------------------------
+1
View File
@@ -58,6 +58,7 @@
<div class="head-actions"> <div class="head-actions">
<span class="form-msg" id="galleryToast"></span> <span class="form-msg" id="galleryToast"></span>
<button class="btn" id="refreshGallery">Refresh</button> <button class="btn" id="refreshGallery">Refresh</button>
<button class="btn danger" id="deleteAllGallery">Delete all</button>
</div> </div>
</header> </header>
<div id="galleryGrid" class="gallery-grid"> <div id="galleryGrid" class="gallery-grid">