Files
olivv-cs-ComfyUI-FunPack/app/shell/pipeline_window.js
T
digital-garbageandClaude Sonnet 5 9e3adf250d feat: an "Add group/node" card in the pipeline window
Groups the pipeline window into a card grid with a "+ Add group/node"
card beside it -- matching v4's card-per-loader layout instead of a
separate toolbar button. Picking it offers a new empty group or a node
outside any group (landed in the existing "Other" bucket).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 18:24:07 +03:00

989 lines
40 KiB
JavaScript

// The pipeline, opened up and edited.
//
// One wide modal, a card per group, and a group that opens into a node list on
// the left with that node's parameters on the right, saved a group at a time.
//
// Two kinds of edit, and they behave differently on purpose:
//
// * A VALUE is drafted. Changing steps from 20 to 30 cannot make a graph
// illegal, so it waits in the draft until Save, and Cancel puts it back.
// * A STRUCTURE change -- adding a node, removing one, pointing a slot at a
// different node, moving it to another group -- goes to the server as it
// happens, because only the server can say whether the result still wires up,
// and a refusal shown ten edits later is a refusal nobody can act on.
//
// Nothing here composes a graph, and nothing here makes an element. Both would
// be a second answer to a question that already has one -- what runs, and what a
// control looks like.
import { composer } from "../composer/composer.js";
import { rendererFor, rendererNameFor, SELF_LABELLING } from "../composer/panel.js";
import { settingFor, label as humanLabel, whyNotEditable } from "./widgets.js";
const UNGROUPED = "Other";
// The value of a list nobody has chosen from. Not "" -- an empty string is a
// value a combo could legitimately offer.
const NOT_SET = "\u0000not-set";
/** Groups in the order they first appear, plus any the user has just made. */
export function groupsOf(slots, extra = []) {
const order = [];
const byGroup = new Map();
const put = (name) => {
if (!byGroup.has(name)) { byGroup.set(name, []); order.push(name); }
return byGroup.get(name);
};
for (const slot of slots) put(slot.group || UNGROUPED).push(slot);
for (const name of extra) put(name);
return { order, byGroup };
}
const isLink = (value) => Array.isArray(value) && value.length === 2
&& typeof value[0] === "string" && typeof value[1] === "number";
/**
* Whether an output of type `given` could feed an input of type `wanted`.
*
* Mirrors core/comfy_types.py's accepts() -- a union on either side is a set
* of members, not one string, so "IMAGE,MASK" takes a MASK. Kept as its own
* copy rather than shared with the server: this is a picker's FILTER, not the
* refusal that matters -- the server checks the same thing again, for real,
* when the wire is actually sent, and is what a bug here would be caught by.
*/
function typeAccepts(wanted, given) {
if (wanted === "*" || given === "*") return true;
const left = new Set(String(wanted).split(",").map((s) => s.trim()).filter(Boolean));
const right = new Set(String(given).split(",").map((s) => s.trim()).filter(Boolean));
if (!left.size || !right.size) return wanted === given;
for (const t of left) if (right.has(t)) return true;
return false;
}
const countOf = (n) => (n === 0 ? "empty" : `${n} node${n === 1 ? "" : "s"}`);
// The one that is open, if one is.
//
// There is a single pipeline, so there is a single window onto it. Without this
// the button opened another one on every press, and two windows over the same
// pipeline are two drafts of it: whichever was saved last wins, and the other
// was edited against a pipeline that had already moved.
let current = null;
/**
* mount({ load, describe, check, search, onApply, setFooter, close }) -> handle
*
* Every server call is injected. The window is then testable without a server
* and -- more to the point -- cannot reach for one that is not there.
*
* `setFooter`/`close` are the two things a modal gives this that a container
* does not have for free -- see tempfiles.js's mount() for the general shape.
* This one additionally exposes its live state (slots/editing/selected/
* pending) and a few structural actions, because `open()` below and the
* jsdom suite both drive it without reading the DOM back; `open()` forwards
* every one of these rather than wrapping a second copy of this state.
*/
export function mount({ load, describe, check, search, onApply, presets,
setFooter = () => {}, close = () => {} } = {}) {
let slots = [];
const nodes = new Map(); // class name -> description | null
const extraGroups = []; // made in this window, no slot in them yet
let draft = null; // slot id -> { input: value }, while editing
let editing = null; // group name
let selected = null; // slot id
let notes = { refused: [], incomplete: [] };
// Kept separate from `notes.refused`: that field is about an EDIT the
// server rejected (commit()), and stays on screen until the user does
// something about it. A rescan() failure is unrelated to any edit -- and
// sharing one field meant a rescan that happened to succeed a moment later
// could silently wipe out a real, still-unaddressed commit() refusal the
// user had not yet seen or acted on.
let scanFailed = null;
let learnGen = 0; // guards against two learn() calls landing out of order
// Bumped by leave() only: every enter() is reached through the index,
// which only ever renders after a leave(), so leave() alone sees every
// group-to-group transition. Which VIEW an in-flight commit() belongs to
// -- a commit started in one group can still answer after the user has
// backed out to the index. Its DATA (slots) must still apply either way,
// an edit that happened does not un-happen because someone stopped
// looking, but its refusal banner belongs to a group that is no longer on
// screen, and painting it on the index is the same misattribution
// rescanGen/scanFailed already exist to prevent for a scan failure.
let viewGen = 0;
const body = composer.region.stack({ gap: "md", label: "Pipeline", fill: true });
// ---------------------------------------------------------------- server
async function refresh() {
const answer = await load();
slots = answer.slots;
// `load` can be backed by a plain read (always `refused: []`) or, when a
// previously-saved pipeline is being re-validated on open, by the same
// check() a structural edit uses -- which can refuse. Reading it here
// rather than hardcoding `[]` is what keeps that case from replacing the
// screen with an unexplained empty pipeline.
notes = { refused: answer.refused || [], incomplete: answer.incomplete || [] };
await learn(slots.map((slot) => slot.node));
}
/**
* Descriptions for classes we have not seen -- or, with `force`, for
* classes we have. A class's combo choices are a file listing (model
* names, LoRAs), and this window's `nodes` cache lives for as long as the
* window is open: without forcing a re-ask on entry, a file added to disk
* mid-session stayed invisible in an already-opened group until the whole
* page reloaded, which throws the in-progress edit away with it.
*/
async function learn(classes, { force = false } = {}) {
const wanted = [...new Set(classes)].filter(Boolean);
const ask = force ? wanted : wanted.filter((c) => !nodes.has(c));
if (!ask.length) return;
const mine = ++learnGen;
const described = await describe(ask);
// A newer learn() (another force-refresh, or the group being left and
// re-entered) answered first. Applying this older one now would put its
// stale choices back over the fresh ones just drawn -- the exact bug this
// whole mechanism exists to fix, reintroduced by its own race.
if (mine !== learnGen) return;
// Every class asked about gets an entry, `null` included: without it an
// absent node is asked for again on every redraw, and reads on screen as a
// node still being looked up rather than one that is not installed.
for (const name of ask) nodes.set(name, described[name] ?? null);
}
// Which edit is the current one. Two structural edits can be in flight at
// once -- moving a node carries the whole slot list and a remove carries one
// id, so the second can easily answer first -- and the older answer was built
// from the pipeline as it stood BEFORE the newer edit. Taking it put the
// removed node back with no refusal and nothing said. The picker's search
// already guards this; the path that decides what gets QUEUED did not.
let edits = 0;
/** Send the pipeline as it now stands and keep what the server says of it. */
async function commit(next, action = {}) {
const mine = ++edits;
const myView = viewGen;
let answer;
try {
answer = await check({ slots: next, ...action });
} catch (err) {
if (mine !== edits) return false;
if (myView === viewGen) notes = { refused: [err.message], incomplete: notes.incomplete };
draw();
return false;
}
// A newer edit has been sent since. Its answer is the one that describes
// the pipeline; this one describes a pipeline that no longer exists.
if (mine !== edits) return false;
// A refused edit did not happen, so what is on screen must stay what it
// was: showing the refusal beside the change it refused is how someone
// comes to believe an edit landed when it did not. But the refusal is
// only shown if the user is still looking at the view that made the
// edit -- otherwise it paints a group's own rejection onto whatever the
// user has since navigated to, the same misattribution scanFailed exists
// to prevent for a failed rescan.
if (answer.refused.length) {
// `incomplete` describes the WHOLE pipeline, not this one edit, so it
// stays live regardless of view -- only `refused`, which is about
// THIS edit specifically, is view-scoped.
notes = { refused: myView === viewGen ? answer.refused : notes.refused, incomplete: notes.incomplete };
draw();
return false;
}
// The edit happened server-side regardless of where the user is looking
// now, so the data applies unconditionally -- only the refusal notes are
// view-scoped, same reasoning as the branch above.
forgetDraftsFor(slots, answer.slots);
slots = answer.slots;
notes = { refused: myView === viewGen ? [] : notes.refused, incomplete: answer.incomplete };
await learn(slots.map((slot) => slot.node));
if (onApply) onApply(slots);
draw();
return true;
}
/**
* Drop edits that were for a node this slot no longer holds.
*
* A draft is keyed by slot id, and a slot's id survives being pointed at a
* different node -- so an unsaved `steps` for one sampler stayed attached
* when the slot was changed to another. Most samplers declare `steps`, and
* the server only refuses an input the new node does NOT declare, so a
* same-named one is written on the next Save with nothing anywhere saying it
* was never chosen for that node.
*
* Keyed on the node CHANGING, not on the slots having come back from the
* server: saving goes through this same path, and a rule that fired on every
* answer would throw the edit away in the act of saving it.
*/
function forgetDraftsFor(before, after) {
if (!draft || !draft.size) return;
const was = new Map(before.map((slot) => [slot.id, slot.node]));
const now = new Map(after.map((slot) => [slot.id, slot.node]));
for (const id of [...draft.keys()]) {
if (!now.has(id) || now.get(id) !== was.get(id)) draft.delete(id);
}
}
/**
* Drop one input's own drafted value after it was wired or unwired.
*
* Wiring is a STRUCTURE change and commits immediately; a value typed into
* that same box earlier is still sitting in the draft, waiting for Save --
* and saveDraft() spreads the draft OVER the slot's current inputs, so an
* untouched stale value would silently overwrite the wire the user just
* watched get confirmed on screen. forgetDraftsFor() does not catch this:
* it only clears a slot's whole draft when the slot's NODE changes, never
* when one of its inputs gets wired on its own.
*/
function forgetDraftedInput(slotId, input) {
const edited = draft && draft.get(slotId);
if (!edited || !Object.prototype.hasOwnProperty.call(edited, input)) return;
const rest = { ...edited };
delete rest[input];
if (Object.keys(rest).length) draft.set(slotId, rest);
else draft.delete(slotId);
setFooter(footer());
}
// ----------------------------------------------------------------- draw
function draw() {
if (editing === null) {
body.set(index());
// Nothing to save from the index: the cards are a way in, not an edit.
setFooter({});
return;
}
body.set(group(editing));
setFooter(footer());
}
/**
* What to say, and where.
*
* A refusal is about the edit just attempted, so it belongs wherever that
* edit was made. What is still unfilled is a property of the WHOLE pipeline,
* and repeating all of it above every group put three warnings about the
* loaders over the sampler's settings, where none of them were about anything
* on screen. Inside a group the unfilled control says so itself -- it reads
* "not set" -- which is the same information in the place it can be acted on.
*/
function messages({ everything = false } = {}) {
return [
...notes.refused.map((text) => composer.banner.danger({ text })),
...(scanFailed ? [composer.banner.danger({ text: scanFailed })] : []),
// Not an error: a fresh install has no model picked, and colouring that
// red says something went wrong when nothing has yet.
...(everything ? notes.incomplete.map((text) => composer.banner.warn({ text })) : []),
];
}
// ---- the index: one card per group
const ADD_CARD = "__add__";
function index() {
const { order, byGroup } = groupsOf(slots, extraGroups);
return [
...messages({ everything: true }),
composer.gallery.cards({
items: [
...order.map((name) => ({
id: name, label: name, hint: countOf(byGroup.get(name).length),
})),
// A card, not a button below the list -- a group and a node outside
// any group are both things this window can hold, and "+" sits
// beside what it adds to rather than under all of it.
{ id: ADD_CARD, icon: "+", label: "Add group/node" },
],
onActivate: (item) => (item.id === ADD_CARD ? addCard() : enter(item.id)),
}),
...(presets ? [composer.toolbar.default({ label: "Groups", items: [presetsButton()] })] : []),
];
}
/** What the "+ Add group/node" card offers: a new empty group, or a node
* that belongs to none -- the "Other" bucket groupsOf() already gives one. */
function addCard() {
const window_ = composer.modal.generic({
title: "Add",
body: composer.gallery.cards({
items: [
{ id: "group", icon: "▧", label: "New group…",
hint: "A named place to put nodes in, like Loaders or Sampling." },
{ id: "node", icon: "▦", label: "Add a node…",
hint: "Not in any group -- appears under “Other”." },
],
onActivate: (item) => {
window_.close("picked");
if (item.id === "group") newGroup();
else pickNode(UNGROUPED);
},
}),
});
}
/**
* A starting point offered by a module, loaded the same way any structural
* edit lands -- through commit(), which is what makes it real: the server
* checks it the same as a hand-drawn wire would be, and a preset that does
* not actually build is refused here rather than shown as if it had loaded.
*/
function presetsButton() {
return composer.button.md({
label: "Load preset…",
onClick: async () => {
let offered;
try {
offered = await presets();
} catch (err) {
notes = { refused: [`Presets could not be read: ${err.message}`], incomplete: notes.incomplete };
draw();
return;
}
if (!offered.length) {
notes = { refused: ["No module offers a preset pipeline."], incomplete: notes.incomplete };
draw();
return;
}
const window_ = composer.modal.generic({
title: "Load a preset",
subtitle: "Replaces the pipeline you are looking at with this one.",
body: composer.gallery.cards({
items: offered.map((p) => ({ id: p.id, label: p.title })),
onActivate: async (item) => {
const picked = offered.find((p) => p.id === item.id);
window_.close("picked");
if (picked) await commit(picked.slots);
},
}),
});
},
});
}
async function newGroup() {
const picked = await composer.modal.prompt({
title: "New group",
label: "Name",
placeholder: "Upscaling",
validate: (name) => {
if (!name.trim()) return "A group needs a name.";
if (groupsOf(slots, extraGroups).byGroup.has(name.trim())) {
return "There is already a group called that.";
}
return null;
},
}).result;
if (picked === null) return;
// Held only for as long as this window is open. A group with no node in
// it is not part of the pipeline, so there is nothing to save it on --
// said in the card rather than left to be discovered after a reload.
extraGroups.push(picked.trim());
draw();
}
// ---- one group: nodes on the left, the selected node's parameters right
let rescanGen = 0;
/**
* Force-refresh a group's node descriptions and report if the server
* could not be reached -- describe() is a real fetch and a network hiccup
* or a restarted server leaves it rejecting, same as every other server
* call this window makes. Silently doing nothing here would make a refresh
* failure look identical to "nothing new to show", which defeats the point.
*
* `learnGen` inside learn() only guards which answer gets WRITTEN to the
* `nodes` cache; it says nothing about which rescan() call is allowed to
* touch `scanFailed`. Without its own token, an older rescan's late
* rejection could still slap a stale error banner over a newer rescan's
* fresh, successful result.
*/
async function rescan(classes) {
const mine = ++rescanGen;
try {
await learn(classes, { force: true });
if (mine !== rescanGen) return;
scanFailed = null;
} catch (err) {
if (mine !== rescanGen) return;
scanFailed = err.message;
}
draw();
}
async function enter(name) {
editing = name;
draft = new Map();
const mine = groupsOf(slots, extraGroups).byGroup.get(name) || [];
selected = mine[0]?.id ?? null;
// A scan failure belongs to the group whose rescan raised it. Carrying
// it into a different group -- or the index, via leave() -- points
// someone at the wrong place to investigate; this group's own rescan()
// below sets its own, current answer moments later.
scanFailed = null;
// Same reasoning as rescanGen above, for an in-flight commit() started
// in whatever group was open before this one: its refusal belongs there,
// not here.
viewGen += 1;
draw();
// Refetched fresh rather than served from `nodes`: a combo's choices are
// a file listing, and a file added to disk since this window opened must
// show up without forcing a full page reload, which would drop the whole
// in-progress edit along with the stale cache.
await rescan(mine.map((slot) => slot.node));
}
function leave() {
editing = null;
draft = null;
selected = null;
scanFailed = null;
// Invalidates any rescan() still in flight for the group being left --
// without this, its answer lands after leave() (with editing now null)
// and a LATE failure writes scanFailed straight onto the index, since
// rescan()'s own guard only checks whether a NEWER rescan() has started,
// never whether the one it belongs to has since been left.
rescanGen += 1;
// Same for an in-flight commit(): its DATA still applies when it lands
// (an edit that happened does not un-happen because someone stopped
// looking), but its refusal banner belongs to the group just left, not
// to the index this draw() is about to show.
viewGen += 1;
draw();
}
function group(name) {
const mine = groupsOf(slots, extraGroups).byGroup.get(name) || [];
if (selected && !mine.some((slot) => slot.id === selected)) selected = mine[0]?.id ?? null;
const left = composer.region.stack({ gap: "sm", label: "Nodes", children: [
composer.filterList.md({
items: mine.map((slot) => ({ id: slot.id, label: slot.id, hint: titleOf(slot.node) })),
value: selected,
placeholder: "Find a node",
empty: "No nodes in this group yet",
onChange: (id) => { selected = id; draw(); },
}),
composer.toolbar.default({ label: "Nodes", items: [
composer.button.sm({ label: "Add node", onClick: () => pickNode(name) }),
composer.button.sm({
label: "Remove", tone: "ghost", disabled: !selected,
onClick: () => remove(selected),
}),
composer.button.sm({
label: "Refresh models", tone: "ghost",
// Same file listing a re-entry already forces -- exposed here too,
// because a file dropped in AFTER this group was opened has no
// other way to appear short of reloading the page, which would
// throw away every edit made in this session.
onClick: () => rescan(mine.map((slot) => slot.node)),
}),
] }),
] });
const right = composer.region.stack({ gap: "sm", label: "Parameters",
children: selected ? parameters(slotBy(selected)) : [
composer.emptyState.default({
icon: "◇", title: "Nothing selected",
hint: "Pick a node on the left, or add one.",
}),
] });
return [
composer.toolbar.default({ label: "Group", items: [
composer.button.sm({ label: "← All groups", tone: "ghost", onClick: leave }),
composer.text.sm({ text: name }),
] }),
...messages(),
composer.splitPane.h({ panes: [left, right], size: 32, label: "Nodes and parameters" }),
];
}
/**
* Save and Cancel, in the modal's own footer rather than in the body.
*
* The footer is the one row outside the scrolling body. A bar built inside it
* sticks to the bottom of the SCROLLER, which is above the card's own edge by
* the body's padding -- so a strip of the settings showed through underneath
* the buttons and scrolled as the settings scrolled.
*/
function footer() {
const pending = draft ? draft.size : 0;
return {
note: pending
? `${pending} node${pending === 1 ? "" : "s"} edited — not saved yet`
: "No changes to save",
actions: [
composer.button.lg({ label: "Cancel", tone: "neutral", onClick: leave }),
composer.button.lg({
label: "Save", tone: "primary",
onClick: async () => { if (await saveDraft()) leave(); },
}),
],
};
}
const slotBy = (id) => slots.find((slot) => slot.id === id) || null;
const titleOf = (className) => (nodes.get(className) || {}).title || className;
// ---- the parameters of one node
function parameters(slot) {
if (!slot) return [];
const described = nodes.get(slot.node);
const rows = [
composer.settingsRow.default({
label: titleOf(slot.node),
hint: slot.node,
control: composer.button.sm({
label: "Change node",
onClick: () => pickNode(slot.group || UNGROUPED, slot.id),
}),
}),
composer.settingsRow.default({
label: "Group",
hint: "Which card this node appears under.",
control: composer.select.md({
options: groupsOf(slots, extraGroups).order.map((g) => ({ value: g, label: g })),
value: slot.group || UNGROUPED,
onChange: (to) => moveTo(slot.id, to),
}),
}),
];
if (!described) {
// The slot names a node this install does not have. Said here, where its
// parameters would be: an empty pane reads as "no settings".
rows.push(composer.banner.danger({
text: `${slot.node} is not installed here, so there is nothing to edit and the `
+ "pipeline will not run until this slot points somewhere else.",
}));
return rows;
}
for (const widget of described.widgets) {
const wired = (slot.inputs || {})[widget.name];
// A widget input can hold a link too -- that is the whole of what a
// "linked input" is: a Primitive node's output feeding this box instead
// of a value typed into it. Shown the same way a wired socket is, not
// as an editable box quietly holding the widget's OWN default while the
// real value comes from somewhere this window never says.
if (isLink(wired)) {
const from = slotBy(wired[0]);
rows.push(composer.settingsRow.default({
label: humanLabel(widget.name),
hint: `${widget.type} — fed by ${wired[0]}${from ? ` (${titleOf(from.node)})` : ""}`,
control: composer.toolbar.default({ label: widget.name, items: [
composer.button.sm({ label: "Change", onClick: () => pickSource(slot, widget) }),
composer.button.sm({
label: "Unwire", tone: "ghost",
onClick: () => unwireInput(slot.id, widget.name),
}),
] }),
}));
continue;
}
let setting = settingFor(widget);
if (setting && unset(slot, widget)) setting = asUnset(setting);
if (!setting) {
rows.push(composer.settingsRow.default({
label: humanLabel(widget.name), hint: whyNotEditable(widget),
}));
continue;
}
const current = valueOf(slot, widget.name, setting.default);
const editor = rendererFor(setting)(setting, current,
(next) => { if (next !== NOT_SET) edit(slot.id, widget.name, next); });
// Every widget can be wired instead of typed into -- ComfyUI's own
// "convert to input" idea, and the only way a value ever gets shared
// across several nodes as one linked input in the first place.
const control = composer.toolbar.default({ label: widget.name, items: [
editor,
composer.iconButton.sm({
icon: "⛓", label: `Wire ${humanLabel(widget.name)} from another node`,
onClick: () => pickSource(slot, widget),
}),
] });
// A checkbox row draws its own label and hint. Wrapping it in a settings
// row printed both of them twice, one above the other.
rows.push(SELF_LABELLING.has(rendererNameFor(setting))
? control
: composer.settingsRow.default({
label: setting.label, hint: setting.hint, control,
}));
}
for (const socket of described.sockets) {
const wired = (slot.inputs || {})[socket.name];
const from = isLink(wired) ? slotBy(wired[0]) : null;
rows.push(composer.settingsRow.default({
label: humanLabel(socket.name),
hint: isLink(wired)
? `${socket.type} — fed by ${wired[0]}${from ? ` (${titleOf(from.node)})` : ""}`
: `${socket.type} — nothing feeds it`,
control: composer.toolbar.default({ label: socket.name, items: [
composer.button.sm({
label: isLink(wired) ? "Change" : "Wire…",
onClick: () => pickSource(slot, socket),
}),
isLink(wired)
? composer.button.sm({
label: "Unwire", tone: "ghost",
onClick: () => unwireInput(slot.id, socket.name),
})
: null,
].filter(Boolean) }),
}));
}
// Outputs are never wired from HERE -- wiring is always asked for by the
// input that wants one, through its own Wire button, so the same link
// is never described two different ways depending on which end you
// opened. But an output that is never SHOWN anywhere on its own node is
// a real blind spot, not just a different workflow: open a node that
// exists to produce things and there was nothing here saying what it
// produces, or whether anything downstream already reads it.
if (described.outputs && described.outputs.length) {
rows.push(composer.label.section({ text: "Outputs" }));
described.outputs.forEach((type, index) => {
const name = (described.output_names || [])[index] || type;
const consumers = consumersOf(slot.id, index);
rows.push(composer.settingsRow.default({
label: name,
hint: consumers.length
? `${type} — feeds ${consumers.join(", ")}`
: `${type} — nothing reads this yet`,
}));
});
}
return rows;
}
/** Every (consumerId.inputName) currently wired to this slot's output. */
function consumersOf(slotId, index) {
const found = [];
for (const other of slots) {
for (const [name, value] of Object.entries(other.inputs || {})) {
if (isLink(value) && value[0] === slotId && value[1] === index) {
found.push(`${other.id}.${name}`);
}
}
}
return found;
}
/** Every OTHER slot's output that could legally feed this socket. */
function sourcesFor(slot, socket) {
const found = [];
for (const candidate of slots) {
if (candidate.id === slot.id) continue; // a self-wire is always a cycle
const described = nodes.get(candidate.node);
if (!described) continue;
described.outputs.forEach((type, index) => {
if (!typeAccepts(socket.type, type)) return;
const name = (described.output_names || [])[index] || type;
found.push({
id: `${candidate.id}${index}`,
label: `${candidate.id} · ${name}`,
hint: `${titleOf(candidate.node)} — ${type}`,
});
});
}
return found;
}
function pickSource(slot, socket) {
const options = sourcesFor(slot, socket);
const body = options.length
? composer.filterList.md({
items: options, placeholder: "Filter", empty: "Nothing matches",
onChange: (id) => {
const sep = id.indexOf("");
const fromSlot = id.slice(0, sep);
const fromOutput = Number(id.slice(sep + 1));
picker.close("picked");
wireInput(slot.id, socket.name, fromSlot, fromOutput);
},
})
: composer.emptyState.default({
icon: "◇", title: "Nothing produces this yet",
hint: `No node in the pipeline currently outputs a ${socket.type}. `
+ "Add one first, then wire it here.",
});
const picker = composer.modal.stacked({
title: `Wire ${humanLabel(socket.name)}`,
size: "md",
body,
});
}
async function wireInput(slotId, input, fromSlot, fromOutput) {
if (await commit(slots, { action: "wire", slot: slotId, input,
from_slot: fromSlot, from_output: fromOutput })) {
forgetDraftedInput(slotId, input);
}
}
async function unwireInput(slotId, input) {
if (await commit(slots, { action: "unwire", slot: slotId, input })) {
forgetDraftedInput(slotId, input);
}
}
/** Has this input been given a value -- by the pipeline, or in this draft? */
function unset(slot, widget) {
const edited = draft && draft.get(slot.id);
if (edited && Object.prototype.hasOwnProperty.call(edited, widget.name)) return false;
return (slot.inputs || {})[widget.name] === undefined;
}
/**
* A choice that has not been made, shown as one.
*
* A select rendered with the first choice looks like a value that was picked:
* the pipeline says "model_name is not filled" while the box beside it reads
* `ltx2_3_video_diffusion_fp8.safetensors`, and picking that same entry fires
* no change event, so the one click that looks like the fix does nothing.
* Only for lists -- a text box showing "" is already showing what it holds.
*/
function asUnset(setting) {
if (setting.type !== "enum") return setting;
return {
...setting,
default: NOT_SET,
options: [{ value: NOT_SET, label: "— not set —" }, ...setting.options],
};
}
/** The drafted value if this node has been edited, else what the slot holds. */
function valueOf(slot, name, fallback) {
const edited = draft && draft.get(slot.id);
if (edited && Object.prototype.hasOwnProperty.call(edited, name)) return edited[name];
const held = (slot.inputs || {})[name];
// A wired input is not a value: rendering the link as text would put
// "model,0" in a box and offer to save it as one.
if (held === undefined || isLink(held)) return fallback;
return held;
}
function edit(slotId, name, value) {
draft.set(slotId, { ...(draft.get(slotId) || {}), [name]: value });
// The footer, not the body. Redrawing the body would rebuild the control
// that fired this and take the cursor out of it mid-edit; leaving the
// footer alone left it reading "No changes to save" over an edit that had
// just been made.
setFooter(footer());
}
async function saveDraft() {
if (!draft.size) return true;
const next = slots.map((slot) => (draft.has(slot.id)
? { ...slot, inputs: { ...(slot.inputs || {}), ...draft.get(slot.id) } }
: slot));
const ok = await commit(next);
if (ok) draft = new Map();
return ok;
}
// ---- structure
async function moveTo(slotId, to) {
await commit(slots.map((slot) => (slot.id === slotId ? { ...slot, group: to } : slot)));
}
async function remove(slotId) {
await commit(slots, { action: "remove", slot: slotId });
}
/** Add a node to `group`, or -- with `replacing` -- point that slot elsewhere. */
function pickNode(group, replacing = null) {
const results = composer.region.stack({ gap: "sm", label: "Results" });
// filterList draws its own search box, which is redundant here: this
// picker already has one (`box`, below) that re-queries the server on
// every keystroke. Left both, someone typing into filterList's own box
// gets its LOCAL "Nothing matches" over a STALE "Showing N of M" hint
// from whatever the outer server search last actually returned -- two
// numbers describing two different things, on screen at once.
results.node.classList.add("cx-node-picker-results");
let latest = 0;
let typing = null;
async function run(query) {
const mine = ++latest;
let answer;
try {
answer = await search(query);
} catch (err) {
results.set([composer.inlineError.default({ text: err.message })]);
return;
}
// An older search finishing after a newer one would overwrite the newer
// results with an answer to a question nobody is asking any more.
if (mine !== latest) return;
results.set([
composer.filterList.md({
items: answer.nodes.map((n) => ({
id: n.node, label: n.title, hint: n.category || n.node,
})),
placeholder: "Filter these results",
empty: "Nothing matches",
onChange: (className) => { picker.close("picked"); chose(className); },
}),
answer.total > answer.nodes.length
? composer.hint.default({
text: `Showing ${answer.nodes.length} of ${answer.total} — `
+ "type to narrow it down.",
})
: null,
].filter(Boolean));
}
// As you type, not on Enter: a picker that answers only when committed to
// makes you guess the whole name before it says whether it exists.
// Debounced, because otherwise every keystroke is a request.
const box = composer.search.md({
placeholder: "Search installed nodes",
label: "Node",
onInput: (query) => { clearTimeout(typing); typing = setTimeout(() => run(query), 150); },
onCommit: (query) => { clearTimeout(typing); run(query); },
});
const picker = composer.modal.stacked({
title: replacing ? `Change ${replacing}` : `Add a node to ${group}`,
size: "md",
body: composer.region.stack({ gap: "sm", children: [box, results] }),
// A pending search must not land on a closed picker, and a search whose
// answer is thrown away is a request nobody needed.
onClose: () => { clearTimeout(typing); latest += 1; },
});
run("");
async function chose(className) {
await learn([className]);
if (replacing) {
await commit(slots, { action: "replace", slot: replacing, node: className });
return;
}
// No inputs: what feeds a new node is a wiring decision, and guessing one
// is how a node ends up quietly reading the wrong thing.
const id = freeId(className);
if (await commit([...slots, { id, group, node: className, inputs: {} }])) {
selected = id;
draw();
}
}
}
/** A slot id nobody is using, derived from the node's name. */
function freeId(className) {
const base = className.replace(/[^A-Za-z0-9]+/g, "_").toLowerCase();
const taken = new Set(slots.map((slot) => slot.id));
if (!taken.has(base)) return base;
for (let n = 2; ; n += 1) if (!taken.has(`${base}_${n}`)) return `${base}_${n}`;
}
// --------------------------------------------------------------- startup
body.set([composer.spinner.md({ label: "Reading the pipeline" })]);
const ready = refresh().then(draw).catch((err) => {
body.set([composer.emptyState.default({
icon: "▲",
title: "The pipeline could not be read",
hint: err.message,
})]);
});
return {
node: body.node,
destroy: () => body.destroy(),
ready,
// The window's own view of things, so a test does not have to infer state
// by reading the DOM back.
get slots() { return slots; },
get editing() { return editing; },
get selected() { return selected; },
get pending() { return draft ? draft.size : 0; },
enter,
leave,
_replace: (slotId, className) =>
commit(slots, { action: "replace", slot: slotId, node: className }),
_moveTo: moveTo,
_remove: remove,
_wire: wireInput,
_unwire: unwireInput,
_sourcesFor: (slotId, inputName) => {
const slot = slotBy(slotId);
const described = slot && nodes.get(slot.node);
const input = described && (described.sockets.find((s) => s.name === inputName)
|| described.widgets.find((w) => w.name === inputName));
return slot && input ? sourcesFor(slot, input) : [];
},
};
}
/**
* open({ load, describe, check, search, onApply }) -> handle
*
* Opening while one is already open returns THAT one and focuses it, rather
* than stacking a second over it.
*/
export function open({ load, describe, check, search, onApply, presets } = {}) {
if (current) {
current.focus();
return current;
}
let window_ = null;
const content = mount({
load, describe, check, search, onApply, presets,
setFooter: (f) => window_ && window_.setFooter(f),
close: () => window_ && window_.close("done"),
});
window_ = composer.modal.generic({
title: "Models and pipeline",
subtitle: "What the run is made of.",
size: "xl",
body: content,
// A half-finished group edit must not vanish because a click landed on the
// backdrop: the draft is the only copy of it.
closeOnOutside: false,
// However it was closed -- the button, Escape, destroy -- the next press
// opens a fresh one rather than finding this one and refusing.
onClose: () => { if (current === handle) current = null; },
});
const handle = {
node: window_.node,
close: (reason) => window_.close(reason),
focus: () => window_.node.focus ? window_.node.focus() : undefined,
ready: content.ready,
get slots() { return content.slots; },
get editing() { return content.editing; },
get selected() { return content.selected; },
get pending() { return content.pending; },
enter: content.enter,
leave: content.leave,
_replace: content._replace,
_moveTo: content._moveTo,
_remove: content._remove,
_wire: content._wire,
_unwire: content._unwire,
_sourcesFor: content._sourcesFor,
};
current = handle;
return handle;
}
/** For tests, which open and close many windows in one process. */
export function _reset() {
if (current) current.close("reset");
current = null;
}