diff --git a/CHANGELOG.md b/CHANGELOG.md index e491e12..aab52b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is inspired by Keep a Changelog and SemVer. ## [Unreleased] +### Added +- **"Install missing for picks" toolbar button** in the Nodes section + (`pi-cloud-download` icon, next to Add and Export). Walks the user's + picks against ComfyUI-Manager's `/customnode/getmappings` mapping, + buckets into already-installed / will-install / unresolved, queues + unique git URLs through `/customnode/install/git_url`, polls + `/manager/queue/status` to drive a progress bar, and prompts to + reboot. Works on any install with ComfyUI-Manager loaded; falls back + to a clipboard URL list (with a `comfy node install` hint) if Manager + isn't reachable. New module `web/sidebar/installer.js` holds the + Manager-API client + resolver; the modal in `web/sidebar/modals.js` + drives the four-phase UI (discovery → confirm → progress → result). + 403s from Manager's security gate are surfaced as actionable language + ("your security level forbids git-URL installs") rather than raw HTTP. + ### Changed - **Save selection toast distinguishes "no selection" from "selection points at deleted nodes."** Previously both produced the generic diff --git a/web/sidebar/constants.js b/web/sidebar/constants.js index 56bcfee..3a9a241 100644 --- a/web/sidebar/constants.js +++ b/web/sidebar/constants.js @@ -114,6 +114,12 @@ export function ensureStyle() { .koolook-tag-chip-x:hover { opacity: 1; color: #ff7777; background: rgba(255,255,255,0.06); } .koolook-tag-add-row { display: flex; gap: 6px; align-items: center; } .koolook-tag-add-row .koolook-modal-input { flex: 1; } +.koolook-install-stat-line { font-size: 12px; line-height: 1.5; padding: 1px 0; } +.koolook-install-stat-line.koolook-install-stat-fail { color: #ff8888; } +.koolook-install-unresolved { font-size: 11px; opacity: 0.75; margin-top: 4px; padding: 6px 8px; background: rgba(255,255,255,0.04); border-radius: 4px; word-break: break-all; max-height: 110px; overflow-y: auto; } +.koolook-install-unresolved-summary { cursor: pointer; font-size: 11px; opacity: 0.75; margin-top: 4px; } +.koolook-install-progress { width: 100%; height: 6px; background: rgba(255,255,255,0.08); border-radius: 3px; margin: 10px 0 4px; overflow: hidden; } +.koolook-install-progress-bar { height: 100%; background: rgba(80,140,235,0.6); width: 0%; transition: width 200ms ease; } `; document.head.appendChild(s); } diff --git a/web/sidebar/installer.js b/web/sidebar/installer.js new file mode 100644 index 0000000..2427afa --- /dev/null +++ b/web/sidebar/installer.js @@ -0,0 +1,187 @@ +// ============================================================================= +// ComfyUI-Manager API client + pick → install resolver. +// +// Detects whether Manager is reachable, walks the user's curated picks +// against /customnode/getmappings to find which packs need installing, +// queues git-URL installs, polls the install queue for progress, and +// reboots. UI-free — modals.js drives the user-facing flow on top of the +// functions exported here. +// +// Routes referenced (Manager source pins): +// GET /manager/queue/status → reachability probe + progress poll +// GET /customnode/getmappings?mode → unified node-id ↔ git-url mapping +// POST /customnode/install/git_url → queue one repo for install (text body) +// POST /manager/queue/start → kick off the worker thread +// POST /manager/reboot → restart ComfyUI to load installed code +// +// `?mode=local` is REQUIRED on getmappings — Manager errors with KeyError if +// the query param is absent (manager_server.py: `request.rel_url.query["mode"]`). +// ============================================================================= + +const ENDPOINT_QUEUE_STATUS = "/manager/queue/status"; +const ENDPOINT_QUEUE_START = "/manager/queue/start"; +const ENDPOINT_GETMAPPINGS = "/customnode/getmappings?mode=local"; +const ENDPOINT_INSTALL_GIT_URL = "/customnode/install/git_url"; +const ENDPOINT_REBOOT = "/manager/reboot"; + +export const POLL_INTERVAL_MS = 1000; + +// ============================================================================= +// Detection +// ============================================================================= + +// Reachability probe. The status endpoint is unauthenticated and side-effect- +// free, so a 200 here is a strong signal Manager is loaded and serving routes. +// Anything else (network error, 404 — e.g. user disabled Manager) routes the +// caller into the copy-URL fallback path. +export async function detectManager() { + try { + const r = await fetch(ENDPOINT_QUEUE_STATUS, { method: "GET" }); + return r.ok; + } catch (e) { + console.warn("[Koolook] Manager reachability probe failed:", e); + return false; + } +} + +// ============================================================================= +// Mapping load + pick resolution +// ============================================================================= + +// /customnode/getmappings response shape (mirrors extension-node-map.json): +// { "": [[node_id1, node_id2, ...], { title, ... }] } +// We invert it to a node-id → git-url Map so a list of picks can be resolved +// in O(picks). First-write-wins on duplicate node IDs across repos — rare, +// but if it happens the user can override by editing picks; either repo is a +// valid install target. +export async function loadMappings() { + const r = await fetch(ENDPOINT_GETMAPPINGS); + if (!r.ok) throw new Error(`getmappings ${r.status}`); + const data = await r.json(); + const urlByNodeId = new Map(); + for (const [gitUrl, val] of Object.entries(data || {})) { + if (!Array.isArray(val) || !Array.isArray(val[0])) continue; + for (const id of val[0]) { + if (typeof id !== "string") continue; + if (!urlByNodeId.has(id)) urlByNodeId.set(id, gitUrl); + } + } + return { urlByNodeId }; +} + +// Bucket the picks by what we need to do with them. The `byUrl` Map dedupes +// repos when several picks live in the same pack — we install once per repo, +// not once per node ID. +// +// `unresolved` collects picks the mapping doesn't know about. Common causes: +// • a private/local node pack (never published to ComfyUI-Manager's index) +// • a pack so new it isn't in the cached extension-node-map.json yet +// • a renamed node that the upstream mapping hasn't caught up with +// We surface those by ID so the user can act manually instead of silently +// dropping them. +export function resolvePicksToInstall(picks, urlByNodeId) { + const registry = (typeof LiteGraph !== "undefined" && LiteGraph.registered_node_types) || {}; + const alreadyInstalled = []; + const unresolved = []; + const byUrl = new Map(); + for (const id of picks) { + if (registry[id]) { + alreadyInstalled.push(id); + continue; + } + const url = urlByNodeId.get(id); + if (!url) { + unresolved.push(id); + continue; + } + if (!byUrl.has(url)) byUrl.set(url, []); + byUrl.get(url).push(id); + } + return { alreadyInstalled, willInstall: { byUrl }, unresolved }; +} + +// ============================================================================= +// Install queue dispatch +// ============================================================================= + +// One git URL → one queue entry. Body is plain text (per the route's +// `await request.text()` on the server); fetch defaults to a text/plain +// content-type for a string body, which Manager accepts. +// +// 403 is the structured "your security level forbids this" response — we map +// it to user-facing language because raw HTTP codes don't help the user act. +// Other non-2xx are surfaced with their status so the result modal can still +// show what failed. +export async function queueInstallGitUrl(gitUrl) { + try { + const r = await fetch(ENDPOINT_INSTALL_GIT_URL, { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: gitUrl, + }); + if (r.ok) return { ok: true }; + const message = r.status === 403 + ? "Manager rejected the install — your security level forbids git-URL installs. Install via Manager UI, or relax the security_level setting." + : `Install request failed (HTTP ${r.status}).`; + return { ok: false, status: r.status, message }; + } catch (e) { + return { ok: false, status: 0, message: `Network error: ${e.message}` }; + } +} + +// Start the worker thread that drains the queue. 200 = started, 201 = already +// processing (e.g. the user kicked off another install moments earlier). +// Both are success from our perspective — the queue is being worked. +export async function startQueue() { + try { + const r = await fetch(ENDPOINT_QUEUE_START, { method: "POST" }); + return r.ok || r.status === 201; + } catch (e) { + console.warn("[Koolook] startQueue failed:", e); + return false; + } +} + +export async function getQueueStatus() { + const r = await fetch(ENDPOINT_QUEUE_STATUS); + if (!r.ok) throw new Error(`queue/status ${r.status}`); + return await r.json(); +} + +// Poll status until `is_processing` flips false. `signal.aborted = true` lets +// the caller bail without canceling installs already in flight (we can't — +// Manager has no per-task cancel) — it just disengages the UI loop. +// +// Returns the final status snapshot, or null if aborted / a poll fetch threw. +export async function pollUntilDone({ intervalMs = POLL_INTERVAL_MS, onTick, signal } = {}) { + while (true) { + if (signal && signal.aborted) return null; + let status; + try { + status = await getQueueStatus(); + } catch (e) { + console.warn("[Koolook] queue status poll failed:", e); + return null; + } + if (typeof onTick === "function") onTick(status); + if (!status.is_processing) return status; + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } +} + +// ============================================================================= +// Restart +// ============================================================================= + +// Manager's reboot endpoint signals the launcher (via a sentinel file) that +// ComfyUI should restart. The HTTP request itself usually fails partway — +// the server is going down — so we treat a network error here as "request +// likely accepted" rather than a hard failure. +export async function reboot() { + try { + const r = await fetch(ENDPOINT_REBOOT, { method: "POST" }); + return r.ok; + } catch (e) { + return true; + } +} diff --git a/web/sidebar/modals.js b/web/sidebar/modals.js index b7413bf..93b4947 100644 --- a/web/sidebar/modals.js +++ b/web/sidebar/modals.js @@ -3,8 +3,17 @@ // click / Cancel / OK dismissal paths and ties the document-level keydown // listener to the overlay's lifetime so opening many modals doesn't leak. // ============================================================================= -import { compareNames } from "./constants.js"; +import { compareNames, toast } from "./constants.js"; import { listDirectoryNames, dirOf } from "./workflows_store.js"; +import { + detectManager, + loadMappings, + resolvePicksToInstall, + queueInstallGitUrl, + startQueue, + pollUntilDone, + reboot, +} from "./installer.js"; function makeModalShell({ title, body, actions }) { const overlay = document.createElement("div"); @@ -623,6 +632,227 @@ export function showTagsModal({ wfName, getCurrentTags, onAddTag, onRemoveTag }) setTimeout(() => { if (!input.disabled) input.focus(); }, 0); } +// ============================================================================= +// Install missing nodes modal — three phases driven by ComfyUI-Manager's HTTP +// API. +// +// 1. Discovery — probe Manager, fetch the node-id → git-url mapping, +// bucket picks into already-installed / will-install / +// unresolved. +// 2. Confirm — show the counts + per-bucket detail; user clicks Install +// or Copy URL list. +// 3. Progress — POST /customnode/install/git_url for each unique URL, +// POST /manager/queue/start, then poll /manager/queue/status +// while updating a progress bar. +// 4. Result — summarize successes/failures and offer Reboot. +// +// We don't render the unresolved list as a hard-fail block — installs can +// still proceed for the resolved subset, and the unresolved IDs surface +// inside a collapsible
so the user can act on them separately. +// ============================================================================= +export function showInstallMissingModal({ picks }) { + const body = document.createElement("div"); + + const message = document.createElement("div"); + message.className = "koolook-modal-message"; + message.textContent = "Checking ComfyUI-Manager…"; + body.appendChild(message); + + const stats = document.createElement("div"); + body.appendChild(stats); + + const progress = document.createElement("div"); + progress.className = "koolook-install-progress"; + progress.style.display = "none"; + const progressBar = document.createElement("div"); + progressBar.className = "koolook-install-progress-bar"; + progress.appendChild(progressBar); + body.appendChild(progress); + + // Track the polling abort so the Cancel button (or modal dismiss) stops + // pumping `pollUntilDone` even if installs are still chugging on the + // server side. We can't *cancel* an install — Manager doesn't expose a + // per-task cancel — so this just disengages our UI loop, not the work. + const pollAbort = { aborted: false }; + let overlay; + + const closeBtn = makeModalButton({ + label: "Cancel", + onClick: () => { pollAbort.aborted = true; overlay.remove(); }, + }); + const copyBtn = makeModalButton({ label: "Copy URL list", onClick: () => {} }); + copyBtn.style.display = "none"; + const installBtn = makeModalButton({ label: "Install via Manager", primary: true, onClick: () => {} }); + installBtn.style.display = "none"; + const rebootBtn = makeModalButton({ label: "Reboot now", primary: true, onClick: () => {} }); + rebootBtn.style.display = "none"; + + ({ overlay } = makeModalShell({ + title: "Install missing nodes", + body, + actions: [closeBtn, copyBtn, installBtn, rebootBtn], + })); + + function setStatLines(lines) { + stats.innerHTML = ""; + for (const line of lines) { + const div = document.createElement("div"); + div.className = "koolook-install-stat-line"; + if (line.fail) div.classList.add("koolook-install-stat-fail"); + div.textContent = line.text; + stats.appendChild(div); + } + } + + function appendUnresolvedDetail(unresolvedIds) { + if (unresolvedIds.length === 0) return; + const details = document.createElement("details"); + const summary = document.createElement("summary"); + summary.className = "koolook-install-unresolved-summary"; + summary.textContent = `Show unresolved (${unresolvedIds.length})`; + details.appendChild(summary); + const list = document.createElement("div"); + list.className = "koolook-install-unresolved"; + list.textContent = unresolvedIds.join(", "); + details.appendChild(list); + stats.appendChild(details); + } + + // ---- Phase 1: Discovery ---- + (async () => { + const managerOk = await detectManager(); + if (!managerOk) { + message.textContent = "ComfyUI-Manager isn't reachable. Install it via the official ComfyUI installer, or run `comfy node install ` from the CLI for each missing pack."; + return; + } + let mappings; + try { + mappings = await loadMappings(); + } catch (e) { + console.warn("[Koolook] loadMappings failed:", e); + message.textContent = `Could not load Manager's mapping database (${e.message}). In Manager → click "Update DB" or restart ComfyUI, then retry.`; + return; + } + const result = resolvePicksToInstall(picks, mappings.urlByNodeId); + const urlCount = result.willInstall.byUrl.size; + const willInstallNodeCount = [...result.willInstall.byUrl.values()] + .reduce((acc, ids) => acc + ids.length, 0); + + message.textContent = ""; + const lines = [ + { text: `Already installed: ${result.alreadyInstalled.length} pick${result.alreadyInstalled.length === 1 ? "" : "s"}` }, + { text: `Will install: ${urlCount} pack${urlCount === 1 ? "" : "s"} (${willInstallNodeCount} pick${willInstallNodeCount === 1 ? "" : "s"})` }, + ]; + if (result.unresolved.length > 0) { + lines.push({ text: `Unresolved (no mapping found): ${result.unresolved.length}` }); + } + setStatLines(lines); + appendUnresolvedDetail(result.unresolved); + + if (urlCount === 0) { + message.textContent = result.alreadyInstalled.length === picks.length && picks.length > 0 + ? "Every pick is already installed." + : "Nothing to install."; + closeBtn.textContent = "Close"; + return; + } + + // ---- Phase 2: Confirm ---- + const urls = [...result.willInstall.byUrl.keys()]; + copyBtn.style.display = ""; + installBtn.style.display = ""; + + copyBtn.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(urls.join("\n") + "\n"); + toast(`Copied ${urls.length} git URL${urls.length === 1 ? "" : "s"} to clipboard.`); + } catch (e) { + console.warn("[Koolook] clipboard write failed:", e); + toast("Clipboard write failed — see console for the URL list."); + console.log("[Koolook] git URLs:\n" + urls.join("\n")); + } + }); + + installBtn.addEventListener("click", async () => { + // Lock buttons so a double-click can't double-queue. + installBtn.disabled = true; + copyBtn.disabled = true; + + // ---- Phase 3: Progress ---- + progress.style.display = ""; + stats.style.display = "none"; + message.textContent = "Queueing installs…"; + + const queueResults = []; + for (const url of urls) { + if (pollAbort.aborted) break; + const r = await queueInstallGitUrl(url); + queueResults.push({ url, ...r }); + } + + const queuedOk = queueResults.filter(r => r.ok).length; + if (queuedOk === 0) { + progress.style.display = "none"; + stats.style.display = ""; + message.textContent = "No installs were accepted."; + setStatLines(queueResults.map(r => ({ + text: `${r.url} — ${r.message}`, + fail: true, + }))); + closeBtn.textContent = "Close"; + installBtn.style.display = "none"; + copyBtn.style.display = "none"; + return; + } + + await startQueue(); + const finalStatus = await pollUntilDone({ + onTick: (s) => { + const pct = s.total_count > 0 ? (s.done_count / s.total_count) * 100 : 0; + progressBar.style.width = `${pct}%`; + message.textContent = `Installing… ${s.done_count} of ${s.total_count}`; + }, + signal: pollAbort, + }); + + // ---- Phase 4: Result ---- + progress.style.display = "none"; + stats.style.display = ""; + installBtn.style.display = "none"; + copyBtn.style.display = "none"; + + const resultLines = []; + if (finalStatus) { + resultLines.push({ + text: `Installed ${finalStatus.done_count} of ${finalStatus.total_count} pack${finalStatus.total_count === 1 ? "" : "s"}.`, + }); + } else { + resultLines.push({ text: "Stopped polling — installs may still complete in the background." }); + } + for (const r of queueResults.filter(r => !r.ok)) { + resultLines.push({ text: `Failed to queue: ${r.url} — ${r.message}`, fail: true }); + } + if (queuedOk > 0) { + resultLines.push({ text: "Restart ComfyUI to load the newly installed nodes." }); + } + message.textContent = ""; + setStatLines(resultLines); + appendUnresolvedDetail(result.unresolved); + + closeBtn.textContent = "Close"; + if (queuedOk > 0) { + rebootBtn.style.display = ""; + rebootBtn.addEventListener("click", async () => { + rebootBtn.disabled = true; + await reboot(); + overlay.remove(); + toast("Reboot requested. Reload the page in a few seconds."); + }); + } + }); + })(); +} + // ============================================================================= // Context menu helper // ============================================================================= diff --git a/web/sidebar/tree.js b/web/sidebar/tree.js index ec85c9a..d807899 100644 --- a/web/sidebar/tree.js +++ b/web/sidebar/tree.js @@ -63,6 +63,7 @@ import { showSaveWorkflowModal, showContextMenu, showTagsModal, + showInstallMissingModal, } from "./modals.js"; // ============================================================================= @@ -1379,6 +1380,19 @@ export function renderPanel(container) { onClick: exportPicks, })); + // "Install missing for picks" — always enabled. The modal itself decides + // whether anything actually needs installing (after probing Manager and + // walking picks against the live LiteGraph registry), so leaving the + // button enabled lets the user trigger the discovery flow whenever they + // want a status check, including the "everything's already installed" + // confirmation. Click handlers don't read picks here — the modal pulls + // a fresh `loadUserPicks()` snapshot at open-time. + nodesRow.appendChild(makeToolbarButton({ + iconClass: "pi pi-cloud-download", + title: "Install missing custom nodes for current picks (via ComfyUI-Manager)", + onClick: () => showInstallMissingModal({ picks: loadUserPicks() }), + })); + container.appendChild(nodesRow); // Divider between the two action bars