Add more functionality
This commit is contained in:
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
+84
-2
@@ -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")
|
||||
|
||||
+318
-187
@@ -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 = `
|
||||
<div id="pp-header">
|
||||
<span>Toolbox</span>
|
||||
<div id="pp-header-btns">
|
||||
<span id="pp-ram" title="System RAM usage">--</span>
|
||||
<span id="pp-vram" title="VRAM usage">--</span>
|
||||
<button id="pp-free" title="Free VRAM / unload models">💨 VRAM</button>
|
||||
<button id="pp-reboot" title="Restart ComfyUI (requires Manager)">⟳ Restart</button>
|
||||
<button id="pp-clear" title="Clear log">Clear</button>
|
||||
<button id="pp-close" title="Close panel">✕</button>
|
||||
<button id="pp-models" title="Scan workflow for missing models">📦 Models</button>
|
||||
<button id="pp-clear" title="Clear log">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pp-bar">
|
||||
<div id="pp-bar-fill"></div>
|
||||
<div id="pp-bar-text">idle</div>
|
||||
</div>
|
||||
<div id="pp-bar"><div id="pp-bar-fill"></div><div id="pp-bar-text">idle</div></div>
|
||||
<div id="pp-log"></div>
|
||||
`;
|
||||
<div id="pp-model-view" style="display:none"></div>`;
|
||||
|
||||
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 = '<div style="color:#f66;padding:20px">Failed to fetch /object_info</div>'; 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 = `<button class="mv-back" id="mv-back">← Back to Log</button>`;
|
||||
html += `<div class="mv-summary">Found ${refs.length} model${refs.length !== 1 ? "s" : ""} — <span style="color:#7c7">${found.length} ✓</span> / <span style="color:#f66">${missing.length} ✗</span></div>`;
|
||||
|
||||
for (const m of missing) {
|
||||
const id = `dl-${m.folder}-${m.name}`.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
html += `<div class="mv-row missing" data-folder="${m.folder}" data-name="${m.name}">
|
||||
<span class="mv-icon">❌</span>
|
||||
<div class="mv-info">
|
||||
<div class="mv-name">${m.name}</div>
|
||||
<div class="mv-folder">📁 models/${m.folder}/ — ${m.nodeType}</div>
|
||||
<div class="mv-dl">
|
||||
<input type="text" id="${id}-url" placeholder="Paste HuggingFace URL…" spellcheck="false">
|
||||
<button id="${id}-btn">⬇ Download</button>
|
||||
</div>
|
||||
<div class="mv-dl-progress" id="${id}-prog" style="display:none"><div class="mv-dl-progress-fill" id="${id}-fill"></div></div>
|
||||
<div class="mv-dl-status" id="${id}-status"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
for (const m of found) {
|
||||
html += `<div class="mv-row found"><span class="mv-icon">✅</span>
|
||||
<div class="mv-info"><div class="mv-name">${m.name}</div>
|
||||
<div class="mv-folder">📁 models/${m.folder}/ — ${m.nodeType}</div></div></div>`;
|
||||
}
|
||||
|
||||
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");
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user