fix: Models shape editing: shown defaults are sent, same-kind slots told apart, unwired links recorded explicitly, swap says what roles it loses

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-03 00:35:42 +03:00
co-authored by Claude Sonnet 5.5
parent b037201fa0
commit a2567151c7
4 changed files with 70 additions and 15 deletions
+33 -3
View File
@@ -46,7 +46,10 @@
function slotDisplayLabel(slot) {
const spec = nodesByClass[slot.node];
return (spec && spec.title) || slot.node || slot.id;
const title = (spec && spec.title) || slot.node || slot.id;
// Two slots of one kind (the positive and negative prompt, two Primitives) are told apart by id.
const twin = (PS.slots() || []).some((o) => o.id !== slot.id && ((nodesByClass[o.node] && nodesByClass[o.node].title) || o.node) === title);
return twin ? `${title} (${slot.id})` : title;
}
function setInput(slotId, name, value) {
@@ -60,10 +63,32 @@
// reason is shown here and nothing changes.
let lastRefusal = [];
// A node that is new (added, swapped in) or whose input was just unwired has nothing SET on it, while
// its form shows the node's defaults: send what is shown, so what runs is what is seen.
async function fillShown(slotId, onlyInput) {
const slot = (PS.slots() || []).find((s) => s.id === slotId);
const spec = slot && nodesByClass[slot.node];
if (!spec || !spec.widgets) return;
const values = {};
spec.widgets.forEach((w) => {
if (onlyInput && w.name !== onlyInput) return;
if (slot.inputs && slot.inputs[w.name] !== undefined) return;
const v = w.default !== undefined ? w.default
: w.type === "COMBO" ? (w.choices || [])[0] : w.type === "BOOLEAN" ? false : undefined;
if (v !== undefined) values[w.name] = v;
});
if (Object.keys(values).length) await PS.save({ inputs: { [slotId]: values } });
}
async function structural(body) {
const res = await PS.edit(body);
lastRefusal = res.refused || [];
await ensureNodesLoaded();
if (!lastRefusal.length) {
if (body.action === "replace") await fillShown(body.slot);
else if (body.action === "unwire") await fillShown(body.slot, body.input);
else if (body.action === "add") { const s = (PS.slots() || []).slice(-1)[0]; if (s) await fillShown(s.id); }
}
render();
return !lastRefusal.length;
}
@@ -84,6 +109,7 @@
group = name;
PS.setGroup(slot.id, name).then(render);
} else {
lastRefusal = [];
PS.setGroup(slot.id, sel.value).then(render);
}
};
@@ -136,7 +162,10 @@
const swap = el("button", "btn ghost tiny", "Swap node…");
swap.title = "Use a different node here. It must still produce what the nodes after it read.";
swap.onclick = () => pickNode(`Swap ${slotDisplayLabel(slot)} for…`, (cls) => {
if (!confirm(`Swap ${slotDisplayLabel(slot)} for ${cls}?\n\nEverything set on this node (files, values, connections into it) is cleared, and the new node starts empty.`)) return;
const roles = (slot.roles || []).map((r) => r.label || r.input).filter(Boolean);
const note = roles.length
? `\n\nThe main window drives this node's ${roles.join(", ")}: those controls keep working only for inputs the new node also has, by the same name.` : "";
if (!confirm(`Swap ${slotDisplayLabel(slot)} for ${cls}?\n\nEverything set on this node (files, values, connections into it) is cleared, and the new node starts empty.${note}`)) return;
structural({ action: "replace", slot: slot.id, node: cls });
});
const rm = el("button", "btn ghost tiny danger", "Remove");
@@ -190,7 +219,8 @@
const row = controlForPlainWidget(slot, widget);
// An input the app itself writes (the prompt box, the source picture, the length) is the app's:
// wiring it elsewhere would make every Generate refuse.
if ((slot.roles || []).some((r) => r.input === widget.name)) return row;
// (Width, height and length are the exception: Generate skips them when they are wired.)
if ((slot.roles || []).some((r) => r.input === widget.name && r.at !== "project.video")) return row;
// Another node can drive this value (one Primitive shared by several inputs is how a "link" is made).
if (widget.type !== "COMBO" || (widget.choices || []).length) {
const sources = sourcesFor(slot, widget.type);
+22 -7
View File
@@ -65,9 +65,10 @@
let queueable = false;
let loading = false;
let loadError = null;
let unwired = {}; // default links the person cut: {slotId: [input, ...]}
let removed = new Set(); // default slots the person took out of this project's pipeline
let adoptGate = null; // a promise while a project's pipeline is being put in
let deferredRemoved;
let deferredRemoved, deferredUnwired;
let deferred = null; // a project's saved pipeline waiting for the first successful load
let epoch = 0; // bumped whenever a project's pipeline replaces the live one
let saving = false;
@@ -131,7 +132,7 @@
}
loading = false;
loadPromise = null;
if (slots !== null && deferred) { const d = deferred, r = deferredRemoved; deferred = null; deferredRemoved = undefined; await adopt(d, r); }
if (slots !== null && deferred) { const d = deferred, r = deferredRemoved, u = deferredUnwired; deferred = null; deferredRemoved = undefined; deferredUnwired = undefined; await adopt(d, r, u); }
})();
return loadPromise;
}
@@ -315,6 +316,14 @@
if (!refusedNow.length) {
if (body.action === "remove" && offered.some((s) => s.id === body.slot)) removed.add(body.slot); // the person's own additions just go
if (body.action === "add") (res.slots || []).forEach((s) => removed.delete(s.id));
if (body.action === "unwire" && offered.some((s) => s.id === body.slot)) {
unwired[body.slot] = [...new Set([...(unwired[body.slot] || []), body.input])];
}
if (body.action === "wire" && unwired[body.slot]) {
unwired[body.slot] = unwired[body.slot].filter((k) => k !== body.input);
if (!unwired[body.slot].length) delete unwired[body.slot];
}
if (body.action === "remove") delete unwired[body.slot];
try { await refreshManifest(); } catch (_) { /* the next save retries it */ }
_changed();
}
@@ -354,17 +363,17 @@
// the saved copy predates, so an update's new settings are not lost to an old project.
// Not announced as a change: it IS the project's own copy.
// True when the project's pipeline is in (or it had none to put in); false when it could not be.
async function adopt(saved, removedIds) {
async function adopt(saved, removedIds, unwiredMap) {
let open;
const gate = new Promise((r) => { open = r; });
adoptGate = gate; // before anything awaits: an edit right behind this waits too
try { await ensureLoaded(); return await _adopt(saved, removedIds); } finally { if (adoptGate === gate) adoptGate = null; open(); }
try { await ensureLoaded(); return await _adopt(saved, removedIds, unwiredMap); } finally { if (adoptGate === gate) adoptGate = null; open(); }
}
async function _adopt(saved, removedIds) {
async function _adopt(saved, removedIds, unwiredMap) {
// Not loaded (ComfyUI unreachable): remember what the project holds and put it in the moment a
// load succeeds, so the default is never what the next edit is built on.
if (slots === null) { if (Array.isArray(saved) && saved.length) { deferred = saved; deferredRemoved = removedIds; } return !(Array.isArray(saved) && saved.length); }
if (slots === null) { if (Array.isArray(saved) && saved.length) { deferred = saved; deferredRemoved = removedIds; deferredUnwired = unwiredMap; } return !(Array.isArray(saved) && saved.length); }
if (!Array.isArray(saved) || !saved.length) return true;
// A saved slot is only what the server would accept: a project file outlives the code that
// wrote it (v4 files name the node differently), and one bad slot refuses every later edit.
@@ -383,7 +392,8 @@
// A default LINK the saved copy lacks was unwired by the person; a default VALUE it lacks is
// one an update added, and fills in.
const inputs = { ...(def.inputs || {}) };
Object.keys(inputs).forEach((k) => { if (Array.isArray(inputs[k]) && !(k in mine.inputs)) delete inputs[k]; });
const cut = (unwiredMap || {})[def.id];
Object.keys(inputs).forEach((k) => { if (Array.isArray(cut) && cut.includes(k) && !(k in mine.inputs)) delete inputs[k]; });
const out = { ...def, inputs: { ...inputs, ...mine.inputs } };
if (typeof mine.group === "string" && mine.group) out.group = mine.group;
if (typeof mine.bypassed === "boolean") out.bypassed = mine.bypassed;
@@ -392,6 +402,10 @@
// Replaces whatever pipeline was live: nothing queued for the old one may land on the new.
const mine = ++epoch;
removed = new Set(gone);
unwired = {};
Object.entries(unwiredMap && typeof unwiredMap === "object" ? unwiredMap : {}).forEach(([id, list]) => {
if (Array.isArray(list)) unwired[id] = list.filter((x) => typeof x === "string");
});
groupEdits = {};
pendingBody = null; pending = false;
for (let waited = 0; saving && waited < 10000; waited += 20) await new Promise((r) => setTimeout(r, 20));
@@ -421,6 +435,7 @@
ensureLoaded, save, edit, setGroup, adopt, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); }, valuesAlreadyPlaced, currentValues, setModuleValue,
modulesById: () => modulesById,
removedIds: () => [...removed],
unwiredMap: () => JSON.parse(JSON.stringify(unwired)),
slots: () => slots,
incomplete: () => incomplete,
refused: () => refused,
+9 -1
View File
@@ -158,8 +158,16 @@ test("a structural edit is sent on its own and its refusal is handed back", asyn
test("a default link the person unwired stays unwired when the project is opened again", async () => {
const PS = load([]);
await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "x" } },
{ id: "gen", node: "Gen", inputs: { steps: 8 } }]); // gen.model (a link) was unwired
{ id: "gen", node: "Gen", inputs: { steps: 8 } }], [], { gen: ["model"] });
assert.strictEqual("model" in PS.slots().find((s) => s.id === "gen").inputs, false);
assert.deepStrictEqual(PS.unwiredMap(), { gen: ["model"] });
});
test("a default link an older project never had (an update added it) is kept", async () => {
const PS = load([]);
await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "x" } },
{ id: "gen", node: "Gen", inputs: { steps: 8 } }]); // nothing recorded as unwired
assert.strictEqual("model" in PS.slots().find((s) => s.id === "gen").inputs, true);
});
test("a group moved while a save is in flight is still moved when it lands", async () => {
+6 -4
View File
@@ -4708,7 +4708,8 @@
const p = (async () => {
try {
// Bounded: a hung request must not hold every later project save behind it.
const ok = await Promise.race([window.PipelineState.adopt(saved, state.project && state.project.models && state.project.models.removed), new Promise((r) => setTimeout(() => r(false), 20000))]);
const ok = await Promise.race([window.PipelineState.adopt(saved, state.project && state.project.models && state.project.models.removed,
state.project && state.project.models && state.project.models.unwired), new Promise((r) => setTimeout(() => r(false), 20000))]);
const live = window.PipelineState.slots();
// The project's pipeline is not in (ComfyUI unreachable, slow, or it refused the file): the
// project's own copy stays exactly as it was and is not rewritten from whatever is live --
@@ -4716,10 +4717,11 @@
_modelsAdopted = !!(ok && live);
const base = (state.project && state.project.models) || {};
state.models = { ...base, slots: _modelsAdopted ? JSON.parse(JSON.stringify(live)) : saved,
removed: _modelsAdopted ? window.PipelineState.removedIds() : (base.removed || []) };
removed: _modelsAdopted ? window.PipelineState.removedIds() : (base.removed || []),
unwired: _modelsAdopted ? window.PipelineState.unwiredMap() : (base.unwired || {}) };
if (!_modelsAdopted && _modelsRetries++ < 3) setTimeout(loadModels, 5000);
else if (_modelsAdopted) _modelsRetries = 0;
} catch (_) { state.models = { slots: saved }; }
} catch (_) { state.models = { ...((state.project && state.project.models) || {}), slots: saved }; _modelsAdopted = false; }
})();
_modelsLoad = p;
await p;
@@ -5022,7 +5024,7 @@
window.PipelineState.subscribe((slots) => {
if (!state.project || !_modelsAdopted) return;
state.models = { ...(state.models || {}), slots: JSON.parse(JSON.stringify(slots || [])),
removed: window.PipelineState.removedIds() };
removed: window.PipelineState.removedIds(), unwired: window.PipelineState.unwiredMap() };
// Soon, and past a Settings window's hold: a model pick must survive the reload that follows it.
_localDirty = true;
clearTimeout(_pipelineSaveTimer);