Fix CS_LoraStacker FLOAT None errors and slot collapse UI

Stop hiding strength/bypass widgets with type converted-widget, which
skipped them in the prompt and shifted the next slot's "None" combo
value into FLOAT fields. Collapse with computeSize/hidden only, coerce
widget values client-side, and coerce strength/bypass inputs server-side.
This commit is contained in:
DrkSun81
2026-07-26 11:25:58 -05:00
parent 9781270440
commit bb8b76d7f1
2 changed files with 294 additions and 25 deletions
+48 -4
View File
@@ -218,6 +218,46 @@ class LoraStacker:
FUNCTION = "apply_loras"
CATEGORY = "CharacterSuite"
@staticmethod
def _as_float(value, default: float = 1.0) -> float:
"""Coerce widget values that may arrive as 'None'/'' after UI desync."""
if value is None or value is False:
return default
if value is True:
return default
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
s = value.strip()
if not s or s.lower() == "none":
return default
try:
return float(s)
except ValueError:
return default
try:
return float(value)
except (TypeError, ValueError):
return default
@staticmethod
def _as_bool(value, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
s = value.strip().lower()
if s in ("", "none"):
return default
if s in ("true", "1", "yes", "on"):
return True
if s in ("false", "0", "no", "off"):
return False
return default
def apply_loras(self, model, clip, unique_id=None, opt_prompt=None, selected_tags="", **kwargs):
current_model = model
current_clip = clip
@@ -226,13 +266,17 @@ class LoraStacker:
for i in range(1, MAX_LORAS + 1):
lora_name = kwargs.get(f"lora_{i}", NONE_CHOICE)
str_model = kwargs.get(f"strength_model_{i}", 1.0)
str_clip = kwargs.get(f"strength_clip_{i}", 1.0)
bypass = kwargs.get(f"bypass_{i}", False)
force_fetch = kwargs.get(f"force_fetch_{i}", False)
str_model = self._as_float(kwargs.get(f"strength_model_{i}", 1.0), 1.0)
str_clip = self._as_float(kwargs.get(f"strength_clip_{i}", 1.0), 1.0)
bypass = self._as_bool(kwargs.get(f"bypass_{i}", False), False)
force_fetch = self._as_bool(kwargs.get(f"force_fetch_{i}", False), False)
# Combo desync can put floats into lora slots or "None" into strengths
if not lora_name or lora_name == NONE_CHOICE:
continue
if not isinstance(lora_name, str):
print(f"[LoraStacker] slot {i}: skipping non-string lora value {lora_name!r}")
continue
# Fetch / cache tags (non-blocking for the model load if already cached)
tags = get_tags_for_lora(lora_name, force=force_fetch)
+246 -21
View File
@@ -312,6 +312,16 @@ function buildChipGroup(loraName, tags, state, opts = {}) {
// ─── aggregate (post-execution) panel ──────────────────────────────────────────
function buildAggregatePanel(container, executionTags, state) {
// Wipe previous aggregate chips from the registry before destroying DOM
// (innerHTML = "" leaves orphan elements in chipRegistry otherwise).
if (state?.chipRegistry) {
for (const [tag, els] of state.chipRegistry.entries()) {
for (const el of [...els]) {
if (container.contains(el) || !document.contains(el)) els.delete(el);
}
if (els.size === 0) state.chipRegistry.delete(tag);
}
}
container.innerHTML = "";
// Merge what the last execution actually applied with whatever per-slot
@@ -397,34 +407,118 @@ async function refreshSlotPanel(container, slotIndex, state, opts = {}) {
renderSlotPanel(container, slotIndex, state, loraName, entry);
}
// ─── widget collapse (hide strength/bypass/force_fetch rows when lora_N == None) ─
// ─── widget collapse (hide strength/bypass/force_fetch when lora_N is None) ────
// IMPORTANT: do NOT set type to "converted-widget". That type is treated as
// "not a real widget" when Comfy builds the prompt, so strength_* rows get
// skipped and the next slot's "None" string is fed into FLOAT fields →
// "could not convert string to float: 'None'".
// Hide visually with computeSize + hidden/CSS only; values must still serialize.
function isLoraNone(value) {
return value == null || value === "" || value === "None" || value === false;
}
function ensureFloatWidgetValue(widget, fallback = 1.0) {
if (!widget) return;
const v = widget.value;
if (typeof v === "number" && Number.isFinite(v)) return;
if (v === "None" || v === "" || v == null || v === false || v === true) {
widget.value = fallback;
return;
}
const n = Number(v);
widget.value = Number.isFinite(n) ? n : fallback;
}
function ensureBoolWidgetValue(widget, fallback = false) {
if (!widget) return;
const v = widget.value;
if (typeof v === "boolean") return;
if (v === "None" || v === "" || v == null) {
widget.value = fallback;
return;
}
if (v === true || v === 1 || v === "true" || v === "True") widget.value = true;
else if (v === false || v === 0 || v === "false" || v === "False") widget.value = false;
else widget.value = fallback;
}
function setWidgetCollapsed(widget, collapsed) {
if (!widget) return;
// Never change widget.type — keeps name in the execution payload.
if (collapsed) {
if (!widget._csOrigComputeSize) widget._csOrigComputeSize = widget.computeSize;
if (!widget._csCollapsed) {
widget._csCollapsed = true;
if (widget._csOrigComputeSize === undefined) {
widget._csOrigComputeSize =
typeof widget.computeSize === "function" ? widget.computeSize.bind(widget) : null;
}
}
widget.computeSize = () => [0, -4];
} else if (widget._csOrigComputeSize) {
widget.computeSize = widget._csOrigComputeSize;
widget.hidden = true;
if (widget.element) widget.element.style.display = "none";
} else {
if (widget._csCollapsed || widget.hidden || widget.computeSize) {
widget._csCollapsed = false;
if (widget._csOrigComputeSize) {
widget.computeSize = widget._csOrigComputeSize;
} else {
try { delete widget.computeSize; } catch (_) { widget.computeSize = undefined; }
}
// Keep _csOrigComputeSize so re-collapse still has a good original
}
widget.hidden = false;
if (widget.element) widget.element.style.display = "";
}
}
function updateSlotCollapse(node, slotIndex) {
const loraWidget = node.widgets?.find(w => w.name === `lora_${slotIndex}`);
const collapsed = !loraWidget || loraWidget.value === "None";
for (const name of [
`strength_model_${slotIndex}`,
`strength_clip_${slotIndex}`,
`bypass_${slotIndex}`,
`force_fetch_${slotIndex}`,
]) {
setWidgetCollapsed(node.widgets?.find(w => w.name === name), collapsed);
}
const val = loraWidget?.value;
const collapsed = !loraWidget || isLoraNone(val);
const sm = node.widgets?.find(w => w.name === `strength_model_${slotIndex}`);
const sc = node.widgets?.find(w => w.name === `strength_clip_${slotIndex}`);
const by = node.widgets?.find(w => w.name === `bypass_${slotIndex}`);
const ff = node.widgets?.find(w => w.name === `force_fetch_${slotIndex}`);
// Always coerce types before hide/show so a desynced "None" never stays on FLOATs
ensureFloatWidgetValue(sm, 1.0);
ensureFloatWidgetValue(sc, 1.0);
ensureBoolWidgetValue(by, false);
ensureBoolWidgetValue(ff, false);
setWidgetCollapsed(sm, collapsed);
setWidgetCollapsed(sc, collapsed);
setWidgetCollapsed(by, collapsed);
setWidgetCollapsed(ff, collapsed);
// Inline tag panel: only show when a LoRA is selected
setWidgetCollapsed(node._csInlineWidgets?.[slotIndex], collapsed);
node.setSize(node.computeSize());
// Force LiteGraph to reflow widget list
try {
if (typeof node.computeSize === "function") {
const size = node.computeSize();
if (Array.isArray(size) && size.length >= 2) node.setSize(size);
}
} catch (_) { /* ignore */ }
node.setDirtyCanvas?.(true, true);
node.graph?.setDirtyCanvas(true, true);
}
function refreshAllSlots(node, state) {
for (let i = 1; i <= MAX_LORAS; i++) {
updateSlotCollapse(node, i);
const container = node._csInlineContainers?.[i];
if (container) refreshSlotPanel(container, i, state);
}
}
function clearChipRegistry(state) {
state.chipRegistry.clear();
}
// ─── widget ordering ────────────────────────────────────────────────────────────
function moveWidgetAfter(node, widget, afterName) {
@@ -432,18 +526,94 @@ function moveWidgetAfter(node, widget, afterName) {
if (idx === -1) return;
node.widgets.splice(idx, 1);
const afterIdx = node.widgets.findIndex(w => w.name === afterName);
if (afterIdx === -1) {
node.widgets.push(widget);
return;
}
node.widgets.splice(afterIdx + 1, 0, widget);
}
/** Place inline tag panel after force_fetch_N so order is:
* lora_N → strength_model → strength_clip → bypass → force_fetch → tags
*/
function placeInlineAfterSlotControls(node, inlineWidget, slotIndex) {
const afterName = `force_fetch_${slotIndex}`;
if (node.widgets?.some(w => w.name === afterName)) {
moveWidgetAfter(node, inlineWidget, afterName);
} else {
moveWidgetAfter(node, inlineWidget, `lora_${slotIndex}`);
}
}
function onSlotLoraChanged(node, slotIndex, state, container) {
updateSlotCollapse(node, slotIndex);
if (container) refreshSlotPanel(container, slotIndex, state);
}
function hookSlotCombo(node, slotIndex, state, container) {
const widget = node.widgets?.find(w => w.name === `lora_${slotIndex}`);
if (!widget) return;
if (!widget || widget._csHooked) return;
widget._csHooked = true;
const origCallback = widget.callback?.bind(widget);
widget.callback = function (value, ...rest) {
const ret = origCallback ? origCallback(value, ...rest) : undefined;
refreshSlotPanel(container, slotIndex, state);
onSlotLoraChanged(node, slotIndex, state, container);
return ret;
};
// Some Comfy builds fire this instead of / in addition to callback
if (typeof widget.afterQueued === "function") {
const origAfter = widget.afterQueued.bind(widget);
widget.afterQueued = function (...args) {
const ret = origAfter(...args);
onSlotLoraChanged(node, slotIndex, state, container);
return ret;
};
}
}
function hookNodeWidgetChanged(node, state) {
if (node._csWidgetChangedHooked) return;
node._csWidgetChangedHooked = true;
const orig = node.onWidgetChanged?.bind(node);
node.onWidgetChanged = function (name, value, oldValue, widget) {
const ret = orig ? orig(name, value, oldValue, widget) : undefined;
if (typeof name === "string" && name.startsWith("lora_")) {
const slotIndex = parseInt(name.slice(5), 10);
if (slotIndex >= 1 && slotIndex <= MAX_LORAS) {
const container = node._csInlineContainers?.[slotIndex];
onSlotLoraChanged(node, slotIndex, state, container);
}
}
return ret;
};
}
/** Poll combo values — catches UI paths that never fire callback/onWidgetChanged. */
function startSlotValueWatch(node, state) {
if (node._csSlotWatch) return;
const last = {};
for (let i = 1; i <= MAX_LORAS; i++) {
const w = node.widgets?.find(x => x.name === `lora_${i}`);
last[i] = w?.value;
}
node._csSlotWatch = setInterval(() => {
if (!node.graph) {
clearInterval(node._csSlotWatch);
node._csSlotWatch = null;
return;
}
for (let i = 1; i <= MAX_LORAS; i++) {
const w = node.widgets?.find(x => x.name === `lora_${i}`);
const v = w?.value;
if (v !== last[i]) {
last[i] = v;
const container = node._csInlineContainers?.[i];
onSlotLoraChanged(node, i, state, container);
}
}
}, 400);
}
// ─── register extension ───────────────────────────────────────────────────────
@@ -500,10 +670,26 @@ app.registerExtension({
const clearBtn = document.createElement("button");
clearBtn.className = "cs-tag-btn";
clearBtn.textContent = "✕ Clear";
clearBtn.title = "Clear selected trigger words (does not change LoRA dropdowns)";
clearBtn.addEventListener("click", () => {
state.selectedSet.clear();
tagsWidget.value = "";
selBox.value = "";
// Drop stale chip element refs so rebuilds don't fight old DOM nodes
clearChipRegistry(state);
// Rebuild aggregate panel from current slotData (empty selection)
buildAggregatePanel(chipsArea, {}, state);
// Re-render inline slot chips so none stay highlighted as active
for (let i = 1; i <= MAX_LORAS; i++) {
const slot = state.slotData[i];
const container = node._csInlineContainers?.[i];
if (container && slot) {
renderSlotPanel(container, i, state, slot.loraName, {
tags: slot.tags || [],
status: slot.status || "ok",
});
}
}
syncAllChipStates(state);
});
@@ -517,10 +703,16 @@ app.registerExtension({
const refreshBtn = document.createElement("button");
refreshBtn.className = "cs-tag-btn";
refreshBtn.textContent = "↻ Refresh";
refreshBtn.title = "Re-pull the tags applied by the last execution";
refreshBtn.title = "Re-pull trigger tags and re-show strength widgets for all LoRA slots";
refreshBtn.addEventListener("click", async () => {
clearChipRegistry(state);
// Re-collapse/expand strength rows + re-fetch per-slot tags
refreshAllSlots(node, state);
const data = await fetchExecutionTags(node.id);
buildAggregatePanel(chipsArea, data, state);
// Keep textarea / selected_tags in sync with selectedSet
state.onChanged();
syncAllChipStates(state);
});
actions.appendChild(clearBtn);
@@ -554,14 +746,35 @@ app.registerExtension({
// A macrotask delay runs after that synchronous configure pass finishes
// (for both a freshly-dragged node, where there's nothing to configure,
// and a loaded one), so by the time we insert, real values are already set.
setTimeout(() => {
// Double rAF + timeout: wait for Comfy to finish applying widgets_values
// (and any Vue/DOM layout pass) before we insert inline panels / collapse.
const mountInlinePanels = () => {
node._csInlineWidgets = {};
node._csInlineContainers = {};
for (let i = 1; i <= MAX_LORAS; i++) {
// Avoid double-insert if nodeCreated re-runs
if (node.widgets?.some(w => w.name === `cs_lora_inline_${i}`)) {
const existing = node.widgets.find(w => w.name === `cs_lora_inline_${i}`);
node._csInlineWidgets[i] = existing;
node._csInlineContainers[i] = existing?.element || existing?.container || null;
const c = node._csInlineContainers[i] || document.createElement("div");
hookSlotCombo(node, i, state, c);
updateSlotCollapse(node, i);
if (node._csInlineContainers[i]) refreshSlotPanel(node._csInlineContainers[i], i, state);
continue;
}
const container = document.createElement("div");
container.className = "cs-inline-slot-panel";
const inlineWidget = node.addDOMWidget(`cs_lora_inline_${i}`, "div", container, { serialize: false });
moveWidgetAfter(node, inlineWidget, `lora_${i}`);
const inlineWidget = node.addDOMWidget(`cs_lora_inline_${i}`, "div", container, {
serialize: false,
// Explicit no-op get/set so nothing injects "" into widgets_values
getValue() { return null; },
setValue() {},
});
// After force_fetch_N so strength rows sit between lora combo and tags
placeInlineAfterSlotControls(node, inlineWidget, i);
node._csInlineWidgets[i] = inlineWidget;
node._csInlineContainers[i] = container;
hookSlotCombo(node, i, state, container);
updateSlotCollapse(node, i);
@@ -569,7 +782,19 @@ app.registerExtension({
// already selected — don't wait for the user to reselect.
refreshSlotPanel(container, i, state);
}
}, 0);
// Second + third pass after values settle (workflow load / combo races)
requestAnimationFrame(() => {
for (let i = 1; i <= MAX_LORAS; i++) updateSlotCollapse(node, i);
setTimeout(() => {
for (let i = 1; i <= MAX_LORAS; i++) updateSlotCollapse(node, i);
}, 100);
});
hookNodeWidgetChanged(node, state);
startSlotValueWatch(node, state);
};
// Prefer microtask-after-configure: rAF×2 then setTimeout
requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(mountInlinePanels, 0)));
// ── poll for the aggregate panel after execution settles ────────────────
node._csTagPoll = null;