Files
ComfyAssets-ComfyUI_PromptM…/web/js/prompt-bridge-adapter.js
Vito 76e9b51079 feat(bridge): add acknowledged ComfyUI prompt destinations (#203)
* feat(bridge): add guarded PromptManager prompt destinations

* fix(ci): install shared contracts before frontend unit tests

* test(comfyui): wait for paired editor resize before measuring layout
2026-10-17 13:14:02 -07:00

114 lines
7.6 KiB
JavaScript

import {validateRoles} from './shared/toolkit/prompt-bridge.js';
const schemas = new Map([
['PromptManager', {positive:'text'}],
['PromptManagerText', {positive:'text'}],
['PromptManagerFocused', {positive:'text'}],
['PromptManagerSplit', {positive:'text'}],
['PromptManagerPair', {positive:'text',negative:'negative_text'}],
]);
const compositionNames = ['prepend_text','append_text'];
function nodes(graph) { return graph?._nodes || []; }
function target(node) { return {id:String(node.id),label:String(node.title || node.type),type:node.type}; }
function connected(node,name) { return Boolean(node.inputs?.some(input => input.name === name && input.link != null)); }
function links(node) { return JSON.stringify((node.inputs || []).map(input => [input.name,input.link ?? null])); }
function capture(node,name) {
const widget = node.widgets?.find(item => item.name === name);
if (!widget) return {name,widget:null};
const index = node.widgets.indexOf(widget);
return {name,widget,index,value:widget.value,input:widget.inputEl,visible:widget.inputEl?.value,serialized:node.widgets_values?.[index],hasSerialization:Array.isArray(node.widgets_values)};
}
function sameField(node,field) {
const now = capture(node,field.name);
const unchanged = ['widget','index','value','input','visible'].every(key => now[key] === field[key]);
// Host serialization may materialize or synchronize its cache from live values.
const serialized = now.hasSerialization === field.hasSerialization && now.serialized === field.serialized;
const normalized = now.hasSerialization && now.serialized === field.value;
return unchanged && (serialized || normalized);
}
function composition(node,fields,roles) {
const value = name => String(fields.find(field => field.name === name)?.value ?? '');
const prependConnected = connected(node,'prepend_text'), appendConnected = connected(node,'append_text');
const mainConnected = connected(node,'text');
const combine = main => [value('prepend_text').trim(),main,value('append_text').trim()].filter(Boolean).join(' ');
return {prepend:value('prepend_text'),append:value('append_text'),prependConnected,appendConnected,
before:prependConnected || appendConnected || mainConnected ? null : combine(value('text')),
after:prependConnected || appendConnected || (mainConnected && !Object.hasOwn(roles,'positive')) ? null : combine(roles.positive ?? value('text'))};
}
function write(node,field,value) {
field.widget.value = value;
if (field.widget.inputEl) field.widget.inputEl.value = value;
if (node.widgets_values) node.widgets_values[field.index] = value;
}
function redraw(app,node) {
node._focusedView?.render();
node.setDirtyCanvas?.(true,true);
app.graph?.setDirtyCanvas?.(true,true);
}
export function createPromptAdapter(app) {
const snapshots = new WeakMap();
function listTargets() {
return nodes(app.graph).filter(node => schemas.has(node.type)).map(node => ({...target(node),roles:Object.keys(schemas.get(node.type))}));
}
function preview(targetId,rawRoles) {
const roles = validateRoles(rawRoles);
const graph = app.graph;
const node = nodes(graph).find(item => String(item.id) === targetId);
const schema = schemas.get(node?.type);
if (!schema) throw new Error('Prompt destination is unavailable.');
const fields = [...new Set([...Object.values(schema),...compositionNames,'category','tags'])].map(name => capture(node,name));
const requested = Object.entries(roles).map(([role,after]) => {
if (!schema[role]) throw new Error(`Destination does not support ${role}.`);
const field = fields.find(item => item.name === schema[role]);
if (!field.widget) throw new Error(`Destination widget ${schema[role]} is unavailable.`);
if (connected(node,field.name)) throw new Error(`${role} is connected to an upstream input.`);
return {role,after,field};
});
const snapshot = Object.freeze({});
snapshots.set(snapshot,{graph,node,type:node.type,id:String(node.id),version:graph._version,widgets:node.widgets,widgetOrder:[...node.widgets],fields,links:links(node),requested});
return {preview:{target:target(node),fields:requested.map(({role,after,field}) => ({role,before:String(field.value ?? ''),after})),composition:composition(node,fields,roles)},snapshot};
}
function apply(snapshot) {
const state = snapshots.get(snapshot);
if (!state) throw new Error('Invalid or expired prompt preview snapshot.');
const {graph,node,fields,requested} = state;
if (app.graph !== graph || !nodes(graph).includes(node) || String(node.id) !== state.id || node.widgets !== state.widgets || node.type !== state.type || graph._version !== state.version || links(node) !== state.links || fields.some(field => !sameField(node,field))) throw new Error('Workflow or prompt changed. Preview again.');
snapshots.delete(snapshot);
const serializedBefore = node.widgets_values;
const rollbackFields = fields.map(field => capture(node,field.name));
try {
node.widgets_values ||= node.widgets.map(widget => widget.value);
for (const {field,after} of requested) write(node,field,after);
for (const {field,after} of requested) field.widget.callback?.(after,app.canvas,node,null,null);
redraw(app,node);
if (app.graph !== graph || !nodes(graph).includes(node) || String(node.id) !== state.id || node.widgets !== state.widgets || node.type !== state.type || links(node) !== state.links || fields.some(field => {
const current = capture(node,field.name);
return current.widget !== field.widget || current.index !== field.index || current.input !== field.input;
})) throw new Error('Workflow changed during prompt update.');
if (fields.some(field => field.widget && !requested.some(item => item.field === field) && !sameField(node,{...field,hasSerialization:true,serialized:field.hasSerialization ? field.serialized : field.value}))) throw new Error('Protected prompt fields changed during update.');
for (const {field,after} of requested) {
if (field.widget.value !== after || (field.widget.inputEl && field.widget.inputEl.value !== after) || (!Array.isArray(node.widgets_values) || node.widgets_values[field.index] !== after)) throw new Error('Prompt values do not match the approved preview.');
}
} catch (error) {
// Widget callbacks are host hooks; restore our fields without repeating them.
node.widgets = state.widgets;
node.widgets.splice(0,node.widgets.length,...state.widgetOrder);
node.widgets_values = serializedBefore;
for (const field of rollbackFields.filter(item => item.widget)) {
node.widgets[field.index] = field.widget;
field.widget.value = field.value;
if (field.widget.inputEl !== field.input) field.widget.inputEl = field.input;
if (field.input) field.input.value = field.visible;
if (field.hasSerialization) node.widgets_values[field.index] = field.serialized;
}
node.widgets_values = serializedBefore;
try { redraw(app,node); } catch { /* Preserve the original callback failure. */ }
throw new Error(`Prompt update failed: ${error.message}`);
}
return {targetId:state.id,values:Object.fromEntries(requested.map(({role,after}) => [role,after]))};
}
return {listTargets,preview,apply};
}