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:
Dariusz L
2026-07-28 14:23:11 +02:00
parent 2596ec804b
commit 67a0fc27e7
4 changed files with 748 additions and 4 deletions
+376
View File
@@ -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')})`);
+2
View File
@@ -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() {
+195 -1
View File
@@ -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) => {
+175 -3
View File
@@ -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>`;
}