The button opened another window on every press -- reported from a real session as two of the same modal. Two windows over one pipeline are two drafts of it: whichever is saved last wins, and the other was edited against a pipeline that had already moved. One window now, focused rather than stacked, and a fresh one after it is closed. commit() took whatever answer arrived. Moving a node carries the whole slot list and removing one carries an id, so the second answers first and the first then describes a pipeline that no longer exists -- putting a removed node back with no refusal and nothing said. The picker guarded this already; the path that decides what gets queued did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
586 lines
22 KiB
JavaScript
586 lines
22 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";
|
|
|
|
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;
|
|
|
|
/**
|
|
* open({ load, describe, check, search, onApply }) -> 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.
|
|
*
|
|
* 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 } = {}) {
|
|
if (current) {
|
|
current.focus();
|
|
return current;
|
|
}
|
|
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: [] };
|
|
|
|
const body = composer.region.stack({ gap: "md", label: "Pipeline", fill: true });
|
|
const modal = composer.modal.generic({
|
|
title: "Models and pipeline",
|
|
subtitle: "What the run is made of.",
|
|
size: "xl",
|
|
body,
|
|
// 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; },
|
|
});
|
|
|
|
// ---------------------------------------------------------------- server
|
|
|
|
async function refresh() {
|
|
const answer = await load();
|
|
slots = answer.slots;
|
|
notes = { refused: [], incomplete: answer.incomplete || [] };
|
|
await learn(slots.map((slot) => slot.node));
|
|
}
|
|
|
|
/** Descriptions for classes we have not seen. Asked once per class. */
|
|
async function learn(classes) {
|
|
const unknown = [...new Set(classes)].filter((c) => c && !nodes.has(c));
|
|
if (!unknown.length) return;
|
|
const described = await describe(unknown);
|
|
// 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 unknown) 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;
|
|
let answer;
|
|
try {
|
|
answer = await check({ slots: next, ...action });
|
|
} catch (err) {
|
|
if (mine !== edits) return false;
|
|
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.
|
|
if (answer.refused.length) {
|
|
notes = { refused: answer.refused, incomplete: notes.incomplete };
|
|
draw();
|
|
return false;
|
|
}
|
|
forgetDraftsFor(slots, answer.slots);
|
|
slots = answer.slots;
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------- draw
|
|
|
|
function draw() {
|
|
if (editing === null) {
|
|
body.set(index());
|
|
// Nothing to save from the index: the cards are a way in, not an edit.
|
|
modal.setFooter({});
|
|
return;
|
|
}
|
|
body.set(group(editing));
|
|
modal.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 })),
|
|
// 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
|
|
|
|
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),
|
|
})),
|
|
onActivate: (item) => enter(item.id),
|
|
}),
|
|
composer.toolbar.default({ label: "Groups", items: [newGroupButton()] }),
|
|
];
|
|
}
|
|
|
|
function newGroupButton() {
|
|
return composer.button.md({
|
|
label: "New group",
|
|
onClick: async () => {
|
|
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
|
|
|
|
function enter(name) {
|
|
editing = name;
|
|
draft = new Map();
|
|
selected = (groupsOf(slots, extraGroups).byGroup.get(name) || [])[0]?.id ?? null;
|
|
draw();
|
|
}
|
|
|
|
function leave() {
|
|
editing = null;
|
|
draft = null;
|
|
selected = null;
|
|
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),
|
|
}),
|
|
] }),
|
|
] });
|
|
|
|
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) {
|
|
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 control = rendererFor(setting)(setting, current,
|
|
(next) => { if (next !== NOT_SET) edit(slot.id, widget.name, next); });
|
|
// 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];
|
|
rows.push(composer.settingsRow.default({
|
|
label: humanLabel(socket.name),
|
|
// Said outright, because there is no control here to look for. Wiring
|
|
// is not something this window does yet, and an input reading only
|
|
// "nothing is wired to it" reads as a thing the user failed to find.
|
|
hint: isLink(wired)
|
|
? `fed by ${wired[0]}`
|
|
: `${socket.type} — nothing feeds it, and this window cannot wire it yet`,
|
|
}));
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
/** 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.
|
|
modal.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" });
|
|
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,
|
|
})]);
|
|
});
|
|
|
|
const handle = {
|
|
node: modal.node,
|
|
close: (reason) => modal.close(reason),
|
|
focus: () => modal.node.focus ? modal.node.focus() : undefined,
|
|
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,
|
|
};
|
|
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;
|
|
}
|