// 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; }