From e667c1cf93ed579ab64742a7038b979096ccfa41 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Tue, 27 Oct 2026 22:03:57 +0100 Subject: [PATCH] Resolve upstream prompt text from string nodes and preserve linked prompts in metadata view --- tests/test_prompt_transfer.mjs | 80 ++++++++++ web/resolver/utils/prompt_utils.js | 141 ++++++++++++++++-- .../views/tabs_image_metadata_methods.js | 2 +- 3 files changed, 207 insertions(+), 16 deletions(-) diff --git a/tests/test_prompt_transfer.mjs b/tests/test_prompt_transfer.mjs index 0621b11..42746d6 100644 --- a/tests/test_prompt_transfer.mjs +++ b/tests/test_prompt_transfer.mjs @@ -607,3 +607,83 @@ test('metadata prompt transfer replaces both selected prompt fields in the curre else globalThis.window = previousWindow; } }); + +test('workflow prompt extraction resolves linked text from upstream StringConcatenate and recognizes widget names', () => { + const workflow = { + nodes: [ + { + id: 3, + type: 'CLIPLoader', + widgets_values: ['qwen.safetensors', 'moss_image', 'default'], + widgets_values_named: { clip_name: 'qwen.safetensors', type: 'moss_image', device: 'default' }, + outputs: [{ name: 'CLIP', type: 'CLIP', links: [2, 3] }], + }, + { + id: 5, + type: 'CLIPTextEncode', + title: 'Positive Condition', + widgets_values: [''], + widgets_values_named: { text: '' }, + inputs: [ + { name: 'clip', type: 'CLIP', link: 2 }, + { name: 'text', type: 'STRING', widget: { name: 'text' }, link: 1 }, + ], + outputs: [{ name: 'CONDITIONING', type: 'CONDITIONING', links: [8] }], + }, + { + id: 6, + type: 'CLIPTextEncode', + title: 'Negative Prompt', + widgets_values: ['bad anatomy, extra limbs'], + widgets_values_named: { text: 'bad anatomy, extra limbs' }, + inputs: [{ name: 'clip', type: 'CLIP', link: 3 }], + outputs: [{ name: 'CONDITIONING', type: 'CONDITIONING', links: [9] }], + }, + { + id: 250, + type: 'StringConcatenate', + widgets_values: ['Testowy promptaasdas', '', ''], + widgets_values_named: { string_a: 'Testowy promptaasdas', string_b: '', delimiter: '' }, + outputs: [{ name: 'STRING', type: 'STRING', links: [1] }], + }, + { + id: '22_17', + type: 'KSampler', + inputs: [ + { name: 'positive', type: 'CONDITIONING', link: 8 }, + { name: 'negative', type: 'CONDITIONING', link: 9 }, + ], + }, + ], + links: [ + [1, 250, 0, 5, 1, 'STRING'], + [2, 3, 0, 5, 0, 'CLIP'], + [3, 3, 0, 6, 0, 'CLIP'], + [8, 5, 0, '22_17', 1, 'CONDITIONING'], + [9, 6, 0, '22_17', 2, 'CONDITIONING'], + ], + }; + + const prompts = extractWorkflowPrompts(workflow); + assert.equal(prompts.some(p => p.node_id === 3), false); + + const node5 = prompts.find(p => p.node_id === 5); + assert.ok(node5); + assert.equal(node5.role, 'positive'); + assert.equal(node5.text, 'Testowy promptaasdas'); + assert.equal(node5.linked, true); + assert.equal(node5.has_resolved_text, true); + + const node250 = prompts.find(p => p.node_id === 250 && p.widget_name === 'string_a'); + assert.ok(node250); + assert.equal(node250.role, 'positive'); + assert.equal(node250.text, 'Testowy promptaasdas'); + assert.equal(node250.linked, false); + + const context = createContext(workflow, createRoleWorkflow('current pos', 'current neg')); + const transferData = context.getMetadataPromptTransferData(context.imageInspectorState); + assert.equal(transferData.rows[0].role, 'positive'); + assert.equal(transferData.rows[0].source.text, 'Testowy promptaasdas'); + assert.equal(transferData.rows[1].role, 'negative'); + assert.equal(transferData.rows[1].source.text, 'bad anatomy, extra limbs'); +}); diff --git a/web/resolver/utils/prompt_utils.js b/web/resolver/utils/prompt_utils.js index 8402ae7..4150e91 100644 --- a/web/resolver/utils/prompt_utils.js +++ b/web/resolver/utils/prompt_utils.js @@ -11,20 +11,37 @@ const PROMPT_WIDGET_NAMES = new Set([ 'text_negative', 'text_a', 'text_b', + 'text_c', + 'text_d', 'text_g', 'text_l', 'text_1', 'text_2', + 'string_a', + 'string_b', + 'string_c', + 'string_d', + 'string_g', + 'string_l', + 'string_1', + 'string_2', + 'string_positive', + 'string_negative', + 'value', + 'clip_l', + 'clip_g', + 't5xxl', + 't5', ]); -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_PATTERN = /(?:clip.*text|text.*encode|primitive.*string|string.*multiline|text.*multiline|multiline.*text|conditioning.*text|textinput|string.*concat|text.*concat|string.*combine|text.*combine|string.*format|text.*format)/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 }, + { pattern: /(?:primitive.*string|string.*multiline|text.*multiline|multiline.*text|textinput|string.*concat|text.*concat|string.*combine|text.*combine|string.*format|text.*format)/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 PROMPT_INPUT_NAMES = new Set(['text', 'prompt', 'text_input', 'string', 'value']); const TEXT_WIDGET_TYPES = new Set(['STRING', 'TEXT']); const NON_TEXT_WIDGET_TYPES = new Set([ 'BOOLEAN', @@ -38,7 +55,7 @@ const NON_TEXT_WIDGET_TYPES = new Set([ '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; +const NON_PROMPT_WIDGET_NAME_PATTERN = /^(?:after_generate|batch(?:_size)?|cfg|checkpoint_name|ckpt_name|clip_name|control_after_generate|counter|delimiter|denoise|device|extension|filename(?:_prefix)?|folder|format|height|list|lora_name|mode|model_name|output(?:_name|_path)?|path|prefix|sampler|scheduler|seed|sep|separator|steps|suffix|unet_name|vae_name|weight_dtype|width)$/i; function normalizePromptName(value) { return String(value ?? '') @@ -49,6 +66,7 @@ function normalizePromptName(value) { } function getInputName(input = {}) { + if (typeof input === 'string') return input.trim(); return String( input?.name || input?.widget_name @@ -79,7 +97,9 @@ function isPromptInputName(value) { || PROMPT_INPUT_NAMES.has(token) || token.includes('prompt') || token.startsWith('text_') - || token.endsWith('_text'); + || token.endsWith('_text') + || token.startsWith('string_') + || token.endsWith('_string'); } function isWidgetInput(input = {}) { @@ -114,6 +134,11 @@ function getWidgetNames(node = {}, valueCount = 0) { const widgetInputs = getWidgetInputs(node); widgetInputs.forEach((input, index) => setName(index, input)); + if (node.widgets_values_named && typeof node.widgets_values_named === 'object') { + const namedKeys = Object.keys(node.widgets_values_named); + namedKeys.forEach((key, index) => setName(index, key)); + } + const scalarInputs = (Array.isArray(node.inputs) ? node.inputs : []) .filter(input => input && input.link == null && ( isWidgetInput(input) @@ -312,6 +337,17 @@ function isPromptPathInput(entry, input = {}) { || type === 'NUMBER' || type === 'SELECT' || type === 'SLIDER' + || type === 'CLIP' + || type === 'MODEL' + || type === 'VAE' + || type === 'IMAGE' + || type === 'LATENT' + || type === 'MASK' + || type === 'CONTROL_NET' + || type === 'NOISE' + || type === 'SIGMAS' + || type === 'SAMPLER' + || type === 'GUIDER' ) { return false; } @@ -411,7 +447,8 @@ function isTextLikeDescriptor(descriptor = {}) { const type = getDescriptorWidgetType(descriptor); return TEXT_WIDGET_TYPES.has(type) || type.includes('STRING') - || type.includes('TEXT'); + || type.includes('TEXT') + || (!type && typeof descriptor.value === 'string'); } function isPromptTextDescriptor(descriptor = {}, { node = {}, hasGraphPromptRole = false } = {}) { @@ -424,6 +461,8 @@ function isPromptTextDescriptor(descriptor = {}, { node = {}, hasGraphPromptRole || name.includes('prompt') || name.startsWith('text_') || name.endsWith('_text') + || name.startsWith('string_') + || name.endsWith('_string') ); if (isNonTextWidgetType(type) || hasWidgetChoices(descriptor)) return false; if (NON_PROMPT_WIDGET_NAME_PATTERN.test(name)) return false; @@ -443,7 +482,7 @@ function getPromptLabelScore(value, role = '') { let score = 0; if (role && getRoleFromLabel(label) === role) score += 180; if (token.includes('prompt')) score += 110; - if (token === 'text' || token.includes('text')) score += 35; + if (token === 'text' || token.includes('text') || token === 'string' || token.includes('string')) score += 35; return score; } @@ -502,12 +541,78 @@ function findWorkflowNodeForPrompt(workflow = {}, prompt = {}) { ?.nodes?.find(node => String(node?.id) === nodeId) || null; } +function getNodeWidgetValue(node = {}, name = '', index = -1) { + if (name && node.widgets_values_named && typeof node.widgets_values_named === 'object') { + if (Object.prototype.hasOwnProperty.call(node.widgets_values_named, name)) { + const v = node.widgets_values_named[name]; + if (v != null) return String(v); + } + } + if (Array.isArray(node.widgets_values) && index >= 0 && index < node.widgets_values.length) { + const v = node.widgets_values[index]; + if (v != null) return String(v); + } + return ''; +} + +function resolveUpstreamPromptText(entry, linkId, entriesByNodeKey, sourcesByLink, visited = new Set(), depth = 0) { + if (!linkId || depth > 10) return ''; + const sourceNodeKey = sourcesByLink.get(`${entry.graphKey}:${String(linkId)}`); + if (!sourceNodeKey || visited.has(sourceNodeKey)) return ''; + visited.add(sourceNodeKey); + + const sourceEntry = entriesByNodeKey.get(sourceNodeKey); + if (!sourceEntry) return ''; + const node = sourceEntry.node; + if (!node || typeof node !== 'object') return ''; + + const nodeType = String(node.type || node.comfyClass || '').trim(); + + // StringConcatenate / text concat / combine nodes + if (/(?:string|text).*(?:concat|combine)/i.test(nodeType)) { + const inputA = (node.inputs || []).find(i => /^(?:string|text)[_-]?a$/i.test(getInputName(i))); + const stringA = (inputA && inputA.link != null && inputA.link !== '') + ? resolveUpstreamPromptText(sourceEntry, inputA.link, entriesByNodeKey, sourcesByLink, visited, depth + 1) + : getNodeWidgetValue(node, 'string_a', 0); + + const inputB = (node.inputs || []).find(i => /^(?:string|text)[_-]?b$/i.test(getInputName(i))); + const stringB = (inputB && inputB.link != null && inputB.link !== '') + ? resolveUpstreamPromptText(sourceEntry, inputB.link, entriesByNodeKey, sourcesByLink, visited, depth + 1) + : getNodeWidgetValue(node, 'string_b', 1); + + const delimiter = getNodeWidgetValue(node, 'delimiter', 2); + if (stringA && stringB) return `${stringA}${delimiter}${stringB}`; + return stringA || stringB || ''; + } + + // Generic text/string nodes + for (const key of ['text', 'string', 'value', 'prompt']) { + const inputKey = (node.inputs || []).find(i => normalizePromptName(getInputName(i)) === key); + if (inputKey && inputKey.link != null && inputKey.link !== '') { + const resolved = resolveUpstreamPromptText(sourceEntry, inputKey.link, entriesByNodeKey, sourcesByLink, visited, depth + 1); + if (resolved) return resolved; + } + const val = getNodeWidgetValue(node, key, 0); + if (val && String(val).trim()) return String(val); + } + + if (Array.isArray(node.widgets_values)) { + for (let i = 0; i < node.widgets_values.length; i++) { + const v = node.widgets_values[i]; + if (typeof v === 'string' && v.trim()) return v; + } + } + + return ''; +} + export function extractWorkflowPrompts(workflow = {}) { const entries = collectWorkflowNodeEntries(workflow); if (!entries.length) return []; const { sourcesByLink } = buildGraphConnections(entries, workflow); const roleCandidatesByNodeKey = buildRoleCandidatesFromGraph(entries, sourcesByLink); + const entriesByNodeKey = new Map(entries.map(e => [e.nodeKey, e])); const prompts = []; for (const entry of entries) { @@ -524,8 +629,7 @@ export function extractWorkflowPrompts(workflow = {}) { const isUnnamedTextFallback = !descriptor.name && !descriptor.input - && stringDescriptorCount === 1 - && (isDedicatedTextNode(node) || nodeRoleCandidates.length === 1); + && (isDedicatedTextNode(node) || (stringDescriptorCount === 1 && nodeRoleCandidates.length === 1)); const candidate = Boolean( isPromptTextDescriptor(descriptor, { node, @@ -540,6 +644,16 @@ export function extractWorkflowPrompts(workflow = {}) { getRoleFromLabel(descriptor.name), ]); const widgetInput = descriptor.input || getWidgetInput(node, descriptor.index, descriptor.name); + const isLinked = Boolean( + widgetInput + && widgetInput.link != null + && widgetInput.link !== '' + ); + const resolvedText = isLinked + ? resolveUpstreamPromptText(entry, widgetInput.link, entriesByNodeKey, sourcesByLink) + : ''; + const finalPromptText = (text && text.trim()) ? text : (resolvedText || text); + prompts.push({ id: promptIdentity(entry, descriptor), node_id: node.id, @@ -547,17 +661,14 @@ export function extractWorkflowPrompts(workflow = {}) { node_title: String(node.title || '').trim(), widget_index: descriptor.index, widget_name: descriptor.name, - text, + text: finalPromptText, 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 !== '' - ), + linked: isLinked, + has_resolved_text: Boolean(resolvedText), }); } } diff --git a/web/resolver/views/tabs_image_metadata_methods.js b/web/resolver/views/tabs_image_metadata_methods.js index 8bb1d91..a1c88b9 100644 --- a/web/resolver/views/tabs_image_metadata_methods.js +++ b/web/resolver/views/tabs_image_metadata_methods.js @@ -1188,7 +1188,7 @@ export const imageMetadataMethods = { const imageState = state || getImageInspectorState(this); const promptTransfer = this.getMetadataPromptTransferState(imageState); const sourcePrompts = extractWorkflowPrompts(imageState.metadata?.workflow || {}) - .filter(prompt => !prompt.linked && String(prompt.text || '').trim()); + .filter(prompt => (!prompt.linked || prompt.has_resolved_text) && String(prompt.text || '').trim()); if (!sourcePrompts.length && imageState.metadata?.civitai_metadata) { const cm = imageState.metadata.civitai_metadata; if (cm.prompt && typeof cm.prompt === 'string' && cm.prompt.trim()) {