From 4fd3b94424bf04bdffad01fd7571d23d248f57a2 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:19:00 +0300 Subject: [PATCH] feat: bypass toggle in Models & Pipeline -- a node is skipped for the run, keeps its values, and refuses with a reason when it cannot be passed through Co-Authored-By: Claude Sonnet 5.5 --- app/ui/features/settings/models.js | 5 +++-- app/ui/shell/pipeline_state.js | 18 ++++++++++++---- app/ui/shell/tests/pipeline_state.test.js | 15 ++++++++++++++ core/graph.py | 9 ++++++++ core/tests/test_graph.py | 25 +++++++++++++++++++++++ 5 files changed, 66 insertions(+), 6 deletions(-) diff --git a/app/ui/features/settings/models.js b/app/ui/features/settings/models.js index bde2f121..22aaba0b 100644 --- a/app/ui/features/settings/models.js +++ b/app/ui/features/settings/models.js @@ -88,7 +88,8 @@ export const models = (app) => function mount() { const groups = () => [...new Set(slotsNow().map((s) => s.group || "Other"))]; const structure = (slot) => { const here = slot.group || "Other"; - return c.field.default({ label: "Group", hint: "Which tab this node sits under. Any node can live in any group.", control: c.toolbar.default({ + const bypass = c.toggle.default({ label: "Bypass", hint: "Skip this node for the run: what it feeds reads what fed it. Its settings stay, switch it back on any time.", checked: Boolean(slot.bypassed), onChange: async (v) => { await ps.setBypass(slot.id, v); draw(); } }); + return c.region.stack({ gap: "sm", children: [bypass, c.field.default({ label: "Group", hint: "Which tab this node sits under. Any node can live in any group.", control: c.toolbar.default({ items: [c.select.md({ label: "Group", value: here, options: [...groups().map((g) => ({ value: g, label: g })), { value: NEW_GROUP, label: "New group…" }], onChange: async (v) => { let name = v; if (v === NEW_GROUP) { name = ((await c.modal.prompt({ title: "New group", label: "Name", confirmLabel: "Create" }).result) || "").trim(); if (!name) return draw(); } @@ -101,7 +102,7 @@ export const models = (app) => function mount() { if (await confirm("Swap node", `Swap ${label(slot)} for ${cls}? Everything set on this node (files, values, connections into it) is cleared, and the new node starts empty.${driven.length ? ` The main window drives this node's ${driven.join(", ")}: those controls keep working only for inputs the new node also has, by the same name.` : ""}`, "danger")) structural({ action: "replace", slot: slot.id, node: cls }); } }), c.button.sm({ label: "Remove", tone: "danger", title: "Take this node out; what it fed is rewired to what fed it, when that is unambiguous.", onClick: async () => { if (await confirm("Remove node", `Remove ${label(slot)} from the pipeline?`, "danger")) structural({ action: "remove", slot: slot.id }); - } })] }) }); + } })] }) })] }); }; // Which pack gives a missing node (from ComfyUI-Manager's map, when it is installed), and a button to clone it. diff --git a/app/ui/shell/pipeline_state.js b/app/ui/shell/pipeline_state.js index bff0e42a..a8ed62eb 100644 --- a/app/ui/shell/pipeline_state.js +++ b/app/ui/shell/pipeline_state.js @@ -480,7 +480,7 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) { 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]; delete groupEdits[body.slot]; } + if (body.action === "remove") { delete unwired[body.slot]; delete groupEdits[body.slot]; delete bypassEdits[body.slot]; } // A default slot that was swapped for the same node, or taken out and put back, starts empty: // none of the default's links may come back over it when the project is opened again. const fresh = body.action === "replace" ? [body.slot] : body.action === "add" ? (res.slots || []).map((s) => s.id) : []; @@ -536,7 +536,7 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) { removed = new Set([...(snap.removed || []), ...(offered || []).map((s) => s.id).filter((id) => !kept.has(id))]); whole = snap.whole !== false; // only a snapshot of an edited default says otherwise unwired = JSON.parse(JSON.stringify(snap.unwired || {})); - groupEdits = {}; pendingValues = {}; pendingBody = null; pending = false; + groupEdits = {}; bypassEdits = {}; pendingValues = {}; pendingBody = null; pending = false; incomplete = res.incomplete || []; refused = []; queueable = !!res.queueable; skipped = skipMap(res); saveNotes = res.notes || []; @@ -557,12 +557,22 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) { // Kept until it has landed: a save already in flight answers with the slots as they were when // it left, and would otherwise put the old group back. let groupEdits = {}; + let bypassEdits = {}; // same life as a group edit: kept until its save has landed function applyGroups() { (slots || []).forEach((s) => { + if (s.id in bypassEdits) { if (bypassEdits[s.id]) s.bypassed = true; else delete s.bypassed; } if (!(s.id in groupEdits)) return; if (groupEdits[s.id]) s.group = groupEdits[s.id]; else delete s.group; }); } + /** Switch a node off for the run (what it feeds reads what fed it) without losing a single value on it. */ + function setBypass(slotId, on) { + if (!(slots || []).some((s) => s.id === slotId)) return Promise.resolve(); + bypassEdits[slotId] = Boolean(on); + applyGroups(); + const mine = bypassEdits[slotId]; + return save({}).then(() => settled()).then(() => { if (bypassEdits[slotId] === mine) delete bypassEdits[slotId]; }); + } function setGroup(slotId, group) { if (!(slots || []).some((s) => s.id === slotId)) return Promise.resolve(); groupEdits[slotId] = String(group || "").trim(); @@ -660,7 +670,7 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) { Object.entries(unwiredMap && typeof unwiredMap === "object" ? unwiredMap : {}).forEach(([id, list]) => { if (Array.isArray(list)) unwired[id] = list.filter((x) => typeof x === "string"); }); - groupEdits = {}; + groupEdits = {}; bypassEdits = {}; pendingBody = null; pending = false; for (let waited = 0; saving && waited < 10000; waited += 20) await new Promise((r) => setTimeout(r, 20)); let unreachable = false, reasons = []; @@ -698,7 +708,7 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) { } return { - ensureLoaded, save, edit, restore, snapshot, setGroup, adopt, settled, readyToRun, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); }, valuesAlreadyPlaced, currentValues, setModuleValue, + ensureLoaded, save, edit, restore, snapshot, setGroup, setBypass, adopt, settled, readyToRun, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); }, valuesAlreadyPlaced, currentValues, setModuleValue, modulesById: () => modulesById, setEditGuard: (fn) => { editGuard = fn; }, adoptedOk: () => adoptedOk, unreachable: () => slots === null || unreachableLast, refusal: () => refusalLast, activeModules, useful, usefulness, isOff, setOff, allOff, setAllOff, refreshControl, control: () => controlState, removedIds: () => [...removed], diff --git a/app/ui/shell/tests/pipeline_state.test.js b/app/ui/shell/tests/pipeline_state.test.js index d9da01bd..56a2b934 100644 --- a/app/ui/shell/tests/pipeline_state.test.js +++ b/app/ui/shell/tests/pipeline_state.test.js @@ -178,6 +178,21 @@ test("a group moved while a save is in flight is still moved when it lands", asy assert.strictEqual(PS.slots().find((s) => s.id === "gen").group, "Mine"); }); +test("a bypass switched while a save is in flight still holds when it lands, and travels with the saved pipeline", async () => { + const PS = load([], { delay: 30 }); + await PS.ensureLoaded(); + const inflight = PS.save({ inputs: { model: { file: "z" } } }); + await new Promise((r) => setTimeout(r, 5)); + await PS.setBypass("gen", true); + await inflight; + assert.strictEqual(PS.slots().find((s) => s.id === "gen").bypassed, true); + const again = load([]); + await again.adopt(JSON.parse(JSON.stringify(PS.slots())), [], {}); + assert.strictEqual(again.slots().find((s) => s.id === "gen").bypassed, true); + await PS.setBypass("gen", false); + assert.strictEqual("bypassed" in PS.slots().find((s) => s.id === "gen"), false, "off leaves no flag behind"); +}); + test("a default slot swapped for the same node, or removed and added back, does not get its links back on reopen", async () => { const PS = load([]); await PS.ensureLoaded(); diff --git a/core/graph.py b/core/graph.py index f0bd4fc3..39a60d92 100644 --- a/core/graph.py +++ b/core/graph.py @@ -212,6 +212,8 @@ def shape_problems(slots: Any) -> List[str]: elif not group.strip(): problems.append(f"{where}'s group is blank; leave it out to leave the " f"slot ungrouped") + if slot.get("bypassed") is not None and not isinstance(slot["bypassed"], bool): + problems.append(f"{where}'s bypassed must be true or false, not {type(slot['bypassed']).__name__}") problems.extend(_role_problems(slot.get("roles"), where)) problems.extend(_optional_problems(slot.get("optional"), where)) return problems @@ -360,6 +362,13 @@ def build(slots: Sequence[dict], schemas: Optional[Schemas] = None, if problems: return {}, problems + # A bypassed slot stays in the saved pipeline with all its values; the run is built as if it were removed. + # Refused, with the reason, when what it fed has nothing to inherit: never quietly left running. + for slot_id in [s["id"] for s in slots if s.get("bypassed") is True]: + slots, bad = remove(slots, slot_id, schemas) + problems.extend(f"bypass on {slot_id!r}: {why}" for why in bad) + if problems: + return {}, problems slots, _skipped = bypass_missing(slots, schemas, optional) known = {s["id"] for s in slots} # Built once. Rebuilding it per link made validating a chain of N slots do N diff --git a/core/tests/test_graph.py b/core/tests/test_graph.py index a6fee039..63ed8eb0 100644 --- a/core/tests/test_graph.py +++ b/core/tests/test_graph.py @@ -822,3 +822,28 @@ def test_a_malformed_optional_table_entry_is_logged_not_swallowed(monkeypatch): slot.pop("optional", None) graph.build(slots, _without("Rewrite", "Markup"), {"Rewrite": {"pass": "bad"}}) assert any("Rewrite" in m and "cannot be skipped" in m for m in seen) + + +# --- bypassing --------------------------------------------------------------- + +def test_a_bypassed_slot_is_left_out_of_the_run_but_kept_with_its_values(): + """The point of bypass over remove: switch a node off to compare, switch it back, nothing retyped.""" + slots = [dict(s, bypassed=True) if s["id"] == "lora" else s for s in pipeline()] + prompt, problems = graph.build(slots, SCHEMAS) + assert problems == [] + assert "lora" not in prompt and prompt["sample"]["inputs"]["model"] == ["model", 0] + kept = graph.slots_by_id(slots)["lora"] + assert kept["bypassed"] is True and kept["inputs"]["strength"] == graph.slots_by_id(pipeline())["lora"]["inputs"]["strength"] + back, problems = graph.build([dict(s, bypassed=False) if s["id"] == "lora" else s for s in slots], SCHEMAS) + assert problems == [] and "lora" in back + + +def test_bypassing_something_with_nothing_to_pass_through_is_refused_and_said(): + slots = [dict(s, bypassed=True) if s["id"] == "latent" else s for s in pipeline()] + prompt, problems = graph.build(slots, SCHEMAS) + assert prompt == {} and any("bypass on 'latent'" in p and "no wired LATENT input" in p for p in problems) + + +def test_bypassed_must_be_true_or_false(): + slots = [dict(s, bypassed="yes") if s["id"] == "lora" else s for s in pipeline()] + assert any("bypassed must be true or false" in p for p in graph.shape_problems(slots))