From 67a0fc27e79ea2718ca88350f2ba090d69713b02 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Tue, 28 Jul 2026 14:23:11 +0200 Subject: [PATCH] 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 --- tests/test_downloads_tab_workflow_route.mjs | 376 ++++++++++++++++++++ web/resolver.js | 2 + web/resolver/model_resolver.js | 196 +++++++++- web/resolver/views/tabs_loaded_methods.js | 178 ++++++++- 4 files changed, 748 insertions(+), 4 deletions(-) diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index a21afe8..5c5d33b 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -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 === '') { + 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(''); + unrelatedWidget.callback(); + assert.deepEqual(calls, [ + ['original-text', ''], + ['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' }, + '', + 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')})`); diff --git a/web/resolver.js b/web/resolver.js index 4800f8e..c5d0826 100644 --- a/web/resolver.js +++ b/web/resolver.js @@ -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() { diff --git a/web/resolver/model_resolver.js b/web/resolver/model_resolver.js index b774847..e200341 100644 --- a/web/resolver/model_resolver.js +++ b/web/resolver/model_resolver.js @@ -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) => { diff --git a/web/resolver/views/tabs_loaded_methods.js b/web/resolver/views/tabs_loaded_methods.js index 53302b8..038a8d0 100644 --- a/web/resolver/views/tabs_loaded_methods.js +++ b/web/resolver/views/tabs_loaded_methods.js @@ -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 += `
`; + html += `
`; html += `
@@ -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 += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; + const modelKey = this.getLoadedModelDomKey(model); + html += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; } html += `
`; } @@ -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 += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; + const modelKey = this.getLoadedModelDomKey(model); + html += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; } html += `
`; }