Files
DemonGatanjieuandClaude Opus 5.5 4abb0100a7 feat(prompts): find prompt boxes by node definition and wiring
One module, prompt_boxes.js, now decides which boxes of a live node take a
prompt and whether each is positive or negative. A prompt box is a
multiline STRING input by the node's ComfyUI definition, not a widget
whose name looks right; its role is its own name (positive / negative) or
the wiring down to a sampler's positive / negative input, not the title.

- Material drops, Prompt Studio, the translator and recipe capture use it;
  their name regexes and fallbacks ("any string widget") are gone.
- Prompt Studio's role detection read graph.links[id], which is a Map in
  current ComfyUI, so it never followed a link; it uses the module now.
- Material writes call onWidgetChanged(name, ...) as ComfyUI expects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 05:46:21 +08:00

145 lines
6.3 KiB
JavaScript

/**
* Prompt boxes on the live canvas: which text boxes of a node take a prompt, and whether
* that prompt is positive or negative. A prompt box is a multiline STRING input by the
* node's own definition (ComfyUI's object_info), whatever the box is called. Its role is
* the box's name when the node's author named it positive / negative; otherwise it comes
* from the wiring: the node's outputs are followed down to an input named positive or
* negative (a guider's single conditioning input counts as positive). No DOM here.
*/
const POSITIVE_NAME = /^(positive|positive_prompt|text_positive|正面|正向|正面提示词|正向提示词)$/i;
const NEGATIVE_NAME = /^(negative|negative_prompt|text_negative|负面|反向|负面提示词|反向提示词)$/i;
const MAX_HOPS = 16;
/** 'positive' | 'negative' | '' for a box (or serialized widget) name that says so itself. */
export function promptNameRole(name) {
const trimmed = String(name || '').trim();
if (POSITIVE_NAME.test(trimmed)) return 'positive';
if (NEGATIVE_NAME.test(trimmed)) return 'negative';
return '';
}
function definition(type) {
return globalThis.LiteGraph?.registered_node_types?.[type]?.nodeData || null;
}
function inputSpec(nodeData, name) {
const input = nodeData?.input || {};
return input.required?.[name] || input.optional?.[name] || null;
}
const isMultilineString = (spec) => Array.isArray(spec) && spec[0] === 'STRING' && Boolean(spec[1]?.multiline);
/** Whether nodes of this type have a prompt box (by their definition). */
export function typeTakesPrompt(type) {
const input = definition(type)?.input || {};
return [...Object.values(input.required || {}), ...Object.values(input.optional || {})].some(isMultilineString);
}
export function isPromptBox(node, widget) {
const nodeData = node?.constructor?.nodeData || definition(node?.type);
return typeof widget?.value === 'string' && isMultilineString(inputSpec(nodeData, widget.name));
}
function downstream(node, graph) {
const found = [];
for (const output of node.outputs || []) {
for (const linkId of output.links || []) {
const link = graph.getLink?.(linkId) ?? graph.links?.[linkId];
const target = link && graph.getNodeById(link.target_id);
if (target) found.push({ target, input: String(target.inputs?.[link.target_slot]?.name || '') });
}
}
return found;
}
/**
* Where a node's output ends up: { role: 'positive' | 'negative' | 'both' | '', samplers }
* with the ids of the nodes whose positive / negative input it reaches.
*/
export function wiredRole(node, graph = node?.graph) {
const roles = new Set();
const samplers = new Set();
if (!node || !graph) return { role: '', samplers };
const seen = new Set([node.id]);
let frontier = [node];
for (let hop = 0; hop < MAX_HOPS && frontier.length; hop++) {
const next = [];
for (const current of frontier) {
for (const { target, input } of downstream(current, graph)) {
const guider = /guider/i.test(target.type) && /^(conditioning|cond\d*)$/i.test(input);
const role = /positive/i.test(input) || guider ? 'positive' : /negative/i.test(input) ? 'negative' : '';
if (role) {
roles.add(role);
samplers.add(target.id);
} else if (!seen.has(target.id)) {
seen.add(target.id);
next.push(target);
}
}
}
frontier = next;
}
return { role: roles.size === 2 ? 'both' : [...roles][0] || '', samplers };
}
/** A node's prompt boxes: [{ index, widget, name, role }], role '' when nothing tells. */
export function promptBoxes(node) {
const boxes = (node?.widgets || []).flatMap((widget, index) => (
isPromptBox(node, widget) ? [{ index, widget, name: widget.name, role: '' }] : []
));
let wired = null;
for (const box of boxes) {
box.role = promptNameRole(box.name) || (wired ||= wiredRole(node)).role;
}
return boxes;
}
/** The text of a prompt envelope ({ positive, negative, singleText, primaryRole }) for a box of `role`. */
export function textForRole(envelope, role) {
if (role === 'negative') return envelope.negative || '';
if (role === 'positive' || role === 'both') return envelope.positive || '';
return envelope.primaryRole === 'negative' ? envelope.negative : (envelope.positive || envelope.singleText || envelope.negative || '');
}
/**
* What filling `node` with an envelope writes: [{ index, value, role }]. With `box` (one of
* the node's boxes, chosen by the user) only that box, with the text of its role, or the
* envelope's main text when the envelope has none of that role. Without it: every box that
* has text of its own role; a node with one box of unknown role takes the main text.
*/
export function planPromptFill(node, envelope, box = null) {
if (box) {
const value = textForRole(envelope, box.role) || textForRole(envelope, '');
return value ? [{ index: box.index, value, role: box.role }] : [];
}
const boxes = promptBoxes(node);
const known = boxes.filter(item => item.role && textForRole(envelope, item.role));
if (known.length) return known.map(item => ({ index: item.index, value: textForRole(envelope, item.role), role: item.role }));
if (boxes.length === 1 && !boxes[0].role) {
const value = textForRole(envelope, '');
return value ? [{ index: boxes[0].index, value, role: '' }] : [];
}
return [];
}
/**
* The one box on another node that takes the opposite role for the same sampler as
* `node`'s output, or null (none, or more than one).
*/
export function partnerBox(node, role, graph = node?.graph) {
if (!graph || (role !== 'positive' && role !== 'negative')) return null;
const { samplers } = wiredRole(node, graph);
if (!samplers.size) return null;
const wanted = role === 'positive' ? 'negative' : 'positive';
const found = [];
for (const other of graph._nodes || []) {
if (other === node) continue;
const boxes = promptBoxes(other).filter(box => box.role === wanted);
if (!boxes.length) continue;
const reach = wiredRole(other, graph).samplers;
if ([...reach].some(id => samplers.has(id))) found.push(...boxes.map(box => ({ node: other, box })));
}
return found.length === 1 ? found[0] : null;
}