Files
Nuked88-ComfyUI-N-Sidebar/web/sidebar.js

551 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { MODULES, settingId, syncModules, migrateLegacy, mergeGroups, groupMap, inspectWorkflow, templateClipboard } from "./core.js";
const DATA_FILE = "n-sidebar-extras.json";
const BOOKMARKS = "Comfy.NodeLibrary.Bookmarks.V2";
const COLORS = "Comfy.NodeLibrary.BookmarksCustomization";
const mounted = new Set();
const panels = new Set();
let ready = false;
function element(tag, text, className) {
const node = document.createElement(tag);
if (text != null) node.textContent = text;
if (className) node.className = className;
return node;
}
function notify(error) {
app.extensionManager.toast.add({ severity: "error", summary: "N-Sidebar", detail: error.message, life: 6000 });
}
function button(label, action) {
const node = element("button", label);
node.type = "button";
node.onclick = async () => {
node.disabled = true;
try { await action(); } catch (error) { notify(error); }
finally { node.disabled = false; }
};
return node;
}
function input(placeholder, type = "text") {
const node = element("input");
node.type = type;
node.placeholder = placeholder;
node.setAttribute("aria-label", placeholder);
return node;
}
function select(options, label) {
const node = element("select");
node.setAttribute("aria-label", label);
options.forEach(([value, text]) => {
const option = element("option", text);
option.value = value;
node.append(option);
});
return node;
}
function row(...nodes) {
const node = element("div", null, "ns-row");
node.append(...nodes);
return node;
}
async function json(route, options = {}) {
const response = await api.fetchApi(route, options);
const data = await response.json();
if (!response.ok) throw new Error(data.error ?? `Request failed (${response.status})`);
return data;
}
const post = (route, data = {}) => json(route, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data),
});
async function readData() {
const response = await api.getUserData(DATA_FILE);
if (response.status === 404) return {};
if (!response.ok) throw new Error("Cannot read N-Sidebar user data");
return response.json();
}
async function saveData(data) {
await api.storeUserData(DATA_FILE, data);
}
function registeredTypes() {
return new Set(Object.keys(globalThis.LiteGraph.registered_node_types));
}
function preview(workflow) {
const node = element("details", null, "ns-preview");
node.append(element("summary", "Used nodes"));
const list = element("ul");
for (const item of inspectWorkflow(workflow, registeredTypes())) {
const text = `${item.type} × ${item.count}${item.missing ? " — missing" : ""}`;
list.append(element("li", text, item.missing && app.extensionManager.setting.get("NSidebar.HighlightMissing") ? "ns-missing" : ""));
}
node.append(list);
return node;
}
function downloadJson(name, data) {
const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }));
const link = element("a");
link.href = url;
link.download = name;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function lifecycle(container, key) {
const root = element("section", null, "ns-panel");
root.dataset.module = key;
container.replaceChildren(root);
const controller = new AbortController();
let active = true;
const cleanup = [];
return { root, signal: controller.signal, alive: () => active,
onCleanup(fn) { cleanup.push(fn); },
dispose() { active = false; controller.abort(); cleanup.forEach(fn => fn()); root.remove(); },
};
}
async function collections(ctx, key) {
const templates = key === "Templates";
const groupKey = templates ? "templateGroups" : "workflowGroups";
const colorKey = templates ? "templateColors" : "workflowColors";
const root = ctx.root;
root.append(element("h3", templates ? "Template collections" : "External workflows"));
const search = input("Search name or collection");
const createName = input("New collection");
const list = element("div", null, "ns-list");
const status = element("p", "Loading…", "ns-status");
let data, items = [], roots = [], queryVersion = 0;
const keyFor = item => templates ? item.name : `${roots[item.root].replaceAll("\\", "/").replace(/\/$/, "")}/${item.path}`;
async function persist() {
await saveData(data);
draw();
}
const create = button("Create", async () => {
const name = createName.value.trim();
if (!name || Object.hasOwn(data[groupKey], name)) return;
Object.defineProperty(data[groupKey], name, { value: [], enumerable: true, configurable: true, writable: true });
createName.value = "";
await persist();
});
root.append(search, row(createName, create), status, list);
async function workflowFor(item) {
if (templates) return templateClipboard(item.data);
return json(`/n-sidebar/workflow?${new URLSearchParams({ root: item.root, path: item.path })}`, { signal: ctx.signal });
}
async function open(item) {
const graph = await workflowFor(item);
if (!ctx.alive()) return;
if (templates) {
const clipboardKey = "litegrapheditor_clipboard";
const previous = localStorage.getItem(clipboardKey);
try {
localStorage.setItem(clipboardKey, JSON.stringify(graph));
const canvas = app.canvas;
const position = [(-canvas.ds.offset[0] + canvas.canvas.width / canvas.ds.scale / 2),
(-canvas.ds.offset[1] + canvas.canvas.height / canvas.ds.scale / 2)];
canvas.pasteFromClipboard({ position });
} finally {
if (previous == null) localStorage.removeItem(clipboardKey);
else localStorage.setItem(clipboardKey, previous);
}
} else {
// A named load opens a native workflow tab and preserves the current workflow.
await app.loadGraphData(graph, true, true, item.name + ".json");
}
}
function draw() {
if (!ctx.alive()) return;
list.replaceChildren();
const query = search.value.toLocaleLowerCase();
const groups = [["", []], ...Object.entries(data[groupKey])];
for (const [name, members] of groups) {
const section = element("details", null, "ns-collection");
section.open = true;
section.append(element("summary", name || "Uncategorized"));
const color = data[colorKey]?.[name];
if (typeof color === "string" && /^#[0-9a-f]{6}$/i.test(color)) section.style.borderInlineStartColor = color;
if (name) {
const colorInput = input("Collection color", "color");
colorInput.value = typeof color === "string" && /^#[0-9a-f]{6}$/i.test(color) ? color : "#808080";
colorInput.onchange = async () => {
data[colorKey] = { ...data[colorKey], [name]: colorInput.value };
try { await persist(); } catch (error) { notify(error); }
};
const rename = input("Rename collection");
rename.value = name;
const options = element("details", null, "ns-collection-options");
options.append(element("summary", "Collection options"), row(rename, button("Rename", async () => {
const next = rename.value.trim();
if (!next || next === name) return;
if (Object.hasOwn(data[groupKey], next)) throw new Error("Collection already exists");
data[groupKey] = groupMap({ ...data[groupKey], [next]: members });
delete data[groupKey][name];
data[colorKey] = { ...data[colorKey], [next]: data[colorKey]?.[name] };
delete data[colorKey][name];
await persist();
})), row(colorInput, button("Remove collection", async () => {
delete data[groupKey][name];
if (data[colorKey]) delete data[colorKey][name];
await persist();
})));
section.append(options);
}
const membersFor = item => members.includes(keyFor(item)) || (!templates && members.includes(item.name));
const matching = items.filter(item => name ? membersFor(item)
: !Object.values(data[groupKey]).some(values => values.includes(keyFor(item)) || (!templates && values.includes(item.name))));
if (!name && !matching.length && groups.length > 1) continue;
if (name) matching.sort((a, b) => members.findIndex(member => member === keyFor(a) || member === a.name)
- members.findIndex(member => member === keyFor(b) || member === b.name));
let shown = 0;
for (const item of matching) {
if (query && !`${item.name} ${name} ${item.path ?? ""}`.toLocaleLowerCase().includes(query)) continue;
shown++;
const card = element("article", null, "ns-card");
card.append(element("strong", item.name));
if (!templates) card.append(element("small", item.path));
const membership = select([["", "Add to collection…"], ...Object.keys(data[groupKey]).map(group => [group, group])], "Add to collection");
membership.onchange = async () => {
if (!membership.value) return;
data[groupKey][membership.value] = [...new Set([...data[groupKey][membership.value], keyFor(item)])];
try { await persist(); } catch (error) { notify(error); }
};
card.append(row(button(templates ? "Insert" : "Open in tab", () => open(item)), membership));
if (name) card.append(row(button("↑", async () => {
const index = members.findIndex(member => member === keyFor(item) || member === item.name);
if (index > 0) [members[index - 1], members[index]] = [members[index], members[index - 1]];
await persist();
}), button("Remove from collection", async () => {
data[groupKey][name] = members.filter(member => member !== keyFor(item) && member !== item.name);
await persist();
})));
if (app.extensionManager.setting.get("NSidebar.Previews")) {
const holder = element("div");
card.append(button("Preview nodes", async () => {
const graph = await workflowFor(item);
if (ctx.alive() && card.isConnected) {
const details = preview(graph);
details.open = true;
holder.replaceChildren(details);
}
}), holder);
}
section.append(card);
}
if (!shown) section.append(element("p", "No matching items", "ns-muted"));
list.append(section);
}
}
async function refresh() {
const version = ++queryVersion;
status.textContent = "Loading…";
try {
const nextData = await readData();
let nextItems, nextRoots = [];
if (templates) {
const response = await api.getUserData("comfy.templates.json", { signal: ctx.signal });
if (response.status === 404) nextItems = [];
else if (!response.ok) throw new Error("Cannot read native node templates");
else nextItems = await response.json();
if (!Array.isArray(nextItems)) throw new Error("Invalid native template file");
} else {
const library = await json("/n-sidebar/workflows", { signal: ctx.signal });
nextItems = library.files;
nextRoots = library.roots;
}
if (!ctx.alive() || version !== queryVersion) return;
data = nextData;
data[groupKey] = groupMap(data[groupKey]);
roots = nextRoots;
// Sort collection items by their saved order before falling back to library order.
items = nextItems;
status.textContent = templates ? `${items.length} native node templates. Save templates from the canvas context menu.`
: roots.length ? `${items.length} workflows in ${roots.length} external libraries.`
: "Set NSIDEBAR_WORKFLOW_ROOTS on the server (one directory per line), or keep the old workflow paths in app/settings.json.";
draw();
} catch (error) {
if (error.name !== "AbortError" && ctx.alive()) status.textContent = error.message;
}
}
root.insertBefore(button("Refresh", refresh), status);
search.oninput = draw;
create.disabled = true;
await refresh();
create.disabled = !data;
}
function inspector(ctx) {
const root = ctx.root;
root.append(element("h3", "Workflow inspector"));
const search = input("Filter node types");
const output = element("div");
let source;
function draw() {
const graph = source ?? app.graph.serialize();
const entries = inspectWorkflow(graph, registeredTypes());
const missing = entries.filter(item => item.missing).length;
output.replaceChildren(element("p", `${entries.length} node types · ${missing} missing`));
const list = element("ul", null, "ns-node-list");
for (const item of entries.filter(item => item.type.toLowerCase().includes(search.value.toLowerCase()))) {
const li = element("li", `${item.type} × ${item.count}${item.missing ? " — missing" : ""}`,
item.missing && app.extensionManager.setting.get("NSidebar.HighlightMissing") ? "ns-missing" : "");
const nodes = app.graph._nodes.filter(node => node.type === item.type);
if (!source && nodes.length) li.append(button("Locate", () => {
app.canvas.selectNodes(nodes);
app.canvas.centerOnNode(nodes[0]);
}));
list.append(li);
}
output.append(list);
}
const file = input("Inspect workflow JSON", "file");
file.accept = ".json,application/json";
file.onchange = async () => {
try {
const candidate = JSON.parse(await file.files[0].text());
if (!Array.isArray(candidate.nodes)) throw new Error("Choose a ComfyUI workflow JSON");
source = candidate;
draw();
} catch (error) { notify(error); }
};
root.append(row(button("Inspect current workflow", () => { source = undefined; draw(); }),
button("Export node report", () => downloadJson("workflow-node-report.json", inspectWorkflow(source ?? app.graph.serialize(), registeredTypes())))),
file, search, output);
search.oninput = draw;
api.addEventListener("graphChanged", draw, { signal: ctx.signal });
draw();
}
function civitai(ctx) {
const root = ctx.root;
root.append(element("h3", "Civitai downloader"));
const query = input("Search Civitai models");
const type = select([["", "All types"], ...["Checkpoint", "LORA", "VAE", "Controlnet", "Upscaler", "TextualInversion"].map(value => [value, value])], "Model type");
const versionInput = input("Model version ID", "number");
const results = element("div", null, "ns-list");
const files = element("div");
const jobs = element("div");
const integrity = element("div");
let page = 1;
let queryVersion = 0;
let fileVersion = 0;
async function showVersion(id) {
const requestVersion = ++fileVersion;
const version = await json(`/n-sidebar/civitai/version/${id}`, { signal: ctx.signal });
const destinations = await json(`/n-sidebar/model-paths?${new URLSearchParams({ type: version.model.type })}`, { signal: ctx.signal });
if (!ctx.alive() || requestVersion !== fileVersion) return;
files.replaceChildren(element("h4", `${version.model.name} · ${version.name}`));
if (!destinations.paths.length) {
files.append(element("p", "No ComfyUI model folder configured for this type."));
return;
}
const destination = select(destinations.paths.map((path, index) => [index, path]), "Model destination");
destination.value = String(destinations.default);
files.append(destination);
for (const file of version.files) {
const size = Math.round((file.sizeKB ?? 0) / 1024);
files.append(row(element("span", `${file.name} (${size} MiB)`), button("Download", async () => {
await post("/n-sidebar/downloads", { version: version.id, file: file.id, destination: Number(destination.value) });
await progress();
})));
}
}
async function search() {
const version = ++queryVersion;
const data = await json(`/n-sidebar/civitai/search?${new URLSearchParams({ query: query.value, types: type.value, page })}`, { signal: ctx.signal });
if (!ctx.alive() || version !== queryVersion) return;
results.replaceChildren();
for (const model of data.items ?? []) {
const card = element("article", null, "ns-card");
card.append(element("strong", model.name), element("small", `${model.type} · ${model.creator?.username ?? ""}`));
const versions = select(model.modelVersions.map(item => [item.id, `${item.name} · ${item.baseModel}`]), "Model version");
card.append(versions, button("Choose file", () => showVersion(versions.value)));
results.append(card);
}
if (!data.items?.length) results.append(element("p", "No matching models"));
next.disabled = !data.metadata?.nextPage;
previous.disabled = page <= 1;
}
async function progress() {
const data = await json("/n-sidebar/downloads", { signal: ctx.signal });
if (!ctx.alive()) return;
jobs.replaceChildren(element("h4", "Downloads"));
for (const job of data) {
const card = element("article", null, "ns-card");
card.append(element("strong", job.name), element("span", `${job.status} · ${Math.round(job.received / 1048576)} MiB${job.total ? ` / ${Math.round(job.total / 1048576)} MiB` : ""}`));
if (job.error) card.append(element("p", job.error, "ns-missing"));
if (["queued", "running", "paused"].includes(job.status)) {
const bar = element("progress");
if (job.total) { bar.max = job.total; bar.value = job.received; }
card.append(bar, row(button(job.status === "paused" ? "Resume" : "Pause", async () => {
await post(`/n-sidebar/downloads/${job.id}/${job.status === "paused" ? "resume" : "pause"}`);
await progress();
}), button("Cancel", async () => { await post(`/n-sidebar/downloads/${job.id}/cancel`); await progress(); })));
}
jobs.append(card);
}
}
const previous = button("Previous", async () => { page = Math.max(1, page - 1); await search(); });
previous.disabled = true;
const next = button("Next", async () => { page++; await search(); });
next.disabled = true;
const form = element("form");
const submit = button("Search", async () => { page = 1; await search(); });
form.append(query, row(type, submit));
form.onsubmit = event => { event.preventDefault(); submit.click(); };
root.append(form, row(versionInput, button("Look up version", () => showVersion(versionInput.value))),
files, results, row(previous, next), jobs, integrity);
if (app.extensionManager.setting.get("NSidebar.Integrity")) {
const library = element("div", null, "ns-list");
integrity.append(element("h4", "Downloaded model integrity"),
button("Scan downloaded models", async () => {
const models = await json("/n-sidebar/models", { signal: ctx.signal });
if (!ctx.alive()) return;
library.replaceChildren();
for (const model of models) {
const status = element("span", model.status);
library.append(row(element("span", model.name), status, button("Verify SHA256", async () => {
const result = await post(`/n-sidebar/models/${model.id}/verify`);
if (ctx.alive()) status.textContent = result.status;
})));
}
if (!models.length) library.append(element("p", "No models downloaded by N-Sidebar v2 yet."));
}), library);
}
progress().catch(error => { if (error.name !== "AbortError" && ctx.alive()) notify(error); });
// Poll only while this native sidebar tab is mounted.
const timer = setInterval(() => progress().catch(error => {
if (error.name !== "AbortError" && ctx.alive()) jobs.textContent = error.message;
}), 2000);
ctx.onCleanup(() => clearInterval(timer));
}
function migration(ctx) {
const root = ctx.root;
root.append(element("h3", "Import & export"), element("p", "Import old pinned nodes and colored categories into the official node library. Existing bookmarks are kept. Template and external workflow collections are preserved here."));
const status = element("p", null, "ns-status");
async function applyLegacy(legacy) {
const migrated = migrateLegacy(legacy, app.extensionManager.setting.get(BOOKMARKS) ?? [], app.extensionManager.setting.get(COLORS) ?? {});
const data = await readData();
// Save a reversible backup before changing native bookmarks or collections.
await api.storeUserData("n-sidebar-before-import.json", {
bookmarks: app.extensionManager.setting.get(BOOKMARKS) ?? [],
colors: app.extensionManager.setting.get(COLORS) ?? {}, data,
});
for (const key of ["templateGroups", "workflowGroups"]) data[key] = mergeGroups(data[key], migrated[key]);
for (const key of ["templateColors", "workflowColors"]) data[key] = { ...data[key], ...migrated[key] };
await saveData(data);
await app.extensionManager.setting.set(BOOKMARKS, migrated.bookmarks);
await app.extensionManager.setting.set(COLORS, migrated.colors);
status.textContent = "Imported. Open the official node library to see your bookmarks and categories.";
}
const legacyFile = input("Import old settings.json", "file");
legacyFile.accept = ".json,application/json";
legacyFile.onchange = async () => {
try { await applyLegacy(JSON.parse(await legacyFile.files[0].text())); }
catch (error) { notify(error); }
};
const extrasFile = input("Import N-Sidebar extras JSON", "file");
extrasFile.accept = ".json,application/json";
extrasFile.onchange = async () => {
try {
const incoming = JSON.parse(await extrasFile.files[0].text());
if (incoming.version !== 2 || !incoming.data) throw new Error("Choose an N-Sidebar v2 export");
const data = await readData();
for (const key of ["templateGroups", "workflowGroups"]) data[key] = mergeGroups(data[key], incoming.data[key]);
for (const key of ["templateColors", "workflowColors"]) data[key] = { ...data[key], ...incoming.data[key] };
await saveData(data);
for (const [id, value] of Object.entries(incoming.settings ?? {})) {
if (settings.some(setting => setting.id === id) && typeof value === "boolean") await app.extensionManager.setting.set(id, value);
}
status.textContent = "Extra collections and module settings imported.";
} catch (error) { notify(error); }
};
root.append(button("Import old server settings", async () => {
const legacy = await json("/n-sidebar/legacy", { signal: ctx.signal });
if (!Object.keys(legacy).length) throw new Error("No legacy settings found. Choose your old settings.json file instead.");
await applyLegacy(legacy);
}), legacyFile, element("hr"), button("Export extras & settings", async () => downloadJson("n-sidebar-extras.json", {
version: 2, data: await readData(),
settings: Object.fromEntries(settings.map(setting => [setting.id, app.extensionManager.setting.get(setting.id)])),
})), extrasFile, status);
}
function diagnostics(ctx) {
const root = ctx.root;
root.append(element("h3", "Server diagnostics"));
const output = element("pre");
async function refresh() {
const stats = await json("/system_stats", { signal: ctx.signal });
if (ctx.alive()) output.textContent = JSON.stringify(stats, null, 2);
}
root.append(button("Refresh", refresh), output);
refresh().catch(error => { if (error.name !== "AbortError" && ctx.alive()) notify(error); });
}
function createPanel(key, container) {
const ctx = lifecycle(container, key);
panels.add(ctx);
const render = { Templates: () => collections(ctx, key), Workflows: () => collections(ctx, key),
Inspector: () => inspector(ctx), Civitai: () => civitai(ctx), Migration: () => migration(ctx), Diagnostics: () => diagnostics(ctx) };
Promise.resolve().then(render[key]).catch(error => { if (error.name !== "AbortError" && ctx.alive()) notify(error); });
return () => { panels.delete(ctx); ctx.dispose(); };
}
function reconcile() {
if (ready) syncModules(app.extensionManager, id => app.extensionManager.setting.get(id), createPanel, mounted);
}
function refreshPanels() {
// Render only the visible panels again after a preview/display setting changes.
for (const ctx of [...panels]) {
const parent = ctx.root.parentElement;
const key = ctx.root.dataset.module;
panels.delete(ctx);
ctx.dispose();
if (parent) {
const tab = app.extensionManager.getSidebarTabs().find(tab => tab.id === `n-sidebar-${key.toLowerCase()}`);
tab?.render(parent);
}
}
}
const settings = [
...MODULES.map(module => ({ id: settingId(module.key), name: module.title.replace("N · ", "") + " — enabled",
category: ["N-Sidebar", "Modules", module.key], type: "boolean", defaultValue: module.enabled, onChange: reconcile })),
{ id: "NSidebar.Previews", name: "Show node previews in template and workflow collections", category: ["N-Sidebar", "Display", "Previews"],
type: "boolean", defaultValue: true, onChange: refreshPanels },
{ id: "NSidebar.HighlightMissing", name: "Highlight missing node types", category: ["N-Sidebar", "Display", "Missing nodes"],
type: "boolean", defaultValue: true, onChange: refreshPanels },
{ id: "NSidebar.Integrity", name: "Show downloaded model integrity checks", category: ["N-Sidebar", "Modules", "Integrity"],
type: "boolean", defaultValue: true, onChange: refreshPanels },
];
app.registerExtension({
name: "Nuked.NSidebar.Extras", settings,
setup() {
const stylesheet = element("link");
stylesheet.rel = "stylesheet";
stylesheet.href = new URL("./sidebar.css", import.meta.url).href;
document.head.append(stylesheet);
ready = true;
reconcile();
},
});