Skip to content

Commit 67a0fc2

Browse files
committed
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
1 parent 2596ec8 commit 67a0fc2

4 files changed

Lines changed: 748 additions & 4 deletions

File tree

tests/test_downloads_tab_workflow_route.mjs

Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,6 +1166,262 @@ test('node context integration preserves existing menu hooks and refreshes after
11661166
]);
11671167
});
11681168

1169+
test('LoRA Manager callback-driven widgets refresh the open workflow analysis', () => {
1170+
const CALLBACK_DRIVEN_MODEL_WIDGETS = {
1171+
LoraLoaderV2: new Set(['text', 'loras']),
1172+
'Lora Loader (LoraManager)': new Set(['text', 'loras']),
1173+
'Lora Stacker (LoraManager)': new Set(['text', 'loras']),
1174+
'Power Lora Loader (rgthree)': new Set(),
1175+
};
1176+
const configureCallbackDrivenModelWidgets = eval(
1177+
`(${extractMethod(modelResolverSource, 'configureCallbackDrivenModelWidgets')})`
1178+
);
1179+
const getCallbackDrivenModelListSignature = eval(
1180+
`(${extractMethod(modelResolverSource, 'getCallbackDrivenModelListSignature')})`
1181+
);
1182+
const getCallbackDrivenModelStrengthSignature = eval(
1183+
`(${extractMethod(modelResolverSource, 'getCallbackDrivenModelStrengthSignature')})`
1184+
);
1185+
const calls = [];
1186+
const lorasWidget = {
1187+
name: 'loras',
1188+
value: [],
1189+
callback(value) {
1190+
calls.push(['original-loras', value]);
1191+
return 'callback-result';
1192+
},
1193+
};
1194+
const textWidget = {
1195+
name: 'text',
1196+
callback(value) {
1197+
calls.push(['original-text', value]);
1198+
if (value === '<lora:second:1>') {
1199+
lorasWidget.value = [
1200+
...lorasWidget.value,
1201+
{ name: 'second', strength: 1, active: true },
1202+
];
1203+
}
1204+
},
1205+
};
1206+
const unrelatedWidget = {
1207+
name: 'seed',
1208+
callback() {
1209+
calls.push(['unrelated']);
1210+
},
1211+
};
1212+
const resolver = {
1213+
callbackDrivenModelWidgetStates: new WeakMap(),
1214+
getCallbackDrivenModelListSignature,
1215+
getCallbackDrivenModelStrengthSignature,
1216+
scheduleNodeContextMenuAnalysis: () => calls.push(['context-analysis']),
1217+
dialog: {
1218+
isWorkflowRefreshSuppressed: () => false,
1219+
scheduleActiveWorkflowRefresh: reason => calls.push(['dialog-refresh', reason]),
1220+
},
1221+
};
1222+
const node = {
1223+
comfyClass: 'Lora Loader (LoraManager)',
1224+
widgets: [textWidget, lorasWidget, unrelatedWidget],
1225+
};
1226+
1227+
configureCallbackDrivenModelWidgets.call(resolver, node);
1228+
lorasWidget.value = [{ name: 'existing', strength: 1, active: true }];
1229+
configureCallbackDrivenModelWidgets.call(resolver, node);
1230+
1231+
textWidget.callback('<lora:second');
1232+
lorasWidget.value = [{ name: 'existing', strength: 0.5, active: true }];
1233+
assert.equal(lorasWidget.callback(lorasWidget.value), 'callback-result');
1234+
textWidget.callback('<lora:second:1>');
1235+
unrelatedWidget.callback();
1236+
assert.deepEqual(calls, [
1237+
['original-text', '<lora:second'],
1238+
['original-loras', [{ name: 'existing', strength: 0.5, active: true }]],
1239+
['original-text', '<lora:second:1>'],
1240+
['context-analysis'],
1241+
['dialog-refresh', 'node-widget-change'],
1242+
['unrelated'],
1243+
]);
1244+
});
1245+
1246+
test('callback-driven widget refresh respects internal workflow update suppression', () => {
1247+
const CALLBACK_DRIVEN_MODEL_WIDGETS = {
1248+
LoraLoaderV2: new Set(['text', 'loras']),
1249+
'Lora Loader (LoraManager)': new Set(['text', 'loras']),
1250+
'Lora Stacker (LoraManager)': new Set(['text', 'loras']),
1251+
'Power Lora Loader (rgthree)': new Set(),
1252+
};
1253+
const configureCallbackDrivenModelWidgets = eval(
1254+
`(${extractMethod(modelResolverSource, 'configureCallbackDrivenModelWidgets')})`
1255+
);
1256+
const getCallbackDrivenModelListSignature = eval(
1257+
`(${extractMethod(modelResolverSource, 'getCallbackDrivenModelListSignature')})`
1258+
);
1259+
const getCallbackDrivenModelStrengthSignature = eval(
1260+
`(${extractMethod(modelResolverSource, 'getCallbackDrivenModelStrengthSignature')})`
1261+
);
1262+
const calls = [];
1263+
const widget = {
1264+
name: 'loras',
1265+
value: [],
1266+
callback: () => calls.push('original'),
1267+
};
1268+
const resolver = {
1269+
callbackDrivenModelWidgetStates: new WeakMap(),
1270+
getCallbackDrivenModelListSignature,
1271+
getCallbackDrivenModelStrengthSignature,
1272+
scheduleNodeContextMenuAnalysis: () => calls.push('context-analysis'),
1273+
dialog: {
1274+
isWorkflowRefreshSuppressed: () => true,
1275+
scheduleActiveWorkflowRefresh: () => calls.push('dialog-refresh'),
1276+
},
1277+
};
1278+
1279+
configureCallbackDrivenModelWidgets.call(resolver, {
1280+
comfyClass: 'Lora Stacker (LoraManager)',
1281+
widgets: [widget],
1282+
});
1283+
widget.value = [{ name: 'new-lora', strength: 1, active: true }];
1284+
widget.callback(widget.value);
1285+
1286+
assert.deepEqual(calls, ['original']);
1287+
});
1288+
1289+
test('rgthree Power Lora Loader updates strength without reloading workflow analysis', async () => {
1290+
const CALLBACK_DRIVEN_MODEL_WIDGETS = {
1291+
LoraLoaderV2: new Set(['text', 'loras']),
1292+
'Lora Loader (LoraManager)': new Set(['text', 'loras']),
1293+
'Lora Stacker (LoraManager)': new Set(['text', 'loras']),
1294+
'Power Lora Loader (rgthree)': new Set(),
1295+
};
1296+
const configureCallbackDrivenModelWidgets = eval(
1297+
`(${extractMethod(modelResolverSource, 'configureCallbackDrivenModelWidgets')})`
1298+
);
1299+
const getCallbackDrivenModelListSignature = eval(
1300+
`(${extractMethod(modelResolverSource, 'getCallbackDrivenModelListSignature')})`
1301+
);
1302+
const getCallbackDrivenModelStrengthSignature = eval(
1303+
`(${extractMethod(modelResolverSource, 'getCallbackDrivenModelStrengthSignature')})`
1304+
);
1305+
const calls = [];
1306+
const node = {
1307+
comfyClass: 'Power Lora Loader (rgthree)',
1308+
widgets: [{
1309+
name: 'lora_1',
1310+
value: {
1311+
on: true,
1312+
lora: 'existing.safetensors',
1313+
strength: 1,
1314+
},
1315+
}],
1316+
setDirtyCanvas() {
1317+
calls.push('dirty');
1318+
},
1319+
};
1320+
const resolver = {
1321+
callbackDrivenModelWidgetStates: new WeakMap(),
1322+
getCallbackDrivenModelListSignature,
1323+
getCallbackDrivenModelStrengthSignature,
1324+
scheduleNodeContextMenuAnalysis: () => calls.push('context-analysis'),
1325+
dialog: {
1326+
isWorkflowRefreshSuppressed: () => false,
1327+
updateLoadedModelStrengthsFromNode: () => calls.push('loaded-strength-update'),
1328+
scheduleActiveWorkflowRefresh: reason => calls.push(`dialog-refresh:${reason}`),
1329+
},
1330+
};
1331+
1332+
configureCallbackDrivenModelWidgets.call(resolver, node);
1333+
1334+
node.widgets[0].value.strength = 0.5;
1335+
node.setDirtyCanvas(true, true);
1336+
await Promise.resolve();
1337+
1338+
node.widgets.push({
1339+
name: 'lora_2',
1340+
value: {
1341+
on: true,
1342+
lora: 'new-model.safetensors',
1343+
strength: 1,
1344+
},
1345+
});
1346+
node.setDirtyCanvas(true, true);
1347+
await Promise.resolve();
1348+
1349+
assert.deepEqual(calls, [
1350+
'dirty',
1351+
'loaded-strength-update',
1352+
'dirty',
1353+
'context-analysis',
1354+
'dialog-refresh:node-widget-change',
1355+
]);
1356+
});
1357+
1358+
test('rgthree dynamic lora widgets bypass the generic full workflow refresh', () => {
1359+
const CALLBACK_DRIVEN_MODEL_WIDGETS = {
1360+
LoraLoaderV2: new Set(['text', 'loras']),
1361+
'Lora Loader (LoraManager)': new Set(['text', 'loras']),
1362+
'Lora Stacker (LoraManager)': new Set(['text', 'loras']),
1363+
'Power Lora Loader (rgthree)': new Set(),
1364+
};
1365+
const configureNodeContextMenu = eval(
1366+
`(${extractMethod(modelResolverSource, 'configureNodeContextMenu')})`
1367+
);
1368+
const isCallbackDrivenModelWidget = eval(
1369+
`(${extractMethod(modelResolverSource, 'isCallbackDrivenModelWidget')})`
1370+
);
1371+
const calls = [];
1372+
class NodeType {}
1373+
NodeType.prototype.comfyClass = 'Power Lora Loader (rgthree)';
1374+
NodeType.prototype.onWidgetChanged = function(name) {
1375+
calls.push(`original:${name}`);
1376+
};
1377+
const node = new NodeType();
1378+
const resolver = {
1379+
callbackDrivenModelWidgetStates: new WeakMap([
1380+
[node, { notify: () => calls.push('selective-update') }],
1381+
]),
1382+
getResolvedModelsForNodeContextMenu: () => [],
1383+
isCallbackDrivenModelWidget,
1384+
scheduleNodeContextMenuAnalysis: () => calls.push('context-analysis'),
1385+
dialog: {
1386+
isWorkflowRefreshSuppressed: () => false,
1387+
isWorkflowStrengthWidgetName: () => false,
1388+
scheduleActiveWorkflowRefresh: () => calls.push('dialog-refresh'),
1389+
},
1390+
};
1391+
1392+
configureNodeContextMenu.call(resolver, NodeType);
1393+
node.onWidgetChanged('lora_1');
1394+
1395+
assert.deepEqual(calls, [
1396+
'original:lora_1',
1397+
'selective-update',
1398+
]);
1399+
});
1400+
1401+
test('LoRA Manager text onWidgetChanged waits for a confirmed model-list change', () => {
1402+
const configureNodeContextMenu = eval(`(${extractMethod(modelResolverSource, 'configureNodeContextMenu')})`);
1403+
const calls = [];
1404+
class NodeType {}
1405+
NodeType.prototype.onWidgetChanged = function(name) {
1406+
calls.push(`original:${name}`);
1407+
};
1408+
const resolver = {
1409+
getResolvedModelsForNodeContextMenu: () => [],
1410+
isCallbackDrivenModelWidget: (_node, widgetName) => widgetName === 'text',
1411+
scheduleNodeContextMenuAnalysis: () => calls.push('context-analysis'),
1412+
dialog: {
1413+
isWorkflowRefreshSuppressed: () => false,
1414+
isWorkflowStrengthWidgetName: () => false,
1415+
scheduleActiveWorkflowRefresh: () => calls.push('dialog-refresh'),
1416+
},
1417+
};
1418+
1419+
configureNodeContextMenu.call(resolver, NodeType);
1420+
new NodeType().onWidgetChanged('text');
1421+
1422+
assert.deepEqual(calls, ['original:text']);
1423+
});
1424+
11691425
test('strength widget changes skip proactive node context analysis', () => {
11701426
const configureNodeContextMenu = eval(`(${extractMethod(modelResolverSource, 'configureNodeContextMenu')})`);
11711427
const isWorkflowStrengthWidgetName = eval(
@@ -1425,6 +1681,83 @@ test('background Loaded Models refresh keeps the current view until new data is
14251681
assert.equal(contentElement.scrollTop, 48);
14261682
});
14271683

1684+
test('Power Lora Loader strength updates only its Loaded Models chip and cache', () => {
1685+
const updateLoadedModelStrengthsFromNode = eval(
1686+
`(${extractMethod(tabsLoadedMethodsSource, 'updateLoadedModelStrengthsFromNode')})`
1687+
);
1688+
const normalizeLoadedModelIdentity = eval(
1689+
`(${extractMethod(tabsLoadedMethodsSource, 'normalizeLoadedModelIdentity')})`
1690+
);
1691+
const getLoadedModelDomKey = eval(
1692+
`(${extractMethod(tabsLoadedMethodsSource, 'getLoadedModelDomKey')})`
1693+
);
1694+
const strengthElement = { textContent: '1.00' };
1695+
const chip = {
1696+
dataset: { mlLoadedModelKey: 'model-key' },
1697+
querySelector: selector => (
1698+
selector === '.mr-model-chip-strength' ? strengthElement : null
1699+
),
1700+
};
1701+
const contentElement = {
1702+
querySelectorAll: selector => (
1703+
selector === '[data-ml-loaded-model-key]' ? [chip] : []
1704+
),
1705+
};
1706+
const loadedModel = {
1707+
node_id: 91,
1708+
widget_index: 0,
1709+
nested_key: 'lora',
1710+
category: 'loras',
1711+
original_path: 'styles/existing.safetensors',
1712+
strength: 1,
1713+
};
1714+
const calls = [];
1715+
const dialog = {
1716+
activeTab: 'loaded',
1717+
contentElement,
1718+
cachedLoadedModelsData: {
1719+
loaded_models: [loadedModel],
1720+
total: 1,
1721+
},
1722+
cachedLoadedModelsSignature: 'old-signature',
1723+
activeWorkflowSignature: 'old-signature',
1724+
normalizeLoadedModelIdentity,
1725+
getLoadedModelDomKey,
1726+
getMissingModelKey: () => 'model-key',
1727+
updateLoadedModelCopyValues: (_container, models) => {
1728+
calls.push(['copy-values', models[0].strength]);
1729+
},
1730+
getCurrentWorkflow: () => ({ nodes: [{ id: 91 }] }),
1731+
getWorkflowSignature: () => 'strength-signature',
1732+
saveLoadedModelsCacheForActiveWorkflow: () => calls.push(['save-cache']),
1733+
displayLoadedModels: () => calls.push(['full-render']),
1734+
};
1735+
const node = {
1736+
id: 91,
1737+
comfyClass: 'Power Lora Loader (rgthree)',
1738+
widgets: [{
1739+
name: 'lora_1',
1740+
value: {
1741+
on: true,
1742+
lora: 'styles/existing.safetensors',
1743+
strength: 0.5,
1744+
},
1745+
}],
1746+
};
1747+
1748+
const updated = updateLoadedModelStrengthsFromNode.call(dialog, node);
1749+
1750+
assert.equal(updated, true);
1751+
assert.equal(loadedModel.strength, 0.5);
1752+
assert.equal(strengthElement.textContent, '0.50');
1753+
assert.equal(dialog.cachedLoadedModelsSignature, 'strength-signature');
1754+
assert.equal(dialog.activeWorkflowSignature, 'strength-signature');
1755+
assert.deepEqual(calls, [
1756+
['copy-values', 0.5],
1757+
['save-cache'],
1758+
]);
1759+
});
1760+
14281761
test('changed workflow model keeps the selected row and batch checkbox for the same loader slot', () => {
14291762
const encodeMissingModelKeyPart = eval(`(${extractMethod(lifecycleGraphMethodsSource, 'encodeMissingModelKeyPart')})`);
14301763
const getMissingModelIdentityPart = eval(`(${extractMethod(lifecycleGraphMethodsSource, 'getMissingModelIdentityPart')})`);
@@ -2209,6 +2542,49 @@ test('Missing Models signature ignores strength but tracks model identity change
22092542
);
22102543
});
22112544

2545+
test('Missing Models signature tracks LoRA Manager list membership changes', () => {
2546+
const getWorkflowSignature = eval(`(${extractMethod(workflowStateMethodsSource, 'getWorkflowSignature')})`);
2547+
const getMissingWorkflowSignature = eval(`(${extractMethod(workflowStateMethodsSource, 'getMissingWorkflowSignature')})`);
2548+
const getWorkflowSignatureData = eval(`(${extractMethod(workflowStateMethodsSource, 'getWorkflowSignatureData')})`);
2549+
const dialog = {
2550+
capabilities: { node_rules: {} },
2551+
getWorkflowSignature,
2552+
getMissingWorkflowSignature,
2553+
getWorkflowSignatureData,
2554+
};
2555+
const makeWorkflow = (loras) => ({
2556+
nodes: [{
2557+
id: 83,
2558+
type: 'Lora Loader (LoraManager)',
2559+
widgets_values: [
2560+
{ version: 1, textWidgetName: 'text' },
2561+
'<lora:first:1>',
2562+
loras,
2563+
],
2564+
}],
2565+
links: [],
2566+
});
2567+
const original = makeWorkflow([
2568+
{ name: 'first', strength: 1, active: true },
2569+
]);
2570+
const strengthChanged = makeWorkflow([
2571+
{ name: 'first', strength: 0.5, active: true },
2572+
]);
2573+
const loraAdded = makeWorkflow([
2574+
{ name: 'first', strength: 0.5, active: true },
2575+
{ name: 'second', strength: 1, active: true },
2576+
]);
2577+
2578+
assert.equal(
2579+
getMissingWorkflowSignature.call(dialog, original),
2580+
getMissingWorkflowSignature.call(dialog, strengthChanged)
2581+
);
2582+
assert.notEqual(
2583+
getMissingWorkflowSignature.call(dialog, strengthChanged),
2584+
getMissingWorkflowSignature.call(dialog, loraAdded)
2585+
);
2586+
});
2587+
22122588
test('workflow hash refresh ignores node movement and tracks model dependency changes', () => {
22132589
const getWorkflowSignature = eval(`(${extractMethod(workflowStateMethodsSource, 'getWorkflowSignature')})`);
22142590
const getWorkflowSignatureData = eval(`(${extractMethod(workflowStateMethodsSource, 'getWorkflowSignatureData')})`);

0 commit comments

Comments
 (0)