feat: add StabilityMatrix-style Model Manager service

New FastAPI container (port 8189) to download and manage models:
- Installed Models, Add/Download (CivitAI/HuggingFace/direct URL), Settings views
- Persistent SQLite storage for API keys and download history (./sparkyui-data)
- Downloads land in ./models, auto-sorted into ComfyUI's standard subfolders
- Default COMFYUI_HOST_PATH and SPARKYUI_DATA_PATH to the project root
- Wire docker-compose service, env defaults, gitignore, README docs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 06:19:44 -04:00
parent 6fa6c5041b
commit 359043ad67
17 changed files with 1355 additions and 16 deletions
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.pytest_cache/
*.db
*.db-wal
*.db-shm
+28
View File
@@ -0,0 +1,28 @@
# SparkyUI Model Manager - StabilityMatrix-style model download/management UI
# Lightweight FastAPI service. Downloads land in the shared host models/ folder
# (mounted read-write here, read-only in the ComfyUI container).
FROM python:3.12-slim
LABEL maintainer="SparkyUI"
LABEL description="Model Manager for ComfyUI on DGX Spark"
WORKDIR /app
# curl is used by the healthcheck.
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
ENV MODELS_DIR=/models \
DATA_DIR=/data
EXPOSE 8189
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8189/api/model-types || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8189"]
View File
+53
View File
@@ -0,0 +1,53 @@
"""Paths, model-type mapping, and folder bootstrap for the Model Manager."""
from __future__ import annotations
import os
from pathlib import Path
# Root of the host `models/` directory (mounted read-write into this container).
MODELS_DIR = Path(os.environ.get("MODELS_DIR", "/models")).resolve()
# Persistent data dir for the SQLite database.
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data")).resolve()
DB_PATH = DATA_DIR / "manager.db"
# StabilityMatrix-style model types mapped to ComfyUI's standard `models/` subfolders.
# `key` is the stable id used by the API/UI; `folder` is the on-disk subdirectory.
MODEL_TYPES: list[dict[str, str]] = [
{"key": "checkpoint", "label": "Checkpoint", "folder": "checkpoints"},
{"key": "lora", "label": "LoRA / LyCORIS", "folder": "loras"},
{"key": "vae", "label": "VAE", "folder": "vae"},
{"key": "embedding", "label": "Textual Inversion", "folder": "embeddings"},
{"key": "controlnet", "label": "ControlNet", "folder": "controlnet"},
{"key": "upscaler", "label": "Upscaler", "folder": "upscale_models"},
{"key": "clip", "label": "CLIP", "folder": "clip"},
{"key": "clip_vision", "label": "CLIP Vision", "folder": "clip_vision"},
{"key": "text_encoder", "label": "Text Encoder", "folder": "text_encoders"},
{"key": "diffusion_model", "label": "Diffusion Model (UNET)", "folder": "diffusion_models"},
{"key": "unet", "label": "UNET", "folder": "unet"},
{"key": "hypernetwork", "label": "Hypernetwork", "folder": "hypernetworks"},
{"key": "style_model", "label": "Style Model", "folder": "style_models"},
{"key": "gligen", "label": "GLIGEN", "folder": "gligen"},
{"key": "vae_approx", "label": "VAE Approx", "folder": "vae_approx"},
{"key": "ipadapter", "label": "IP-Adapter", "folder": "ipadapter"},
{"key": "other", "label": "Other", "folder": "other"},
]
# Quick lookups.
TYPE_BY_KEY: dict[str, dict[str, str]] = {t["key"]: t for t in MODEL_TYPES}
FOLDER_BY_KEY: dict[str, str] = {t["key"]: t["folder"] for t in MODEL_TYPES}
KEY_BY_FOLDER: dict[str, str] = {t["folder"]: t["key"] for t in MODEL_TYPES}
def folder_for_type(type_key: str) -> str:
"""Return the on-disk subfolder for a model type key, defaulting to `other`."""
return FOLDER_BY_KEY.get(type_key, "other")
def ensure_dirs() -> None:
"""Create the data dir and all standard model subfolders if missing."""
DATA_DIR.mkdir(parents=True, exist_ok=True)
MODELS_DIR.mkdir(parents=True, exist_ok=True)
for t in MODEL_TYPES:
(MODELS_DIR / t["folder"]).mkdir(parents=True, exist_ok=True)
+131
View File
@@ -0,0 +1,131 @@
"""SQLite persistence: settings (API keys) and download history/queue."""
from __future__ import annotations
import sqlite3
import threading
import time
from typing import Any, Optional
from .config import DB_PATH, ensure_dirs
# A single connection guarded by a lock keeps things simple and safe across the
# async event loop + background download tasks (sqlite writes are serialized).
_lock = threading.Lock()
_conn: Optional[sqlite3.Connection] = None
def _connect() -> sqlite3.Connection:
global _conn
if _conn is None:
ensure_dirs()
_conn = sqlite3.connect(str(DB_PATH), check_same_thread=False)
_conn.row_factory = sqlite3.Row
_conn.execute("PRAGMA journal_mode=WAL")
return _conn
def init_db() -> None:
with _lock:
conn = _connect()
conn.execute(
"""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
source TEXT,
model_type TEXT,
filename TEXT,
dest_path TEXT,
status TEXT NOT NULL DEFAULT 'queued',
bytes_done INTEGER NOT NULL DEFAULT 0,
bytes_total INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
"""
)
conn.commit()
# ---- settings -------------------------------------------------------------
def get_setting(key: str) -> Optional[str]:
with _lock:
row = _connect().execute(
"SELECT value FROM settings WHERE key = ?", (key,)
).fetchone()
return row["value"] if row else None
def set_setting(key: str, value: Optional[str]) -> None:
with _lock:
conn = _connect()
if value is None or value == "":
conn.execute("DELETE FROM settings WHERE key = ?", (key,))
else:
conn.execute(
"INSERT INTO settings(key, value) VALUES(?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)
conn.commit()
# ---- downloads ------------------------------------------------------------
def create_download(url: str, source: str, model_type: str, filename: str,
dest_path: str) -> int:
now = time.time()
with _lock:
conn = _connect()
cur = conn.execute(
"INSERT INTO downloads(url, source, model_type, filename, dest_path, "
"status, created_at, updated_at) VALUES(?, ?, ?, ?, ?, 'queued', ?, ?)",
(url, source, model_type, filename, dest_path, now, now),
)
conn.commit()
return int(cur.lastrowid)
def update_download(download_id: int, **fields: Any) -> None:
if not fields:
return
fields["updated_at"] = time.time()
cols = ", ".join(f"{k} = ?" for k in fields)
values = list(fields.values()) + [download_id]
with _lock:
conn = _connect()
conn.execute(f"UPDATE downloads SET {cols} WHERE id = ?", values)
conn.commit()
def get_download(download_id: int) -> Optional[dict]:
with _lock:
row = _connect().execute(
"SELECT * FROM downloads WHERE id = ?", (download_id,)
).fetchone()
return dict(row) if row else None
def list_downloads() -> list[dict]:
with _lock:
rows = _connect().execute(
"SELECT * FROM downloads ORDER BY id DESC"
).fetchall()
return [dict(r) for r in rows]
def delete_download(download_id: int) -> None:
with _lock:
conn = _connect()
conn.execute("DELETE FROM downloads WHERE id = ?", (download_id,))
conn.commit()
+129
View File
@@ -0,0 +1,129 @@
"""Async streaming downloader with progress tracking and cancellation."""
from __future__ import annotations
import asyncio
import os
import re
from pathlib import Path
from typing import Optional
import httpx
from . import db
from .config import MODELS_DIR, folder_for_type
# download_id -> asyncio.Event set when a cancel is requested.
_cancel_flags: dict[int, asyncio.Event] = {}
_FILENAME_STAR_RE = re.compile(r"filename\*=(?:UTF-8'')?([^;]+)", re.IGNORECASE)
_FILENAME_RE = re.compile(r'filename="?([^";]+)"?', re.IGNORECASE)
def sanitize_filename(name: str) -> str:
"""Strip any directory components and unsafe characters from a filename."""
name = os.path.basename(name.strip().strip('"'))
name = name.replace("\x00", "")
# Disallow path separators / parent refs that survived basename on other OSes.
name = name.replace("/", "_").replace("\\", "_")
if name in ("", ".", ".."):
name = "model.bin"
return name
def _filename_from_disposition(value: str) -> Optional[str]:
from urllib.parse import unquote
m = _FILENAME_STAR_RE.search(value)
if m:
return unquote(m.group(1))
m = _FILENAME_RE.search(value)
if m:
return m.group(1)
return None
def safe_dest(model_type: str, filename: str) -> Path:
"""Build a destination path under MODELS_DIR/<folder>, guarding traversal."""
folder = folder_for_type(model_type)
filename = sanitize_filename(filename)
base = (MODELS_DIR / folder).resolve()
dest = (base / filename).resolve()
if not str(dest).startswith(str(base) + os.sep):
raise ValueError("Refusing path outside the model folder")
return dest
def request_cancel(download_id: int) -> bool:
"""Signal an in-flight download to stop. Returns True if it was active."""
ev = _cancel_flags.get(download_id)
if ev is not None:
ev.set()
return True
return False
async def run_download(download_id: int, url: str, headers: dict[str, str],
model_type: str, filename: Optional[str]) -> None:
"""Stream `url` to disk, updating the DB row as it progresses."""
cancel = asyncio.Event()
_cancel_flags[download_id] = cancel
part_path: Optional[Path] = None
try:
db.update_download(download_id, status="downloading")
async with httpx.AsyncClient(follow_redirects=True, timeout=None,
headers=headers) as client:
async with client.stream("GET", url) as resp:
resp.raise_for_status()
# Prefer a server-provided filename if we don't have a good one.
disp = resp.headers.get("content-disposition")
if disp:
server_name = _filename_from_disposition(disp)
if server_name:
filename = server_name
if not filename:
filename = os.path.basename(str(resp.url).split("?")[0]) or "model.bin"
dest = safe_dest(model_type, filename)
part_path = dest.with_suffix(dest.suffix + ".part")
total = int(resp.headers.get("content-length", 0) or 0)
db.update_download(download_id, filename=dest.name,
dest_path=str(dest), bytes_total=total)
done = 0
last_report = 0.0
with open(part_path, "wb") as fh:
async for chunk in resp.aiter_bytes(chunk_size=1024 * 256):
if cancel.is_set():
raise asyncio.CancelledError()
fh.write(chunk)
done += len(chunk)
# Throttle DB writes to ~5/sec.
now = asyncio.get_event_loop().time()
if now - last_report > 0.2:
db.update_download(download_id, bytes_done=done)
last_report = now
db.update_download(download_id, bytes_done=done)
os.replace(part_path, dest)
part_path = None
db.update_download(download_id, status="completed")
except asyncio.CancelledError:
db.update_download(download_id, status="canceled", error="Canceled by user")
_cleanup(part_path)
except httpx.HTTPStatusError as exc:
db.update_download(download_id, status="failed",
error=f"HTTP {exc.response.status_code}")
_cleanup(part_path)
except Exception as exc: # noqa: BLE001 - surface any failure to the UI
db.update_download(download_id, status="failed", error=str(exc))
_cleanup(part_path)
finally:
_cancel_flags.pop(download_id, None)
def _cleanup(part_path: Optional[Path]) -> None:
if part_path is not None:
try:
part_path.unlink(missing_ok=True)
except OSError:
pass
+191
View File
@@ -0,0 +1,191 @@
"""SparkyUI Model Manager - FastAPI app, API routes, and static UI."""
from __future__ import annotations
import asyncio
import os
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import db, downloader, registries
from .config import (
KEY_BY_FOLDER,
MODEL_TYPES,
MODELS_DIR,
TYPE_BY_KEY,
ensure_dirs,
)
STATIC_DIR = Path(__file__).parent / "static"
app = FastAPI(title="SparkyUI Model Manager")
@app.on_event("startup")
def _startup() -> None:
ensure_dirs()
db.init_db()
# Any download left mid-flight by a restart is no longer running.
for d in db.list_downloads():
if d["status"] in ("queued", "downloading"):
db.update_download(d["id"], status="failed",
error="Interrupted by restart")
# ---- request models -------------------------------------------------------
class SettingsIn(BaseModel):
civitai_api_key: Optional[str] = None
huggingface_token: Optional[str] = None
class DownloadIn(BaseModel):
url: str
model_type: Optional[str] = None
filename: Optional[str] = None
# ---- model types & installed models ---------------------------------------
@app.get("/api/model-types")
def model_types() -> list[dict]:
return MODEL_TYPES
@app.get("/api/models")
def list_models() -> list[dict]:
results: list[dict] = []
for folder, type_key in KEY_BY_FOLDER.items():
base = MODELS_DIR / folder
if not base.is_dir():
continue
for entry in sorted(base.iterdir()):
if entry.name.startswith(".") or entry.name.endswith(".part"):
continue
if not entry.is_file():
continue
stat = entry.stat()
results.append({
"name": entry.name,
"type": type_key,
"type_label": TYPE_BY_KEY[type_key]["label"],
"folder": folder,
"size": stat.st_size,
"mtime": stat.st_mtime,
})
return results
class DeleteModelIn(BaseModel):
folder: str
name: str
@app.delete("/api/models")
def delete_model(body: DeleteModelIn) -> dict:
if body.folder not in KEY_BY_FOLDER:
raise HTTPException(400, "Unknown model folder")
name = os.path.basename(body.name)
base = (MODELS_DIR / body.folder).resolve()
target = (base / name).resolve()
if not str(target).startswith(str(base) + os.sep):
raise HTTPException(400, "Invalid path")
if not target.is_file():
raise HTTPException(404, "File not found")
target.unlink()
return {"deleted": name}
# ---- settings -------------------------------------------------------------
@app.get("/api/settings")
def get_settings() -> dict:
# Never return the secrets themselves, only whether they are configured.
return {
"civitai_api_key_set": bool(db.get_setting("civitai_api_key")),
"huggingface_token_set": bool(db.get_setting("huggingface_token")),
}
@app.post("/api/settings")
def save_settings(body: SettingsIn) -> dict:
# `None` leaves a value untouched; empty string clears it.
if body.civitai_api_key is not None:
db.set_setting("civitai_api_key", body.civitai_api_key)
if body.huggingface_token is not None:
db.set_setting("huggingface_token", body.huggingface_token)
return get_settings()
# ---- downloads ------------------------------------------------------------
@app.get("/api/downloads")
def get_downloads() -> list[dict]:
return db.list_downloads()
@app.post("/api/downloads")
async def start_download(body: DownloadIn) -> dict:
url = body.url.strip()
if not url:
raise HTTPException(400, "URL is required")
try:
resolved = await registries.resolve(url)
except Exception as exc: # noqa: BLE001 - report resolution failure to the user
raise HTTPException(400, f"Could not resolve URL: {exc}")
# Pick the model type: explicit user choice wins, else registry suggestion.
model_type = body.model_type or resolved.model_type
if not model_type:
raise HTTPException(
400, "Please choose a model type for this download.")
if model_type not in TYPE_BY_KEY:
raise HTTPException(400, "Unknown model type")
filename = body.filename or resolved.filename or ""
download_id = db.create_download(
url=resolved.download_url,
source=resolved.source,
model_type=model_type,
filename=filename,
dest_path="",
)
asyncio.create_task(downloader.run_download(
download_id=download_id,
url=resolved.download_url,
headers=resolved.headers,
model_type=model_type,
filename=filename or None,
))
row = db.get_download(download_id)
return row or {"id": download_id}
@app.delete("/api/downloads/{download_id}")
def delete_download(download_id: int) -> dict:
row = db.get_download(download_id)
if not row:
raise HTTPException(404, "Download not found")
# If it's running, signal cancel; the task will mark it canceled.
if downloader.request_cancel(download_id):
return {"canceled": download_id}
db.delete_download(download_id)
return {"removed": download_id}
# ---- static UI ------------------------------------------------------------
@app.get("/")
def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
app.mount("/", StaticFiles(directory=str(STATIC_DIR)), name="static")
+142
View File
@@ -0,0 +1,142 @@
"""Resolve a user-supplied URL into a concrete download (direct URL + auth + metadata).
Supports direct URLs, CivitAI (model/version pages or api download links), and
HuggingFace `resolve` URLs. API keys are read from the settings table.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Optional
from urllib.parse import unquote, urlparse, parse_qs
import httpx
from . import db
# CivitAI model `type` -> our model-type key.
CIVITAI_TYPE_MAP = {
"Checkpoint": "checkpoint",
"LORA": "lora",
"LoCon": "lora",
"DoRA": "lora",
"TextualInversion": "embedding",
"Hypernetwork": "hypernetwork",
"VAE": "vae",
"Controlnet": "controlnet",
"Upscaler": "upscaler",
"MotionModule": "other",
"Poses": "other",
"Wildcards": "other",
"Other": "other",
}
@dataclass
class Resolved:
download_url: str
source: str
headers: dict[str, str] = field(default_factory=dict)
filename: Optional[str] = None
model_type: Optional[str] = None # suggested type if the registry tells us
def detect_source(url: str) -> str:
host = (urlparse(url).hostname or "").lower()
if "civitai.com" in host:
return "civitai"
if "huggingface.co" in host or "hf.co" in host:
return "huggingface"
return "direct"
def _filename_from_url(url: str) -> Optional[str]:
path = urlparse(url).path
name = unquote(path.rsplit("/", 1)[-1]) if path else ""
return name or None
async def resolve(url: str) -> Resolved:
source = detect_source(url)
if source == "civitai":
return await _resolve_civitai(url)
if source == "huggingface":
return _resolve_huggingface(url)
return _resolve_direct(url)
def _resolve_direct(url: str) -> Resolved:
return Resolved(download_url=url, source="direct",
filename=_filename_from_url(url))
def _resolve_huggingface(url: str) -> Resolved:
headers: dict[str, str] = {}
token = db.get_setting("huggingface_token")
if token:
headers["Authorization"] = f"Bearer {token}"
return Resolved(download_url=url, source="huggingface", headers=headers,
filename=_filename_from_url(url))
_CIVITAI_VERSION_RE = re.compile(r"/api/download/models/(\d+)")
_CIVITAI_MODEL_RE = re.compile(r"/models/(\d+)")
async def _resolve_civitai(url: str) -> Resolved:
headers: dict[str, str] = {}
api_key = db.get_setting("civitai_api_key")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
# Case 1: already a direct api/download URL -> use as-is.
if _CIVITAI_VERSION_RE.search(urlparse(url).path):
return Resolved(download_url=url, source="civitai", headers=headers,
filename=_filename_from_url(url))
# Case 2: a model page URL. Find the version id (explicit query or first version).
parsed = urlparse(url)
qs = parse_qs(parsed.query)
version_id: Optional[str] = None
if "modelVersionId" in qs:
version_id = qs["modelVersionId"][0]
if version_id is None:
m = _CIVITAI_MODEL_RE.search(parsed.path)
if not m:
# Can't understand it; fall back to treating it as a direct link.
return Resolved(download_url=url, source="civitai", headers=headers,
filename=_filename_from_url(url))
model_id = m.group(1)
async with httpx.AsyncClient(timeout=30, headers=headers) as client:
resp = await client.get(f"https://civitai.com/api/v1/models/{model_id}")
resp.raise_for_status()
data = resp.json()
versions = data.get("modelVersions") or []
if not versions:
raise ValueError("CivitAI model has no downloadable versions")
version_id = str(versions[0]["id"])
# Resolve the version to a concrete file + type.
async with httpx.AsyncClient(timeout=30, headers=headers) as client:
resp = await client.get(
f"https://civitai.com/api/v1/model-versions/{version_id}")
resp.raise_for_status()
version = resp.json()
files = version.get("files") or []
# Prefer the primary file, else the first.
chosen = next((f for f in files if f.get("primary")), files[0] if files else None)
if not chosen:
raise ValueError("CivitAI version has no files")
civ_type = (version.get("model") or {}).get("type") or "Other"
model_type = CIVITAI_TYPE_MAP.get(civ_type, "other")
return Resolved(
download_url=chosen["downloadUrl"],
source="civitai",
headers=headers,
filename=chosen.get("name") or _filename_from_url(chosen["downloadUrl"]),
model_type=model_type,
)
+292
View File
@@ -0,0 +1,292 @@
"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 = `<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 (!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 = `<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; }
}
// ---- 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");
})();
+115
View File
@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SparkyUI · Model Manager</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand">
<span class="spark"></span>
<div>
<div class="brand-title">SparkyUI</div>
<div class="brand-sub">Model Manager</div>
</div>
</div>
<nav>
<button class="nav-item active" data-view="installed">
<span class="nav-ico"></span> Installed Models
</button>
<button class="nav-item" data-view="download">
<span class="nav-ico"></span> Add / Download
</button>
<button class="nav-item" data-view="settings">
<span class="nav-ico"></span> Settings
</button>
</nav>
<div class="sidebar-foot" id="modelsDir"></div>
</aside>
<main class="content">
<!-- Installed Models -->
<section class="view active" id="view-installed">
<header class="view-head">
<h1>Installed Models</h1>
<div class="head-actions">
<input type="search" id="modelSearch" placeholder="Filter…" />
<button class="btn" id="refreshModels">Refresh</button>
</div>
</header>
<div id="modelsContainer" class="models-container">
<p class="empty">Loading…</p>
</div>
</section>
<!-- Add / Download -->
<section class="view" id="view-download">
<header class="view-head"><h1>Add / Download Model</h1></header>
<div class="card form-card">
<label>Download URL</label>
<input type="text" id="dlUrl"
placeholder="Direct URL, CivitAI model link, or HuggingFace resolve URL" />
<div class="source-hint" id="sourceHint"></div>
<div class="row">
<div class="col">
<label>Model type</label>
<select id="dlType"></select>
<div class="hint">CivitAI links auto-detect type; leave as-is to use it.</div>
</div>
<div class="col">
<label>Filename (optional)</label>
<input type="text" id="dlFilename" placeholder="Auto-detected if blank" />
</div>
</div>
<div class="form-actions">
<button class="btn primary" id="startDownload">Start Download</button>
<span class="form-msg" id="dlMsg"></span>
</div>
</div>
<h2 class="subhead">Downloads</h2>
<div id="downloadsContainer" class="downloads-container">
<p class="empty">No downloads yet.</p>
</div>
</section>
<!-- Settings -->
<section class="view" id="view-settings">
<header class="view-head"><h1>Settings</h1></header>
<div class="card form-card">
<h2 class="subhead first">API Keys</h2>
<p class="hint">
Stored persistently and sent as auth headers when downloading from the
matching site. Leave blank to keep the existing value.
</p>
<label>CivitAI API Key <span class="badge" id="civitaiBadge"></span></label>
<input type="password" id="civitaiKey" placeholder="••••••••" autocomplete="off" />
<label>HuggingFace Token <span class="badge" id="hfBadge"></span></label>
<input type="password" id="hfToken" placeholder="••••••••" autocomplete="off" />
<div class="form-actions">
<button class="btn primary" id="saveSettings">Save</button>
<span class="form-msg" id="settingsMsg"></span>
</div>
</div>
<div class="card form-card">
<h2 class="subhead first">Model Storage</h2>
<p class="hint">
Models are saved into the project <code>models/</code> directory, sorted into
ComfyUI's standard sub-folders by type. ComfyUI reads from the same folder.
</p>
</div>
</section>
</main>
</div>
<script src="/app.js"></script>
</body>
</html>
+152
View File
@@ -0,0 +1,152 @@
:root {
--bg: #14161c;
--bg-2: #1b1e27;
--bg-3: #232735;
--border: #2c3142;
--text: #e6e8ee;
--text-dim: #99a0b3;
--accent: #6c8cff;
--accent-2: #8a6cff;
--green: #36c98b;
--red: #ef5d6b;
--amber: #f0b400;
--radius: 10px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
font-size: 14px;
}
.app { display: flex; height: 100vh; }
/* ---- sidebar ---- */
.sidebar {
width: 240px;
flex-shrink: 0;
background: var(--bg-2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 18px 14px;
}
.brand { display: flex; align-items: center; gap: 12px; padding: 4px 6px 22px; }
.brand .spark { font-size: 26px; }
.brand-title { font-weight: 700; font-size: 16px; }
.brand-sub { color: var(--text-dim); font-size: 12px; }
nav { display: flex; flex-direction: column; gap: 4px; }
.nav-item {
display: flex; align-items: center; gap: 10px;
background: transparent; color: var(--text-dim);
border: none; border-radius: 8px;
padding: 10px 12px; font-size: 14px; text-align: left;
cursor: pointer; transition: background .12s, color .12s;
}
.nav-item:hover { background: var(--bg-3); color: var(--text); }
.nav-item.active {
background: linear-gradient(90deg, rgba(108,140,255,.18), rgba(138,108,255,.12));
color: var(--text);
}
.nav-ico { width: 18px; text-align: center; opacity: .85; }
.sidebar-foot {
margin-top: auto; color: var(--text-dim); font-size: 11px;
word-break: break-all; padding: 8px 6px 0;
}
/* ---- content ---- */
.content { flex: 1; overflow-y: auto; padding: 26px 32px; }
.view { display: none; }
.view.active { display: block; }
.view-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.view-head h1 { font-size: 22px; margin: 0; }
.head-actions { display: flex; gap: 10px; }
.subhead { font-size: 15px; color: var(--text-dim); margin: 28px 0 12px; }
.subhead.first { margin-top: 0; }
/* ---- inputs/buttons ---- */
input, select {
background: var(--bg-3); color: var(--text);
border: 1px solid var(--border); border-radius: 8px;
padding: 9px 11px; font-size: 14px; width: 100%;
}
input:focus, select:focus { outline: none; border-color: var(--accent); }
input[type=search] { width: 200px; }
label { display: block; margin: 14px 0 6px; color: var(--text-dim); font-size: 13px; }
.btn {
background: var(--bg-3); color: var(--text);
border: 1px solid var(--border); border-radius: 8px;
padding: 9px 16px; font-size: 14px; cursor: pointer;
transition: background .12s, border-color .12s;
}
.btn:hover { background: #2a3042; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #0c1020; font-weight: 600; }
.btn.primary:hover { background: #5b7dff; }
.btn.danger { color: var(--red); border-color: transparent; background: transparent; padding: 6px 10px; }
.btn.danger:hover { background: rgba(239,93,107,.12); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
/* ---- cards ---- */
.card {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px; margin-bottom: 18px;
}
.form-card { max-width: 720px; }
.row { display: flex; gap: 18px; }
.col { flex: 1; }
.hint { color: var(--text-dim); font-size: 12px; margin-top: 6px; }
.source-hint { font-size: 12px; margin-top: 8px; min-height: 16px; color: var(--accent); }
.form-actions { display: flex; align-items: center; gap: 14px; margin-top: 20px; }
.form-msg { font-size: 13px; }
.form-msg.ok { color: var(--green); }
.form-msg.err { color: var(--red); }
.badge { font-size: 11px; padding: 1px 8px; border-radius: 10px; margin-left: 6px; }
.badge.set { background: rgba(54,201,139,.18); color: var(--green); }
.badge.unset { background: var(--bg-3); color: var(--text-dim); }
code { background: var(--bg-3); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
/* ---- installed models ---- */
.models-container { display: flex; flex-direction: column; gap: 22px; }
.type-group h3 {
font-size: 13px; text-transform: uppercase; letter-spacing: .04em;
color: var(--text-dim); margin: 0 0 10px; font-weight: 600;
}
.model-grid {
display: grid; gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
.model-card {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px;
display: flex; flex-direction: column; gap: 6px;
}
.model-card .name { font-weight: 600; word-break: break-all; }
.model-card .meta { color: var(--text-dim); font-size: 12px; }
.model-card .card-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 4px; }
.empty { color: var(--text-dim); }
/* ---- downloads ---- */
.downloads-container { display: flex; flex-direction: column; gap: 10px; max-width: 860px; }
.dl-row {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: var(--radius); padding: 12px 14px;
}
.dl-top { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.dl-name { font-weight: 600; word-break: break-all; }
.dl-sub { color: var(--text-dim); font-size: 12px; margin-top: 2px; }
.dl-status { font-size: 12px; padding: 2px 9px; border-radius: 10px; white-space: nowrap; }
.dl-status.downloading { background: rgba(108,140,255,.18); color: var(--accent); }
.dl-status.completed { background: rgba(54,201,139,.18); color: var(--green); }
.dl-status.failed { background: rgba(239,93,107,.18); color: var(--red); }
.dl-status.canceled { background: var(--bg-3); color: var(--text-dim); }
.dl-status.queued { background: rgba(240,180,0,.16); color: var(--amber); }
.progress { height: 6px; background: var(--bg-3); border-radius: 4px; margin-top: 10px; overflow: hidden; }
.progress > span { display: block; height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-2)); width: 0; transition: width .3s; }
.dl-actions { display: flex; align-items: center; gap: 8px; }
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
httpx==0.28.1
pydantic==2.10.4