Files
SparkyUI/model-manager/app/static/app.js
T
TBNilles 15e0dc3772 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>
2026-06-07 16:37:33 -04:00

675 lines
22 KiB
JavaScript

"use strict";
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
let MODEL_TYPES = [];
let downloadsTimer = null;
// ---- helpers --------------------------------------------------------------
async function api(path, opts) {
const resp = await fetch(path, opts);
const ct = resp.headers.get("content-type") || "";
const data = ct.includes("application/json") ? await resp.json() : null;
if (!resp.ok) {
const msg = (data && data.detail) ? data.detail : `HTTP ${resp.status}`;
throw new Error(msg);
}
return data;
}
function fmtBytes(n) {
if (!n) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
function fmtDate(epoch) {
if (!epoch) return "";
return new Date(epoch * 1000).toLocaleString();
}
// In-app confirmation dialog (replaces window.confirm, which browsers can
// suppress after repeated use). Returns a Promise<boolean>.
function confirmDialog(message, okLabel = "Delete") {
return new Promise((resolve) => {
const overlay = $("#confirmModal");
$("#confirmMsg").textContent = message;
$("#confirmOk").textContent = okLabel;
overlay.hidden = false;
const done = (val) => {
overlay.hidden = true;
$("#confirmOk").onclick = null;
$("#confirmCancel").onclick = null;
overlay.onclick = null;
document.removeEventListener("keydown", onKey);
resolve(val);
};
function onKey(e) {
if (e.key === "Escape") done(false);
if (e.key === "Enter") done(true);
}
$("#confirmOk").onclick = () => done(true);
$("#confirmCancel").onclick = () => done(false);
overlay.onclick = (e) => { if (e.target === overlay) done(false); };
document.addEventListener("keydown", onKey);
});
}
// ---- navigation -----------------------------------------------------------
function showView(name) {
$$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
$$(".view").forEach((v) => v.classList.toggle("active", v.id === `view-${name}`));
if (name === "installed") loadModels();
if (name === "gallery") loadGallery(true);
if (name === "browse" && !browseState.loaded) runSearch(true);
if (name === "download") { loadDownloads(); startDownloadsPolling(); }
else stopDownloadsPolling();
if (name === "settings") loadSettings();
}
$$(".nav-item").forEach((btn) =>
btn.addEventListener("click", () => showView(btn.dataset.view)));
// ---- model types ----------------------------------------------------------
async function loadModelTypes() {
MODEL_TYPES = await api("/api/model-types");
const sel = $("#dlType");
sel.innerHTML = "";
for (const t of MODEL_TYPES) {
const opt = document.createElement("option");
opt.value = t.key;
opt.textContent = t.label;
sel.appendChild(opt);
}
}
// ---- installed models -----------------------------------------------------
async function loadModels() {
const container = $("#modelsContainer");
try {
const models = await api("/api/models");
renderModels(models);
} catch (err) {
container.innerHTML = `<p class="empty">Error: ${err.message}</p>`;
}
}
function renderModels(models) {
const container = $("#modelsContainer");
const filter = $("#modelSearch").value.trim().toLowerCase();
const shown = filter
? models.filter((m) => m.name.toLowerCase().includes(filter))
: models;
if (!shown.length) {
container.innerHTML = `<p class="empty">${
models.length ? "No models match your filter." : "No models installed yet. Use Add / Download."
}</p>`;
return;
}
const groups = {};
for (const m of shown) (groups[m.type_label] ||= []).push(m);
container.innerHTML = "";
for (const label of Object.keys(groups).sort()) {
const group = document.createElement("div");
group.className = "type-group";
group.innerHTML = `<h3>${label} · ${groups[label].length}</h3>`;
const grid = document.createElement("div");
grid.className = "model-grid";
for (const m of groups[label]) {
const card = document.createElement("div");
card.className = "model-card";
card.innerHTML = `
<div class="name"></div>
<div class="meta">${fmtBytes(m.size)} · ${fmtDate(m.mtime)}</div>
<div class="card-foot">
<span class="meta">${m.folder}/</span>
<button class="btn danger">Delete</button>
</div>`;
card.querySelector(".name").textContent = m.name;
card.querySelector(".danger").addEventListener("click", () => deleteModel(m));
grid.appendChild(card);
}
group.appendChild(grid);
container.appendChild(group);
}
}
async function deleteModel(m) {
if (!(await confirmDialog(`Delete ${m.name}?`))) return;
try {
await api("/api/models", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folder: m.folder, name: m.name }),
});
loadModels();
} catch (err) {
alert(`Delete failed: ${err.message}`);
}
}
$("#refreshModels").addEventListener("click", loadModels);
$("#modelSearch").addEventListener("input", () => loadModels());
// ---- downloads ------------------------------------------------------------
function detectSource(url) {
url = url.toLowerCase();
if (url.includes("civitai.com")) return "CivitAI (type auto-detected, API key used)";
if (url.includes("huggingface.co") || url.includes("hf.co")) return "HuggingFace (token used)";
if (url) return "Direct download";
return "";
}
$("#dlUrl").addEventListener("input", (e) => {
$("#sourceHint").textContent = detectSource(e.target.value.trim());
});
$("#startDownload").addEventListener("click", async () => {
const url = $("#dlUrl").value.trim();
const msg = $("#dlMsg");
if (!url) { msg.className = "form-msg err"; msg.textContent = "Enter a URL."; return; }
const btn = $("#startDownload");
btn.disabled = true;
msg.className = "form-msg"; msg.textContent = "Starting…";
try {
await api("/api/downloads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url,
model_type: $("#dlType").value || null,
filename: $("#dlFilename").value.trim() || null,
}),
});
msg.className = "form-msg ok"; msg.textContent = "Download started.";
$("#dlUrl").value = ""; $("#dlFilename").value = ""; $("#sourceHint").textContent = "";
loadDownloads();
startDownloadsPolling();
} catch (err) {
msg.className = "form-msg err"; msg.textContent = err.message;
} finally {
btn.disabled = false;
}
});
async function loadDownloads() {
let rows;
try {
rows = await api("/api/downloads");
} catch {
return;
}
renderDownloads(rows);
const active = rows.some((r) => r.status === "downloading" || r.status === "queued");
if (!active) stopDownloadsPolling();
}
function renderDownloads(rows) {
const container = $("#downloadsContainer");
if (!rows.length) {
container.innerHTML = `<p class="empty">No downloads yet.</p>`;
return;
}
container.innerHTML = "";
for (const r of rows) {
const pct = r.bytes_total ? Math.floor((r.bytes_done / r.bytes_total) * 100) : 0;
const row = document.createElement("div");
row.className = "dl-row";
const sizeText = r.bytes_total
? `${fmtBytes(r.bytes_done)} / ${fmtBytes(r.bytes_total)} (${pct}%)`
: fmtBytes(r.bytes_done);
const sub = r.status === "failed" && r.error ? r.error
: `${r.source} · ${r.model_type} · ${sizeText}`;
row.innerHTML = `
<div class="dl-top">
<div>
<div class="dl-name"></div>
<div class="dl-sub"></div>
</div>
<div class="dl-actions">
<span class="dl-status ${r.status}">${r.status}</span>
<button class="btn danger"></button>
</div>
</div>
${r.status === "downloading"
? `<div class="progress"><span style="width:${pct}%"></span></div>` : ""}`;
row.querySelector(".dl-name").textContent = r.filename || r.url;
row.querySelector(".dl-sub").textContent = sub;
const actionBtn = row.querySelector(".danger");
const active = r.status === "downloading" || r.status === "queued";
actionBtn.textContent = active ? "Cancel" : "Remove";
actionBtn.addEventListener("click", () => removeDownload(r.id));
container.appendChild(row);
}
}
async function removeDownload(id) {
try {
await api(`/api/downloads/${id}`, { method: "DELETE" });
loadDownloads();
} catch (err) {
alert(`Failed: ${err.message}`);
}
}
function startDownloadsPolling() {
if (downloadsTimer) return;
downloadsTimer = setInterval(loadDownloads, 1000);
}
function stopDownloadsPolling() {
if (downloadsTimer) { clearInterval(downloadsTimer); downloadsTimer = null; }
}
// ---- gallery (generated photos) -------------------------------------------
const galleryState = { offset: 0, limit: 60, total: 0 };
async function loadGallery(reset) {
const grid = $("#galleryGrid");
const moreBtn = $("#galleryMore");
if (reset) {
galleryState.offset = 0;
grid.innerHTML = `<p class="empty">Loading…</p>`;
} else {
moreBtn.textContent = "Loading…";
}
try {
const data = await api(
`/api/gallery?limit=${galleryState.limit}&offset=${galleryState.offset}`);
if (reset) grid.innerHTML = "";
if (!data.items.length && reset) {
grid.innerHTML = `<p class="empty">No generated photos yet.</p>`;
} else {
for (const p of data.items) grid.appendChild(renderPhotoCard(p));
}
galleryState.total = data.total;
galleryState.offset += data.returned;
const hasMore = galleryState.offset < galleryState.total;
moreBtn.style.display = hasMore ? "" : "none";
moreBtn.textContent = "Load more";
} catch (err) {
if (reset) grid.innerHTML = `<p class="empty">Error: ${err.message}</p>`;
moreBtn.textContent = "Load more";
}
}
function photoUrl(p) {
return `/gallery/file?path=${encodeURIComponent(p.path)}`;
}
function renderPhotoCard(p) {
const card = document.createElement("div");
card.className = "photo-card";
const img = document.createElement("img");
img.loading = "lazy";
img.src = photoUrl(p);
img.alt = p.name;
const del = document.createElement("button");
del.className = "p-del";
del.title = "Delete";
del.textContent = "🗑";
del.addEventListener("click", (e) => { e.stopPropagation(); deletePhoto(p, card); });
const meta = document.createElement("div");
meta.className = "p-meta";
meta.textContent = `${p.name} · ${fmtBytes(p.size)}`;
card.append(img, del, meta);
card.addEventListener("click", () => openLightbox(p, card));
return card;
}
async function deletePhoto(p, card) {
if (!(await confirmDialog(`Permanently delete ${p.name}?`))) return;
try {
await api("/api/gallery", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: p.path }),
});
if (card) card.remove();
galleryState.total = Math.max(0, galleryState.total - 1);
galleryState.offset = Math.max(0, galleryState.offset - 1);
closeLightbox();
toast($("#galleryToast"), "Deleted", false);
if (!$("#galleryGrid").children.length) loadGallery(true);
} catch (err) {
toast($("#galleryToast"), err.message, true);
}
}
// lightbox
let lbCurrent = null;
function openLightbox(p, card) {
lbCurrent = { p, card };
$("#lbImage").src = photoUrl(p);
$("#lbName").textContent = p.path;
$("#lbOpen").href = photoUrl(p);
$("#lightbox").hidden = false;
}
function closeLightbox() {
$("#lightbox").hidden = true;
$("#lbImage").src = "";
lbCurrent = null;
}
$("#lbClose").addEventListener("click", closeLightbox);
$("#lightbox").addEventListener("click", (e) => {
if (e.target.id === "lightbox") closeLightbox();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !$("#lightbox").hidden) closeLightbox();
});
$("#lbDelete").addEventListener("click", () => {
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));
$("#deleteAllGallery").addEventListener("click", deleteAllPhotos);
$("#galleryMore").addEventListener("click", () => loadGallery(false));
// ---- browse civitai -------------------------------------------------------
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");
return p;
}
async function runSearch(reset) {
if (browseState.busy) return;
browseState.busy = true;
browseState.loaded = true;
const grid = $("#browseGrid");
const moreBtn = $("#bMore");
const p = browseParams();
if (reset) {
browseState.page = null;
browseState.cursor = null;
grid.innerHTML = `<p class="empty">Searching…</p>`;
} else {
if (browseState.cursor) p.set("cursor", browseState.cursor);
else if (browseState.page) p.set("page", browseState.page);
moreBtn.textContent = "Loading…";
}
try {
const data = await api(`/api/civitai/search?${p.toString()}`);
if (reset) grid.innerHTML = "";
if (!data.items.length && reset) {
grid.innerHTML = `<p class="empty">No models found.</p>`;
} else {
for (const m of data.items) grid.appendChild(renderBrowseCard(m));
}
// Set up "load more": prefer cursor, fall back to next page.
browseState.cursor = data.nextCursor || null;
browseState.page = data.nextPage || null;
const hasMore = Boolean(browseState.cursor || browseState.page);
moreBtn.style.display = hasMore ? "" : "none";
moreBtn.textContent = "Load more";
} catch (err) {
if (reset) grid.innerHTML = `<p class="empty">Error: ${err.message}</p>`;
else toast($("#browseToast"), err.message, true);
moreBtn.textContent = "Load more";
} finally {
browseState.busy = false;
}
}
function renderBrowseCard(m) {
const card = document.createElement("div");
card.className = "browse-card";
const thumb = document.createElement("div");
thumb.className = "thumb";
if (m.thumbnail && m.thumbnail_type === "image") {
thumb.style.backgroundImage = `url("${m.thumbnail}")`;
}
if (m.type) {
const tag = document.createElement("span");
tag.className = "type-tag";
tag.textContent = m.type;
thumb.appendChild(tag);
}
if (m.nsfw) {
const n = document.createElement("span");
n.className = "nsfw-tag";
n.textContent = "NSFW";
thumb.appendChild(n);
}
if (m.early_access) {
const ea = document.createElement("span");
ea.className = "ea-tag";
ea.textContent = "EARLY ACCESS";
thumb.appendChild(ea);
}
const body = document.createElement("div");
body.className = "body";
const name = document.createElement("div");
name.className = "b-name";
name.textContent = m.name || "(untitled)";
const meta = document.createElement("div");
meta.className = "b-meta";
meta.textContent = `${m.creator || "?"} · ${fmtNum(m.downloads)} downloads`;
const foot = document.createElement("div");
foot.className = "b-foot";
// Version selector when there's more than one.
let versionSel = null;
if (m.versions && m.versions.length > 1) {
versionSel = document.createElement("select");
for (const v of m.versions) {
const o = document.createElement("option");
o.value = v.id;
o.textContent = (v.name || `v${v.id}`) + (v.early_access ? " — Early Access" : "");
o.dataset.ea = v.early_access ? "1" : "";
versionSel.appendChild(o);
}
foot.appendChild(versionSel);
}
const btn = document.createElement("button");
btn.className = "btn primary";
btn.textContent = "Download";
btn.addEventListener("click", () => {
let vid, isEa;
if (versionSel) {
vid = Number(versionSel.value);
isEa = versionSel.selectedOptions[0]?.dataset.ea === "1";
} else {
vid = m.primary_version_id;
isEa = m.early_access;
}
downloadVersion(vid, btn, isEa);
});
foot.appendChild(btn);
body.append(name, meta, foot);
card.append(thumb, body);
return card;
}
async function downloadVersion(versionId, btn, isEarlyAccess) {
if (!versionId) { toast($("#browseToast"), "No version available", true); return; }
if (isEarlyAccess) {
const ok = await confirmDialog(
"This is an Early Access version. CivitAI only allows downloading it if " +
"your account has purchased/unlocked access — otherwise it will fail with " +
"401. Try anyway?", "Download");
if (!ok) return;
}
btn.disabled = true;
const original = btn.textContent;
btn.textContent = "Queued ✓";
try {
await api("/api/civitai/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ version_id: versionId }),
});
toast($("#browseToast"), "Download started — see Add / Download", false);
setTimeout(() => { btn.disabled = false; btn.textContent = original; }, 2500);
} catch (err) {
toast($("#browseToast"), err.message, true);
btn.disabled = false;
btn.textContent = original;
}
}
function fmtNum(n) {
if (n == null) return "?";
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
return String(n);
}
let toastTimer = null;
function toast(el, msg, isErr) {
el.textContent = msg;
el.className = `form-msg ${isErr ? "err" : "ok"}`;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.textContent = ""; }, 4000);
}
$("#bGo").addEventListener("click", () => runSearch(true));
$("#bSearch").addEventListener("keydown", (e) => { if (e.key === "Enter") runSearch(true); });
$("#bMore").addEventListener("click", () => runSearch(false));
// ---- settings -------------------------------------------------------------
async function loadSettings() {
try {
const s = await api("/api/settings");
setBadge($("#civitaiBadge"), s.civitai_api_key_set);
setBadge($("#hfBadge"), s.huggingface_token_set);
} catch { /* ignore */ }
}
function setBadge(el, isSet) {
el.textContent = isSet ? "configured" : "not set";
el.className = `badge ${isSet ? "set" : "unset"}`;
}
$("#saveSettings").addEventListener("click", async () => {
const msg = $("#settingsMsg");
const body = {};
const civ = $("#civitaiKey").value;
const hf = $("#hfToken").value;
// Only send fields the user actually typed into (blank = leave unchanged).
if (civ !== "") body.civitai_api_key = civ;
if (hf !== "") body.huggingface_token = hf;
if (!Object.keys(body).length) {
msg.className = "form-msg"; msg.textContent = "Nothing to save.";
return;
}
try {
await api("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
msg.className = "form-msg ok"; msg.textContent = "Saved.";
$("#civitaiKey").value = ""; $("#hfToken").value = "";
loadSettings();
} catch (err) {
msg.className = "form-msg err"; msg.textContent = err.message;
}
});
// ---- boot -----------------------------------------------------------------
(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");
})();