feat(sidebar): one-click install of missing nodes via ComfyUI-Manager (#69)
Adds an "Install missing for picks" toolbar button to the Nodes section.
On click it walks the user's curated picks against Manager's
/customnode/getmappings, buckets them into already-installed /
will-install / unresolved, queues each unique git URL through
/customnode/install/git_url, polls /manager/queue/status to drive a
progress bar, and prompts to reboot — collapsing what was previously
a per-node "open node, let Manager prompt, click install, restart"
loop into a single confirm-and-go flow.
New `web/sidebar/installer.js` holds the Manager-API client (detect /
mappings / queue / poll / reboot) plus the picks → installs resolver.
The modal in `web/sidebar/modals.js` drives the four-phase UI
(discovery → confirm → progress → result) and surfaces 403s as
actionable language ("your security level forbids git-URL installs")
rather than raw HTTP. Falls back to a clipboard URL list (with a
`comfy node install` hint) if Manager isn't reachable.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
8ebe0af210
commit
b89355ded1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
// { "<git_url>": [[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;
|
||||
}
|
||||
}
|
||||
+231
-1
@@ -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 <details> 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 <pack>` 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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user