feat: add callback-driven LoRA widget handling for Loaded Models refresh
- Configure callback-driven model widgets for LoRA Manager, LoraLoaderV2, Lora Stacker, and Power Lora Loader nodes in graph and loaded graph hooks - Track callback-driven widget states to trigger context analysis and dialog refresh when LoRA lists or strengths change via widget callbacks - Add signature helpers for callback-driven model list and strength changes, including Power Lora Loader dynamic lora_N widgets - Extend Loaded Models tab with model DOM keys, identity normalization, copy-value updates with active/inactive grouping, and targeted strength updates from node widgets without full re-render - Add tests verifying callback-driven refresh behavior, suppression respect, Power Lora Loader strength chip updates, and LoRA Manager list membership signature tracking
This commit is contained in:
@@ -1166,6 +1166,262 @@ test('node context integration preserves existing menu hooks and refreshes after
|
||||
]);
|
||||
});
|
||||
|
||||
test('LoRA Manager callback-driven widgets refresh the open workflow analysis', () => {
|
||||
const CALLBACK_DRIVEN_MODEL_WIDGETS = {
|
||||
LoraLoaderV2: new Set(['text', 'loras']),
|
||||
'Lora Loader (LoraManager)': new Set(['text', 'loras']),
|
||||
'Lora Stacker (LoraManager)': new Set(['text', 'loras']),
|
||||
'Power Lora Loader (rgthree)': new Set(),
|
||||
};
|
||||
const configureCallbackDrivenModelWidgets = eval(
|
||||
`(${extractMethod(modelResolverSource, 'configureCallbackDrivenModelWidgets')})`
|
||||
);
|
||||
const getCallbackDrivenModelListSignature = eval(
|
||||
`(${extractMethod(modelResolverSource, 'getCallbackDrivenModelListSignature')})`
|
||||
);
|
||||
const getCallbackDrivenModelStrengthSignature = eval(
|
||||
`(${extractMethod(modelResolverSource, 'getCallbackDrivenModelStrengthSignature')})`
|
||||
);
|
||||
const calls = [];
|
||||
const lorasWidget = {
|
||||
name: 'loras',
|
||||
value: [],
|
||||
callback(value) {
|
||||
calls.push(['original-loras', value]);
|
||||
return 'callback-result';
|
||||
},
|
||||
};
|
||||
const textWidget = {
|
||||
name: 'text',
|
||||
callback(value) {
|
||||
calls.push(['original-text', value]);
|
||||
if (value === '<lora:second:1>') {
|
||||
lorasWidget.value = [
|
||||
...lorasWidget.value,
|
||||
{ name: 'second', strength: 1, active: true },
|
||||
];
|
||||
}
|
||||
},
|
||||
};
|
||||
const unrelatedWidget = {
|
||||
name: 'seed',
|
||||
callback() {
|
||||
calls.push(['unrelated']);
|
||||
},
|
||||
};
|
||||
const resolver = {
|
||||
callbackDrivenModelWidgetStates: new WeakMap(),
|
||||
getCallbackDrivenModelListSignature,
|
||||
getCallbackDrivenModelStrengthSignature,
|
||||
scheduleNodeContextMenuAnalysis: () => calls.push(['context-analysis']),
|
||||
dialog: {
|
||||
isWorkflowRefreshSuppressed: () => false,
|
||||
scheduleActiveWorkflowRefresh: reason => calls.push(['dialog-refresh', reason]),
|
||||
},
|
||||
};
|
||||
const node = {
|
||||
comfyClass: 'Lora Loader (LoraManager)',
|
||||
widgets: [textWidget, lorasWidget, unrelatedWidget],
|
||||
};
|
||||
|
||||
configureCallbackDrivenModelWidgets.call(resolver, node);
|
||||
lorasWidget.value = [{ name: 'existing', strength: 1, active: true }];
|
||||
configureCallbackDrivenModelWidgets.call(resolver, node);
|
||||
|
||||
textWidget.callback('<lora:second');
|
||||
lorasWidget.value = [{ name: 'existing', strength: 0.5, active: true }];
|
||||
assert.equal(lorasWidget.callback(lorasWidget.value), 'callback-result');
|
||||
textWidget.callback('<lora:second:1>');
|
||||
unrelatedWidget.callback();
|
||||
assert.deepEqual(calls, [
|
||||
['original-text', '<lora:second'],
|
||||
['original-loras', [{ name: 'existing', strength: 0.5, active: true }]],
|
||||
['original-text', '<lora:second:1>'],
|
||||
['context-analysis'],
|
||||
['dialog-refresh', 'node-widget-change'],
|
||||
['unrelated'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('callback-driven widget refresh respects internal workflow update suppression', () => {
|
||||
const CALLBACK_DRIVEN_MODEL_WIDGETS = {
|
||||
LoraLoaderV2: new Set(['text', 'loras']),
|
||||
'Lora Loader (LoraManager)': new Set(['text', 'loras']),
|
||||
'Lora Stacker (LoraManager)': new Set(['text', 'loras']),
|
||||
'Power Lora Loader (rgthree)': new Set(),
|
||||
};
|
||||
const configureCallbackDrivenModelWidgets = eval(
|
||||
`(${extractMethod(modelResolverSource, 'configureCallbackDrivenModelWidgets')})`
|
||||
);
|
||||
const getCallbackDrivenModelListSignature = eval(
|
||||
`(${extractMethod(modelResolverSource, 'getCallbackDrivenModelListSignature')})`
|
||||
);
|
||||
const getCallbackDrivenModelStrengthSignature = eval(
|
||||
`(${extractMethod(modelResolverSource, 'getCallbackDrivenModelStrengthSignature')})`
|
||||
);
|
||||
const calls = [];
|
||||
const widget = {
|
||||
name: 'loras',
|
||||
value: [],
|
||||
callback: () => calls.push('original'),
|
||||
};
|
||||
const resolver = {
|
||||
callbackDrivenModelWidgetStates: new WeakMap(),
|
||||
getCallbackDrivenModelListSignature,
|
||||
getCallbackDrivenModelStrengthSignature,
|
||||
scheduleNodeContextMenuAnalysis: () => calls.push('context-analysis'),
|
||||
dialog: {
|
||||
isWorkflowRefreshSuppressed: () => true,
|
||||
scheduleActiveWorkflowRefresh: () => calls.push('dialog-refresh'),
|
||||
},
|
||||
};
|
||||
|
||||
configureCallbackDrivenModelWidgets.call(resolver, {
|
||||
comfyClass: 'Lora Stacker (LoraManager)',
|
||||
widgets: [widget],
|
||||
});
|
||||
widget.value = [{ name: 'new-lora', strength: 1, active: true }];
|
||||
widget.callback(widget.value);
|
||||
|
||||
assert.deepEqual(calls, ['original']);
|
||||
});
|
||||
|
||||
test('rgthree Power Lora Loader updates strength without reloading workflow analysis', async () => {
|
||||
const CALLBACK_DRIVEN_MODEL_WIDGETS = {
|
||||
LoraLoaderV2: new Set(['text', 'loras']),
|
||||
'Lora Loader (LoraManager)': new Set(['text', 'loras']),
|
||||
'Lora Stacker (LoraManager)': new Set(['text', 'loras']),
|
||||
'Power Lora Loader (rgthree)': new Set(),
|
||||
};
|
||||
const configureCallbackDrivenModelWidgets = eval(
|
||||
`(${extractMethod(modelResolverSource, 'configureCallbackDrivenModelWidgets')})`
|
||||
);
|
||||
const getCallbackDrivenModelListSignature = eval(
|
||||
`(${extractMethod(modelResolverSource, 'getCallbackDrivenModelListSignature')})`
|
||||
);
|
||||
const getCallbackDrivenModelStrengthSignature = eval(
|
||||
`(${extractMethod(modelResolverSource, 'getCallbackDrivenModelStrengthSignature')})`
|
||||
);
|
||||
const calls = [];
|
||||
const node = {
|
||||
comfyClass: 'Power Lora Loader (rgthree)',
|
||||
widgets: [{
|
||||
name: 'lora_1',
|
||||
value: {
|
||||
on: true,
|
||||
lora: 'existing.safetensors',
|
||||
strength: 1,
|
||||
},
|
||||
}],
|
||||
setDirtyCanvas() {
|
||||
calls.push('dirty');
|
||||
},
|
||||
};
|
||||
const resolver = {
|
||||
callbackDrivenModelWidgetStates: new WeakMap(),
|
||||
getCallbackDrivenModelListSignature,
|
||||
getCallbackDrivenModelStrengthSignature,
|
||||
scheduleNodeContextMenuAnalysis: () => calls.push('context-analysis'),
|
||||
dialog: {
|
||||
isWorkflowRefreshSuppressed: () => false,
|
||||
updateLoadedModelStrengthsFromNode: () => calls.push('loaded-strength-update'),
|
||||
scheduleActiveWorkflowRefresh: reason => calls.push(`dialog-refresh:${reason}`),
|
||||
},
|
||||
};
|
||||
|
||||
configureCallbackDrivenModelWidgets.call(resolver, node);
|
||||
|
||||
node.widgets[0].value.strength = 0.5;
|
||||
node.setDirtyCanvas(true, true);
|
||||
await Promise.resolve();
|
||||
|
||||
node.widgets.push({
|
||||
name: 'lora_2',
|
||||
value: {
|
||||
on: true,
|
||||
lora: 'new-model.safetensors',
|
||||
strength: 1,
|
||||
},
|
||||
});
|
||||
node.setDirtyCanvas(true, true);
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'dirty',
|
||||
'loaded-strength-update',
|
||||
'dirty',
|
||||
'context-analysis',
|
||||
'dialog-refresh:node-widget-change',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rgthree dynamic lora widgets bypass the generic full workflow refresh', () => {
|
||||
const CALLBACK_DRIVEN_MODEL_WIDGETS = {
|
||||
LoraLoaderV2: new Set(['text', 'loras']),
|
||||
'Lora Loader (LoraManager)': new Set(['text', 'loras']),
|
||||
'Lora Stacker (LoraManager)': new Set(['text', 'loras']),
|
||||
'Power Lora Loader (rgthree)': new Set(),
|
||||
};
|
||||
const configureNodeContextMenu = eval(
|
||||
`(${extractMethod(modelResolverSource, 'configureNodeContextMenu')})`
|
||||
);
|
||||
const isCallbackDrivenModelWidget = eval(
|
||||
`(${extractMethod(modelResolverSource, 'isCallbackDrivenModelWidget')})`
|
||||
);
|
||||
const calls = [];
|
||||
class NodeType {}
|
||||
NodeType.prototype.comfyClass = 'Power Lora Loader (rgthree)';
|
||||
NodeType.prototype.onWidgetChanged = function(name) {
|
||||
calls.push(`original:${name}`);
|
||||
};
|
||||
const node = new NodeType();
|
||||
const resolver = {
|
||||
callbackDrivenModelWidgetStates: new WeakMap([
|
||||
[node, { notify: () => calls.push('selective-update') }],
|
||||
]),
|
||||
getResolvedModelsForNodeContextMenu: () => [],
|
||||
isCallbackDrivenModelWidget,
|
||||
scheduleNodeContextMenuAnalysis: () => calls.push('context-analysis'),
|
||||
dialog: {
|
||||
isWorkflowRefreshSuppressed: () => false,
|
||||
isWorkflowStrengthWidgetName: () => false,
|
||||
scheduleActiveWorkflowRefresh: () => calls.push('dialog-refresh'),
|
||||
},
|
||||
};
|
||||
|
||||
configureNodeContextMenu.call(resolver, NodeType);
|
||||
node.onWidgetChanged('lora_1');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'original:lora_1',
|
||||
'selective-update',
|
||||
]);
|
||||
});
|
||||
|
||||
test('LoRA Manager text onWidgetChanged waits for a confirmed model-list change', () => {
|
||||
const configureNodeContextMenu = eval(`(${extractMethod(modelResolverSource, 'configureNodeContextMenu')})`);
|
||||
const calls = [];
|
||||
class NodeType {}
|
||||
NodeType.prototype.onWidgetChanged = function(name) {
|
||||
calls.push(`original:${name}`);
|
||||
};
|
||||
const resolver = {
|
||||
getResolvedModelsForNodeContextMenu: () => [],
|
||||
isCallbackDrivenModelWidget: (_node, widgetName) => widgetName === 'text',
|
||||
scheduleNodeContextMenuAnalysis: () => calls.push('context-analysis'),
|
||||
dialog: {
|
||||
isWorkflowRefreshSuppressed: () => false,
|
||||
isWorkflowStrengthWidgetName: () => false,
|
||||
scheduleActiveWorkflowRefresh: () => calls.push('dialog-refresh'),
|
||||
},
|
||||
};
|
||||
|
||||
configureNodeContextMenu.call(resolver, NodeType);
|
||||
new NodeType().onWidgetChanged('text');
|
||||
|
||||
assert.deepEqual(calls, ['original:text']);
|
||||
});
|
||||
|
||||
test('strength widget changes skip proactive node context analysis', () => {
|
||||
const configureNodeContextMenu = eval(`(${extractMethod(modelResolverSource, 'configureNodeContextMenu')})`);
|
||||
const isWorkflowStrengthWidgetName = eval(
|
||||
@@ -1425,6 +1681,83 @@ test('background Loaded Models refresh keeps the current view until new data is
|
||||
assert.equal(contentElement.scrollTop, 48);
|
||||
});
|
||||
|
||||
test('Power Lora Loader strength updates only its Loaded Models chip and cache', () => {
|
||||
const updateLoadedModelStrengthsFromNode = eval(
|
||||
`(${extractMethod(tabsLoadedMethodsSource, 'updateLoadedModelStrengthsFromNode')})`
|
||||
);
|
||||
const normalizeLoadedModelIdentity = eval(
|
||||
`(${extractMethod(tabsLoadedMethodsSource, 'normalizeLoadedModelIdentity')})`
|
||||
);
|
||||
const getLoadedModelDomKey = eval(
|
||||
`(${extractMethod(tabsLoadedMethodsSource, 'getLoadedModelDomKey')})`
|
||||
);
|
||||
const strengthElement = { textContent: '1.00' };
|
||||
const chip = {
|
||||
dataset: { mlLoadedModelKey: 'model-key' },
|
||||
querySelector: selector => (
|
||||
selector === '.mr-model-chip-strength' ? strengthElement : null
|
||||
),
|
||||
};
|
||||
const contentElement = {
|
||||
querySelectorAll: selector => (
|
||||
selector === '[data-ml-loaded-model-key]' ? [chip] : []
|
||||
),
|
||||
};
|
||||
const loadedModel = {
|
||||
node_id: 91,
|
||||
widget_index: 0,
|
||||
nested_key: 'lora',
|
||||
category: 'loras',
|
||||
original_path: 'styles/existing.safetensors',
|
||||
strength: 1,
|
||||
};
|
||||
const calls = [];
|
||||
const dialog = {
|
||||
activeTab: 'loaded',
|
||||
contentElement,
|
||||
cachedLoadedModelsData: {
|
||||
loaded_models: [loadedModel],
|
||||
total: 1,
|
||||
},
|
||||
cachedLoadedModelsSignature: 'old-signature',
|
||||
activeWorkflowSignature: 'old-signature',
|
||||
normalizeLoadedModelIdentity,
|
||||
getLoadedModelDomKey,
|
||||
getMissingModelKey: () => 'model-key',
|
||||
updateLoadedModelCopyValues: (_container, models) => {
|
||||
calls.push(['copy-values', models[0].strength]);
|
||||
},
|
||||
getCurrentWorkflow: () => ({ nodes: [{ id: 91 }] }),
|
||||
getWorkflowSignature: () => 'strength-signature',
|
||||
saveLoadedModelsCacheForActiveWorkflow: () => calls.push(['save-cache']),
|
||||
displayLoadedModels: () => calls.push(['full-render']),
|
||||
};
|
||||
const node = {
|
||||
id: 91,
|
||||
comfyClass: 'Power Lora Loader (rgthree)',
|
||||
widgets: [{
|
||||
name: 'lora_1',
|
||||
value: {
|
||||
on: true,
|
||||
lora: 'styles/existing.safetensors',
|
||||
strength: 0.5,
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
const updated = updateLoadedModelStrengthsFromNode.call(dialog, node);
|
||||
|
||||
assert.equal(updated, true);
|
||||
assert.equal(loadedModel.strength, 0.5);
|
||||
assert.equal(strengthElement.textContent, '0.50');
|
||||
assert.equal(dialog.cachedLoadedModelsSignature, 'strength-signature');
|
||||
assert.equal(dialog.activeWorkflowSignature, 'strength-signature');
|
||||
assert.deepEqual(calls, [
|
||||
['copy-values', 0.5],
|
||||
['save-cache'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('changed workflow model keeps the selected row and batch checkbox for the same loader slot', () => {
|
||||
const encodeMissingModelKeyPart = eval(`(${extractMethod(lifecycleGraphMethodsSource, 'encodeMissingModelKeyPart')})`);
|
||||
const getMissingModelIdentityPart = eval(`(${extractMethod(lifecycleGraphMethodsSource, 'getMissingModelIdentityPart')})`);
|
||||
@@ -2209,6 +2542,49 @@ test('Missing Models signature ignores strength but tracks model identity change
|
||||
);
|
||||
});
|
||||
|
||||
test('Missing Models signature tracks LoRA Manager list membership changes', () => {
|
||||
const getWorkflowSignature = eval(`(${extractMethod(workflowStateMethodsSource, 'getWorkflowSignature')})`);
|
||||
const getMissingWorkflowSignature = eval(`(${extractMethod(workflowStateMethodsSource, 'getMissingWorkflowSignature')})`);
|
||||
const getWorkflowSignatureData = eval(`(${extractMethod(workflowStateMethodsSource, 'getWorkflowSignatureData')})`);
|
||||
const dialog = {
|
||||
capabilities: { node_rules: {} },
|
||||
getWorkflowSignature,
|
||||
getMissingWorkflowSignature,
|
||||
getWorkflowSignatureData,
|
||||
};
|
||||
const makeWorkflow = (loras) => ({
|
||||
nodes: [{
|
||||
id: 83,
|
||||
type: 'Lora Loader (LoraManager)',
|
||||
widgets_values: [
|
||||
{ version: 1, textWidgetName: 'text' },
|
||||
'<lora:first:1>',
|
||||
loras,
|
||||
],
|
||||
}],
|
||||
links: [],
|
||||
});
|
||||
const original = makeWorkflow([
|
||||
{ name: 'first', strength: 1, active: true },
|
||||
]);
|
||||
const strengthChanged = makeWorkflow([
|
||||
{ name: 'first', strength: 0.5, active: true },
|
||||
]);
|
||||
const loraAdded = makeWorkflow([
|
||||
{ name: 'first', strength: 0.5, active: true },
|
||||
{ name: 'second', strength: 1, active: true },
|
||||
]);
|
||||
|
||||
assert.equal(
|
||||
getMissingWorkflowSignature.call(dialog, original),
|
||||
getMissingWorkflowSignature.call(dialog, strengthChanged)
|
||||
);
|
||||
assert.notEqual(
|
||||
getMissingWorkflowSignature.call(dialog, strengthChanged),
|
||||
getMissingWorkflowSignature.call(dialog, loraAdded)
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow hash refresh ignores node movement and tracks model dependency changes', () => {
|
||||
const getWorkflowSignature = eval(`(${extractMethod(workflowStateMethodsSource, 'getWorkflowSignature')})`);
|
||||
const getWorkflowSignatureData = eval(`(${extractMethod(workflowStateMethodsSource, 'getWorkflowSignatureData')})`);
|
||||
|
||||
@@ -35,10 +35,12 @@ app.registerExtension({
|
||||
},
|
||||
nodeCreated(node) {
|
||||
modelResolver.configureWorkflowDependencyMarkerNode(node);
|
||||
modelResolver.configureCallbackDrivenModelWidgets(node);
|
||||
modelResolver.scheduleNodeContextMenuAnalysis();
|
||||
},
|
||||
loadedGraphNode(node) {
|
||||
modelResolver.configureWorkflowDependencyMarkerNode(node);
|
||||
modelResolver.configureCallbackDrivenModelWidgets(node);
|
||||
modelResolver.scheduleNodeContextMenuAnalysis();
|
||||
},
|
||||
afterConfigureGraph() {
|
||||
|
||||
@@ -36,6 +36,12 @@ const WORKFLOW_DEPENDENCY_MARKER_BUTTON_NAME = "Open Model Resolver";
|
||||
const WORKFLOW_DEPENDENCY_MARKER_AUTO_ID = -918273646;
|
||||
const WORKFLOW_DEPENDENCY_MARKER_DEFAULT_SIZE = Object.freeze([160, 40]);
|
||||
const WORKFLOW_DEPENDENCY_MARKER_MIN_SIZE = Object.freeze([160, 40]);
|
||||
const CALLBACK_DRIVEN_MODEL_WIDGETS = Object.freeze({
|
||||
LoraLoaderV2: new Set(["text", "loras"]),
|
||||
"Lora Loader (LoraManager)": new Set(["text", "loras"]),
|
||||
"Lora Stacker (LoraManager)": new Set(["text", "loras"]),
|
||||
"Power Lora Loader (rgthree)": new Set(),
|
||||
});
|
||||
|
||||
function getKeybindingSetting(id) {
|
||||
try {
|
||||
@@ -129,6 +135,7 @@ export class ModelResolver {
|
||||
this.nodeContextAnalysisSignature = null;
|
||||
this.nodeContextAnalysisPromise = null;
|
||||
this.nodeContextAnalysisTimer = null;
|
||||
this.callbackDrivenModelWidgetStates = new WeakMap();
|
||||
}
|
||||
|
||||
setup = async () => {
|
||||
@@ -523,7 +530,13 @@ export class ModelResolver {
|
||||
nodeType.prototype.onWidgetChanged = function() {
|
||||
const result = originalOnWidgetChanged?.apply(this, arguments);
|
||||
const widgetName = arguments[0];
|
||||
if (!owner.dialog?.isWorkflowRefreshSuppressed?.()) {
|
||||
const handledByCallbackDrivenWidget = owner.isCallbackDrivenModelWidget?.(
|
||||
this,
|
||||
widgetName
|
||||
);
|
||||
if (handledByCallbackDrivenWidget) {
|
||||
owner.callbackDrivenModelWidgetStates?.get(this)?.notify?.();
|
||||
} else if (!owner.dialog?.isWorkflowRefreshSuppressed?.()) {
|
||||
if (!owner.dialog?.isWorkflowStrengthWidgetName?.(widgetName)) {
|
||||
owner.scheduleNodeContextMenuAnalysis();
|
||||
}
|
||||
@@ -534,6 +547,187 @@ export class ModelResolver {
|
||||
nodeType.prototype.__modelResolverContextMenuPatched = true;
|
||||
}
|
||||
|
||||
isCallbackDrivenModelWidget(node, widgetName = '') {
|
||||
const nodeTypes = [
|
||||
node?.comfyClass,
|
||||
node?.type,
|
||||
node?.constructor?.comfyClass,
|
||||
node?.constructor?.ComfyClass,
|
||||
];
|
||||
const nodeType = nodeTypes.find(candidate => CALLBACK_DRIVEN_MODEL_WIDGETS[candidate]);
|
||||
if (
|
||||
nodeType === 'Power Lora Loader (rgthree)'
|
||||
&& /^lora_\d+$/.test(widgetName)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(CALLBACK_DRIVEN_MODEL_WIDGETS[nodeType]?.has(widgetName));
|
||||
}
|
||||
|
||||
getCallbackDrivenModelListSignature(node) {
|
||||
const widgets = Array.isArray(node?.widgets) ? node.widgets : [];
|
||||
const isRgthreePowerLoraLoader = [
|
||||
node?.comfyClass,
|
||||
node?.type,
|
||||
node?.constructor?.comfyClass,
|
||||
node?.constructor?.ComfyClass,
|
||||
].includes('Power Lora Loader (rgthree)');
|
||||
const lorasWidget = widgets.find(widget => widget?.name === 'loras');
|
||||
const loras = isRgthreePowerLoraLoader
|
||||
? widgets
|
||||
.filter(widget => /^lora_\d+$/.test(widget?.name) && widget?.value?.lora)
|
||||
.map(widget => ({
|
||||
name: widget.value.lora,
|
||||
active: widget.value.on !== false,
|
||||
}))
|
||||
: (Array.isArray(lorasWidget?.value) ? lorasWidget.value : []);
|
||||
const normalized = loras
|
||||
.map((lora) => {
|
||||
if (lora && typeof lora === 'object') {
|
||||
const name = String(
|
||||
lora.name
|
||||
|| lora.filename
|
||||
|| lora.path
|
||||
|| ''
|
||||
).trim();
|
||||
return name ? { name, active: lora.active !== false } : null;
|
||||
}
|
||||
const name = String(lora || '').trim();
|
||||
return name ? { name, active: true } : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => (
|
||||
left.name.localeCompare(right.name)
|
||||
|| Number(left.active) - Number(right.active)
|
||||
));
|
||||
return JSON.stringify(normalized);
|
||||
}
|
||||
|
||||
getCallbackDrivenModelStrengthSignature(node) {
|
||||
const widgets = Array.isArray(node?.widgets) ? node.widgets : [];
|
||||
const isRgthreePowerLoraLoader = [
|
||||
node?.comfyClass,
|
||||
node?.type,
|
||||
node?.constructor?.comfyClass,
|
||||
node?.constructor?.ComfyClass,
|
||||
].includes('Power Lora Loader (rgthree)');
|
||||
const lorasWidget = widgets.find(widget => widget?.name === 'loras');
|
||||
const loras = isRgthreePowerLoraLoader
|
||||
? widgets
|
||||
.filter(widget => /^lora_\d+$/.test(widget?.name) && widget?.value?.lora)
|
||||
.map(widget => ({
|
||||
name: widget.value.lora,
|
||||
strength: widget.value.strength,
|
||||
}))
|
||||
: (Array.isArray(lorasWidget?.value) ? lorasWidget.value : []);
|
||||
const normalized = loras
|
||||
.map((lora) => {
|
||||
if (!lora || typeof lora !== 'object') return null;
|
||||
const name = String(
|
||||
lora.name
|
||||
|| lora.filename
|
||||
|| lora.path
|
||||
|| ''
|
||||
).trim();
|
||||
if (!name) return null;
|
||||
const numericStrength = Number(lora.strength);
|
||||
return {
|
||||
name,
|
||||
strength: Number.isFinite(numericStrength) ? numericStrength : null,
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => (
|
||||
left.name.localeCompare(right.name)
|
||||
|| (left.strength ?? 0) - (right.strength ?? 0)
|
||||
));
|
||||
return JSON.stringify(normalized);
|
||||
}
|
||||
|
||||
configureCallbackDrivenModelWidgets(node) {
|
||||
const nodeTypes = [
|
||||
node?.comfyClass,
|
||||
node?.type,
|
||||
node?.constructor?.comfyClass,
|
||||
node?.constructor?.ComfyClass,
|
||||
];
|
||||
const nodeType = nodeTypes.find(candidate => CALLBACK_DRIVEN_MODEL_WIDGETS[candidate]);
|
||||
const watchedWidgetNames = CALLBACK_DRIVEN_MODEL_WIDGETS[nodeType];
|
||||
if (!watchedWidgetNames || !Array.isArray(node?.widgets)) return;
|
||||
|
||||
const owner = this;
|
||||
if (!(this.callbackDrivenModelWidgetStates instanceof WeakMap)) {
|
||||
this.callbackDrivenModelWidgetStates = new WeakMap();
|
||||
}
|
||||
let state = this.callbackDrivenModelWidgetStates.get(node);
|
||||
if (!state) {
|
||||
state = {
|
||||
signature: this.getCallbackDrivenModelListSignature(node),
|
||||
strengthSignature: this.getCallbackDrivenModelStrengthSignature(node),
|
||||
};
|
||||
this.callbackDrivenModelWidgetStates.set(node, state);
|
||||
} else {
|
||||
state.signature = this.getCallbackDrivenModelListSignature(node);
|
||||
state.strengthSignature = this.getCallbackDrivenModelStrengthSignature(node);
|
||||
}
|
||||
|
||||
const notifyIfModelListChanged = () => {
|
||||
const nextSignature = owner.getCallbackDrivenModelListSignature(node);
|
||||
const nextStrengthSignature = owner.getCallbackDrivenModelStrengthSignature(node);
|
||||
const modelListChanged = nextSignature !== state.signature;
|
||||
const strengthChanged = nextStrengthSignature !== state.strengthSignature;
|
||||
state.signature = nextSignature;
|
||||
state.strengthSignature = nextStrengthSignature;
|
||||
if (owner.dialog?.isWorkflowRefreshSuppressed?.()) return;
|
||||
|
||||
if (
|
||||
modelListChanged
|
||||
) {
|
||||
owner.scheduleNodeContextMenuAnalysis();
|
||||
owner.dialog?.scheduleActiveWorkflowRefresh?.('node-widget-change');
|
||||
} else if (strengthChanged) {
|
||||
owner.dialog?.updateLoadedModelStrengthsFromNode?.(node);
|
||||
}
|
||||
};
|
||||
state.notify = notifyIfModelListChanged;
|
||||
|
||||
for (const widget of node.widgets) {
|
||||
if (!watchedWidgetNames.has(widget?.name)) continue;
|
||||
if (widget.callback?.__modelResolverCallbackDrivenWidget) continue;
|
||||
|
||||
const originalCallback = widget.callback;
|
||||
const wrappedCallback = function() {
|
||||
const result = originalCallback?.apply(this, arguments);
|
||||
notifyIfModelListChanged();
|
||||
return result;
|
||||
};
|
||||
wrappedCallback.__modelResolverCallbackDrivenWidget = true;
|
||||
widget.callback = wrappedCallback;
|
||||
}
|
||||
|
||||
if (
|
||||
nodeType === 'Power Lora Loader (rgthree)'
|
||||
&& typeof node.setDirtyCanvas === 'function'
|
||||
&& !node.setDirtyCanvas.__modelResolverCallbackDrivenNode
|
||||
) {
|
||||
const originalSetDirtyCanvas = node.setDirtyCanvas;
|
||||
let inspectionQueued = false;
|
||||
const wrappedSetDirtyCanvas = function() {
|
||||
const result = originalSetDirtyCanvas.apply(this, arguments);
|
||||
if (!inspectionQueued) {
|
||||
inspectionQueued = true;
|
||||
queueMicrotask(() => {
|
||||
inspectionQueued = false;
|
||||
notifyIfModelListChanged();
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
wrappedSetDirtyCanvas.__modelResolverCallbackDrivenNode = true;
|
||||
node.setDirtyCanvas = wrappedSetDirtyCanvas;
|
||||
}
|
||||
}
|
||||
|
||||
waitForResolverDialogReady(timeoutMs = 2500) {
|
||||
const startedAt = Date.now();
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -199,6 +199,176 @@ export const tabsLoadedMethods = {
|
||||
};
|
||||
},
|
||||
|
||||
getLoadedModelDomKey(model = {}) {
|
||||
return this.getMissingModelKey(model);
|
||||
},
|
||||
|
||||
normalizeLoadedModelIdentity(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
.toLowerCase();
|
||||
},
|
||||
|
||||
updateLoadedModelCopyValues(container, loadedModels = []) {
|
||||
if (!container) return;
|
||||
|
||||
const byCategory = {};
|
||||
for (const model of loadedModels) {
|
||||
const category = model.category || 'unknown';
|
||||
if (!byCategory[category]) {
|
||||
byCategory[category] = { active: [], inactive: [] };
|
||||
}
|
||||
const filter = model.active !== false && model.connected !== false
|
||||
? 'active'
|
||||
: 'inactive';
|
||||
byCategory[category][filter].push(model);
|
||||
}
|
||||
|
||||
const buildString = (filter) => Object.entries(byCategory)
|
||||
.flatMap(([category, models]) => {
|
||||
const selected = filter === 'all'
|
||||
? [...models.active, ...models.inactive]
|
||||
: models[filter];
|
||||
return selected.map(model => this.getModelToken(model, category));
|
||||
})
|
||||
.join(' ');
|
||||
const strings = {
|
||||
active: buildString('active'),
|
||||
inactive: buildString('inactive'),
|
||||
all: buildString('all'),
|
||||
};
|
||||
|
||||
container.dataset.mlActiveString = strings.active;
|
||||
container.dataset.mlInactiveString = strings.inactive;
|
||||
container.dataset.mlAllString = strings.all;
|
||||
|
||||
const copySection = container.querySelector?.('.mr-copy-section');
|
||||
if (copySection) {
|
||||
copySection.dataset.mlActive = strings.active;
|
||||
copySection.dataset.mlInactive = strings.inactive;
|
||||
copySection.dataset.mlAll = strings.all;
|
||||
const activeMode = copySection.querySelector?.('.mr-copy-mode.active')
|
||||
?.dataset?.mlCopyMode || 'all';
|
||||
const copyCode = copySection.querySelector?.('.mr-copy-code');
|
||||
if (copyCode) {
|
||||
copyCode.textContent = strings[activeMode] || strings.all;
|
||||
}
|
||||
}
|
||||
|
||||
const sections = container.querySelectorAll?.(
|
||||
'.mr-model-section[data-ml-category]'
|
||||
) || [];
|
||||
for (const section of sections) {
|
||||
const category = section.dataset?.mlCategory || 'unknown';
|
||||
const categoryModels = byCategory[category];
|
||||
if (!categoryModels) continue;
|
||||
for (const filter of ['active', 'inactive']) {
|
||||
const button = section.querySelector?.(
|
||||
`.mr-model-group-${filter} .mr-btn-copy-compact`
|
||||
);
|
||||
if (!button) continue;
|
||||
const text = categoryModels[filter]
|
||||
.map(model => this.getModelToken(model, category))
|
||||
.join(' ');
|
||||
button.setAttribute(
|
||||
'onclick',
|
||||
`window.MLCopy(${this.escapeJsString(text)}, this)`
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
updateLoadedModelStrengthsFromNode(node) {
|
||||
if (
|
||||
this.activeTab !== 'loaded'
|
||||
|| !this.contentElement
|
||||
|| !Array.isArray(this.cachedLoadedModelsData?.loaded_models)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const widgets = Array.isArray(node?.widgets) ? node.widgets : [];
|
||||
const dynamicLoras = widgets
|
||||
.filter(widget => /^lora_\d+$/.test(widget?.name) && widget?.value?.lora)
|
||||
.map(widget => widget.value);
|
||||
const lorasWidget = widgets.find(widget => widget?.name === 'loras');
|
||||
const loras = dynamicLoras.length
|
||||
? dynamicLoras
|
||||
: (Array.isArray(lorasWidget?.value) ? lorasWidget.value : []);
|
||||
const entries = loras
|
||||
.map((lora) => {
|
||||
if (!lora || typeof lora !== 'object') return null;
|
||||
const identity = lora.lora
|
||||
|| lora.name
|
||||
|| lora.filename
|
||||
|| lora.path
|
||||
|| '';
|
||||
const strength = Number(lora.strength);
|
||||
return identity && Number.isFinite(strength)
|
||||
? {
|
||||
identity: this.normalizeLoadedModelIdentity(identity),
|
||||
strength,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (!entries.length) return false;
|
||||
|
||||
const nodeId = String(node?.id ?? '');
|
||||
const modelBuckets = new Map();
|
||||
for (const model of this.cachedLoadedModelsData.loaded_models) {
|
||||
if (String(model.node_id ?? '') !== nodeId) continue;
|
||||
const identity = this.normalizeLoadedModelIdentity(
|
||||
model.original_lora_name
|
||||
|| model.original_path
|
||||
|| model.name
|
||||
|| model.filename
|
||||
);
|
||||
if (!identity) continue;
|
||||
if (!modelBuckets.has(identity)) {
|
||||
modelBuckets.set(identity, []);
|
||||
}
|
||||
modelBuckets.get(identity).push(model);
|
||||
}
|
||||
|
||||
const chips = Array.from(
|
||||
this.contentElement.querySelectorAll?.('[data-ml-loaded-model-key]') || []
|
||||
);
|
||||
let updated = false;
|
||||
for (const entry of entries) {
|
||||
const model = modelBuckets.get(entry.identity)?.shift();
|
||||
if (!model || Number(model.strength) === entry.strength) continue;
|
||||
|
||||
model.strength = entry.strength;
|
||||
const modelKey = this.getLoadedModelDomKey(model);
|
||||
const chip = chips.find(
|
||||
candidate => candidate.dataset?.mlLoadedModelKey === modelKey
|
||||
);
|
||||
const strengthElement = chip?.querySelector?.('.mr-model-chip-strength');
|
||||
if (strengthElement) {
|
||||
strengthElement.textContent = entry.strength.toFixed(2);
|
||||
}
|
||||
updated = true;
|
||||
}
|
||||
if (!updated) return false;
|
||||
|
||||
this.updateLoadedModelCopyValues(
|
||||
this.contentElement,
|
||||
this.cachedLoadedModelsData.loaded_models
|
||||
);
|
||||
const workflow = this.getCurrentWorkflow?.();
|
||||
const workflowSignature = workflow
|
||||
? this.getWorkflowSignature?.(workflow)
|
||||
: '';
|
||||
if (workflowSignature) {
|
||||
this.cachedLoadedModelsSignature = workflowSignature;
|
||||
this.activeWorkflowSignature = workflowSignature;
|
||||
}
|
||||
this.saveLoadedModelsCacheForActiveWorkflow?.();
|
||||
return true;
|
||||
},
|
||||
|
||||
displayLoadedModels(container, data) {
|
||||
const loadedModels = data.loaded_models || [];
|
||||
const total = data.total || 0;
|
||||
@@ -272,7 +442,7 @@ export const tabsLoadedMethods = {
|
||||
const hasInactive = modelsObj.inactive.length > 0;
|
||||
const sectionTotal = modelsObj.active.length + modelsObj.inactive.length;
|
||||
|
||||
html += `<div class="mr-model-section" data-ml-filter="all" data-ml-active="${hasActive}" data-ml-inactive="${hasInactive}">`;
|
||||
html += `<div class="mr-model-section" data-ml-filter="all" data-ml-category="${this.escapeHtml(category)}" data-ml-active="${hasActive}" data-ml-inactive="${hasInactive}">`;
|
||||
|
||||
html += `<div class="mr-model-section-header">
|
||||
<div class="mr-model-section-heading">
|
||||
@@ -298,7 +468,8 @@ export const tabsLoadedMethods = {
|
||||
for (const model of modelsObj.active) {
|
||||
const { name, strength } = this.getModelNameAndStrength(model);
|
||||
const fullName = model.original_path || model.name || name;
|
||||
html += `<span class="mr-model-chip"${this.getContextMenuAttrs(this.getLoadedModelContext(model))}${this.getModelPreviewTooltipAttrs(model, fullName)}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
|
||||
const modelKey = this.getLoadedModelDomKey(model);
|
||||
html += `<span class="mr-model-chip" data-ml-loaded-model-key="${this.escapeHtml(modelKey)}"${this.getContextMenuAttrs(this.getLoadedModelContext(model))}${this.getModelPreviewTooltipAttrs(model, fullName)}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
@@ -316,7 +487,8 @@ export const tabsLoadedMethods = {
|
||||
for (const model of modelsObj.inactive) {
|
||||
const { name, strength } = this.getModelNameAndStrength(model);
|
||||
const fullName = model.original_path || model.name || name;
|
||||
html += `<span class="mr-model-chip"${this.getContextMenuAttrs(this.getLoadedModelContext(model))}${this.getModelPreviewTooltipAttrs(model, fullName)}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
|
||||
const modelKey = this.getLoadedModelDomKey(model);
|
||||
html += `<span class="mr-model-chip" data-ml-loaded-model-key="${this.escapeHtml(modelKey)}"${this.getContextMenuAttrs(this.getLoadedModelContext(model))}${this.getModelPreviewTooltipAttrs(model, fullName)}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user