Files
Azornes-Comfyui-Model-Resolver/web/resolver/utils/prompt_utils.js
T

598 lines
20 KiB
JavaScript

const PROMPT_WIDGET_NAMES = new Set([
'text',
'prompt',
'positive',
'negative',
'positive_prompt',
'negative_prompt',
'prompt_positive',
'prompt_negative',
'text_positive',
'text_negative',
'text_a',
'text_b',
'text_g',
'text_l',
'text_1',
'text_2',
]);
const PROMPT_NODE_TYPE_PATTERN = /(?:clip.*text|text.*encode|primitive.*string|string.*multiline|text.*multiline|multiline.*text|conditioning.*text|textinput)/i;
const PROMPT_NODE_TYPE_SCORE_RULES = [
{ pattern: /(?:clip.*text|text.*encode)/i, score: 500 },
{ pattern: /conditioning.*text/i, score: 420 },
{ pattern: /(?:primitive.*string|string.*multiline|text.*multiline|multiline.*text|textinput)/i, score: 320 },
];
const SCALAR_WIDGET_TYPES = new Set(['STRING', 'INT', 'FLOAT', 'BOOLEAN']);
const PROMPT_INPUT_NAMES = new Set(['text', 'prompt', 'text_input', 'string']);
const TEXT_WIDGET_TYPES = new Set(['STRING', 'TEXT']);
const NON_TEXT_WIDGET_TYPES = new Set([
'BOOLEAN',
'COMBO',
'DROPDOWN',
'ENUM',
'FLOAT',
'INT',
'LIST',
'NUMBER',
'SELECT',
'SLIDER',
]);
const NON_PROMPT_WIDGET_NAME_PATTERN = /^(?:after_generate|batch(?:_size)?|cfg|control_after_generate|counter|denoise|extension|filename(?:_prefix)?|folder|format|height|list|mode|output(?:_name|_path)?|path|prefix|sampler|scheduler|seed|steps|suffix|width)$/i;
function normalizePromptName(value) {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
function getInputName(input = {}) {
return String(
input?.name
|| input?.widget_name
|| input?.widgetName
|| input?.widget?.name
|| input?.widget?.widget
|| ''
).trim();
}
function getRoleFromLabel(value) {
const token = normalizePromptName(value);
if (!token) return '';
const parts = new Set(token.split('_').filter(Boolean));
if (parts.has('negative') || parts.has('neg') || parts.has('uncond') || parts.has('unconditional')) {
return 'negative';
}
if (parts.has('positive') || parts.has('pos') || parts.has('cond') || parts.has('conditional')) {
return 'positive';
}
return '';
}
function isPromptInputName(value) {
const token = normalizePromptName(value);
return PROMPT_WIDGET_NAMES.has(token)
|| PROMPT_INPUT_NAMES.has(token)
|| token.includes('prompt')
|| token.startsWith('text_')
|| token.endsWith('_text');
}
function isWidgetInput(input = {}) {
if (!input || typeof input !== 'object') return false;
if (
input.widget !== undefined
|| input.widget_name !== undefined
|| input.widgetName !== undefined
|| input.isWidget === true
) return true;
const type = String(input.type || '').trim().toUpperCase();
return input.link == null && SCALAR_WIDGET_TYPES.has(type);
}
function getWidgetInputs(node = {}) {
const inputs = Array.isArray(node.inputs) ? node.inputs : [];
return inputs.filter(isWidgetInput);
}
function getWidgetNames(node = {}, valueCount = 0) {
const names = Array.from({ length: valueCount }, () => '');
const setName = (index, value) => {
if (index < 0 || index >= names.length || names[index]) return;
const name = getInputName(value);
if (name) names[index] = name;
};
const widgets = Array.isArray(node.widgets) ? node.widgets : [];
widgets.forEach((widget, index) => setName(index, widget));
const widgetInputs = getWidgetInputs(node);
widgetInputs.forEach((input, index) => setName(index, input));
const scalarInputs = (Array.isArray(node.inputs) ? node.inputs : [])
.filter(input => input && input.link == null && (
isWidgetInput(input)
|| SCALAR_WIDGET_TYPES.has(String(input.type || '').trim().toUpperCase())
));
scalarInputs.forEach((input, index) => setName(index, input));
return names;
}
function getWidgetInput(node = {}, widgetIndex = -1, widgetName = '') {
const inputs = Array.isArray(node.inputs) ? node.inputs : [];
const normalizedName = normalizePromptName(widgetName);
if (normalizedName) {
const named = inputs.find(input => normalizePromptName(getInputName(input)) === normalizedName);
if (named) return named;
}
const widgetInputs = getWidgetInputs(node);
return widgetInputs[widgetIndex]
|| inputs.filter(input => input && input.link == null)[widgetIndex]
|| null;
}
function getWidgetDescriptors(node = {}) {
const values = Array.isArray(node.widgets_values) ? node.widgets_values : [];
const names = getWidgetNames(node, values.length);
const widgets = Array.isArray(node.widgets) ? node.widgets : [];
const namedValues = node.widgets_values_named && typeof node.widgets_values_named === 'object'
? node.widgets_values_named
: {};
return Array.from({ length: values.length }, (_, index) => {
const value = values[index];
const name = names[index] || '';
const namedValue = name && Object.prototype.hasOwnProperty.call(namedValues, name)
? namedValues[name]
: value;
return {
index,
name,
value: namedValue,
input: getWidgetInput(node, index, name),
widget: widgets[index] || null,
};
});
}
function collectWorkflowNodeEntries(workflow = {}) {
const entries = [];
if (!workflow || typeof workflow !== 'object') return entries;
const appendNodes = (nodes, {
graphKey,
isTopLevel,
subgraphId = '',
subgraphName = '',
}) => {
if (!Array.isArray(nodes)) return;
for (const node of nodes) {
if (!node || typeof node !== 'object' || node.id == null) continue;
entries.push({
node,
graphKey,
isTopLevel,
subgraphId,
subgraphName,
nodeKey: `${graphKey}:${String(node.id)}`,
});
}
};
appendNodes(workflow.nodes, { graphKey: 'top', isTopLevel: true });
const subgraphs = workflow.definitions?.subgraphs;
if (Array.isArray(subgraphs)) {
subgraphs.forEach((subgraph, index) => {
if (!subgraph || typeof subgraph !== 'object') return;
const subgraphId = String(subgraph.id ?? `index-${index}`);
appendNodes(subgraph.nodes, {
graphKey: `sub:${subgraphId}`,
isTopLevel: false,
subgraphId,
subgraphName: String(subgraph.name || subgraph.title || '').trim(),
});
});
}
return entries;
}
function getOutputLinks(node = {}) {
const links = [];
for (const output of Array.isArray(node.outputs) ? node.outputs : []) {
const values = Array.isArray(output?.links)
? output.links
: output?.link != null
? [output.link]
: [];
for (const link of values) {
if (link != null && link !== '') links.push(String(link));
}
}
return links;
}
function getSerializedLinkSource(link) {
const values = Array.isArray(link)
? link
: [
link?.id ?? link?.link_id,
link?.origin_id ?? link?.originId ?? link?.source_id ?? link?.sourceId,
];
const linkId = values[0];
const originNodeId = values[1];
if (linkId == null || linkId === '' || originNodeId == null || originNodeId === '') return null;
return { linkId: String(linkId), originNodeId: String(originNodeId) };
}
function buildGraphConnections(entries, workflow = {}) {
const sourcesByLink = new Map();
for (const entry of entries) {
for (const link of getOutputLinks(entry.node)) {
sourcesByLink.set(`${entry.graphKey}:${link}`, entry.nodeKey);
}
}
const entriesByNodeKey = new Map(entries.map(entry => [entry.nodeKey, entry]));
const addSerializedLinks = (links, graphKey) => {
for (const link of Array.isArray(links) ? links : []) {
const serialized = getSerializedLinkSource(link);
if (!serialized) continue;
const sourceNodeKey = `${graphKey}:${serialized.originNodeId}`;
if (!entriesByNodeKey.has(sourceNodeKey)) continue;
const linkKey = `${graphKey}:${serialized.linkId}`;
if (!sourcesByLink.has(linkKey)) sourcesByLink.set(linkKey, sourceNodeKey);
}
};
addSerializedLinks(workflow.links, 'top');
const subgraphs = Array.isArray(workflow.definitions?.subgraphs)
? workflow.definitions.subgraphs
: [];
for (const [index, subgraph] of subgraphs.entries()) {
if (!subgraph || typeof subgraph !== 'object') continue;
const subgraphId = String(subgraph.id ?? `index-${index}`);
addSerializedLinks(subgraph.links, `sub:${subgraphId}`);
}
return { sourcesByLink };
}
function resolveRole(roleCandidates) {
const candidates = Array.from(new Set(roleCandidates)).filter(Boolean);
return {
role: candidates.length === 1 ? candidates[0] : 'unknown',
roleCandidates: candidates,
};
}
function getNodeRoleCandidates(entry) {
const node = entry.node;
const candidates = [];
for (const label of [
node.title,
node.name,
node.properties?.['Node name for S&R'],
node.properties?.node_name,
node.properties?.nodeName,
node.type,
]) {
const role = getRoleFromLabel(label);
if (role) candidates.push(role);
}
return candidates;
}
function isDedicatedTextNode(node = {}) {
const nodeType = String(node.type || node.comfyClass || '').trim();
return PROMPT_NODE_TYPE_PATTERN.test(nodeType);
}
function isPromptPathInput(entry, input = {}) {
const name = normalizePromptName(getInputName(input));
const type = normalizeWidgetType(input.type);
if (!name && !type) return false;
if (NON_PROMPT_WIDGET_NAME_PATTERN.test(name)) return false;
if (
type === 'INT'
|| type === 'FLOAT'
|| type === 'BOOLEAN'
|| type === 'COMBO'
|| type === 'DROPDOWN'
|| type === 'ENUM'
|| type === 'LIST'
|| type === 'NUMBER'
|| type === 'SELECT'
|| type === 'SLIDER'
) {
return false;
}
return Boolean(
getRoleFromLabel(name)
|| isPromptInputName(name)
|| name.includes('conditioning')
|| type === 'STRING'
|| type === 'TEXT'
|| type.includes('STRING')
|| type.includes('TEXT')
|| type.includes('CONDITIONING')
|| (isDedicatedTextNode(entry.node) && Boolean(name))
);
}
function addRoleCandidate(roleCandidatesByNodeKey, nodeKey, role) {
if (!role) return;
const candidates = roleCandidatesByNodeKey.get(nodeKey) || [];
if (!candidates.includes(role)) candidates.push(role);
roleCandidatesByNodeKey.set(nodeKey, candidates);
}
function buildRoleCandidatesFromGraph(entries, sourcesByLink) {
const roleCandidatesByNodeKey = new Map();
for (const entry of entries) {
roleCandidatesByNodeKey.set(entry.nodeKey, getNodeRoleCandidates(entry));
}
const entriesByNodeKey = new Map(entries.map(entry => [entry.nodeKey, entry]));
const visitUpstream = (startEntry, role) => {
const queue = [startEntry];
const visited = new Set();
while (queue.length) {
const entry = queue.shift();
if (!entry || visited.has(entry.nodeKey)) continue;
visited.add(entry.nodeKey);
addRoleCandidate(roleCandidatesByNodeKey, entry.nodeKey, role);
for (const input of Array.isArray(entry.node.inputs) ? entry.node.inputs : []) {
if (input?.link == null || input.link === '' || !isPromptPathInput(entry, input)) continue;
const sourceNodeKey = sourcesByLink.get(`${entry.graphKey}:${String(input.link)}`);
const sourceEntry = entriesByNodeKey.get(sourceNodeKey);
if (sourceEntry && !visited.has(sourceEntry.nodeKey)) queue.push(sourceEntry);
}
}
};
for (const entry of entries) {
for (const input of Array.isArray(entry.node.inputs) ? entry.node.inputs : []) {
const role = getRoleFromLabel(getInputName(input));
if (!role || input?.link == null || input.link === '') continue;
const sourceNodeKey = sourcesByLink.get(`${entry.graphKey}:${String(input.link)}`);
const sourceEntry = entriesByNodeKey.get(sourceNodeKey);
if (sourceEntry) visitUpstream(sourceEntry, role);
}
}
return roleCandidatesByNodeKey;
}
function normalizeWidgetType(value) {
const rawValue = Array.isArray(value) ? value[0] : value;
return String(rawValue ?? '')
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '_');
}
function getDescriptorWidgetType(descriptor = {}) {
return normalizeWidgetType(
descriptor.input?.type
|| descriptor.widget?.type
|| descriptor.input?.widget?.type
|| descriptor.input?.widget?.widget_type
|| descriptor.input?.widget?.widgetType
);
}
function hasWidgetChoices(descriptor = {}) {
return [descriptor.input, descriptor.widget, descriptor.input?.widget]
.some(source => Array.isArray(source?.options) && source.options.length > 0);
}
function isNonTextWidgetType(type) {
return NON_TEXT_WIDGET_TYPES.has(type)
|| type.includes('COMBO')
|| type.includes('DROPDOWN')
|| type.includes('ENUM')
|| type.includes('LIST')
|| type.includes('SELECT')
|| type.includes('SLIDER');
}
function isTextLikeDescriptor(descriptor = {}) {
const type = getDescriptorWidgetType(descriptor);
return TEXT_WIDGET_TYPES.has(type)
|| type.includes('STRING')
|| type.includes('TEXT');
}
function isPromptTextDescriptor(descriptor = {}, { node = {}, hasGraphPromptRole = false } = {}) {
const name = normalizePromptName(descriptor.name);
const type = getDescriptorWidgetType(descriptor);
const hasExplicitPromptName = Boolean(
getRoleFromLabel(descriptor.name)
|| PROMPT_WIDGET_NAMES.has(name)
|| PROMPT_INPUT_NAMES.has(name)
|| name.includes('prompt')
|| name.startsWith('text_')
|| name.endsWith('_text')
);
if (isNonTextWidgetType(type) || hasWidgetChoices(descriptor)) return false;
if (NON_PROMPT_WIDGET_NAME_PATTERN.test(name)) return false;
return hasExplicitPromptName
|| (isTextLikeDescriptor(descriptor) && (
isDedicatedTextNode(node)
|| hasGraphPromptRole
));
}
function getPromptLabelScore(value, role = '') {
const label = String(value ?? '').trim();
const token = normalizePromptName(label);
if (!token) return 0;
let score = 0;
if (role && getRoleFromLabel(label) === role) score += 180;
if (token.includes('prompt')) score += 110;
if (token === 'text' || token.includes('text')) score += 35;
return score;
}
function getPromptNodeTypeScore(value) {
const nodeType = String(value ?? '').trim();
return PROMPT_NODE_TYPE_SCORE_RULES.find(rule => rule.pattern.test(nodeType))?.score || 0;
}
export function getPromptCandidateScore(prompt = {}, role = '') {
const requestedRole = String(role || '').trim();
const roleCandidates = Array.isArray(prompt.role_candidates)
? prompt.role_candidates
: [];
let score = 0;
if (requestedRole && prompt.role === requestedRole) score += 1000;
else if (requestedRole && roleCandidates.includes(requestedRole)) score += 650;
score += getPromptNodeTypeScore(prompt.node_type);
score += getPromptLabelScore(prompt.node_title, requestedRole);
score += getPromptLabelScore(prompt.widget_name, requestedRole);
return score;
}
export function sortPromptCandidates(prompts = [], role = '') {
if (!Array.isArray(prompts)) return [];
return prompts
.map((prompt, index) => ({
prompt,
index,
score: getPromptCandidateScore(prompt, role),
}))
.sort((left, right) => right.score - left.score || left.index - right.index)
.map(({ prompt }) => prompt);
}
function promptIdentity(entry, descriptor) {
return `${entry.graphKey}:${String(entry.node.id)}:${descriptor.index}:${normalizePromptName(descriptor.name)}`;
}
function findWorkflowNodeForPrompt(workflow = {}, prompt = {}) {
const nodeId = String(prompt.node_id ?? '');
if (!nodeId) return null;
if (prompt.is_top_level !== false) {
return (workflow.nodes || []).find(node => String(node?.id) === nodeId) || null;
}
const subgraphId = String(prompt.subgraph_id || '');
const subgraphs = Array.isArray(workflow.definitions?.subgraphs)
? workflow.definitions.subgraphs
: [];
return subgraphs
.find(subgraph => String(subgraph?.id ?? '') === subgraphId)
?.nodes?.find(node => String(node?.id) === nodeId) || null;
}
export function extractWorkflowPrompts(workflow = {}) {
const entries = collectWorkflowNodeEntries(workflow);
if (!entries.length) return [];
const { sourcesByLink } = buildGraphConnections(entries, workflow);
const roleCandidatesByNodeKey = buildRoleCandidatesFromGraph(entries, sourcesByLink);
const prompts = [];
for (const entry of entries) {
const node = entry.node;
const descriptors = getWidgetDescriptors(node);
const nodeRoleCandidates = roleCandidatesByNodeKey.get(entry.nodeKey) || [];
const stringDescriptorCount = descriptors.filter(descriptor => (
descriptor && typeof descriptor.value === 'string'
)).length;
for (const descriptor of descriptors) {
if (!descriptor || typeof descriptor.value !== 'string') continue;
const text = descriptor.value;
const isUnnamedTextFallback = !descriptor.name
&& !descriptor.input
&& stringDescriptorCount === 1
&& (isDedicatedTextNode(node) || nodeRoleCandidates.length === 1);
const candidate = Boolean(
isPromptTextDescriptor(descriptor, {
node,
hasGraphPromptRole: nodeRoleCandidates.length > 0,
})
|| isUnnamedTextFallback
);
if (!candidate) continue;
const combinedRole = resolveRole([
...nodeRoleCandidates,
getRoleFromLabel(descriptor.name),
]);
const widgetInput = descriptor.input || getWidgetInput(node, descriptor.index, descriptor.name);
prompts.push({
id: promptIdentity(entry, descriptor),
node_id: node.id,
node_type: String(node.type || node.comfyClass || '').trim(),
node_title: String(node.title || '').trim(),
widget_index: descriptor.index,
widget_name: descriptor.name,
text,
role: combinedRole.role,
role_candidates: combinedRole.roleCandidates,
is_top_level: entry.isTopLevel,
subgraph_id: entry.subgraphId,
subgraph_name: entry.subgraphName,
linked: Boolean(
widgetInput
&& widgetInput.link != null
&& widgetInput.link !== ''
),
});
}
}
return prompts;
}
export function updateWorkflowPromptValue(workflow, prompt = {}, value = '') {
if (!workflow || !prompt || prompt.linked) return false;
const node = findWorkflowNodeForPrompt(workflow, prompt);
const widgetIndex = Number(prompt.widget_index);
if (!node || !Array.isArray(node.widgets_values) || !Number.isInteger(widgetIndex) || widgetIndex < 0) {
return false;
}
const widgetInput = getWidgetInput(node, widgetIndex, prompt.widget_name);
if (widgetInput && widgetInput.link != null && widgetInput.link !== '') return false;
const nextValue = String(value ?? '');
node.widgets_values[widgetIndex] = nextValue;
if (
prompt.widget_name
&& node.widgets_values_named
&& typeof node.widgets_values_named === 'object'
&& Object.prototype.hasOwnProperty.call(node.widgets_values_named, prompt.widget_name)
) {
node.widgets_values_named[prompt.widget_name] = nextValue;
}
return true;
}
export function getPromptRoleLabel(role) {
if (role === 'positive') return 'Positive prompt';
if (role === 'negative') return 'Negative prompt';
return 'Unclassified prompt';
}