Files
chrmc40 8e3dceae99 ComfyUI-PromptChain
A prompt-authoring and image-iteration suite for ComfyUI.
2026-06-17 19:35:25 -07:00

230 lines
8.5 KiB
JavaScript

// Order chain — builds and renders the hierarchical chain labels on input slots.
// Handles mode icons, child mode labels, chain walking, label updates, and
// wildcard conflict detection.
import { app } from "../../../scripts/app.js";
import { NODE_TYPE } from "./config.js";
import { isPromptChain, getSourceNode, hasCustomTitle, getConnectedInputs, hasOptions, getLinkInfo } from "./slot-utils.js";
import { attachSlotClickHandlers } from "./slot-dropdown.js";
import { countLabeledOptions, getSelfLabelOptions } from "./label-utils.js";
// Get the first-order mode icon based on the parent node's mode.
// Only shows when parent has 2+ connected inputs and isn't in switch mode.
function getModeIcon(node, slotIndex) {
const mode = node.properties?.pcrMode || "switch";
if (mode === "switch") {
const switchIndex = node.properties?.pcrSwitchIndex ?? 1;
if (slotIndex !== undefined) {
return switchIndex === slotIndex ? "✅ " : "❌ ";
}
return switchIndex === 0 ? "❌ " : "✅ ";
}
const connectedCount = getConnectedInputs(node).length;
if (connectedCount < 2) return "";
if (mode === "combine") return "📚 ";
if (mode === "roll") return "🎲 ";
if (mode === "iterate") return "♻️ ";
return "";
}
// Get the child node's mode label for display.
function getChildModeLabel(childNode) {
const mode = childNode.properties?.pcrMode || "switch";
if (mode === "roll") {
const rollIdx = childNode.properties?.pcrRollSelected;
if (rollIdx >= 1) {
const inputs = getConnectedInputs(childNode);
if (rollIdx <= inputs.length) {
const source = getSourceNode(childNode, inputs[rollIdx - 1]);
if (source) {
const name = hasCustomTitle(source) ? source.title : "PromptChain";
return `🎲 ${name}`;
}
}
// fallback: inline ::Label:: options
const selfLabels = getSelfLabelOptions(childNode);
if (rollIdx <= selfLabels.length) {
return `🎲 ${selfLabels[rollIdx - 1].label}`;
}
}
return "🎲 Randomize";
}
if (mode === "combine") return "📚 Combine";
if (mode === "switch") {
const switchIndex = childNode.properties?.pcrSwitchIndex ?? 1;
if (switchIndex === 0) return "❌ None";
const childInputs = getConnectedInputs(childNode);
if (switchIndex >= 1 && switchIndex <= childInputs.length) {
const selectedSlot = childInputs[switchIndex - 1];
const grandchild = getSourceNode(childNode, selectedSlot);
if (grandchild) {
const name = hasCustomTitle(grandchild) ? grandchild.title : "PromptChain";
return `✅ ${name}`;
}
}
// check self labels
const promptWidget = childNode.widgets?.find(w => w.name === "prompt");
const text = promptWidget?.value || "";
const labelMatch = text.match(/^::([^:]+)::/m);
if (labelMatch) {
const lines = text.split("\n").filter(l => /^::([^:]+)::/.test(l.trim()));
if (switchIndex >= 1 && switchIndex <= lines.length) {
const selected = lines[switchIndex - 1].trim().match(/^::([^:]+)::/);
if (selected) return `✅ ${selected[1]}`;
}
}
return "🟢 Switch";
}
if (mode === "iterate") {
const total = countLabeledOptions(childNode) || (childNode.properties?.pcrIterateTotal ?? 0);
const cur = childNode.properties?.pcrIterateCurrent ?? 0;
const cycle = childNode.properties?.pcrIterateCycle ?? 1;
if (total > 0) {
const display = `(${cur + 1}/${total})`;
const cycleText = cycle > 1 ? ` x${cycle}` : "";
return `♻️ Iterate ${display}${cycleText}`;
}
return "♻️ Iterate";
}
return "📚 Combine";
}
// Build order chain: walks the hierarchy from a child node downward.
// Returns array of { node, modeLabel, hasOptions } for each level.
export function buildOrderChain(childNode, maxDepth = 10) {
const chain = [];
let current = childNode;
for (let depth = 0; depth < maxDepth && current; depth++) {
if (!hasOptions(current)) break;
const modeLabel = getChildModeLabel(current);
chain.push({ node: current, modeLabel, hasOptions: true });
// walk to selected child if in switch mode
const mode = current.properties?.pcrMode || "switch";
if (mode === "switch") {
const switchIndex = current.properties?.pcrSwitchIndex ?? 1;
const inputs = getConnectedInputs(current);
if (switchIndex >= 1 && switchIndex <= inputs.length) {
const nextNode = getSourceNode(current, inputs[switchIndex - 1]);
if (nextNode && isPromptChain(nextNode)) {
current = nextNode;
continue;
}
}
}
break;
}
return chain;
}
// Update input slot labels with full order chain.
export function updateInputLabels(node) {
if (!node.inputs) return;
for (let i = 0; i < node.inputs.length; i++) {
const slot = node.inputs[i];
if (!slot.name?.includes("in_")) continue;
if (slot.link == null) {
slot.label = "in";
slot.localized_name = "in";
} else {
const source = getSourceNode(node, slot);
if (source) {
const slotName = slot.name.replace("inputs.", "");
const slotIndex = parseInt(slotName.split("_")[1]) + 1;
const modeIcon = getModeIcon(node, slotIndex);
const name = hasCustomTitle(source) ? source.title : "PromptChain";
const chain = buildOrderChain(source);
if (chain.length > 0) {
const chainStr = chain.map(c => c.modeLabel).join(" ");
slot.label = `${modeIcon}${name} ${chainStr}`;
} else {
slot.label = `${modeIcon}${name}`;
}
slot.localized_name = slot.label;
// store chain data for click handlers
slot._pcrOrderChain = chain;
slot._pcrSourceNode = source;
}
}
// New object reference so Vue detects the label change — but skip
// during active link drags to preserve slot identity for connectSlots.
if (!app.canvas?.linkConnector?.isConnecting) {
node.inputs[i] = { ...slot };
}
}
// notify Vue reactivity that slot labels changed
// node:slot-label:changed triggers shallowReactive array re-splice in Vue
node.graph?.trigger?.("node:slot-label:changed", {
nodeId: node.id, slotType: 1,
});
app.canvas?.setDirty(true, true);
// re-attach click handlers after Vue re-renders the label DOM
requestAnimationFrame(() => attachSlotClickHandlers(node));
}
// Update labels on all parent nodes connected to this node's outputs, recursively.
export function updateParentLabels(childNode) {
if (!childNode.outputs) return;
for (const output of childNode.outputs) {
if (!output.links) continue;
for (const linkId of output.links) {
const link = getLinkInfo(linkId);
if (!link) continue;
const parentNode = app.graph?.getNodeById?.(link.target_id);
if (parentNode?.comfyClass === NODE_TYPE) {
updateInputLabels(parentNode);
updateParentLabels(parentNode);
}
}
}
}
// Check for inline wildcard + child input conflict.
// When both exist, inline ::Label:: lines are ignored (children take priority).
// Visual warning: adds a CSS class that the linter/style can use to highlight labels.
const toastShown = new Set();
export function checkWildcardConflict(node) {
// read from CM editor when available (survives widget value corruption/timing)
const text = node._pcrEditor?.state.doc.toString()
|| node.widgets?.find(w => w.name === "prompt")?.value
|| "";
const hasLabels = /^::([^:]+)::/m.test(text);
const hasChildren = getConnectedInputs(node).length > 0;
const conflict = hasLabels && hasChildren;
node._pcrWildcardConflict = conflict;
// dim via CSS class on the container — try live DOM first (after undo,
// node's container may be detached even with widget.onRemove cleanup)
const container = document.querySelector(`[data-node-id="${node.id}"] .pcr-editor`)
|| node._pcrEditor?.dom?.closest(".pcr-editor");
if (container) container.classList.toggle("pcr-wildcard-conflict", conflict);
// toast once per node per conflict
if (conflict && !toastShown.has(node.id)) {
toastShown.add(node.id);
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "info",
summary: "PromptChain",
detail: "Inline wildcards dimmed — child inputs take priority",
life: 3000,
});
} else {
console.warn("[PromptChain] Inline wildcards ignored — child inputs take priority");
}
} else if (!conflict) {
toastShown.delete(node.id);
}
}
export function cleanupOrderChain(nodeId) {
toastShown.delete(nodeId);
}