diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba0430d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ \ No newline at end of file diff --git a/__init__.py b/__init__.py index 5773f88..21eadba 100644 --- a/__init__.py +++ b/__init__.py @@ -1,5 +1,87 @@ -# Empty pack — only ships a frontend extension. -# ComfyUI auto-serves files under ./web/ when WEB_DIRECTORY is set. +"""Toolbox — ComfyUI extension with model download support.""" +import os +import asyncio +import aiohttp +from aiohttp import web + WEB_DIRECTORY = "./web" NODE_CLASS_MAPPINGS = {} __all__ = ["NODE_CLASS_MAPPINGS", "WEB_DIRECTORY"] + +_downloads = {} + +def _models_dir(): + return os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "models" + ) + +try: + from server import PromptServer + + @PromptServer.instance.routes.post("/toolbox/download") + async def start_download(request): + data = await request.json() + url = data.get("url", "").strip() + folder = data.get("folder", "").strip() + filename = data.get("filename", "").strip() + if not url or not folder or not filename: + return web.json_response({"error": "url, folder, filename required"}, status=400) + + target_dir = os.path.join(_models_dir(), folder) + os.makedirs(target_dir, exist_ok=True) + target_path = os.path.join(target_dir, filename) + + if os.path.exists(target_path): + return web.json_response({"error": "file already exists", "path": target_path}, status=409) + + dl_id = f"{folder}/{filename}" + if dl_id in _downloads and _downloads[dl_id].get("status") == "downloading": + return web.json_response({"error": "already downloading"}, status=409) + + _downloads[dl_id] = {"status": "starting", "progress": 0, "total": 0, "error": None} + asyncio.ensure_future(_do_download(dl_id, url, target_path)) + return web.json_response({"id": dl_id, "status": "started", "path": target_path}) + + async def _do_download(dl_id, url, target_path): + tmp = target_path + ".part" + try: + _downloads[dl_id]["status"] = "downloading" + async with aiohttp.ClientSession() as session: + async with session.get(url, allow_redirects=True) as resp: + if resp.status != 200: + _downloads[dl_id] = {"status": "error", "error": f"HTTP {resp.status}", "progress": 0, "total": 0} + return + total = int(resp.headers.get("content-length", 0)) + _downloads[dl_id]["total"] = total + done = 0 + with open(tmp, "wb") as f: + async for chunk in resp.content.iter_chunked(1024 * 1024): + f.write(chunk) + done += len(chunk) + _downloads[dl_id]["progress"] = done + os.rename(tmp, target_path) + _downloads[dl_id]["status"] = "done" + _downloads[dl_id]["progress"] = _downloads[dl_id]["total"] + except Exception as e: + _downloads[dl_id]["status"] = "error" + _downloads[dl_id]["error"] = str(e) + if os.path.exists(tmp): + try: os.remove(tmp) + except: pass + + @PromptServer.instance.routes.get("/toolbox/download/status") + async def download_status(request): + return web.json_response(_downloads) + + @PromptServer.instance.routes.post("/toolbox/download/cancel") + async def cancel_download(request): + # Mark as cancelled — the download loop checks this + data = await request.json() + dl_id = data.get("id", "") + if dl_id in _downloads: + _downloads[dl_id]["status"] = "cancelled" + return web.json_response({"ok": True}) + +except ImportError: + print("[toolbox] Could not import PromptServer — download features disabled") diff --git a/web/progressPanel.js b/web/progressPanel.js index 571d9fa..f38fcf5 100644 --- a/web/progressPanel.js +++ b/web/progressPanel.js @@ -1,35 +1,44 @@ -// ComfyUI Progress Panel — shows live execution events in a toggleable side panel. -// Install: ComfyUI/custom_nodes/progress-panel/web/progressPanel.js -// Also create an empty __init__.py in progress-panel/ so ComfyUI loads the pack. - +// ComfyUI Toolbox — execution log, stats, model checker & downloader. import { app } from "../../scripts/app.js"; const MAX_LINES = 500; +// Known model-loading node types → widget name + folder +const MODEL_NODES = { + CheckpointLoaderSimple: [{ w: "ckpt_name", f: "checkpoints" }], + CheckpointLoader: [{ w: "ckpt_name", f: "checkpoints" }], + UNETLoader: [{ w: "unet_name", f: "diffusion_models" }], + VAELoader: [{ w: "vae_name", f: "vae" }], + CLIPLoader: [{ w: "clip_name", f: "text_encoders" }], + DualCLIPLoader: [{ w: "clip_name1", f: "text_encoders" }, { w: "clip_name2", f: "text_encoders" }], + TripleCLIPLoader: [{ w: "clip_name1", f: "text_encoders" }, { w: "clip_name2", f: "text_encoders" }, { w: "clip_name3", f: "text_encoders" }], + LoraLoader: [{ w: "lora_name", f: "loras" }], + LoraLoaderModelOnly: [{ w: "lora_name", f: "loras" }], + ControlNetLoader: [{ w: "control_net_name", f: "controlnet" }], + UpscaleModelLoader: [{ w: "model_name", f: "upscale_models" }], + CLIPVisionLoader: [{ w: "clip_name", f: "clip_vision" }], + StyleModelLoader: [{ w: "style_model_name", f: "style_models" }], +}; + app.registerExtension({ name: "local.toolbox", - async setup() { - // ---- build DOM ---- const panel = document.createElement("div"); panel.id = "pp-panel"; panel.innerHTML = `
- Toolbox
+ -- -- - - + +
-
-
-
idle
-
+
idle
- `; + `; const toggle = document.createElement("button"); toggle.id = "pp-toggle"; @@ -38,66 +47,45 @@ app.registerExtension({ const style = document.createElement("style"); style.textContent = ` - #pp-toggle { - position: fixed; left: 12px; top: 60px; z-index: 9998; - background: #2a2a2a; color: #ddd; border: 1px solid #444; - padding: 6px 12px; border-radius: 6px; font: 12px sans-serif; - cursor: grab; user-select: none; - } - #pp-toggle.dragging { cursor: grabbing; opacity: 0.8; } - #pp-toggle:hover { background: #3a3a3a; } - #pp-panel { - position: fixed; width: 460px; height: 360px; - background: #1a1a1a; border: 1px solid #444; border-radius: 8px; - display: none; flex-direction: column; z-index: 9999; - box-shadow: 0 4px 20px rgba(0,0,0,0.5); font: 12px monospace; - color: #ddd; - } - #pp-panel.open { display: flex; } - #pp-header { - padding: 6px 10px; background: #252525; border-bottom: 1px solid #444; - display: flex; justify-content: space-between; align-items: center; - border-radius: 8px 8px 0 0; font-family: sans-serif; - } - #pp-header-btns { display: flex; gap: 4px; } - #pp-header-btns button { - background: #333; border: 1px solid #555; color: #ddd; - padding: 3px 8px; border-radius: 4px; cursor: pointer; - font: 11px sans-serif; - } - #pp-header-btns button:hover { background: #444; } - #pp-header-btns button:disabled { opacity: 0.4; cursor: not-allowed; } - #pp-vram { - font: 11px monospace; padding: 3px 6px; - background: #222; border: 1px solid #444; border-radius: 4px; - color: #7c7; min-width: 80px; text-align: center; - } - #pp-vram.warn { color: #e9a; } - #pp-vram.crit { color: #f66; } - #pp-bar { - position: relative; - height: 18px; background: #2a2a2a; overflow: hidden; - } - #pp-bar-fill { - height: 100%; width: 0%; background: linear-gradient(90deg, #4a9eff, #6ac); - transition: width 0.15s ease; - } - #pp-bar-text { - position: absolute; inset: 0; - display: flex; align-items: center; justify-content: center; - font: 11px sans-serif; color: #fff; - text-shadow: 0 0 3px rgba(0,0,0,0.9), 0 0 3px rgba(0,0,0,0.9); - pointer-events: none; - } - #pp-log { - flex: 1; overflow-y: auto; padding: 8px 12px; - white-space: pre-wrap; word-break: break-word; line-height: 1.5; - } - #pp-log .ts { color: #666; } - #pp-log .ok { color: #7c7; } - #pp-log .warn { color: #e9a; } - #pp-log .err { color: #f66; } - #pp-log .info { color: #9cf; } + #pp-toggle{position:fixed;left:12px;top:60px;z-index:9998;background:#2a2a2a;color:#ddd;border:1px solid #444;padding:6px 12px;border-radius:6px;font:12px sans-serif;cursor:grab;user-select:none} + #pp-toggle.dragging{cursor:grabbing;opacity:.8} + #pp-toggle:hover{background:#3a3a3a} + #pp-panel{position:fixed;width:560px;height:400px;background:#1a1a1a;border:1px solid #444;border-radius:8px;display:none;flex-direction:column;z-index:9999;box-shadow:0 4px 20px rgba(0,0,0,.5);font:12px monospace;color:#ddd} + #pp-panel.open{display:flex} + #pp-header{padding:8px 10px;background:#252525;border-bottom:1px solid #3a3a3a;display:flex;align-items:center;border-radius:8px 8px 0 0;font-family:sans-serif} + #pp-header-btns{display:flex;gap:6px;align-items:center;flex:1} + #pp-header-btns button{background:#333;border:1px solid #4a4a4a;color:#ddd;padding:5px 10px;border-radius:5px;cursor:pointer;font:11px sans-serif;transition:background .12s;white-space:nowrap} + #pp-header-btns button:hover{background:#444;border-color:#5a5a5a} + #pp-header-btns button:active{background:#2a2a2a} + #pp-header-btns button:disabled{opacity:.4;cursor:not-allowed} + #pp-clear{margin-left:auto;padding:5px 9px;font-size:13px} + #pp-vram,#pp-ram{font:11px -apple-system,system-ui,sans-serif;padding:4px 8px;background:#1e1e1e;border:1px solid #3a3a3a;border-radius:5px;color:#8ec98e;white-space:nowrap;letter-spacing:.2px} + #pp-vram.warn,#pp-ram.warn{color:#e9b96e;border-color:#5a4a2a} + #pp-vram.crit,#pp-ram.crit{color:#ef7b7b;border-color:#5a2a2a;background:#2a1a1a} + #pp-bar{position:relative;height:18px;background:#2a2a2a;overflow:hidden} + #pp-bar-fill{height:100%;width:0%;background:linear-gradient(90deg,#4a9eff,#6ac);transition:width .15s ease} + #pp-bar-text{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font:11px sans-serif;color:#fff;text-shadow:0 0 3px rgba(0,0,0,.9),0 0 3px rgba(0,0,0,.9);pointer-events:none} + #pp-log{flex:1;overflow-y:auto;padding:8px 12px;white-space:pre-wrap;word-break:break-word;line-height:1.5} + #pp-log .ts{color:#666} #pp-log .ok{color:#7c7} #pp-log .warn{color:#e9a} #pp-log .err{color:#f66} #pp-log .info{color:#9cf} + #pp-model-view{flex:1;overflow-y:auto;padding:10px 12px;font:12px -apple-system,system-ui,sans-serif} + .mv-back{background:none;border:none;color:#9cf;cursor:pointer;font:12px sans-serif;padding:0 0 8px;text-decoration:underline} + .mv-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:5px;margin:3px 0} + .mv-row.found{background:#1a2a1a;border:1px solid #2a3a2a} + .mv-row.missing{background:#2a1a1a;border:1px solid #3a2a2a} + .mv-row.downloading{background:#1a1a2a;border:1px solid #2a2a3a} + .mv-icon{font-size:14px;flex-shrink:0} + .mv-info{flex:1;min-width:0} + .mv-name{font-weight:600;color:#ddd;word-break:break-all} + .mv-folder{font-size:10px;color:#888;margin-top:1px} + .mv-dl{display:flex;gap:4px;align-items:center;flex-shrink:0} + .mv-dl input{width:180px;padding:4px 6px;background:#222;border:1px solid #444;border-radius:4px;color:#ddd;font:11px monospace} + .mv-dl button{padding:4px 10px;background:#2a5a2a;border:1px solid #3a6a3a;color:#cfc;border-radius:4px;cursor:pointer;font:11px sans-serif;white-space:nowrap} + .mv-dl button:hover{background:#3a6a3a} + .mv-dl button:disabled{opacity:.4;cursor:not-allowed} + .mv-dl-progress{width:100%;height:3px;background:#333;border-radius:2px;margin-top:3px;overflow:hidden} + .mv-dl-progress-fill{height:100%;background:#4a9eff;transition:width .3s} + .mv-dl-status{font-size:10px;color:#9cf;margin-top:2px} + .mv-summary{padding:6px 0 10px;color:#888;font-size:11px} `; document.head.appendChild(style); document.body.appendChild(panel); @@ -106,6 +94,10 @@ app.registerExtension({ const logEl = panel.querySelector("#pp-log"); const barEl = panel.querySelector("#pp-bar-fill"); const barText = panel.querySelector("#pp-bar-text"); + const modelView = panel.querySelector("#pp-model-view"); + + const showLog = () => { logEl.style.display = ""; modelView.style.display = "none"; }; + const showModels = () => { logEl.style.display = "none"; modelView.style.display = ""; }; // ---- position restore ---- const POS_KEY = "pp-toggle-pos"; @@ -118,11 +110,9 @@ app.registerExtension({ } catch {} const anchorPanel = () => { - // position panel just below the toggle button, keep on-screen const r = toggle.getBoundingClientRect(); - const pw = 460, ph = 360, margin = 8; - let x = r.left; - let y = r.bottom + 6; + const pw = 560, ph = 400, margin = 8; + let x = r.left, y = r.bottom + 6; if (x + pw > window.innerWidth - margin) x = window.innerWidth - pw - margin; if (y + ph > window.innerHeight - margin) y = r.top - ph - 6; if (x < margin) x = margin; @@ -131,7 +121,7 @@ app.registerExtension({ panel.style.top = `${y}px`; }; - // ---- drag logic ---- + // ---- drag ---- let dragging = false, moved = false, startX = 0, startY = 0, origX = 0, origY = 0; toggle.addEventListener("pointerdown", (e) => { dragging = true; moved = false; @@ -145,10 +135,8 @@ app.registerExtension({ if (!dragging) return; const dx = e.clientX - startX, dy = e.clientY - startY; if (Math.abs(dx) + Math.abs(dy) > 3) moved = true; - const nx = Math.max(0, Math.min(window.innerWidth - toggle.offsetWidth, origX + dx)); - const ny = Math.max(0, Math.min(window.innerHeight - toggle.offsetHeight, origY + dy)); - toggle.style.left = `${nx}px`; - toggle.style.top = `${ny}px`; + toggle.style.left = `${Math.max(0, Math.min(window.innerWidth - toggle.offsetWidth, origX + dx))}px`; + toggle.style.top = `${Math.max(0, Math.min(window.innerHeight - toggle.offsetHeight, origY + dy))}px`; toggle.style.right = "auto"; toggle.style.bottom = "auto"; if (panel.classList.contains("open")) anchorPanel(); }); @@ -161,95 +149,64 @@ app.registerExtension({ localStorage.setItem(POS_KEY, JSON.stringify({ x: r.left, y: r.top })); if (!moved) { panel.classList.toggle("open"); - if (panel.classList.contains("open")) { - anchorPanel(); - startVramPolling(); - } else { - stopVramPolling(); - } + if (panel.classList.contains("open")) { anchorPanel(); startStatsPolling(); } + else stopStatsPolling(); } }); - window.addEventListener("resize", () => { - if (panel.classList.contains("open")) anchorPanel(); - }); + window.addEventListener("resize", () => { if (panel.classList.contains("open")) anchorPanel(); }); // ---- header buttons ---- - panel.querySelector("#pp-close").onclick = () => { - panel.classList.remove("open"); - stopVramPolling(); - }; panel.querySelector("#pp-clear").onclick = () => { logEl.innerHTML = ""; }; panel.querySelector("#pp-free").onclick = async () => { line("info", "→ freeing VRAM…"); try { - const r = await fetch("/free", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ unload_models: true, free_memory: true }), - }); + const r = await fetch("/free", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ unload_models: true, free_memory: true }) }); line(r.ok ? "ok" : "err", r.ok ? "✓ VRAM freed" : `✗ /free returned ${r.status}`); - updateVram(); // refresh readout immediately - } catch (err) { - line("err", `✗ ${err.message}`); - } - }; - - // ---- VRAM polling ---- - const vramEl = panel.querySelector("#pp-vram"); - let vramLoggedShape = false; - const updateVram = async () => { - try { - const r = await fetch("/api/system_stats"); - if (!r.ok) { vramEl.textContent = "n/a"; return; } - const data = await r.json(); - const dev = data?.devices?.[0]; - if (!dev || typeof dev.vram_total !== "number" || typeof dev.vram_free !== "number") { - vramEl.textContent = "??"; - if (!vramLoggedShape) { - console.log("[toolbox] system_stats shape:", data); - vramLoggedShape = true; - } - return; - } - const total = dev.vram_total; - const used = total - dev.vram_free; - const pct = (used / total) * 100; - const gb = (n) => (n / (1024 ** 3)).toFixed(1); - vramEl.textContent = `${gb(used)}/${gb(total)} GB (${pct.toFixed(0)}%)`; - vramEl.classList.toggle("warn", pct >= 70 && pct < 90); - vramEl.classList.toggle("crit", pct >= 90); - } catch (err) { - vramEl.textContent = "err"; - } - }; - let vramTimer = null; - const startVramPolling = () => { - updateVram(); - if (vramTimer) clearInterval(vramTimer); - vramTimer = setInterval(updateVram, 2000); - }; - const stopVramPolling = () => { - if (vramTimer) { clearInterval(vramTimer); vramTimer = null; } + updateStats(); + } catch (err) { line("err", `✗ ${err.message}`); } }; panel.querySelector("#pp-reboot").onclick = async () => { if (!confirm("Restart ComfyUI? Current run will be lost.")) return; line("warn", "→ requesting reboot…"); try { - // ComfyUI-Manager exposes /manager/reboot const r = await fetch("/manager/reboot", { method: "GET" }); - if (r.ok) { - line("ok", "✓ reboot requested — reload page when server is back"); - } else { - line("err", `✗ /manager/reboot returned ${r.status} (is ComfyUI-Manager installed?)`); - } - } catch (err) { - // server actually going down often causes a network error — that's the success case - line("warn", `server dropped connection (likely restarting): ${err.message}`); - } + if (r.ok) line("ok", "✓ reboot requested — reload page when server is back"); + else line("err", `✗ /manager/reboot returned ${r.status} (is ComfyUI-Manager installed?)`); + } catch (err) { line("warn", `server dropped connection (likely restarting): ${err.message}`); } }; + // ---- stats polling ---- + const vramEl = panel.querySelector("#pp-vram"); + const ramEl = panel.querySelector("#pp-ram"); + const gb = (n) => (n / (1024 ** 3)).toFixed(1); + const setBadge = (el, used, total, label) => { + if (typeof used !== "number" || typeof total !== "number" || total <= 0) { el.textContent = "??"; return; } + const pct = (used / total) * 100; + el.textContent = `${label} · ${gb(used)}/${gb(total)} · ${pct.toFixed(0)}%`; + el.classList.toggle("warn", pct >= 70 && pct < 90); + el.classList.toggle("crit", pct >= 90); + }; + const updateStats = async () => { + try { + const r = await fetch("/api/system_stats"); + if (!r.ok) { vramEl.textContent = "n/a"; ramEl.textContent = "n/a"; return; } + const d = await r.json(); + const dev = d?.devices?.[0]; + if (dev?.vram_total != null && dev?.vram_free != null) setBadge(vramEl, dev.vram_total - dev.vram_free, dev.vram_total, "VRAM"); + else vramEl.textContent = "??"; + const sys = d?.system; + if (sys?.ram_total != null && sys?.ram_free != null) setBadge(ramEl, sys.ram_total - sys.ram_free, sys.ram_total, "RAM"); + else ramEl.textContent = "??"; + } catch { vramEl.textContent = "err"; ramEl.textContent = "err"; } + }; + let statsTimer = null; + const startStatsPolling = () => { updateStats(); if (statsTimer) clearInterval(statsTimer); statsTimer = setInterval(updateStats, 2000); }; + const stopStatsPolling = () => { if (statsTimer) { clearInterval(statsTimer); statsTimer = null; } }; + + // ---- log helper ---- const line = (cls, text) => { const ts = new Date().toLocaleTimeString(); const row = document.createElement("div"); @@ -259,63 +216,237 @@ app.registerExtension({ logEl.scrollTop = logEl.scrollHeight; }; - // ---- hook into ComfyUI's api events ---- + // ---- subgraph-aware node lookup ---- + const resolveNode = (rawId) => { + const s = String(rawId); + if (!s.includes(":")) return app.graph?.getNodeById?.(Number(s)) || null; + const parts = s.split(":").map(Number); + let graph = app.graph, node = null; + for (let i = 0; i < parts.length; i++) { + if (!graph?.getNodeById) return null; + node = graph.getNodeById(parts[i]); + if (!node) return null; + if (i < parts.length - 1) { graph = node.subgraph || node._subgraph || null; if (!graph) return node; } + } + return node; + }; + const labelFor = (rawId) => { const n = resolveNode(rawId); return n ? `${n.title || n.type || "node"} #${rawId}` : `node #${rawId}`; }; + + // ---- execution events ---- const api = app.api; - let runStart = 0; - let currentNodeLabel = ""; + let runStart = 0, currentNodeLabel = ""; api.addEventListener("execution_start", (e) => { - runStart = performance.now(); - barEl.style.width = "0%"; - barText.textContent = "starting…"; + runStart = performance.now(); barEl.style.width = "0%"; barText.textContent = "starting…"; line("info", `▶ Run started (prompt ${e.detail?.prompt_id ?? "?"})`); }); - api.addEventListener("executing", (e) => { const nodeId = e.detail; if (nodeId === null) { const dur = ((performance.now() - runStart) / 1000).toFixed(1); - barEl.style.width = "100%"; - barText.textContent = `done in ${dur}s`; - currentNodeLabel = ""; + barEl.style.width = "100%"; barText.textContent = `done in ${dur}s`; currentNodeLabel = ""; line("ok", `✓ Run finished in ${dur}s`); - } else { - const node = app.graph?.getNodeById?.(Number(nodeId)); - currentNodeLabel = node ? `${node.type} #${nodeId}` : `node #${nodeId}`; - barEl.style.width = "0%"; - barText.textContent = currentNodeLabel; - line("info", `→ ${currentNodeLabel}`); - } + } else { currentNodeLabel = labelFor(nodeId); barEl.style.width = "0%"; barText.textContent = currentNodeLabel; line("info", `→ ${currentNodeLabel}`); } }); - api.addEventListener("progress", (e) => { const { value, max } = e.detail || {}; if (typeof value === "number" && typeof max === "number" && max > 0) { - const pct = (value / max) * 100; - barEl.style.width = `${pct}%`; - const prefix = currentNodeLabel ? `${currentNodeLabel} — ` : ""; - barText.textContent = `${prefix}${value}/${max} (${pct.toFixed(0)}%)`; + const pct = (value / max) * 100; barEl.style.width = `${pct}%`; + barText.textContent = `${currentNodeLabel ? currentNodeLabel + " — " : ""}${value}/${max} (${pct.toFixed(0)}%)`; } }); - - api.addEventListener("execution_cached", (e) => { - const n = e.detail?.nodes?.length ?? 0; - if (n) line("ts", `cached ${n} node${n === 1 ? "" : "s"}`); - }); - + api.addEventListener("execution_cached", (e) => { const n = e.detail?.nodes?.length ?? 0; if (n) line("ts", `cached ${n} node${n === 1 ? "" : "s"}`); }); api.addEventListener("execution_error", (e) => { const d = e.detail || {}; line("err", `✗ ${d.exception_type || "Error"}: ${d.exception_message || "unknown"}`); if (d.node_type) line("err", ` in ${d.node_type} (#${d.node_id})`); - if (d.traceback) { - const tb = Array.isArray(d.traceback) ? d.traceback.join("") : d.traceback; - line("err", tb.split("\n").slice(-8).join("\n")); - } - panel.classList.add("open"); // auto-open on error + if (d.traceback) { const tb = Array.isArray(d.traceback) ? d.traceback.join("") : d.traceback; line("err", tb.split("\n").slice(-8).join("\n")); } + panel.classList.add("open"); }); - api.addEventListener("execution_interrupted", () => line("warn", "⊘ Interrupted")); + // ==== MODEL CHECKER ==== + // Capture URLs from ComfyUI's native "Copy URL" buttons + const captureModelUrls = async () => { + const urls = {}; + const orig = navigator.clipboard.writeText; + navigator.clipboard.writeText = async (text) => { + if (text && text.includes('huggingface.co')) { + const filename = text.split('/').pop(); + if (filename) urls[filename] = text; + } + return Promise.resolve(); + }; + const copyBtns = [...document.querySelectorAll('button')].filter( + b => /copy url/i.test(b.textContent) && !b.id?.startsWith('dl-') && !b.id?.startsWith('pp-') + ); + for (const btn of copyBtns) { + try { btn.click(); } catch {} + await new Promise(r => setTimeout(r, 80)); + } + navigator.clipboard.writeText = orig; + return urls; + }; + + const scanModels = async () => { + // Try to capture URLs from ComfyUI's Missing Models panel first + const knownUrls = await captureModelUrls(); + // 1. fetch available models from /object_info + let objInfo; + try { + const r = await fetch("/object_info"); + objInfo = await r.json(); + } catch { modelView.innerHTML = '
Failed to fetch /object_info
'; showModels(); return; } + + // 2. collect all model refs from graph + const refs = []; // {name, folder, found, nodeType, nodeId} + const seen = new Set(); + const allNodes = []; + const collectNodes = (graph) => { + if (!graph) return; + const nodes = graph._nodes || graph.nodes || []; + for (const n of nodes) { + allNodes.push(n); + if (n.subgraph || n._subgraph) collectNodes(n.subgraph || n._subgraph); + } + }; + collectNodes(app.graph); + + for (const node of allNodes) { + const defs = MODEL_NODES[node.type]; + if (!defs) continue; + for (const { w, f } of defs) { + const widget = node.widgets?.find(x => x.name === w); + const val = widget?.value; + if (!val || val === "None" || val === "none") continue; + const key = `${f}/${val}`; + if (seen.has(key)) continue; + seen.add(key); + // check if available in object_info + const nodeInfo = objInfo[node.type]; + const inputDef = nodeInfo?.input?.required?.[w]; + const options = Array.isArray(inputDef?.[0]) ? inputDef[0] : []; + const found = options.includes(val); + refs.push({ name: val, folder: f, found, nodeType: node.type, nodeId: node.id }); + } + } + + // 3. render + const missing = refs.filter(r => !r.found); + const found = refs.filter(r => r.found); + let html = ``; + html += `
Found ${refs.length} model${refs.length !== 1 ? "s" : ""} — ${found.length} ✓ / ${missing.length} ✗
`; + + for (const m of missing) { + const id = `dl-${m.folder}-${m.name}`.replace(/[^a-zA-Z0-9_-]/g, "_"); + html += `
+ ❌ +
+
${m.name}
+
📁 models/${m.folder}/ — ${m.nodeType}
+
+ + +
+ +
+
+
`; + } + for (const m of found) { + html += `
✅ +
${m.name}
+
📁 models/${m.folder}/ — ${m.nodeType}
`; + } + + modelView.innerHTML = html; + modelView.querySelector("#mv-back").onclick = showLog; + + // wire download buttons + for (const m of missing) { + const id = `dl-${m.folder}-${m.name}`.replace(/[^a-zA-Z0-9_-]/g, "_"); + const btn = modelView.querySelector(`#${id}-btn`); + const urlInput = modelView.querySelector(`#${id}-url`); + const progBar = modelView.querySelector(`#${id}-prog`); + const progFill = modelView.querySelector(`#${id}-fill`); + const statusEl = modelView.querySelector(`#${id}-status`); + if (!btn) continue; + + btn.onclick = async () => { + let url = urlInput.value.trim(); + if (!url) { statusEl.textContent = "⚠ Paste a download URL first"; statusEl.style.color = "#e9a"; return; } + // If user pasted a HF page URL, convert to direct download + if (url.includes("huggingface.co") && !url.includes("/resolve/")) { + url = url.replace("/blob/", "/resolve/"); + } + btn.disabled = true; + statusEl.textContent = "Starting…"; statusEl.style.color = "#9cf"; + progBar.style.display = ""; + try { + const r = await fetch("/toolbox/download", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url, folder: m.folder, filename: m.name }) + }); + const d = await r.json(); + if (!r.ok) { statusEl.textContent = `✗ ${d.error}`; statusEl.style.color = "#f66"; btn.disabled = false; return; } + // poll progress + const dlId = d.id; + const poll = setInterval(async () => { + try { + const sr = await fetch("/toolbox/download/status"); + const all = await sr.json(); + const st = all[dlId]; + if (!st) return; + if (st.total > 0) { + const pct = (st.progress / st.total * 100).toFixed(1); + const dlMB = (st.progress / 1e6).toFixed(0); + const totMB = (st.total / 1e6).toFixed(0); + progFill.style.width = `${pct}%`; + statusEl.textContent = `⬇ ${dlMB}/${totMB} MB (${pct}%)`; + } else { + statusEl.textContent = `⬇ ${(st.progress / 1e6).toFixed(0)} MB…`; + } + if (st.status === "done") { + clearInterval(poll); + progFill.style.width = "100%"; + statusEl.textContent = "✓ Downloaded"; + statusEl.style.color = "#7c7"; + // update row style + btn.closest(".mv-row").classList.remove("missing"); + btn.closest(".mv-row").classList.add("found"); + btn.closest(".mv-row").querySelector(".mv-icon").textContent = "✅"; + line("ok", `✓ Downloaded ${m.name} → models/${m.folder}/`); + } + if (st.status === "error") { + clearInterval(poll); + statusEl.textContent = `✗ ${st.error}`; statusEl.style.color = "#f66"; + btn.disabled = false; + line("err", `✗ Download failed: ${m.name} — ${st.error}`); + } + } catch {} + }, 1000); + } catch (err) { + statusEl.textContent = `✗ ${err.message}`; statusEl.style.color = "#f66"; + btn.disabled = false; + } + }; + } + showModels(); + + // Auto-fill URLs captured from ComfyUI's Missing Models panel + for (const m of missing) { + const id = `dl-${m.folder}-${m.name}`.replace(/[^a-zA-Z0-9_-]/g, "_"); + const urlInput = modelView.querySelector(`#${id}-url`); + const statusEl = modelView.querySelector(`#${id}-status`); + if (urlInput && knownUrls[m.name]) { + urlInput.value = knownUrls[m.name]; + if (statusEl) { statusEl.textContent = "✓ URL auto-detected"; statusEl.style.color = "#7c7"; } + } + } + }; + + panel.querySelector("#pp-models").onclick = scanModels; + line("ts", "Progress panel ready"); }, });