Add image metadata prompt transfer and keep transfer nodes selectable

This commit is contained in:
Dariusz L
2026-09-17 00:05:43 +02:00
parent 4054be2a04
commit 2d6394be5c
5 changed files with 1689 additions and 29 deletions
+35
View File
@@ -214,6 +214,41 @@ test('metadata transfer shows a spinner while applying', () => {
assert.match(html, /<span>Applying\.\.\.<\/span>/);
});
test('metadata transfer keeps target node checkboxes selectable without an imported workflow', () => {
const context = createTransferContext();
const state = createTransferState([]);
state.loadedModels = null;
state.transfer.selectedNodeKeys = new Set();
let renderCount = 0;
context.imageInspectorState = state;
context.renderMetadataTransferPanelInPlace = () => {
renderCount += 1;
return true;
};
const html = context.renderMetadataTransferPanel(state);
assert.match(html, /data-image-transfer-node="top:1"/);
const targetButton = html.match(/<button[^>]*data-image-transfer-node="top:1"[^>]*>/)?.[0] || '';
assert.doesNotMatch(targetButton, /\sdisabled(?:\s|=|>)/);
context.toggleMetadataTransferNode('top:1');
assert.deepEqual(Array.from(state.transfer.selectedNodeKeys), ['top:1']);
assert.equal(renderCount, 1);
});
test('metadata transfer does not disable a target category missing from the imported workflow', () => {
const context = createTransferContext();
const state = createTransferState([
{ category: 'checkpoints', original_path: 'imported-checkpoint.safetensors' },
]);
const html = context.renderMetadataTransferPanel(state);
assert.match(html, /No source model/);
const targetButton = html.match(/<button[^>]*data-image-transfer-node="top:1"[^>]*>/)?.[0] || '';
assert.doesNotMatch(targetButton, /\sdisabled(?:\s|=|>)/);
});
test('metadata transfer exposes Undo transfer while the post-transfer workflow is unchanged', () => {
const context = createTransferContext();
const state = createTransferState([
+484
View File
@@ -0,0 +1,484 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { imageMetadataMethods } from '../web/resolver/views/tabs_image_metadata_methods.js';
import {
extractWorkflowPrompts,
updateWorkflowPromptValue,
} from '../web/resolver/utils/prompt_utils.js';
function clipTextNode(id, text, outputLink, { title = '', textLink = null } = {}) {
return {
id,
type: 'CLIPTextEncode',
title,
widgets_values: [text],
inputs: [
{ name: 'clip', type: 'CLIP', link: 1 },
{ name: 'text', type: 'STRING', widget: { name: 'text' }, link: textLink },
],
outputs: [{ links: [outputLink] }],
};
}
function createRoleWorkflow(positiveText = 'positive source', negativeText = 'negative source') {
return {
nodes: [
clipTextNode(1, positiveText, 11),
clipTextNode(2, negativeText, 12),
{
id: 3,
type: 'KSampler',
inputs: [
{ name: 'positive', type: 'CONDITIONING', link: 11 },
{ name: 'negative', type: 'CONDITIONING', link: 12 },
],
},
],
};
}
function createContext(sourceWorkflow, currentWorkflow) {
const state = {
metadata: { workflow: sourceWorkflow },
promptTransfer: null,
};
const notifications = [];
let workflow = currentWorkflow;
return {
...imageMetadataMethods,
imageInspectorState: state,
getCurrentWorkflow: () => workflow,
getWorkflowSignature: value => JSON.stringify(value),
updateWorkflowInComfyUI: async value => {
workflow = value;
return true;
},
openMetadataTransfer() {},
renderMetadataPromptTransferPanelInPlace() {},
escapeHtml: value => String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;'),
showNotification(message, type) {
notifications.push({ message, type });
},
getWorkflow() {
return workflow;
},
notifications,
};
}
test('workflow prompt extraction distinguishes positive and negative CLIP text nodes', () => {
const prompts = extractWorkflowPrompts(createRoleWorkflow());
assert.deepEqual(
prompts.map(prompt => [prompt.node_id, prompt.text, prompt.role, prompt.widget_name]),
[
[1, 'positive source', 'positive', 'text'],
[2, 'negative source', 'negative', 'text'],
],
);
});
test('workflow prompt extraction inherits a role for a plain text node feeding CLIPTextEncode', () => {
const workflow = {
nodes: [
{
id: 10,
type: 'PrimitiveStringMultiline',
widgets_values: ['plain positive'],
outputs: [{ links: [20] }],
},
clipTextNode(11, 'stale value', 30, { textLink: 20 }),
{
id: 12,
type: 'KSampler',
inputs: [{ name: 'positive', type: 'CONDITIONING', link: 30 }],
},
],
};
const prompts = extractWorkflowPrompts(workflow);
assert.equal(prompts.find(prompt => prompt.node_id === 10)?.role, 'positive');
assert.equal(prompts.find(prompt => prompt.node_id === 10)?.text, 'plain positive');
assert.equal(prompts.find(prompt => prompt.node_id === 11)?.linked, true);
});
test('workflow prompt extraction recognizes generic text nodes through prompt-node links', () => {
const workflow = {
nodes: [
{
id: 20,
type: 'StringLiteral',
widgets_values: ['generic positive'],
outputs: [{ links: [40] }],
},
{
id: 21,
type: 'CLIPTextEncodeSD3',
widgets_values: ['stale value'],
inputs: [{ name: 'clip_l', type: 'STRING', widget: { name: 'clip_l' }, link: 40 }],
outputs: [{ links: [41] }],
},
{
id: 22,
type: 'KSampler',
inputs: [{ name: 'positive', type: 'CONDITIONING', link: 41 }],
},
],
};
const prompt = extractWorkflowPrompts(workflow).find(item => item.node_id === 20);
assert.equal(prompt?.text, 'generic positive');
assert.equal(prompt?.role, 'positive');
});
test('workflow prompt extraction ignores list and control widgets on prompt-named nodes', () => {
const workflow = {
nodes: [
{
id: 30,
type: 'Prompt enhancement seed',
title: 'Prompt enhancement seed (if used)',
widgets: [
{ name: 'value', type: 'INT' },
{ name: 'control_after_generate', type: 'COMBO', options: ['fixed', 'increment'] },
],
widgets_values: [1, 'fixed'],
inputs: [
{ name: 'value', type: 'INT' },
{ name: 'control_after_generate', type: 'COMBO', options: ['fixed', 'increment'] },
],
},
{
id: 31,
type: 'Prompt enhancement seed',
title: 'Prompt enhancement seed (if used)',
widgets_values: [1, 'fixed'],
},
],
};
assert.deepEqual(extractWorkflowPrompts(workflow), []);
});
test('workflow prompt extraction ignores filename fields on image output nodes', () => {
const workflow = {
nodes: [
{
id: 371,
type: 'CLIPTextEncode',
title: 'Prompt',
widgets_values: ['This is prompt positive'],
outputs: [{ name: 'CONDITIONING', type: 'CONDITIONING', links: [584] }],
},
{
id: 594,
type: 'SaveImage',
title: 'Almost done',
widgets: [{ name: 'filename_prefix', type: 'STRING', value: 'ComfyUI' }],
widgets_values: ['ComfyUI'],
inputs: [{ name: 'images', type: 'IMAGE', link: 942 }],
outputs: [{ name: 'images', type: 'IMAGE', links: null }],
},
{
id: 596,
type: 'SaveImage',
title: 'Your Porn',
widgets: [{ name: 'filename_prefix', type: 'STRING', value: 'ComfyUI' }],
widgets_values: ['ComfyUI'],
inputs: [{ name: 'images', type: 'IMAGE', link: 945 }],
outputs: [{ name: 'images', type: 'IMAGE', links: null }],
},
],
};
assert.deepEqual(
extractWorkflowPrompts(workflow).map(prompt => prompt.node_id),
[371],
);
});
test('workflow prompt extraction tolerates sparse widget value arrays', () => {
const widgetsValues = new Array(2);
widgetsValues[1] = 'sparse prompt';
const workflow = {
nodes: [{
id: 32,
type: 'PrimitiveStringMultiline',
widgets_values: widgetsValues,
}],
};
assert.deepEqual(
extractWorkflowPrompts(workflow).map(prompt => [prompt.node_id, prompt.widget_index, prompt.text]),
[[32, 1, 'sparse prompt']],
);
});
test('workflow prompt extraction follows serialized positive links and leaves negative optional', () => {
const workflow = {
nodes: [
{
id: 40,
type: 'PrimitiveStringMultiline',
widgets_values: ['serialized positive'],
},
{
id: 41,
type: 'CLIPTextEncode',
widgets_values: ['stale value'],
inputs: [{ name: 'text', type: 'STRING', widget: { name: 'text' }, link: 401 }],
},
{
id: 42,
type: 'KSampler',
inputs: [{ name: 'positive', type: 'CONDITIONING', link: 402 }],
},
],
links: [
[401, 40, 0, 41, 0, 'STRING'],
[402, 41, 0, 42, 0, 'CONDITIONING'],
],
};
const prompts = extractWorkflowPrompts(workflow);
assert.equal(prompts.find(prompt => prompt.node_id === 40)?.role, 'positive');
assert.equal(prompts.some(prompt => prompt.role === 'negative'), false);
});
test('workflow prompt traversal follows text inputs and skips unrelated seed branches', () => {
const workflow = {
nodes: [
{
id: 50,
type: 'StringLiteral',
widgets_values: ['connected positive'],
outputs: [{ links: [501] }],
},
{
id: 51,
type: 'PromptEnhancer',
widgets: [
{ name: 'text', type: 'STRING' },
{ name: 'control_after_generate', type: 'COMBO', options: ['fixed', 'increment'] },
],
widgets_values: ['enhanced positive', 'fixed'],
inputs: [
{ name: 'text', type: 'STRING', link: 501 },
{ name: 'seed', type: 'INT', link: 502 },
],
outputs: [{ links: [503] }],
},
{
id: 52,
type: 'Prompt enhancement seed',
widgets: [
{ name: 'value', type: 'INT' },
{ name: 'control_after_generate', type: 'COMBO', options: ['fixed', 'increment'] },
],
widgets_values: [1, 'fixed'],
outputs: [{ links: [502] }],
},
{
id: 53,
type: 'KSampler',
inputs: [{ name: 'positive', type: 'CONDITIONING', link: 503 }],
},
],
};
const prompts = extractWorkflowPrompts(workflow);
assert.deepEqual(
prompts.map(prompt => [prompt.node_id, prompt.text, prompt.role]),
[
[50, 'connected positive', 'positive'],
[51, 'enhanced positive', 'positive'],
],
);
});
test('workflow prompt replacement updates the serialized text widget and named value', () => {
const workflow = createRoleWorkflow();
workflow.nodes[0].widgets_values_named = { text: 'positive source' };
const prompt = extractWorkflowPrompts(workflow).find(item => item.role === 'positive');
assert.equal(updateWorkflowPromptValue(workflow, prompt, 'replacement'), true);
assert.equal(workflow.nodes[0].widgets_values[0], 'replacement');
assert.equal(workflow.nodes[0].widgets_values_named.text, 'replacement');
});
test('linked prompt inputs are not treated as replaceable text fields', () => {
const workflow = createRoleWorkflow('linked value');
workflow.nodes[0].inputs[1].link = 99;
const prompt = extractWorkflowPrompts(workflow).find(item => item.node_id === 1);
assert.equal(prompt.linked, true);
assert.equal(updateWorkflowPromptValue(workflow, prompt, 'replacement'), false);
assert.equal(workflow.nodes[0].widgets_values[0], 'linked value');
});
test('metadata prompt panel renders separate positive and negative transfer rows', () => {
const context = createContext(createRoleWorkflow(), createRoleWorkflow('current positive', 'current negative'));
const contextMenuCalls = [];
context.getContextMenuAttrs = (model, tooltip) => {
contextMenuCalls.push({ model, tooltip });
return ` data-context-scope="${model.context_scope}" data-node-id="${model.node_id}"`;
};
const rows = context.getMetadataPromptTransferRows(context.imageInspectorState);
const html = context.renderMetadataPromptTransferPanel(context.imageInspectorState);
assert.deepEqual(rows.map(row => row.role), ['positive', 'negative']);
assert.equal(rows[0].source.text, 'positive source');
assert.equal(rows[1].source.text, 'negative source');
assert.match(html, /Positive prompt/);
assert.match(html, /Negative prompt/);
assert.match(html, /data-image-prompt-source="positive"/);
assert.match(html, /data-image-prompt-target="negative"/);
assert.match(html, /data-image-prompt-role-selection="positive"[^>]*checked/);
assert.match(html, /data-image-prompt-role-selection="negative"[^>]*checked/);
const positiveTargetIndex = html.indexOf('data-image-prompt-target="positive"');
const positiveSourceIndex = html.indexOf('data-image-prompt-source="positive"');
assert.ok(positiveTargetIndex >= 0 && positiveTargetIndex < positiveSourceIndex);
assert.deepEqual(
contextMenuCalls.map(call => [call.model.context_scope, call.model.node_id]),
[['workflow_node', 1], ['workflow_node', 2]],
);
assert.match(html, /class="mr-image-prompt-side is-context-menu" data-context-scope="workflow_node" data-node-id="1"/);
assert.match(html, /Replace prompts/);
});
test('metadata prompt role checkboxes stay selectable without an imported workflow', () => {
const context = createContext(null, createRoleWorkflow('current positive', 'current negative'));
context.imageInspectorState.metadata = null;
const html = context.renderMetadataPromptTransferPanel(context.imageInspectorState);
for (const role of ['positive', 'negative']) {
const checkbox = html.match(new RegExp(`<input type="checkbox" data-image-prompt-role-selection="${role}"[^>]*>`))?.[0] || '';
assert.match(checkbox, /type="checkbox"/);
assert.doesNotMatch(checkbox, /disabled/);
}
});
test('metadata prompt role selection survives importing a source workflow', async () => {
const context = createContext(null, createRoleWorkflow('current positive', 'current negative'));
const promptTransfer = context.getMetadataPromptTransferState(context.imageInspectorState);
promptTransfer.selectedRoles = new Set(['positive']);
promptTransfer.sourceSelections = { positive: 'old-source' };
context.fetchJson = async (_endpoint, options) => {
const payload = JSON.parse(options.body);
if (payload.source_type === 'workflow') {
return { workflow: createRoleWorkflow('imported positive', 'imported negative') };
}
return { loaded_models: [], total: 0 };
};
context.renderImageMetadataResult = () => {};
await context.runImageMetadataInspection({
source_type: 'workflow',
filename: 'source.json',
});
assert.deepEqual(Array.from(context.imageInspectorState.promptTransfer.selectedRoles), ['positive']);
assert.deepEqual(context.imageInspectorState.promptTransfer.sourceSelections, {});
});
test('metadata prompt transfer applies only the selected prompt role', async () => {
const previousWindow = globalThis.window;
globalThis.window = { dispatchEvent() {} };
try {
for (const [selectedRole, replacedNodeIndex, untouchedNodeIndex] of [
['positive', 0, 1],
['negative', 1, 0],
]) {
const context = createContext(
createRoleWorkflow('imported positive', 'imported negative'),
createRoleWorkflow('current positive', 'current negative'),
);
const state = context.getMetadataPromptTransferState(context.imageInspectorState);
state.selectedRoles = new Set([selectedRole]);
const html = context.renderMetadataPromptTransferPanel(context.imageInspectorState);
const selectedCheckbox = html.match(new RegExp(`<input type="checkbox" data-image-prompt-role-selection="${selectedRole}"[^>]*>`))?.[0] || '';
const otherRole = selectedRole === 'positive' ? 'negative' : 'positive';
const otherCheckbox = html.match(new RegExp(`<input type="checkbox" data-image-prompt-role-selection="${otherRole}"[^>]*>`))?.[0] || '';
assert.match(selectedCheckbox, /checked/);
assert.doesNotMatch(otherCheckbox, /checked/);
const result = await context.applyMetadataPromptTransfer();
const workflow = context.getWorkflow();
assert.equal(result.updated, 1);
assert.equal(workflow.nodes[replacedNodeIndex].widgets_values[0], `imported ${selectedRole}`);
assert.equal(
workflow.nodes[untouchedNodeIndex].widgets_values[0],
`current ${otherRole}`,
);
}
} finally {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
}
});
test('metadata prompt transfer keeps empty current text fields selectable', () => {
const context = createContext(createRoleWorkflow(), createRoleWorkflow('', ''));
const rows = context.getMetadataPromptTransferRows(context.imageInspectorState);
assert.equal(rows[0].target.node_id, 1);
assert.equal(rows[0].target.text, '');
assert.equal(rows[1].target.node_id, 2);
assert.equal(rows[1].target.text, '');
});
test('metadata prompt transfer avoids selecting one unknown target for both roles', () => {
const source = {
nodes: [
{ id: 1, type: 'PrimitiveStringMultiline', widgets_values: ['first'] },
{ id: 2, type: 'PrimitiveStringMultiline', widgets_values: ['second'] },
],
};
const current = {
nodes: [
{ id: 10, type: 'PrimitiveStringMultiline', widgets_values: ['target one'] },
{ id: 11, type: 'PrimitiveStringMultiline', widgets_values: ['target two'] },
],
};
const context = createContext(source, current);
const rows = context.getMetadataPromptTransferRows(context.imageInspectorState);
assert.notEqual(rows[0].target.id, rows[1].target.id);
});
test('metadata prompt transfer replaces both selected prompt fields in the current workflow', async () => {
const context = createContext(
createRoleWorkflow('imported positive', 'imported negative'),
createRoleWorkflow('current positive', 'current negative'),
);
const previousWindow = globalThis.window;
globalThis.window = { dispatchEvent() {} };
try {
const result = await context.applyMetadataPromptTransfer();
const workflow = context.getWorkflow();
assert.equal(result.updated, 2);
assert.equal(workflow.nodes[0].widgets_values[0], 'imported positive');
assert.equal(workflow.nodes[1].widgets_values[0], 'imported negative');
assert.deepEqual(context.notifications, [{
message: 'Replaced 2 prompt fields in the current workflow.',
type: 'success',
}]);
} finally {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
}
});
+217 -1
View File
@@ -4920,6 +4920,211 @@ button.mr-info-link:disabled {
background: transparent;
}
.mr-image-prompt-transfer {
margin: var(--mr-spacing-12) 0 var(--mr-spacing-16);
padding: 0;
}
.mr-image-prompt-header,
.mr-image-prompt-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mr-spacing-10);
}
.mr-image-prompt-header {
margin-bottom: var(--mr-spacing-10);
}
.mr-image-prompt-note,
.mr-image-prompt-selection-count,
.mr-image-prompt-meta {
color: var(--mr-text-muted);
font-size: var(--mr-font-xs);
line-height: 1.45;
}
.mr-image-prompt-note {
margin: var(--mr-spacing-8) 0 var(--mr-spacing-10);
}
.mr-image-prompt-list {
display: flex;
flex-direction: column;
gap: var(--mr-spacing-8);
}
.mr-image-prompt-row {
min-width: 0;
padding: var(--mr-spacing-10);
border: 1px solid var(--mr-border);
border-radius: var(--mr-radius-lg);
background: rgba(255, 255, 255, 0.018);
}
.mr-image-prompt-row.is-positive {
border-color: rgba(114, 211, 138, 0.28);
}
.mr-image-prompt-row.is-negative {
border-color: rgba(240, 180, 100, 0.28);
}
.mr-image-prompt-row.is-selected {
background: rgba(114, 211, 138, 0.055);
}
.mr-image-prompt-row-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mr-spacing-8);
margin-bottom: var(--mr-spacing-8);
}
.mr-image-prompt-role-option {
display: inline-flex;
align-items: center;
gap: var(--mr-spacing-8);
min-width: 0;
cursor: pointer;
}
.mr-image-prompt-role-option input {
width: 16px;
height: 16px;
flex: 0 0 16px;
margin: 0;
accent-color: var(--mr-accent);
}
.mr-image-prompt-role-option input:focus-visible {
outline: 2px solid var(--mr-accent);
outline-offset: 2px;
}
.mr-image-prompt-role-option input:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.mr-image-prompt-role {
color: var(--mr-text);
font-size: var(--mr-font-sm);
font-weight: 700;
}
.mr-image-prompt-role-chip {
padding: var(--mr-spacing-4) var(--mr-spacing-7);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
color: var(--mr-text-muted);
font-size: var(--mr-font-xs);
white-space: nowrap;
}
.mr-image-prompt-row.is-positive .mr-image-prompt-role-chip {
border-color: rgba(114, 211, 138, 0.30);
color: var(--mr-accent);
}
.mr-image-prompt-row.is-negative .mr-image-prompt-role-chip {
border-color: rgba(240, 180, 100, 0.30);
color: var(--mr-warning, var(--mr-text-muted));
}
.mr-image-prompt-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 18px minmax(0, 1fr);
align-items: center;
gap: var(--mr-spacing-10);
}
.mr-image-prompt-side {
display: flex;
flex-direction: column;
gap: var(--mr-spacing-6);
min-width: 0;
}
.mr-image-prompt-side.is-context-menu {
cursor: context-menu;
}
.mr-image-prompt-side-label {
color: var(--mr-text-muted);
font-size: var(--mr-font-xs);
font-weight: 650;
}
.mr-image-prompt-select {
width: 100%;
min-width: 0;
min-height: 26px;
padding: var(--mr-spacing-4) var(--mr-spacing-7);
border: 1px solid var(--mr-border-strong, rgba(255, 255, 255, 0.24));
border-radius: var(--mr-radius-md, var(--mr-radius-lg));
background: rgba(0, 0, 0, 0.2);
color: var(--mr-text);
font: inherit;
font-size: var(--mr-font-xs);
}
.mr-image-prompt-select:focus {
border-color: var(--mr-accent);
outline: none;
box-shadow: 0 0 0 2px var(--mr-missing-control-focus-ring, rgba(114, 211, 138, 0.08));
}
.mr-image-prompt-select:disabled {
opacity: 0.82;
}
.mr-image-prompt-text {
box-sizing: border-box;
width: 100%;
max-height: 150px;
margin: 0;
overflow: auto;
padding: var(--mr-spacing-8);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--mr-radius-md, var(--mr-radius-lg));
background: rgba(0, 0, 0, 0.16);
color: var(--mr-text);
font-family: var(--mr-font-family-mono);
font-size: var(--mr-font-xs);
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.mr-image-prompt-text.is-current {
background: rgba(255, 255, 255, 0.045);
}
.mr-image-prompt-arrow {
color: var(--mr-accent);
font-size: var(--mr-font-lg);
font-weight: 700;
text-align: center;
}
.mr-image-prompt-empty {
margin: 0;
padding: var(--mr-spacing-8);
border: 1px dashed var(--mr-border);
border-radius: var(--mr-radius-md, var(--mr-radius-lg));
color: var(--mr-text-muted);
font-size: var(--mr-font-xs);
}
.mr-image-prompt-actions {
margin-top: var(--mr-spacing-10);
padding-top: var(--mr-spacing-10);
border-top: 1px solid var(--mr-border);
}
.mr-image-transfer-header,
.mr-image-transfer-column-header,
.mr-image-transfer-actions {
@@ -5684,6 +5889,15 @@ button.mr-info-link:disabled {
grid-template-columns: 1fr;
}
.mr-image-prompt-grid {
grid-template-columns: minmax(0, 1fr);
}
.mr-image-prompt-arrow {
justify-self: start;
transform: rotate(90deg);
}
.mr-image-transfer-category-header {
align-items: flex-start;
flex-direction: column;
@@ -5734,7 +5948,9 @@ button.mr-info-link:disabled {
}
.mr-image-transfer-header,
.mr-image-transfer-actions {
.mr-image-transfer-actions,
.mr-image-prompt-header,
.mr-image-prompt-actions {
align-items: flex-start;
flex-direction: column;
}
+546
View File
@@ -0,0 +1,546 @@
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 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 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';
}
+407 -28
View File
@@ -4,11 +4,18 @@ import {
toResolverContextModel,
} from "../node_context_menu.js";
import { normalizeCategoryValue } from "../utils/category_utils.js";
import {
extractWorkflowPrompts,
getPromptRoleLabel,
updateWorkflowPromptValue,
} from "../utils/prompt_utils.js";
const MAX_IMAGE_FILE_SIZE = 64 * 1024 * 1024;
const MAX_WORKFLOW_FILE_SIZE = 16 * 1024 * 1024;
const METADATA_TRANSFER_MODE_STORAGE_KEY = 'model_resolver_metadata_transfer_mode';
const METADATA_TRANSFER_ACTIVITY_SCOPE_STORAGE_KEY = 'model_resolver_metadata_transfer_activity_scope';
const METADATA_PROMPT_ROLES = ['positive', 'negative'];
const MAX_PROMPT_PREVIEW_LENGTH = 12_000;
const MERGE_LORA_NODE_TYPES = new Set([
'LoraLoaderV2',
@@ -73,6 +80,7 @@ function getImageInspectorState(dialog) {
loading: '',
requestToken: null,
transfer: null,
promptTransfer: null,
lastTransfer: null,
};
}
@@ -263,6 +271,7 @@ export const imageMetadataMethods = {
if (action === 'retry-transfer-targets') void this.openMetadataTransfer({ force: true });
if (action === 'undo-transfer') void this.undoMetadataTransfer();
if (action === 'apply-transfer') void this.applyMetadataTransfer();
if (action === 'apply-prompts') void this.applyMetadataPromptTransfer();
if (action === 'analyze-paste') {
const textarea = root.querySelector('.mr-image-inspector-textarea');
void this.inspectPastedWorkflow(textarea?.value || '');
@@ -290,6 +299,42 @@ export const imageMetadataMethods = {
}
return;
}
if (input.matches?.('[data-image-prompt-source]')) {
const state = getImageInspectorState(this);
const role = String(input.dataset.imagePromptSource || '').trim();
if (state.promptTransfer && METADATA_PROMPT_ROLES.includes(role)) {
state.promptTransfer.sourceSelections = {
...(state.promptTransfer.sourceSelections || {}),
[role]: input.value,
};
this.renderMetadataPromptTransferPanelInPlace();
}
return;
}
if (input.matches?.('[data-image-prompt-role-selection]')) {
const state = getImageInspectorState(this);
const role = String(input.dataset.imagePromptRoleSelection || '').trim();
if (state.promptTransfer && METADATA_PROMPT_ROLES.includes(role)) {
const selectedRoles = new Set(this.getMetadataPromptTransferState(state).selectedRoles);
if (input.checked) selectedRoles.add(role);
else selectedRoles.delete(role);
state.promptTransfer.selectedRoles = selectedRoles;
this.renderMetadataPromptTransferPanelInPlace();
}
return;
}
if (input.matches?.('[data-image-prompt-target]')) {
const state = getImageInspectorState(this);
const role = String(input.dataset.imagePromptTarget || '').trim();
if (state.promptTransfer && METADATA_PROMPT_ROLES.includes(role)) {
state.promptTransfer.targetSelections = {
...(state.promptTransfer.targetSelections || {}),
[role]: input.value,
};
this.renderMetadataPromptTransferPanelInPlace();
}
return;
}
if (input.matches?.('[data-image-transfer-mode]')) {
const state = getImageInspectorState(this);
if (state.transfer) state.transfer.mode = this.setMetadataTransferMode(input.value);
@@ -425,6 +470,11 @@ export const imageMetadataMethods = {
state.requestToken = requestToken;
state.metadata = null;
state.loadedModels = null;
if (state.promptTransfer) {
state.promptTransfer.applying = false;
state.promptTransfer.error = '';
state.promptTransfer.sourceSelections = {};
}
if (state.transfer) {
state.transfer.requestToken = null;
state.transfer.loading = false;
@@ -503,6 +553,7 @@ export const imageMetadataMethods = {
: 'Reading embedded metadata...';
result.innerHTML = `
<div class="mr-image-inspector-loading"><span class="mr-spinner"></span>${this.escapeHtml(message)}</div>
${this.renderMetadataPromptTransferPanel(state)}
${this.renderMetadataTransferPanel(state)}
`;
return;
@@ -510,12 +561,13 @@ export const imageMetadataMethods = {
if (state.error) {
result.innerHTML = `
<p class="mr-error-text">${this.escapeHtml(state.error)}</p>
${this.renderMetadataPromptTransferPanel(state)}
${this.renderMetadataTransferPanel(state)}
`;
return;
}
if (!state.metadata) {
result.innerHTML = this.renderMetadataTransferPanel(state);
result.innerHTML = `${this.renderMetadataPromptTransferPanel(state)}${this.renderMetadataTransferPanel(state)}`;
return;
}
@@ -577,6 +629,7 @@ export const imageMetadataMethods = {
</div>
<table class="mr-info-table mr-image-inspector-info-table"><tbody>${rowsHtml}${parametersHtml}</tbody></table>
</section>
${this.renderMetadataPromptTransferPanel(state)}
${this.renderMetadataTransferPanel(state)}
<section class="mr-image-inspector-models">
<div id="${this.escapeHtml(modelContainerId)}"></div>
@@ -606,6 +659,348 @@ export const imageMetadataMethods = {
this.bindTooltips?.(result);
},
getMetadataPromptTransferState(state = null) {
const imageState = state || getImageInspectorState(this);
if (!imageState.promptTransfer) {
imageState.promptTransfer = {
applying: false,
error: '',
sourceSelections: {},
targetSelections: {},
selectedRoles: new Set(METADATA_PROMPT_ROLES),
};
}
if (!(imageState.promptTransfer.selectedRoles instanceof Set)) {
const selectedRoles = Array.isArray(imageState.promptTransfer.selectedRoles)
? imageState.promptTransfer.selectedRoles
: METADATA_PROMPT_ROLES;
imageState.promptTransfer.selectedRoles = new Set(
selectedRoles.filter(role => METADATA_PROMPT_ROLES.includes(role)),
);
}
return imageState.promptTransfer;
},
getMetadataPromptLabel(prompt = {}, fallback = 'Text prompt') {
const nodeLabel = String(
prompt.node_title
|| prompt.node_type
|| fallback
).trim() || fallback;
const nodeId = prompt.node_id === undefined || prompt.node_id === null
? ''
: ` · Node ${String(prompt.node_id)}`;
const scope = prompt.is_top_level === false
? ` · ${String(prompt.subgraph_name || prompt.subgraph_id || 'subgraph')}`
: '';
const widget = prompt.widget_name ? ` · ${String(prompt.widget_name)}` : '';
return `${nodeLabel}${nodeId}${scope}${widget}`;
},
getMetadataPromptNodeContextMenuAttrs(prompt = {}) {
const nodeId = prompt.node_id;
if (
typeof this.getContextMenuAttrs !== 'function'
|| nodeId === undefined
|| nodeId === null
|| nodeId === ''
) {
return '';
}
return this.getContextMenuAttrs({
context_scope: 'workflow_node',
node_id: nodeId,
node_type: prompt.node_type,
node_title: prompt.node_title,
subgraph_id: prompt.subgraph_id,
subgraph_name: prompt.subgraph_name,
is_top_level: prompt.is_top_level,
}, 'Right-click to locate this node in the workflow');
},
getMetadataPromptPreviewText(prompt = {}) {
const text = String(prompt.text || '');
if (text.length <= MAX_PROMPT_PREVIEW_LENGTH) return text;
return `${text.slice(0, MAX_PROMPT_PREVIEW_LENGTH)}\n…`;
},
getMetadataPromptTransferData(state = null) {
const imageState = state || getImageInspectorState(this);
const promptTransfer = this.getMetadataPromptTransferState(imageState);
const sourcePrompts = extractWorkflowPrompts(imageState.metadata?.workflow || {})
.filter(prompt => !prompt.linked && String(prompt.text || '').trim());
const targetPrompts = extractWorkflowPrompts(this.getCurrentWorkflow?.() || {})
.filter(prompt => !prompt.linked);
const selectedRoles = this.getMetadataPromptTransferState(imageState).selectedRoles;
const sourceSelections = promptTransfer.sourceSelections || {};
const targetSelections = promptTransfer.targetSelections || {};
const usedSourceIds = new Set();
const usedTargetIds = new Set();
const rows = METADATA_PROMPT_ROLES.map(role => {
const sourceOptions = sourcePrompts.filter(prompt => (
prompt.role === role || prompt.role === 'unknown'
));
const targetOptions = targetPrompts.filter(prompt => (
prompt.role === role || prompt.role === 'unknown'
));
const requestedSource = sourceOptions.find(prompt => (
String(prompt.id) === String(sourceSelections[role] || '')
));
const selectedSource = requestedSource
|| sourceOptions.find(prompt => prompt.role === role)
|| sourceOptions.find(prompt => !usedSourceIds.has(String(prompt.id)))
|| sourceOptions[0]
|| null;
const requestedTarget = targetOptions.find(prompt => (
String(prompt.id) === String(targetSelections[role] || '')
));
const selectedTarget = requestedTarget
|| targetOptions.find(prompt => prompt.role === role)
|| targetOptions.find(prompt => !usedTargetIds.has(String(prompt.id)))
|| targetOptions[0]
|| null;
if (selectedSource) usedSourceIds.add(String(selectedSource.id));
if (selectedTarget) usedTargetIds.add(String(selectedTarget.id));
return {
role,
label: getPromptRoleLabel(role),
sourceOptions,
targetOptions,
source: selectedSource,
target: selectedTarget,
selected: selectedRoles.has(role),
applicable: Boolean(
selectedSource
&& selectedTarget
&& String(selectedSource.text || '').trim()
&& !selectedTarget.linked
),
};
});
const targetSelectionCounts = new Map();
for (const row of rows.filter(row => row.selected && row.applicable)) {
if (!row.target) continue;
const targetId = String(row.target.id);
targetSelectionCounts.set(targetId, (targetSelectionCounts.get(targetId) || 0) + 1);
}
return {
sourcePrompts,
targetPrompts,
rows,
selectedRoles,
selectedRoleCount: rows.filter(row => row.selected).length,
applicableSelectedRoleCount: rows.filter(row => row.selected && row.applicable).length,
unclassifiedSourceCount: sourcePrompts.filter(prompt => prompt.role === 'unknown').length,
unclassifiedTargetCount: targetPrompts.filter(prompt => prompt.role === 'unknown').length,
hasDuplicateTargetSelection: Array.from(targetSelectionCounts.values()).some(count => count > 1),
};
},
getMetadataPromptTransferRows(state = null) {
return this.getMetadataPromptTransferData(state).rows;
},
renderMetadataPromptTransferPanel(state) {
const imageState = state || getImageInspectorState(this);
const promptTransfer = this.getMetadataPromptTransferState(imageState);
const data = this.getMetadataPromptTransferData(imageState);
const sourceSummary = data.sourcePrompts.length
? `${data.sourcePrompts.length} editable prompt${data.sourcePrompts.length === 1 ? '' : 's'} found in the imported workflow.`
: imageState.loading
? 'Reading prompt text from the imported workflow...'
: imageState.metadata?.workflow
? 'No editable prompt text nodes were found in the imported workflow.'
: 'Import an image or workflow JSON with prompt nodes to enable prompt transfer.';
const targetSummary = data.targetPrompts.length
? `${data.targetPrompts.length} editable prompt field${data.targetPrompts.length === 1 ? '' : 's'} in the current workflow.`
: 'No editable prompt text fields were found in the current workflow.';
const hasApplicablePair = data.applicableSelectedRoleCount > 0;
const applyDisabled = promptTransfer.applying
|| !hasApplicablePair
|| data.hasDuplicateTargetSelection;
const roleRows = data.rows.map(row => {
const sourceOptions = row.sourceOptions.map(prompt => `
<option value="${this.escapeHtml(prompt.id)}"${row.source?.id === prompt.id ? ' selected' : ''}>
${this.escapeHtml(this.getMetadataPromptLabel(prompt))}
</option>
`).join('');
const targetOptions = row.targetOptions.map(prompt => `
<option value="${this.escapeHtml(prompt.id)}"${row.target?.id === prompt.id ? ' selected' : ''}>
${this.escapeHtml(this.getMetadataPromptLabel(prompt))}
</option>
`).join('');
const sourceRoleHint = row.source?.role === row.role
? 'Role detected from workflow connections.'
: 'Role not explicit; choose the source text for this slot.';
const targetRoleHint = row.target?.role === row.role
? 'Positive/negative role detected from workflow connections.'
: 'Role not explicit; choose the target text field for this slot.';
const targetContextMenuAttrs = row.target
? this.getMetadataPromptNodeContextMenuAttrs?.(row.target) || ''
: '';
const sourceHtml = row.source
? `
<select class="mr-image-prompt-select" data-image-prompt-source="${row.role}" aria-label="Select imported ${row.role} prompt"${row.sourceOptions.length < 2 ? ' disabled' : ''}>
${sourceOptions}
</select>
<pre class="mr-image-prompt-text" title="${this.escapeHtml(String(row.source.text || ''))}">${this.escapeHtml(this.getMetadataPromptPreviewText(row.source) || '(empty)')}</pre>
<span class="mr-image-prompt-meta">${this.escapeHtml(sourceRoleHint)}</span>
`
: '<p class="mr-image-prompt-empty">No imported prompt available.</p>';
const targetHtml = row.target
? `
<select class="mr-image-prompt-select" data-image-prompt-target="${row.role}" aria-label="Select current ${row.role} text field"${row.targetOptions.length < 2 ? ' disabled' : ''}>
${targetOptions}
</select>
<pre class="mr-image-prompt-text is-current" title="${this.escapeHtml(String(row.target.text || ''))}">${this.escapeHtml(this.getMetadataPromptPreviewText(row.target) || '(empty)')}</pre>
<span class="mr-image-prompt-meta">${this.escapeHtml(targetRoleHint)}</span>
`
: '<p class="mr-image-prompt-empty">No current text field available.</p>';
return `
<article class="mr-image-prompt-row is-${row.role}${row.selected ? ' is-selected' : ''}">
<div class="mr-image-prompt-row-header">
<label class="mr-image-prompt-role-option">
<input type="checkbox" data-image-prompt-role-selection="${row.role}" aria-label="Replace ${this.escapeHtml(row.label)}"${row.selected ? ' checked' : ''}>
<span class="mr-image-prompt-role">${this.escapeHtml(row.label)}</span>
</label>
<span class="mr-image-prompt-role-chip">${row.selected ? (row.applicable ? 'Selected' : 'Needs selection') : 'Not selected'}</span>
</div>
<div class="mr-image-prompt-grid">
<div class="mr-image-prompt-side${targetContextMenuAttrs ? ' is-context-menu' : ''}"${targetContextMenuAttrs}>
<span class="mr-image-prompt-side-label">Current text field</span>
${targetHtml}
</div>
<span class="mr-image-prompt-arrow" aria-hidden="true">→</span>
<div class="mr-image-prompt-side">
<span class="mr-image-prompt-side-label">Imported</span>
${sourceHtml}
</div>
</div>
</article>
`;
}).join('');
const unclassifiedNote = data.unclassifiedSourceCount || data.unclassifiedTargetCount
? 'Some prompts have no explicit role in the graph and remain selectable in both slots.'
: 'Positive and negative roles are detected from connected node input names whenever available.';
const statusHtml = promptTransfer.error
? `<p class="mr-error-text">${this.escapeHtml(promptTransfer.error)}</p>`
: '';
return `
<section class="mr-image-prompt-transfer">
<div class="mr-image-prompt-header">
<div>
<h3 class="mr-loaded-models-title">Transfer prompts to current workflow</h3>
<p class="mr-loaded-models-subtitle">${this.escapeHtml(sourceSummary)} ${this.escapeHtml(targetSummary)}</p>
</div>
</div>
${statusHtml}
<p class="mr-image-prompt-note">${this.escapeHtml(unclassifiedNote)} Select which prompt roles to replace, then choose the source and target text fields.</p>
<div class="mr-image-prompt-list">
${roleRows || '<p class="mr-image-prompt-empty">No positive or negative prompt slots are available.</p>'}
</div>
<div class="mr-image-prompt-actions">
<span class="mr-image-prompt-selection-count">${this.escapeHtml(
data.hasDuplicateTargetSelection
? 'Choose a different current text field for each selected role.'
: !data.selectedRoleCount
? 'Select positive, negative, or both prompt roles.'
: hasApplicablePair
? `${data.applicableSelectedRoleCount} prompt role${data.applicableSelectedRoleCount === 1 ? '' : 's'} selected for replacement.`
: 'Select an imported prompt and a current text field.'
)}</span>
<button type="button" class="mr-btn mr-btn-primary mr-btn-sm" data-image-inspector-action="apply-prompts" ${applyDisabled ? 'disabled' : ''} aria-busy="${promptTransfer.applying ? 'true' : 'false'}">
${promptTransfer.applying
? '<span class="mr-image-transfer-action-spinner" aria-hidden="true"></span><span>Replacing...</span>'
: 'Replace prompts'}
</button>
</div>
</section>
`;
},
renderMetadataPromptTransferPanelInPlace({ fallback = true } = {}) {
const result = this.contentElement?.querySelector('[data-image-inspector-result]');
const currentPanel = result?.querySelector('.mr-image-prompt-transfer');
if (!currentPanel) {
if (fallback) this.renderImageMetadataResult();
return false;
}
currentPanel.outerHTML = this.renderMetadataPromptTransferPanel(getImageInspectorState(this));
this.bindTooltips?.(result);
return true;
},
async applyMetadataPromptTransfer() {
const state = getImageInspectorState(this);
const promptTransfer = this.getMetadataPromptTransferState(state);
if (promptTransfer.applying) return null;
const data = this.getMetadataPromptTransferData(state);
const pairs = data.rows.filter(row => row.selected && row.applicable);
if (!pairs.length) {
this.showNotification('Select at least one prompt role with an imported and current text field first.', 'warning');
return null;
}
if (data.hasDuplicateTargetSelection) {
this.showNotification('Choose a different current text field for each selected role.', 'warning');
return null;
}
const workflow = this.getCurrentWorkflow?.();
if (!workflow) {
this.showNotification('No current workflow is available.', 'warning');
return null;
}
const updatedWorkflow = cloneWorkflowSnapshot(workflow);
if (!updatedWorkflow) {
this.showNotification('Could not prepare the prompt replacement.', 'error');
return null;
}
let updatedCount = 0;
for (const pair of pairs) {
if (updateWorkflowPromptValue(updatedWorkflow, pair.target, pair.source.text)) {
updatedCount += 1;
}
}
if (!updatedCount) {
this.showNotification('The selected current text fields cannot be replaced.', 'warning');
return null;
}
promptTransfer.applying = true;
promptTransfer.error = '';
this.renderMetadataPromptTransferPanelInPlace();
try {
const applied = await this.updateWorkflowInComfyUI(updatedWorkflow, []);
if (!applied) throw new Error('ComfyUI could not apply the prompt replacement.');
promptTransfer.applying = false;
this.cachedLoadedModelsSignature = null;
this.cachedLoadedModelsData = null;
const currentWorkflow = this.getCurrentWorkflow?.() || updatedWorkflow;
this.activeWorkflowSignature = this.getWorkflowSignature?.(currentWorkflow) || null;
void this.openMetadataTransfer({ preserveContent: true });
this.showNotification(
`Replaced ${updatedCount} prompt field${updatedCount === 1 ? '' : 's'} in the current workflow.`,
'success',
);
window.dispatchEvent?.(new Event('model-resolver-active-workflowchange'));
return { workflow: updatedWorkflow, updated: updatedCount };
} catch (error) {
promptTransfer.applying = false;
promptTransfer.error = error?.message || 'The prompt replacement failed.';
this.renderMetadataPromptTransferPanelInPlace();
return null;
}
},
getMetadataTransferSourceModels(loadedModels = null) {
const models = Array.isArray(loadedModels?.loaded_models)
? loadedModels.loaded_models
@@ -698,7 +1093,7 @@ export const imageMetadataMethods = {
},
getMetadataTransferModelInteractionAttrs(model = {}, contextScope = 'loaded_model', label = '') {
if (!isExistingResolvedModel(model)) return '';
if (!model || !isExistingResolvedModel(model)) return '';
let contextModel;
if (contextScope === 'local_model') {
@@ -1336,7 +1731,7 @@ export const imageMetadataMethods = {
`;
}).join('');
};
const renderNode = (node, isAvailable) => {
const renderNode = node => {
const selected = selectedNodeKeys.has(node.nodeKey);
const scopeLabel = node.isTopLevel
? ''
@@ -1345,7 +1740,6 @@ export const imageMetadataMethods = {
const nodeType = node.nodeType && node.nodeType !== nodeName
? node.nodeType
: 'Workflow node';
const unavailable = sourceEntries.length > 0 && !isAvailable;
const contextMenuAttrs = this.getContextMenuAttrs?.({
context_scope: 'workflow_node',
node_id: node.nodeId,
@@ -1362,12 +1756,11 @@ export const imageMetadataMethods = {
: '<p class="mr-image-transfer-empty">No imported LoRAs match the selected activity scope.</p>'
: renderUnselectedPreview(node);
return `
<div class="mr-image-transfer-node-row${selected ? ' is-selected' : ''}${unavailable ? ' is-unavailable' : ''}" ${contextMenuAttrs}>
<div class="mr-image-transfer-node-row${selected ? ' is-selected' : ''}" ${contextMenuAttrs}>
<button type="button" class="mr-image-transfer-node-select"
data-image-transfer-node="${this.escapeHtml(node.nodeKey)}"
aria-pressed="${selected ? 'true' : 'false'}"
aria-disabled="${unavailable ? 'true' : 'false'}"
${unavailable ? 'disabled title="No matching model category in the metadata workflow"' : ''}>
aria-disabled="false">
<span class="mr-image-transfer-node-check" aria-hidden="true">${selected ? '✓' : ''}</span>
<span class="mr-image-transfer-node-details">
<span class="mr-image-transfer-node-name">${this.escapeHtml(nodeName)} · Node ${this.escapeHtml(node.nodeId)}${this.escapeHtml(scopeLabel)}</span>
@@ -1400,7 +1793,7 @@ export const imageMetadataMethods = {
${isUnavailable ? '<span class="mr-image-transfer-unavailable-pill">No source model</span>' : ''}
</span>
</div>
${collapsed ? '' : `<div class="mr-image-transfer-category-nodes">${nodes.map(node => renderNode(node, isAvailable)).join('')}</div>`}
${collapsed ? '' : `<div class="mr-image-transfer-category-nodes">${nodes.map(renderNode).join('')}</div>`}
</section>
`;
}).join('')
@@ -1557,6 +1950,7 @@ export const imageMetadataMethods = {
: [];
}
this.renderMetadataTransferPanelInPlace({ fallback: !keepCurrentTransferContent });
this.renderMetadataPromptTransferPanelInPlace({ fallback: !keepCurrentTransferContent });
if (!workflow) {
state.transfer.loading = false;
@@ -1594,23 +1988,17 @@ export const imageMetadataMethods = {
state.transfer.refreshing = false;
state.transfer.targetModels = targetModels;
state.transfer.targetGroups = this.getMetadataTransferTargetGroups(targetModels);
const sourceEntries = this.getMetadataTransferSourceModels(state.loadedModels);
const sourceCategories = new Set(
sourceEntries
.map(({ model }) => normalizeTransferCategory(model.category))
);
const selectableNodeKeys = new Set(
const targetNodeKeys = new Set(
state.transfer.targetGroups.flatMap(group => (
sourceEntries.length === 0 || sourceCategories.has(group.category)
? (group.nodes || []).map(node => node.nodeKey)
: []
(group.nodes || []).map(node => node.nodeKey)
))
);
state.transfer.selectedNodeKeys = new Set(
Array.from(state.transfer.selectedNodeKeys || [])
.filter(nodeKey => selectableNodeKeys.has(nodeKey))
.filter(nodeKey => targetNodeKeys.has(nodeKey))
);
this.renderMetadataTransferPanelInPlace();
this.renderMetadataPromptTransferPanelInPlace({ fallback: false });
return targetModels;
} catch (error) {
if (
@@ -1621,6 +2009,7 @@ export const imageMetadataMethods = {
state.transfer.refreshing = false;
state.transfer.error = error?.message || 'Could not scan the current workflow.';
this.renderMetadataTransferPanelInPlace();
this.renderMetadataPromptTransferPanelInPlace({ fallback: false });
return null;
}
},
@@ -1653,17 +2042,7 @@ export const imageMetadataMethods = {
.filter(node => node.nodeKey === String(nodeKey));
if (!nodeGroups.length) return;
const sourceCategories = new Set(
this.getMetadataTransferSourceModels(state.loadedModels)
.map(({ model }) => normalizeTransferCategory(model.category))
);
const selected = new Set(state.transfer.selectedNodeKeys || []);
if (sourceCategories.size && !nodeGroups.some(node => {
const categoryGroup = (state.transfer.targetGroups || [])
.find(group => group.nodes.includes(node));
return categoryGroup && sourceCategories.has(categoryGroup.category);
}) && !selected.has(String(nodeKey))) return;
if (selected.has(String(nodeKey))) {
selected.delete(String(nodeKey));
} else {