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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
4852d50609
commit
4fd3b94424
@@ -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.
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user