"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(); } // ---- 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 === "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 = `

Error: ${err.message}

`; } } 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 = `

${ models.length ? "No models match your filter." : "No models installed yet. Use Add / Download." }

`; 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 = `

${label} · ${groups[label].length}

`; 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 = `
${fmtBytes(m.size)} · ${fmtDate(m.mtime)}
${m.folder}/
`; 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 (!confirm(`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 = `

No downloads yet.

`; 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 = `
${r.status}
${r.status === "downloading" ? `
` : ""}`; 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; } } // ---- 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(); showView("installed"); })();