From d3bf2896b306c06ed3e3fe8ae7e9ab02ced20582 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:56:57 +0300 Subject: [PATCH] feat(ui): Models & Pipeline shape editing: swap, add, remove, rewire, regroup, start from a model, revert, export settings card Co-Authored-By: Claude Sonnet 5.5 --- app/ui/features/settings/models.js | 162 +++++++++++++++++++----- app/ui/features/settings/nodepick.js | 22 ++++ app/ui/features/settings/wiring.js | 31 +++++ app/ui/features/settings/wiring.test.js | 23 ++++ app/ui/shell/api.js | 8 ++ 5 files changed, 214 insertions(+), 32 deletions(-) create mode 100644 app/ui/features/settings/nodepick.js create mode 100644 app/ui/features/settings/wiring.js create mode 100644 app/ui/features/settings/wiring.test.js diff --git a/app/ui/features/settings/models.js b/app/ui/features/settings/models.js index ee569e28..2267edbf 100644 --- a/app/ui/features/settings/models.js +++ b/app/ui/features/settings/models.js @@ -1,6 +1,11 @@ -// Models & Pipeline: every node in the live pipeline, grouped as the pipeline groups them, with its values editable. -// Changing the shape of the pipeline (swap, add, remove, wire) is not here yet. +// Models & Pipeline: every node in the live pipeline by group, its values editable, and its shape: swap, add, remove, rewire, regroup. +// The server refuses what would not build (a type that does not fit, a loop) and says why; the reason is shown and nothing changes. import { composer as c } from "../../composer/composer.js"; +import { pickNode } from "./nodepick.js"; +import { labelOf, shownValues, sourcesFor } from "./wiring.js"; + +const SEP = "\0", NEW_GROUP = "\0new"; +const fed = (v) => Array.isArray(v); const widgetControl = (w, current, set) => { const label = w.name; @@ -18,42 +23,135 @@ const widgetControl = (w, current, set) => { }; export const models = (app) => function mount() { - const ps = app.pipeline; + const ps = app.pipeline, api = app.api; const page = c.region.stack({ gap: "md" }); - let specs = {}, note = "", off = null; + let specs = {}, note = "", group = "", off = null, opened = null, gone = false; const slotsNow = () => ps.slots() || []; + const say = (text) => { note = text; draw(); }; + const label = (slot) => labelOf(slot, specs, slotsNow()); - async function draw() { - const slots = slotsNow(); - const missing = [...new Set(slots.map((s) => s.node))].filter((n) => !(n in specs)); - if (missing.length) { - try { Object.assign(specs, (await app.api.describeNodes(missing)).nodes || {}); } catch (err) { note = err.message; } - missing.forEach((n) => { if (!(n in specs)) specs[n] = null; }); + async function describe() { + const missing = [...new Set(slotsNow().map((s) => s.node))].filter((n) => !(n in specs)); + if (!missing.length) return; + try { Object.assign(specs, (await api.describeNodes(missing)).nodes || {}); } catch (err) { note = err.message; } + missing.forEach((n) => { if (!(n in specs)) specs[n] = null; }); + } + const setInput = (slot, name) => async (value) => { + if (JSON.stringify((slot.inputs || {})[name]) === JSON.stringify(value)) return; // a blur that changed nothing saves nothing + await ps.save({ inputs: { [slot.id]: { [name]: value } } }); + say([...(ps.refused() || []), ...ps.saveNotes()].join(" ")); + }; + async function structural(body) { + const res = await ps.edit(body); + await describe(); + if (!res.refused.length) { + const target = body.action === "add" ? slotsNow().slice(-1)[0] : body.action === "replace" || body.action === "unwire" ? slotsNow().find((s) => s.id === body.slot) : null; + const values = target && shownValues(target, specs[target.node], body.action === "unwire" ? body.input : undefined); + if (values && Object.keys(values).length) await ps.save({ inputs: { [target.id]: values } }); } - const set = (slot, name) => async (value) => { - if (JSON.stringify((slot.inputs || {})[name]) === JSON.stringify(value)) return; // a blur that changed nothing saves nothing - await ps.save({ inputs: { [slot.id]: { [name]: value } } }); - note = [...(ps.refused() || []), ...ps.saveNotes()].join(" "); draw(); - }; - const groups = []; - slots.forEach((s) => { const g = s.group || "Other"; (groups.find((x) => x[0] === g) || (groups.push([g, []]), groups[groups.length - 1]))[1].push(s); }); - page.set([ - note ? c.banner.warn({ text: note }) : null, - !slots.length ? c.emptyState.default({ icon: "⬡", title: "No pipeline loaded", hint: ps.loadError() || "Is ComfyUI reachable?" }) : null, - ...groups.flatMap(([group, list]) => [c.label.section({ text: group }), ...list.flatMap((slot) => { - const spec = specs[slot.node]; - if (!spec) return [c.hint.default({ text: `${slot.node} is not installed — this slot can't be edited or run.` })]; - const rows = (spec.widgets || []).filter((w) => !Array.isArray(slot.inputs && slot.inputs[w.name])) // fed by another node: edited there - .map((w) => w.type === "BOOLEAN" ? c.toggle.default({ label: w.name, hint: w.tooltip || "", checked: Boolean(slot.inputs && slot.inputs[w.name] !== undefined ? slot.inputs[w.name] : w.default), onChange: set(slot, w.name) }) : c.settingsRow.default({ label: w.name, hint: w.tooltip || "", control: widgetControl(w, slot.inputs && slot.inputs[w.name] !== undefined ? slot.inputs[w.name] : w.default, set(slot, w.name)) })); - return [c.header.sm({ text: spec.title || slot.node }), ...rows]; - })]), - ].filter(Boolean)); + say(res.refused.length ? `Not changed: ${res.refused.join(" ")}` : ""); + return !res.refused.length; + } + const confirm = (title, message, tone) => c.modal.dialogue({ title, message, tone, confirmLabel: title.split(" ")[0] }).result; + + // A select over what could feed an input: "a value" (or not connected), or an output of another node. + const wiring = (slot, input, type, current, valueLabel) => { + const options = [{ value: "", label: valueLabel }]; + const sources = sourcesFor(slot, type, slotsNow(), specs), now = fed(current) ? `${current[0]}${SEP}${current[1]}` : ""; + if (now && !sources.some((s) => s.value === now)) options.push({ value: now, label: `${current[0]} · output ${current[1]}` }); + sources.forEach((s) => options.push({ value: s.value, label: `Fed by ${s.label}` })); + return c.select.md({ label: input, value: now, options, onChange: (v) => { + if (!v) structural({ action: "unwire", slot: slot.id, input }); + else { const [from, idx] = v.split(SEP); structural({ action: "wire", slot: slot.id, input, from_slot: from, from_output: Number(idx) }); } + } }); + }; + + const groups = () => [...new Set(slotsNow().map((s) => s.group || "Other"))]; + const structure = (slot) => { + const here = slot.group || "Other"; + return c.field.default({ label: "Group", hint: "Cards in the sidebar. Any node can live in any group.", control: c.toolbar.default({ + items: [c.select.md({ label: "Group", value: here, options: [...groups().map((g) => ({ value: g, label: g })), { value: NEW_GROUP, label: "New group…" }], onChange: async (v) => { + let name = v; + if (v === NEW_GROUP) { name = ((await c.modal.prompt({ title: "New group", label: "Name", confirmLabel: "Create" }).result) || "").trim(); if (!name) return draw(); } + await ps.setGroup(slot.id, name); group = name; draw(); + } })], + trailing: [c.button.sm({ label: "Swap node…", tone: "ghost", title: "Use a different node here. It must still produce what the nodes after it read.", onClick: async () => { + const cls = await pickNode(api, `Swap ${label(slot)} for…`); + if (!cls) return; + const driven = (slot.roles || []).map((r) => r.label || r.input).filter(Boolean); + if (await confirm("Swap node", `Swap ${label(slot)} for ${cls}? Everything set on this node (files, values, connections into it) is cleared, and the new node starts empty.${driven.length ? ` The main window drives this node's ${driven.join(", ")}: those controls keep working only for inputs the new node also has, by the same name.` : ""}`, "danger")) structural({ action: "replace", slot: slot.id, node: cls }); + } }), c.button.sm({ label: "Remove", tone: "danger", title: "Take this node out; what it fed is rewired to what fed it, when that is unambiguous.", onClick: async () => { + if (await confirm("Remove node", `Remove ${label(slot)} from the pipeline?`, "danger")) structural({ action: "remove", slot: slot.id }); + } })] }) }); + }; + + const slotBlock = (slot) => { + const spec = specs[slot.node]; + if (spec === undefined) return [c.hint.default({ text: "Loading…" })]; + const head = c.header.sm({ text: label(slot) }); + if (spec === null) return [head, structure(slot), c.hint.default({ text: `${slot.node} is not installed — this slot can't be edited or run. Swap it for another node, or remove it.` })]; + const sockets = (spec.sockets || []).map((s) => c.field.default({ label: s.name, hint: s.required ? `${s.type} · needed` : s.type, control: wiring(slot, s.name, s.type, slot.inputs && slot.inputs[s.name], "Not connected") })); + const rows = (spec.widgets || []).flatMap((w) => { + const cur = slot.inputs && slot.inputs[w.name]; + if (fed(cur)) return [c.field.default({ label: w.name, hint: "Fed by another node: change it there, or pick “A value” to type one here.", control: wiring(slot, w.name, w.type, cur, "A value (typed here)") })]; + const value = cur !== undefined ? cur : w.default; + const row = w.type === "BOOLEAN" ? c.toggle.default({ label: w.name, hint: w.tooltip || "", checked: Boolean(value), onChange: setInput(slot, w.name) }) + : c.settingsRow.default({ label: w.name, hint: w.tooltip || "", control: widgetControl(w, value, setInput(slot, w.name)) }); + const appOwned = (slot.roles || []).some((r) => r.input === w.name && r.at !== "project.video"); // the app writes it: wiring it elsewhere would make every Generate refuse + const canTake = !appOwned && (w.type !== "COMBO" || (w.choices || []).length) && sourcesFor(slot, w.type, slotsNow(), specs).length; + return canTake ? [row, c.field.default({ label: "↳ or take it from", hint: "Another node's output can drive this value; one Primitive can drive several inputs.", control: wiring(slot, w.name, w.type, null, "A value (typed here)") })] : [row]; + }); + return [head, structure(slot), ...sockets, ...rows, ...(!rows.length && !sockets.length ? [c.hint.default({ text: "Nothing to configure on this node." })] : [])]; + }; + + function status() { + const incomplete = ps.incomplete(), refused = ps.refused(), notes = ps.saveNotes(); + return [refused.length ? c.banner.warn({ text: `Could not save: ${refused.join(" ")}` }) : null, notes.length ? c.banner.info({ text: notes.join(" ") }) : null, + incomplete.length ? c.banner.warn({ text: `Not ready to generate yet: ${incomplete.join(" ")}` }) : ps.queueable() ? c.hint.default({ text: "Every slot is filled — this pipeline is ready to generate." }) : null]; + } + + const tools = () => c.toolbar.default({ items: [c.button.sm({ label: "Start from…", tone: "ghost", title: "Replace this pipeline with a model's own starting point", onClick: presets }), + c.button.sm({ label: "Revert", tone: "ghost", disabled: !opened, title: "Put back what was here when this page was opened", onClick: async () => { const r = await ps.restore(opened); say(r.refused.length ? r.refused[0] : ""); } })], + trailing: [c.button.sm({ label: "🖼 Export settings…", tone: "ghost", title: "Render this pipeline as a PNG: loaders, typed-in values, torch / CUDA / attention", onClick: card })] }); + + async function presets() { + let found; + try { found = (await api.pipelinePresets()).presets || []; } catch (err) { return say(err.message); } + if (!found.length) return say("No module offers a starting point."); + const id = await c.modal.choice({ title: "Start from…", items: found.map((p) => ({ id: p.id, label: p.title, hint: p.module })) }).result; + const pick = found.find((p) => p.id === id); + if (!pick || !(await confirm("Replace pipeline", `Replace the whole pipeline with “${pick.title}”? Every file and value you set here is cleared. Revert puts it back until you leave this page.`, "danger"))) return; + const r = await ps.restore({ slots: pick.slots, removed: [], unwired: {} }); + specs = {}; await describe(); say(r.refused.length ? `Not changed: ${r.refused[0]}` : ""); + } + async function card() { + let url; + try { url = URL.createObjectURL(await api.settingsCard(slotsNow(), app.project.project && app.project.project.name, app.theme && app.theme.get ? app.theme.get() : "dark")); } catch (err) { return say(`Could not render the card: ${err.message}`); } + const img = Object.assign(document.createElement("img"), { src: url, alt: "Pipeline settings", style: "max-width:100%" }); + const win = c.modal.generic({ title: "Pipeline settings", size: "lg", body: { node: img }, onClose: () => URL.revokeObjectURL(url) }); + win.setFooter({ actions: [c.button.sm({ label: "Save PNG", tone: "primary", onClick: () => Object.assign(document.createElement("a"), { href: url, download: "funpack-settings.png" }).click() })] }); + } + + function draw() { + if (gone) return; + if (ps.loading()) return page.set([c.hint.default({ text: "Loading…" })]); + if (ps.loadError()) return page.set([c.banner.warn({ text: `Could not load models & pipeline: ${ps.loadError()}` })]); + const all = groups(), slots = slotsNow(); + if (!all.includes(group)) group = all[0] || ""; + page.set([tools(), note ? c.banner.warn({ text: note }) : null, ...status(), + !slots.length ? c.emptyState.default({ icon: "⬡", title: "No pipeline loaded", hint: "Is ComfyUI reachable? Or start from a model's starting point above." }) : null, + all.length ? c.tabs.underline({ label: "Group", tabs: all.map((g) => ({ value: g, label: g })), value: group, onChange: (g) => { group = g; draw(); } }) : null, + group ? c.button.sm({ label: `+ Add a node to ${group}`, tone: "ghost", title: "Put any installed node in this group, then wire it to the nodes around it.", onClick: async () => { + const cls = await pickNode(api, `Add a node to ${group}`); + if (cls) structural({ action: "add", node: cls, group }); + } }) : null, + ...slots.filter((s) => (s.group || "Other") === group).flatMap(slotBlock)].filter(Boolean)); } // A redraw while a box has focus would eat what is being typed: it waits for the focus to leave. const typing = () => page.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName); page.node.addEventListener("focusout", () => setTimeout(() => { if (!typing()) draw(); })); - ps.ensureLoaded().then(draw); - off = ps.subscribe(() => { if (!typing()) draw(); }); - const handle = { node: page.node, destroy: () => { off && off(); page.node.remove(); } }; - return handle; + draw(); + ps.ensureLoaded().then(async () => { opened = ps.snapshot(); await describe(); draw(); }); + off = ps.subscribe(() => { if (!typing()) { describe().then(draw); } }); + return { node: page.node, destroy: () => { gone = true; off && off(); page.node.remove(); } }; }; diff --git a/app/ui/features/settings/nodepick.js b/app/ui/features/settings/nodepick.js new file mode 100644 index 00000000..a668c963 --- /dev/null +++ b/app/ui/features/settings/nodepick.js @@ -0,0 +1,22 @@ +// A search box over every installed node. -> the chosen node class, or null. +import { composer as c } from "../../composer/composer.js"; + +export function pickNode(api, title) { + return new Promise((resolve) => { + const list = c.region.stack({ gap: "xs" }), note = c.hint.default({ text: "" }); + let timer = 0, ticket = 0, done = false; + const finish = (v) => { if (!done) { done = true; resolve(v); } }; + const win = c.modal.generic({ title, size: "md", onClose: () => finish(null), + body: c.region.stack({ gap: "sm", children: [c.input.md({ label: "Search", placeholder: "Search installed nodes by name or category…", onInput: (q) => { clearTimeout(timer); timer = setTimeout(() => run(q), 200); } }), note, list] }) }); + async function run(q) { + const mine = ++ticket; + let found; + try { found = await api.searchNodes(q, 40); } catch (err) { return note.setText(`Could not search: ${err.message}`); } + if (mine !== ticket) return; + const nodes = found.nodes || []; + list.set(nodes.map((n) => c.button.sm({ label: `${n.title || n.node} ${n.category || ""}${n.outputs && n.outputs.length ? ` → ${n.outputs.join(", ")}` : ""}`, tone: "ghost", onClick: () => { finish(n.node); win.close("done"); } }))); + note.setText(found.total > nodes.length ? `${nodes.length} of ${found.total} shown: type more to narrow it.` : nodes.length ? "" : "Nothing matches."); + } + run(""); + }); +} diff --git a/app/ui/features/settings/wiring.js b/app/ui/features/settings/wiring.js new file mode 100644 index 00000000..ccb9aa2a --- /dev/null +++ b/app/ui/features/settings/wiring.js @@ -0,0 +1,31 @@ +// What a pipeline slot can be fed by, and what to say it is called. Pure. +export const labelOf = (slot, specs, slots) => { + const title = (specs[slot.node] && specs[slot.node].title) || slot.node || slot.id; + const twin = slots.some((o) => o.id !== slot.id && ((specs[o.node] && specs[o.node].title) || o.node) === title); + return twin ? `${title} (${slot.id})` : title; +}; + +/** Outputs of the other slots that could feed an input of `type`: [{ value: "slot\0index", label }]. */ +export function sourcesFor(slot, type, slots, specs) { + const out = []; + for (const other of slots) { + const spec = specs[other.node]; + if (other.id === slot.id || !spec || !spec.outputs) continue; + spec.outputs.forEach((kind, i) => { + if (kind === type || kind === "*" || type === "*") out.push({ value: `${other.id}\0${i}`, label: `${labelOf(other, specs, slots)} · ${(spec.output_names && spec.output_names[i]) || kind}` }); + }); + } + return out; +} + +/** A node that is new, or whose input was just unwired, has nothing SET on it while its form shows defaults: + * these are the values to send so that what runs is what is seen. */ +export function shownValues(slot, spec, onlyInput) { + const out = {}; + for (const w of (spec && spec.widgets) || []) { + if ((onlyInput && w.name !== onlyInput) || (slot.inputs && slot.inputs[w.name] !== undefined)) continue; + const v = w.default !== undefined ? w.default : w.type === "COMBO" ? (w.choices || [])[0] : w.type === "BOOLEAN" ? false : undefined; + if (v !== undefined) out[w.name] = v; + } + return out; +} diff --git a/app/ui/features/settings/wiring.test.js b/app/ui/features/settings/wiring.test.js new file mode 100644 index 00000000..df17c4f2 --- /dev/null +++ b/app/ui/features/settings/wiring.test.js @@ -0,0 +1,23 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { labelOf, shownValues, sourcesFor } from "./wiring.js"; + +const specs = { A: { title: "Loader", outputs: ["MODEL", "VAE"], output_names: ["model", "vae"] }, B: { title: "Loader", outputs: ["INT"] }, C: { title: "Sampler", widgets: [{ name: "steps", type: "INT", default: 8 }, { name: "mode", type: "COMBO", choices: ["x", "y"] }, { name: "on", type: "BOOLEAN" }] } }; +const slots = [{ id: "a", node: "A" }, { id: "b", node: "B" }, { id: "c", node: "C" }]; + +test("two slots of one title are told apart by id", () => { + assert.equal(labelOf(slots[0], specs, slots), "Loader (a)"); + assert.equal(labelOf(slots[2], specs, slots), "Sampler"); +}); + +test("only outputs of the right type, from other slots, can feed an input", () => { + assert.deepEqual(sourcesFor(slots[2], "VAE", slots, specs).map((s) => s.label), ["Loader (a) · vae"]); + assert.deepEqual(sourcesFor(slots[2], "INT", slots, specs).map((s) => s.value), ["b\u00000"]); + assert.equal(sourcesFor(slots[0], "VAE", slots, specs).length, 0); // not itself +}); + +test("what the form shows is sent for a slot with nothing set, and nothing already set is overwritten", () => { + assert.deepEqual(shownValues({ id: "c", inputs: {} }, specs.C), { steps: 8, mode: "x", on: false }); + assert.deepEqual(shownValues({ id: "c", inputs: { steps: 4 } }, specs.C), { mode: "x", on: false }); + assert.deepEqual(shownValues({ id: "c", inputs: {} }, specs.C, "steps"), { steps: 8 }); +}); diff --git a/app/ui/shell/api.js b/app/ui/shell/api.js index 92f83b5e..ecb9f392 100644 --- a/app/ui/shell/api.js +++ b/app/ui/shell/api.js @@ -23,6 +23,14 @@ export const api = { editPipeline: (body) => call("POST", "/api/pipeline", body), modules: (traits) => call("GET", `/api/modules${traits ? `?traits=${encodeURIComponent(traits)}` : ""}`), releaseModule: (id) => call("POST", "/api/control/release", { id }), + searchNodes: (q, limit = 40) => call("GET", `/api/nodes/search?q=${encodeURIComponent(q || "")}&limit=${limit}`), + pipelinePresets: () => call("GET", "/api/pipeline/presets"), + /** The pipeline as a PNG (loaders, typed-in values, host torch/CUDA). -> a Blob. */ + async settingsCard(slots, projectName, theme) { + const res = await fetch(`${BASE}/api/settings-card`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slots, project_name: projectName || null, theme: theme || "dark" }) }); + if (!res.ok) throw new Error(why(res, await res.json().catch(() => null))); + return res.blob(); + }, describeNodes: (classes) => call("GET", `/api/nodes?classes=${encodeURIComponent(classes.join(","))}`), system: () => call("GET", "/api/system"), storyMarkers: () => call("GET", "/api/story/markers"),