Files
chrmc40 49954df995 Boot-time DB-safe auto-updater + tag-builder UI/preset fixes
- Self-updater (core/update_boot.py + __init__ boot hook + system_api routes):
  apply staged updates before sqlite opens — git ff-only + DB backup + quick_check
  + paired rollback; discards only the tracked DB. 24h event-driven check, About
  'Update' entry, post-boot status toast (js/lib/update-check.js, js/main.js).
- tag-builder: NSFW toggle styling + NSFW Actions category, card/list view toggle,
  colon-bearing thumbnail serve fix, model-scoped Styles/templates preset matching,
  styles carry their own header + negative prompt.
- sidebar/onboarding/asset-browser: Windows asset drop + minor UI.
2026-06-29 18:30:22 -07:00

994 lines
39 KiB
JavaScript

// Onboarding splash — shown once on first boot.
// Step 1: setup — settings toggles + CivitAI key.
// Step 2: install features now, or skip (each feature self-installs on first use).
// Step 3: feature checklist — installs each chosen pack one by one, with live
// per-row progress + a streaming log so the user always sees activity.
import { app } from "../../../scripts/app.js";
import { renderAiSetting } from "./settings/ai-setting.js";
let _splashShown = false;
export async function checkOnboarding() {
if (_splashShown) return;
try {
const res = await fetch("/promptchain/onboarding/status");
const data = await res.json();
if (data.onboarded) return;
} catch {
return;
}
_splashShown = true;
showSplash();
}
// Standalone AI-setup modal — same provider chooser as Settings, surfaced
// when the AI Assistant (experimental, off by default) opens with nothing
// configured. This is the ONLY guided entry into AI setup now that the
// onboarding splash no longer has an AI screen.
// Fired via the `promptchain:show-ai-setup` event so the Svelte panel doesn't
// import vanilla modules across the bundle boundary.
export function showAiSetupModal() {
if (document.querySelector(".pcr-ai-setup-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "pcr-onboarding-overlay pcr-ai-setup-overlay";
const card = document.createElement("div");
card.className = "pcr-onboarding-card";
card.style.maxWidth = "560px";
const title = document.createElement("h2");
title.className = "pcr-onboarding-toggle-title";
title.style.cssText = "font-size:18px;margin:0 0 4px;";
title.textContent = "🪄 Set up the AI Assistant";
card.appendChild(title);
const subtitle = document.createElement("p");
subtitle.className = "pcr-onboarding-subtitle";
subtitle.innerHTML = "The AI Assistant needs a provider. <strong>Local</strong> runs free via Ollama; <strong>Cloud</strong> uses Claude, OpenAI and others with your API key.";
card.appendChild(subtitle);
const aiWrap = document.createElement("div");
aiWrap.className = "pcr-onboarding-section";
aiWrap.appendChild(renderAiSetting());
card.appendChild(aiWrap);
const row = document.createElement("div");
row.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:4px;";
const dontShow = document.createElement("button");
dontShow.textContent = "Don't show again";
dontShow.style.cssText = "background:none;border:none;color:#888;cursor:pointer;font-size:13px;text-decoration:underline;";
dontShow.addEventListener("click", async () => {
dontShow.disabled = true;
try {
await fetch("/promptchain/ai-setup/dismiss", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
} catch (e) {
console.error("[PromptChain] AI setup dismiss failed:", e);
}
overlay.remove();
});
const close = document.createElement("button");
close.className = "pcr-onboarding-btn";
close.style.cssText = "width:auto;padding:8px 24px;";
close.textContent = "Done";
close.addEventListener("click", () => overlay.remove());
row.append(dontShow, close);
card.appendChild(row);
overlay.appendChild(card);
document.body.appendChild(overlay);
}
if (!window.__pcrAiSetupListener) {
window.__pcrAiSetupListener = true;
window.addEventListener("promptchain:show-ai-setup", showAiSetupModal);
}
const PROMPTCHAIN_REPO = "https://github.com/mobcat40/ComfyUI-PromptChain";
// About modal — the welcome splash re-opened as a dismissible card: logo on top
// like the install step, no step nav and no tagline, the ⭐ Star-us footer kept,
// plus a user-triggered update check. Opened from the sidebar kebab and the
// PromptChain settings section. Closable by clicking the backdrop or Escape —
// the first-boot splash deliberately can't be, but About always can.
export function openAboutModal(opts = {}) {
if (document.querySelector(".pcr-about-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "pcr-onboarding-overlay pcr-about-overlay";
// Reuse the welcome hero card (splash art + logo + bottom bar), minus the
// step nav and tagline, and make it dismissible.
const card = document.createElement("div");
card.className = "pcr-onboarding-card pcr-about-card pcr-welcome-mode";
const onKey = (e) => { if (e.key === "Escape") close(); };
function close() {
window.removeEventListener("keydown", onKey);
overlay.remove();
}
overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) close(); });
window.addEventListener("keydown", onKey);
const hero = document.createElement("div");
hero.className = "pcr-welcome pcr-about-hero";
hero.style.backgroundImage = `url(${new URL("../splash-hero.png", import.meta.url).href})`;
const closeBtn = document.createElement("button");
closeBtn.className = "pcr-about-close";
closeBtn.textContent = "✕";
closeBtn.title = "Close";
closeBtn.addEventListener("click", close);
hero.appendChild(closeBtn);
const top = document.createElement("div");
top.className = "pcr-welcome-top";
const logoLink = document.createElement("a");
logoLink.href = PROMPTCHAIN_REPO;
logoLink.target = "_blank";
logoLink.rel = "noopener";
const logo = document.createElement("img");
logo.className = "pcr-welcome-logo";
logo.src = new URL("../splash-logo.png", import.meta.url).href;
logo.alt = "PromptChain";
logoLink.appendChild(logo);
top.appendChild(logoLink);
hero.appendChild(top);
const bar = document.createElement("div");
bar.className = "pcr-welcome-bar";
const left = document.createElement("div");
left.className = "pcr-about-barleft";
const version = document.createElement("div");
version.className = "pcr-about-version";
version.textContent = "Loading build info…";
left.append(version, starFootEl());
const update = document.createElement("button");
update.className = "pcr-about-update";
update.textContent = "Check for updates";
wireUpdateButton(update, version);
bar.append(left, update);
hero.appendChild(bar);
card.appendChild(hero);
overlay.appendChild(card);
document.body.appendChild(overlay);
// Opened from the "Update" menu entry — run the check immediately so the user
// lands on the result, not a button they still have to press.
if (opts.autoCheck) update.click();
(async () => {
try {
const v = await fetch("/promptchain/system/version").then((r) => r.json());
// Don't clobber a result the auto-check (opts.autoCheck) may already have
// written — only fill in build info if the line is still its placeholder.
if (version.textContent !== "Loading build info…") return;
version.textContent = v.is_git
? `Build ${v.date || ""}${v.commit ? " · " + v.commit : ""}`
: "Installed";
} catch {
if (version.textContent === "Loading build info…") version.textContent = "";
}
})();
}
// The update button cycles through three modes against the /system update
// routes: check → (behind) apply → restart. Each mode rewrites its own label,
// so a single click handler covers the whole flow.
function wireUpdateButton(btn, version) {
let mode = "check";
btn.addEventListener("click", async () => {
if (mode === "restart") {
btn.disabled = true;
btn.textContent = "Restarting…";
version.textContent = "Restarting… this page reconnects when the server is back.";
fetch("/promptchain/system/restart", { method: "POST" }).catch(() => {});
return;
}
if (mode === "check") {
btn.disabled = true;
btn.textContent = "Checking…";
let data;
try {
data = await fetch("/promptchain/system/check-updates", { method: "POST" }).then((r) => r.json());
} catch {
data = { status: "unknown", detail: "Couldn't reach the server." };
}
btn.disabled = false;
if (data.status === "current") {
version.textContent = "✓ You're on the latest version.";
btn.textContent = "Up to date";
btn.disabled = true;
} else if (data.status === "behind") {
version.textContent = `Update available — ${data.behind} new commit${data.behind > 1 ? "s" : ""}.`;
btn.textContent = "Update now →";
mode = "apply";
} else {
version.textContent = data.detail || "Couldn't check for updates.";
btn.textContent = "Check for updates";
}
return;
}
// mode === "apply" — stages the update (a live pull would fail on the
// open, locked tag-builder DB); it's applied at the next restart.
btn.disabled = true;
btn.textContent = "Staging…";
let res;
try {
res = await fetch("/promptchain/system/apply-update", { method: "POST" }).then((r) => r.json());
} catch {
res = { ok: false, detail: "Update failed." };
}
btn.disabled = false;
if (res.ok) {
version.textContent = "Update staged. Restart ComfyUI to apply it.";
btn.textContent = "Restart ComfyUI";
mode = "restart";
} else {
version.textContent = res.detail || "Update failed.";
btn.textContent = "Update now →";
}
});
}
if (!window.__pcrAboutListener) {
window.__pcrAboutListener = true;
window.addEventListener("promptchain:show-about", (e) => openAboutModal(e?.detail || {}));
}
function getCurrentPreviewMethod() {
const val = app.ui?.settings?.getSettingValue?.("Comfy.Execution.PreviewMethod");
return (!val || val === "default") ? "none" : val;
}
function setPreviewMethod(method) {
app.ui?.settings?.setSettingValue?.("Comfy.Execution.PreviewMethod", method);
}
// Returns the outcome so callers can gate on it: "valid" | "invalid" |
// "unreachable". A network failure/timeout is NOT invalid — we can't confirm
// either way, so we don't block onboarding when CivitAI is merely down.
async function validateCivitaiKey(key, statusEl) {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 5000);
const res = await fetch("/promptchain/civitai/validate-key", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
signal: ctrl.signal,
});
clearTimeout(timer);
const data = await res.json();
if (data.valid) {
statusEl.textContent = `✓ ${data.username}`;
statusEl.className = "pcr-onboarding-apikey-status pcr-apikey-valid";
return "valid";
}
statusEl.textContent = "✗ " + (data.error || "Invalid key");
statusEl.className = "pcr-onboarding-apikey-status pcr-apikey-invalid";
return "invalid";
} catch {
statusEl.textContent = "⚠ couldn't verify (CivitAI unreachable)";
statusEl.className = "pcr-onboarding-apikey-status";
return "unreachable";
}
}
// The install step is driven by the unified section registry
// (/promptchain/install/sections) — the same source of truth the in-app
// Settings install page reads — so the splash detects current install state
// and never drifts from a hand-maintained list.
// A pre-checked settings toggle, with an "already set" disabled state when the
// setting is already at the recommended value.
function recommendedToggle(title, desc, alreadySet) {
const section = document.createElement("div");
section.className = "pcr-onboarding-section";
const label = document.createElement("label");
label.className = "pcr-onboarding-toggle";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.checked = true;
const text = document.createElement("div");
text.className = "pcr-onboarding-toggle-text";
const t = document.createElement("span");
t.className = "pcr-onboarding-toggle-title";
t.textContent = title;
const d = document.createElement("span");
d.className = "pcr-onboarding-toggle-desc";
d.textContent = alreadySet ? "Already set. No change needed." : desc;
text.append(t, d);
label.append(cb, text);
label.addEventListener("click", (e) => { if (e.target !== cb) cb.checked = !cb.checked; });
if (alreadySet) cb.disabled = true;
section.appendChild(label);
return { section, cb };
}
// Step 1 — setup: all settings + the CivitAI key on one screen.
function showSplash() {
const overlay = document.createElement("div");
overlay.className = "pcr-onboarding-overlay";
const card = document.createElement("div");
card.className = "pcr-onboarding-card";
// logo
const logoLink = document.createElement("a");
logoLink.href = "https://github.com/mobcat40/ComfyUI-PromptChain";
logoLink.target = "_blank";
const logo = document.createElement("img");
logo.src = new URL("../logo.png", import.meta.url).href;
logo.className = "pcr-onboarding-logo";
logo.alt = "PromptChain";
logoLink.appendChild(logo);
card.appendChild(logoLink);
const setupNav = stepNav(1, (i) => gotoStep(card, i));
setupNav.style.margin = "6px 0 16px";
card.appendChild(setupNav);
const subtitle = document.createElement("p");
subtitle.className = "pcr-onboarding-subtitle";
subtitle.textContent = "One-time setup. Uncheck anything you'd rather leave as-is.";
card.appendChild(subtitle);
// ── live preview (TAESD) ──
const taesd = recommendedToggle(
"Enable live preview (TAESD)",
"See images as they generate. Change later in Settings > Execution > Preview Method.",
((m) => m === "taesd" || m === "auto" || m === "latent2rgb")(getCurrentPreviewMethod()));
card.appendChild(taesd.section);
// ── hide node source badges ──
const badges = recommendedToggle(
"Hide node source badges",
"Removes the type label at the bottom of nodes. Change later in Settings > Node > Source Badge Mode.",
app.ui?.settings?.getSettingValue?.("Comfy.NodeBadge.NodeSourceBadgeMode") === "None");
card.appendChild(badges.section);
// ── Nodes 2.0 ──
const nodes2 = recommendedToggle(
"Use Nodes 2.0 (new rendering)",
"PromptChain's interface is designed for ComfyUI's new Vue-based nodes. Change later in Settings.",
app.ui?.settings?.getSettingValue?.("Comfy.VueNodes.Enabled") === true);
card.appendChild(nodes2.section);
// ── disable workflow auto-restore ──
const persist = recommendedToggle(
"Disable workflow auto-restore",
"Start with a clean canvas each load instead of restoring the previous workflow. Change later in Settings > Comfy > Workflow.",
app.ui?.settings?.getSettingValue?.("Comfy.Workflow.Persist") === false);
card.appendChild(persist.section);
// ── CivitAI API key ──
const civitai = document.createElement("div");
civitai.className = "pcr-onboarding-section";
const civitaiTitle = document.createElement("span");
civitaiTitle.className = "pcr-onboarding-toggle-title";
civitaiTitle.textContent = "CivitAI API key (optional)";
civitai.appendChild(civitaiTitle);
const civitaiDesc = document.createElement("span");
civitaiDesc.className = "pcr-onboarding-toggle-desc";
civitaiDesc.innerHTML = 'Required for auto-downloading models from CivitAI. <a href="https://civitai.com/user/account" target="_blank" style="color: #5b9bd5;">Get your key here.</a>';
civitaiDesc.style.display = "block";
civitaiDesc.style.marginBottom = "8px";
civitai.appendChild(civitaiDesc);
const civitaiRow = document.createElement("div");
civitaiRow.style.cssText = "display: flex; align-items: center; gap: 8px;";
// type=text + CSS masking, NOT type=password: a real password field makes
// the browser's password manager offer to "save" the API key when the
// field is removed on Next. Masking gives the same visual hiding without
// tripping that heuristic.
const civitaiInput = document.createElement("input");
civitaiInput.type = "text";
civitaiInput.style.webkitTextSecurity = "disc";
civitaiInput.className = "pcr-onboarding-apikey-input";
civitaiInput.placeholder = "Paste your API key...";
civitaiInput.spellcheck = false;
civitaiInput.autocomplete = "off";
civitaiRow.appendChild(civitaiInput);
const civitaiStatus = document.createElement("span");
civitaiStatus.className = "pcr-onboarding-apikey-status";
civitaiRow.appendChild(civitaiStatus);
civitai.appendChild(civitaiRow);
let validateTimer = null;
// Validation outcome for the value currently in the field, so Next can gate
// on it: empty | checking | valid | invalid | unreachable.
let keyState = { value: "", status: "empty" };
async function runKeyValidation(key) {
keyState = { value: key, status: "checking" };
const status = await validateCivitaiKey(key, civitaiStatus);
// Ignore a stale result the user has already typed past.
if (civitaiInput.value.trim() === key) keyState = { value: key, status };
return status;
}
civitaiInput.addEventListener("input", () => {
clearTimeout(validateTimer);
const val = civitaiInput.value.trim();
if (!val) {
civitaiStatus.textContent = "";
civitaiStatus.className = "pcr-onboarding-apikey-status";
keyState = { value: "", status: "empty" };
return;
}
civitaiStatus.textContent = "...";
civitaiStatus.className = "pcr-onboarding-apikey-status";
keyState = { value: val, status: "checking" };
validateTimer = setTimeout(() => runKeyValidation(val), 600);
});
card.appendChild(civitai);
// tags section
const tags = document.createElement("div");
tags.className = "pcr-onboarding-section";
const tagsInfo = document.createElement("div");
tagsInfo.className = "pcr-onboarding-info";
tagsInfo.innerHTML = "<strong>Tag databases</strong> will be copied to your user folder. You can customize or remove them later.";
tags.appendChild(tagsInfo);
card.appendChild(tags);
// Next → applies the settings + saves the key, then advances to Extras
// (the install screen).
const btn = navButton("Next →", true);
btn.addEventListener("click", async () => {
btn.disabled = true;
btn.textContent = "Saving...";
// Gate on the CivitAI key first. Don't save or advance a confirmed-bad
// key; an empty field or an unreachable CivitAI passes through.
const apiKey = civitaiInput.value.trim();
if (apiKey) {
let status = keyState.value === apiKey ? keyState.status : "checking";
if (status === "checking" || status === "empty") {
clearTimeout(validateTimer);
btn.textContent = "Checking key...";
status = await runKeyValidation(apiKey);
}
if (status === "invalid") {
btn.disabled = false;
btn.textContent = "Next →";
civitaiInput.focus();
return;
}
try {
await fetch("/promptchain/civitai/api-key", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: apiKey }),
});
} catch (e) {
console.error("[PromptChain] Failed to save CivitAI API key:", e);
}
}
if (taesd.cb.checked && !taesd.cb.disabled) setPreviewMethod("taesd");
if (badges.cb.checked && !badges.cb.disabled) {
app.ui?.settings?.setSettingValue?.("Comfy.NodeBadge.NodeSourceBadgeMode", "None");
}
if (nodes2.cb.checked && !nodes2.cb.disabled) {
app.ui?.settings?.setSettingValue?.("Comfy.VueNodes.Enabled", true);
}
if (persist.cb.checked && !persist.cb.disabled) {
app.ui?.settings?.setSettingValue?.("Comfy.Workflow.Persist", false);
}
btn.disabled = false;
btn.textContent = "Next →";
showChecklist(overlay, card);
});
card.appendChild(onboardingBar([btn]));
overlay.appendChild(card);
document.body.appendChild(overlay);
// Show the Welcome hero first; the setup form becomes screen 1 behind it.
captureScreen1(card);
card._pcrScreen0 = buildWelcomeScreen(overlay, card);
card.appendChild(card._pcrScreen0);
setCardMode(card, "welcome");
showOnly(card, "_pcrScreen0");
}
async function completeOnboarding() {
try {
await fetch("/promptchain/onboarding/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
} catch (e) {
console.error("[PromptChain] Onboarding completion failed:", e);
}
}
// Screens share one card so Back preserves state: screen1 (setup) is captured
// then hidden, never rebuilt; screen2 (install or skip) and screen3 (checklist)
// are built lazily and toggled.
const NAV_BTN = "font-size:14px;font-weight:500;border-radius:6px;padding:9px 22px;cursor:pointer;line-height:1.2;";
function captureScreen1(card) {
if (card._pcrScreen1) return;
const s1 = document.createElement("div");
while (card.firstChild) s1.appendChild(card.firstChild);
card.appendChild(s1);
card._pcrScreen1 = s1;
}
function showOnly(card, key) {
for (const k of ["_pcrScreen0", "_pcrScreen1", "_pcrScreen2", "_pcrScreen3"]) {
if (card[k]) card[k].style.display = k === key ? "" : "none";
}
}
function navButton(label, primary) {
const b = document.createElement("button");
b.textContent = label;
b.style.cssText = NAV_BTN + (primary
? "background:#c36216;border:1px solid #c36216;color:#fff;"
: "background:transparent;border:1px solid #555;color:#ddd;");
return b;
}
// The ⭐ Star-us footer link, shared by the welcome hero and the form-step bars.
function starFootEl() {
const foot = document.createElement("div");
foot.className = "pcr-welcome-foot";
foot.innerHTML = '⭐ <a href="https://github.com/mobcat40/ComfyUI-PromptChain" target="_blank" rel="noopener">Star us on GitHub!</a>';
return foot;
}
// Bottom action bar for the form steps (Setup, Extras): star-foot on the left,
// the step's action button(s) on the right. Mirrors the welcome hero's bar.
function onboardingBar(rightButtons) {
const bar = document.createElement("div");
bar.className = "pcr-onboarding-bar";
const right = document.createElement("div");
right.style.cssText = "display:flex;align-items:center;gap:10px;flex:none;";
rightButtons.forEach((b) => right.appendChild(b));
bar.append(starFootEl(), right);
return bar;
}
const ONBOARD_STEPS = ["Welcome", "Setup", "Extras"];
// The Welcome · Setup · Finish progress indicator, with the active step lit.
// Styled to read both on the hero art and on the dark form/install cards.
function stepNav(active, onNavigate) {
const nav = document.createElement("div");
nav.className = "pcr-welcome-steps";
ONBOARD_STEPS.forEach((label, i) => {
// Already-completed steps are clickable to go back to them.
const clickable = i < active && !!onNavigate;
const step = document.createElement("span");
step.className = "pcr-welcome-step" + (i === active ? " is-active" : "") + (clickable ? " is-clickable" : "");
const dot = document.createElement("span");
dot.className = "pcr-welcome-dot";
const txt = document.createElement("span");
txt.textContent = label;
step.append(dot, txt);
if (clickable) step.addEventListener("click", () => onNavigate(i));
nav.appendChild(step);
});
return nav;
}
// Navigate to an onboarding step by index — backs the clickable step nav.
function gotoStep(card, index) {
if (index <= 0) { setCardMode(card, "welcome"); showOnly(card, "_pcrScreen0"); }
else if (index === 1) { setCardMode(card, "form"); showOnly(card, "_pcrScreen1"); }
else { setCardMode(card, "form"); showOnly(card, "_pcrScreen3"); }
}
// Welcome uses a full-bleed hero card; the form/install screens use the padded
// dark card. Toggle without inline max-width fighting the welcome-mode class.
function setCardMode(card, mode) {
if (mode === "welcome") {
card.style.maxWidth = "";
card.classList.add("pcr-welcome-mode");
} else {
card.classList.remove("pcr-welcome-mode");
card.style.maxWidth = "560px";
}
}
// Step 0 — hero welcome screen. The product art (splash-hero.png) is the
// background; logo, step nav, tagline, footer link, and CTA are DOM on top.
function buildWelcomeScreen(overlay, card) {
const welcome = document.createElement("div");
welcome.className = "pcr-welcome";
welcome.style.backgroundImage = `url(${new URL("../splash-hero.png", import.meta.url).href})`;
const top = document.createElement("div");
top.className = "pcr-welcome-top";
const logo = document.createElement("img");
logo.className = "pcr-welcome-logo";
logo.src = new URL("../splash-logo.png", import.meta.url).href;
logo.alt = "PromptChain";
top.append(logo, stepNav(0));
welcome.appendChild(top);
const tagline = document.createElement("div");
tagline.className = "pcr-welcome-tagline";
tagline.textContent = "The full prompting IDE toolkit for ComfyUI";
welcome.appendChild(tagline);
const bar = document.createElement("div");
bar.className = "pcr-welcome-bar";
// Low-key escape — features still self-install on first use. Begin setup
// stays the obvious primary; Skip is a quiet grey out for those who want it.
const skip = document.createElement("button");
skip.className = "pcr-welcome-skip";
skip.textContent = "Skip";
skip.title = "Skip setup — features still install the first time you use them";
skip.addEventListener("click", async () => {
skip.disabled = true;
await completeOnboarding();
overlay.remove();
});
const cta = document.createElement("button");
cta.className = "pcr-welcome-cta";
cta.textContent = "Begin setup →";
cta.addEventListener("click", () => {
setCardMode(card, "form");
showOnly(card, "_pcrScreen1");
});
const right = document.createElement("div");
right.style.cssText = "display:flex;align-items:center;gap:12px;";
right.append(skip, cta);
bar.append(starFootEl(), right);
welcome.appendChild(bar);
return welcome;
}
// (The old "install now or skip" choice screen was folded into Extras: Setup →
// Next goes straight there, where the bar offers Skip / Install selected.)
// Step 3 (Extras) — feature checklist + sequential install with live progress.
function showChecklist(overlay, card) {
card.style.maxWidth = "600px";
if (!card._pcrScreen3) card._pcrScreen3 = buildChecklistScreen(overlay, card);
showOnly(card, "_pcrScreen3");
}
function buildChecklistScreen(overlay, card) {
const screen = document.createElement("div");
const logo = document.createElement("img");
logo.src = new URL("../logo.png", import.meta.url).href;
logo.className = "pcr-onboarding-logo";
logo.alt = "PromptChain";
screen.appendChild(logo);
const nav = stepNav(2, (i) => gotoStep(card, i));
nav.style.margin = "6px 0 16px";
screen.appendChild(nav);
const title = document.createElement("h2");
title.className = "pcr-onboarding-toggle-title";
title.style.cssText = "font-size:18px;margin:0 0 4px;";
title.textContent = "📦 Install features";
screen.appendChild(title);
const subtitle = document.createElement("p");
subtitle.className = "pcr-onboarding-subtitle";
subtitle.textContent = "Already-installed features are detected and left unchecked. Sizes are approximate; shared models download only once.";
screen.appendChild(subtitle);
// Rows are filled async from /install/sections so the step shows real state.
const listEl = document.createElement("div");
screen.appendChild(listEl);
const loading = document.createElement("p");
loading.className = "pcr-onboarding-subtitle";
loading.textContent = "Checking what's already installed…";
listEl.appendChild(loading);
// Overall progress line + streaming log (hidden until install starts).
const progressLine = document.createElement("p");
progressLine.className = "pcr-onboarding-subtitle";
progressLine.style.cssText = "margin:8px 0 4px;display:none;";
screen.appendChild(progressLine);
const logBox = document.createElement("div");
logBox.className = "pcr-onboarding-info";
logBox.style.cssText = "display:none;max-height:120px;overflow-y:auto;font-family:ui-monospace,Consolas,monospace;font-size:11px;line-height:1.5;white-space:pre-wrap;background:#1a1a20;color:#9c9;padding:8px 10px;border-radius:6px;";
screen.appendChild(logBox);
const log = (line) => {
logBox.style.display = "block";
const div = document.createElement("div");
div.textContent = line;
logBox.appendChild(div);
logBox.scrollTop = logBox.scrollHeight;
};
// Skip (left) installs nothing and finishes; Install selected (right) runs it.
const skip = navButton("Skip for now", false);
skip.addEventListener("click", async () => {
skip.disabled = true; install.disabled = true;
await completeOnboarding();
overlay.remove();
});
const install = navButton("Install selected", true);
install.disabled = true; // enabled once we know what's installed
screen.appendChild(onboardingBar([skip, install]));
const rows = [];
install.addEventListener("click", () => runInstall({ overlay, card, rows, progressLine, log, nav, skip, install }));
// Detect current install state. A wiped user folder re-runs this splash, but
// installs live in custom_nodes/ + models/ (OUTSIDE the user folder), so the
// sections route still finds them: installed → shown done + unchecked,
// partial → unchecked with an "N of M" note, missing → pre-checked.
(async () => {
let sections = [];
try {
sections = (await fetch("/promptchain/install/sections").then((r) => r.json())).sections || [];
} catch {
loading.textContent = "Couldn't check install status. Restart ComfyUI and reopen, or Skip (features still install on first use).";
return;
}
loading.remove();
if (!sections.length) {
const none = document.createElement("p");
none.className = "pcr-onboarding-subtitle";
none.textContent = "Nothing optional to install — you're all set.";
listEl.appendChild(none);
return;
}
for (const sec of sections) rows.push(renderSectionRow(sec, listEl));
// Nothing left to install (all detected present) → drop Skip/Install for a
// single Finish; otherwise enable Install.
if (rows.some((r) => !r.cb.disabled)) {
install.disabled = false;
} else {
subtitle.textContent = "Everything's already installed — you're all set.";
const finish = navButton("Finish", true);
finish.addEventListener("click", async () => { await completeOnboarding(); overlay.remove(); });
skip.replaceWith(finish);
install.remove();
}
})();
card.appendChild(screen);
return screen;
}
// One install-step row reflecting a section's detected health.
function fmtBytes(n) {
if (!n || n < 0) return "0 B";
const u = ["B", "KB", "MB", "GB"];
let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${u[i]}`;
}
// Render the live download stat for the status cell: "12.3 MB/s · 1.2 GB / 3.2 GB".
function fmtProgress(p) {
const parts = [];
if (p.bps) parts.push(`${fmtBytes(p.bps)}/s`);
if (p.total) parts.push(`${fmtBytes(p.done || 0)} / ${fmtBytes(p.total)}`);
if (!parts.length) parts.push(`${p.pct || 0}%`);
return parts.join(" · ");
}
function renderSectionRow(sec, parent) {
const section = document.createElement("div");
section.className = "pcr-onboarding-section";
section.style.cssText = "display:flex;align-items:flex-start;gap:10px;padding:9px 10px;";
const installed = sec.health === "installed";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.style.marginTop = "2px";
// Missing → pre-checked (fresh-install default). Partial → off (you already
// have some; tick to fetch the rest). Installed → off + disabled.
cb.checked = sec.health === "missing";
cb.disabled = installed;
const text = document.createElement("div");
text.style.cssText = "flex:1;min-width:0;";
const t = document.createElement("div");
t.className = "pcr-onboarding-toggle-title";
t.innerHTML = `${sec.label} <span style="font-weight:400;opacity:.6;font-size:12px;">${sec.size || ""}</span>`;
const d = document.createElement("div");
d.className = "pcr-onboarding-toggle-desc";
const deferredNote = (sec.deferred && sec.deferred.length)
? " · +" + sec.deferred.map((x) => ` ${x.label} (${x.size}) on first use`).join(";")
: "";
d.textContent = sec.desc + deferredNote;
text.append(t, d);
// Live download progress bar — hidden until this row starts a byte-counted
// download (bundled-pack copies have no byte stream, so they stay text-only).
const barTrack = document.createElement("div");
barTrack.style.cssText = "display:none;height:4px;border-radius:2px;background:rgba(255,255,255,.12);margin-top:7px;overflow:hidden;";
const barFill = document.createElement("div");
barFill.style.cssText = "height:100%;width:0%;border-radius:2px;background:#e8821e;transition:width .2s ease;";
barTrack.appendChild(barFill);
text.appendChild(barTrack);
const status = document.createElement("div");
status.style.cssText = "font-size:12px;white-space:nowrap;max-width:240px;overflow:hidden;text-overflow:ellipsis;text-align:right;margin-top:2px;";
if (installed) { status.style.color = "#6c6"; status.textContent = "✓ Installed"; }
else if (sec.health === "partial") { status.style.color = "#e0a13a"; status.textContent = `${sec.installed_count} of ${sec.total} installed`; }
section.append(cb, text, status);
section.addEventListener("click", (e) => {
if (e.target !== cb && !cb.disabled) cb.checked = !cb.checked;
});
parent.appendChild(section);
return { sec, cb, status, barTrack, barFill };
}
// Consume one per-pack install SSE stream, reporting stage text + log lines.
// Throws on an {error} event.
async function installOnePack(spec, onStage, onLog) {
const res = await fetch(spec.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(spec.body),
signal: spec.signal,
});
if (!res.ok || !res.body) {
const detail = await res.text().catch(() => "");
throw new Error(detail || `HTTP ${res.status}`);
}
const STAGE = {
clone: "Downloading pack…",
copy: "Copying files…",
pin: "Pinning version…",
pip: "Installing dependencies…",
install_script: "Running setup…",
fix_deps: "Verifying core dependencies…",
};
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf("\n\n")) >= 0) {
const chunk = buf.slice(0, nl).trim();
buf = buf.slice(nl + 2);
if (!chunk.startsWith("data:")) continue;
let evt;
try { evt = JSON.parse(chunk.slice(5).trim()); } catch { continue; }
if (evt.error) throw new Error(evt.error);
if (evt.line) { onLog(" " + evt.line); continue; }
if (evt.stage === "download") { onStage({ pct: evt.pct ?? 0, done: evt.done, total: evt.total, bps: evt.bps, file: evt.file }); continue; }
if (evt.stage === "prefetch") { onStage(`Pre-fetching ${evt.file || "extra weights"}…`); continue; }
if (evt.stage === "models") { onStage(`Downloading ${evt.count || ""} model file${evt.count > 1 ? "s" : ""}…`); continue; }
if (evt.stage) { onStage(STAGE[evt.stage] || evt.stage); continue; }
// evt.done — pack finished
}
}
}
async function runInstall({ overlay, card, rows, progressLine, log, nav, skip, install }) {
const selected = rows.filter((r) => r.cb.checked && !r.cb.disabled);
if (!selected.length) { await completeOnboarding(); overlay.remove(); return; }
if (nav) nav.style.pointerEvents = "none"; // no step-nav back-nav mid-install
if (skip) skip.disabled = true;
install.disabled = true;
install.textContent = "Installing…";
install.style.opacity = "0.55";
install.style.cursor = "default";
rows.forEach((r) => { r.cb.disabled = true; });
progressLine.style.display = "block";
// Cancel: aborts the in-flight fetch AND tells the server to stop. The current
// file finishes its chunk (keeping a .part for resume), then it bails.
const controller = new AbortController();
let cancelled = false;
const cancelBtn = navButton("Cancel", false);
cancelBtn.style.marginRight = "8px";
cancelBtn.addEventListener("click", () => {
if (cancelled) return;
cancelled = true;
cancelBtn.disabled = true;
cancelBtn.textContent = "Cancelling…";
controller.abort();
fetch("/promptchain/install/cancel", { method: "POST" }).catch(() => {});
progressLine.textContent = "Cancelling… the current file finishes, then it stops.";
});
install.before(cancelBtn);
let anyFailed = false;
for (let i = 0; i < selected.length; i++) {
if (cancelled) break;
const { sec, status, barTrack, barFill } = selected[i];
progressLine.textContent = `Installing ${i + 1} of ${selected.length}…`;
status.style.color = "#7bd";
status.textContent = "⏳ starting…";
if (barTrack) { barTrack.style.display = "none"; barFill.style.width = "0%"; }
log(`▸ ${sec.label}`);
try {
// One SSE per section. The section installer skips already-present members
// server-side, so a partial section only fetches what's still missing.
await installOnePack(
{ url: "/promptchain/install/install", body: { section: sec.id }, signal: controller.signal },
(s) => {
if (typeof s === "string") {
if (barTrack) barTrack.style.display = "none";
status.textContent = s;
} else {
// byte-counted download → fill the bar, show rate + size in the cell
if (barTrack && s.total) { barTrack.style.display = "block"; barFill.style.width = (s.pct || 0) + "%"; }
status.textContent = fmtProgress(s);
}
}, log);
status.style.color = "#6c6";
status.textContent = "✓ done";
if (barTrack) barTrack.style.display = "none";
} catch (e) {
if (barTrack) barTrack.style.display = "none";
if (cancelled || e.name === "AbortError") {
cancelled = true;
status.style.color = "#999";
status.textContent = "— cancelled";
break;
}
anyFailed = true;
status.style.color = "#e77";
status.textContent = "✗ failed";
log(` ✘ ${sec.label}: ${e.message}`);
}
}
await completeOnboarding();
cancelBtn.remove();
// Done — the copied-out packs need a restart to register their nodes. Replace
// the Install button entirely (not repurpose it — its addEventListener handler
// can't be cleared by onclick=null and would re-fire runInstall on click).
progressLine.textContent = cancelled
? "Cancelled. Restart ComfyUI to load whatever finished installing."
: anyFailed
? "Finished with some errors (see log). Restart ComfyUI to load what installed."
: "All set! Restart ComfyUI to load the new features.";
if (skip) skip.style.display = "none";
const restartBtn = navButton("Restart ComfyUI", true);
restartBtn.addEventListener("click", () => {
restartBtn.disabled = true;
restartBtn.textContent = "Restarting…";
fetch("/promptchain/system/restart", { method: "POST" }).catch(() => {});
progressLine.textContent = "Restarting… this page will reconnect when the server is back.";
});
// A quiet "finish without restarting" escape hatch.
const later = navButton("Later", false);
later.style.marginLeft = "8px";
later.addEventListener("click", () => overlay.remove());
install.replaceWith(restartBtn);
restartBtn.after(later);
}