diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 9f75063..61dbbe1 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -537,6 +537,33 @@ test('node context integration preserves existing menu hooks and refreshes after ]); }); +test('strength widget changes skip proactive node context analysis', () => { + const configureNodeContextMenu = eval(`(${extractMethod(modelResolverSource, 'configureNodeContextMenu')})`); + const isWorkflowStrengthWidgetName = eval( + `(${extractMethod(workflowStateMethodsSource, 'isWorkflowStrengthWidgetName')})` + ); + const calls = []; + class NodeType {} + const resolver = { + getResolvedModelsForNodeContextMenu: () => [], + scheduleNodeContextMenuAnalysis: () => calls.push('context-analysis'), + dialog: { + isWorkflowStrengthWidgetName, + scheduleActiveWorkflowRefresh: reason => calls.push(`dialog-refresh:${reason}`), + }, + }; + + configureNodeContextMenu.call(resolver, NodeType); + const node = new NodeType(); + node.onWidgetChanged('strength_model'); + node.onWidgetChanged('lora_2_clip_strength'); + + assert.deepEqual(calls, [ + 'dialog-refresh:node-widget-change', + 'dialog-refresh:node-widget-change', + ]); +}); + test('background Loaded Models refresh keeps the current view until new data is ready', async () => { const loadLoadedModels = eval(`(${extractMethod(tabsLoadedMethodsSource, 'loadLoadedModels')})`); const workflow = { @@ -671,7 +698,7 @@ test('background Missing Models refresh keeps the current view until new data is cachedWorkflowSignature: 'old-signature', cachedAnalysisData: { missing_models: [], resolved_models: [] }, syncWorkflowScopedQueue() {}, - getWorkflowSignature: () => 'new-signature', + getMissingWorkflowSignature: () => 'new-signature', workflowHasNodes: () => true, renderAnalysisProgress() { progressRenderCount += 1; @@ -734,12 +761,14 @@ test('node widget changes request a content-preserving Missing Models refresh', _workflowRefreshGeneration: 1, activeWorkflowRouteKey: 'workflow-a', activeWorkflowSignature: 'old-signature', + activeMissingWorkflowSignature: 'old-missing-signature', activeTab: 'missing', contentElement: { style: {} }, isVisible: () => true, getActiveWorkflowRouteKey: () => 'workflow-a', getCurrentWorkflow: () => workflow, getWorkflowSignature: () => 'new-signature', + getMissingWorkflowSignature: () => 'new-missing-signature', syncWorkflowScopedQueue() { preserveSearchCacheAtSync = this.preserveSearchCacheAcrossNextWorkflowSync; }, @@ -763,6 +792,55 @@ test('node widget changes request a content-preserving Missing Models refresh', assert.equal(preserveSearchCacheAtSync, true); }); +test('strength-only widget changes synchronize workflow state without refreshing Missing Models', async () => { + const log = { debug() {} }; + const refreshForActiveWorkflowChange = eval( + `(${extractMethod(workflowStateMethodsSource, 'refreshForActiveWorkflowChange')})` + ); + const workflow = { + nodes: [{ + id: 7, + type: 'LoraLoader', + widgets_values: ['model.safetensors', 1.25], + }], + }; + let syncCount = 0; + let loadCount = 0; + const dialog = { + _workflowRefreshGeneration: 1, + activeWorkflowRouteKey: 'workflow-a', + activeWorkflowSignature: 'old-full-signature', + activeMissingWorkflowSignature: 'same-missing-signature', + activeTab: 'missing', + contentElement: { style: {} }, + isVisible: () => true, + getActiveWorkflowRouteKey: () => 'workflow-a', + getCurrentWorkflow: () => workflow, + getWorkflowSignature: () => 'new-full-signature', + getMissingWorkflowSignature: () => 'same-missing-signature', + syncWorkflowScopedQueue() { + syncCount += 1; + }, + async loadWorkflowData() { + loadCount += 1; + }, + }; + + await refreshForActiveWorkflowChange.call(dialog, { + reason: 'node-widget-change', + expectedRoute: 'workflow-a', + previousSignature: 'old-full-signature', + attempt: 8, + generation: 1, + candidateRoute: 'workflow-a', + candidateSignature: 'new-full-signature', + }); + + assert.equal(syncCount, 1); + assert.equal(loadCount, 0); + assert.equal(dialog.preserveSearchCacheAcrossNextWorkflowSync, true); +}); + test('workflow synchronization carries searched results across a strength-only signature change', () => { const syncWorkflowScopedQueue = eval( `(${extractMethod(workflowStateMethodsSource, 'syncWorkflowScopedQueue')})` @@ -784,12 +862,17 @@ test('workflow synchronization carries searched results across a strength-only s preserveSearchCacheAcrossNextWorkflowSync: true, activeWorkflowRouteKey: 'workflow-a', activeWorkflowSignature: 'old-signature', + activeMissingWorkflowSignature: 'same-missing-signature', searchResultCache: new Map([['loras:missing.safetensors', searchedState]]), backgroundSearchJobs: new Map(), getCurrentWorkflow: () => workflow, getActiveWorkflowRouteKey: () => 'workflow-a', getWorkflowSignature: () => 'new-signature', - getWorkflowScopedQueueKey(route = dialog.activeWorkflowRouteKey, signature = dialog.activeWorkflowSignature) { + getMissingWorkflowSignature: () => 'same-missing-signature', + getWorkflowScopedQueueKey( + route = dialog.activeWorkflowRouteKey, + signature = dialog.activeMissingWorkflowSignature || dialog.activeWorkflowSignature + ) { return `${route}\n${signature}`; }, cloneSearchResultCache(cache) { @@ -815,6 +898,7 @@ test('workflow synchronization carries searched results across a strength-only s syncWorkflowScopedQueue.call(dialog, workflow); assert.equal(dialog.activeWorkflowSignature, 'new-signature'); + assert.equal(dialog.activeMissingWorkflowSignature, 'same-missing-signature'); assert.equal( dialog.searchResultCache.get('loras:missing.safetensors'), searchedState @@ -1286,6 +1370,47 @@ test('workflow signature tracks EasyLoraStack mode and indexed strengths', () => ]); }); +test('Missing Models signature ignores strength but tracks model identity 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 = (modelName, strength) => ({ + nodes: [{ + id: 34, + type: 'LoraLoader', + inputs: [ + { name: 'lora_name', widget: { name: 'lora_name' } }, + { name: 'strength_model', widget: { name: 'strength_model' } }, + ], + outputs: [], + widgets_values: [modelName, strength], + }], + links: [], + }); + const original = makeWorkflow('AnimeEditV2.safetensors', 0.65); + const strengthChanged = makeWorkflow('AnimeEditV2.safetensors', 1.25); + const modelChanged = makeWorkflow('AnotherModel.safetensors', 1.25); + + assert.notEqual( + getWorkflowSignature.call(dialog, original), + getWorkflowSignature.call(dialog, strengthChanged) + ); + assert.equal( + getMissingWorkflowSignature.call(dialog, original), + getMissingWorkflowSignature.call(dialog, strengthChanged) + ); + assert.notEqual( + getMissingWorkflowSignature.call(dialog, strengthChanged), + getMissingWorkflowSignature.call(dialog, modelChanged) + ); +}); + 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/model_resolver.js b/web/resolver/model_resolver.js index 4c215ca..8384ab8 100644 --- a/web/resolver/model_resolver.js +++ b/web/resolver/model_resolver.js @@ -331,7 +331,8 @@ export class ModelResolver { const workflow = app?.graph?.serialize?.(); if (!workflow) return { workflow: null, signature: null }; - const signature = this.dialog?.getWorkflowSignature?.(workflow) + const signature = this.dialog?.getMissingWorkflowSignature?.(workflow) + || this.dialog?.getWorkflowSignature?.(workflow) || JSON.stringify(workflow); return { workflow, signature }; } @@ -521,7 +522,10 @@ export class ModelResolver { }; nodeType.prototype.onWidgetChanged = function() { const result = originalOnWidgetChanged?.apply(this, arguments); - owner.scheduleNodeContextMenuAnalysis(); + const widgetName = arguments[0]; + if (!owner.dialog?.isWorkflowStrengthWidgetName?.(widgetName)) { + owner.scheduleNodeContextMenuAnalysis(); + } owner.dialog?.scheduleActiveWorkflowRefresh?.('node-widget-change'); return result; }; diff --git a/web/resolver/resolver_dialog.js b/web/resolver/resolver_dialog.js index 21d4ecb..e316ca8 100644 --- a/web/resolver/resolver_dialog.js +++ b/web/resolver/resolver_dialog.js @@ -97,6 +97,7 @@ export class ResolverManagerDialog extends ComfyDialog { this._viewportClampFrame = null; this.activeWorkflowRouteKey = this.getActiveWorkflowRouteKey(); this.activeWorkflowSignature = null; + this.activeMissingWorkflowSignature = null; this._workflowRefreshTimer = null; this._workflowRefreshRetryTimer = null; this._workflowRefreshGeneration = 0; diff --git a/web/resolver/shell/lifecycle_graph_methods.js b/web/resolver/shell/lifecycle_graph_methods.js index 4bacea0..569d23d 100644 --- a/web/resolver/shell/lifecycle_graph_methods.js +++ b/web/resolver/shell/lifecycle_graph_methods.js @@ -150,7 +150,7 @@ export const lifecycleGraphMethods = { loadToken = `missing-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; this._workflowDataLoadToken = loadToken; - const workflowSignature = this.getWorkflowSignature(workflow); + const workflowSignature = this.getMissingWorkflowSignature(workflow); if (force) { this.invalidateLoadedModelsCacheForActiveWorkflow(); } diff --git a/web/resolver/shell/workflow_state_methods.js b/web/resolver/shell/workflow_state_methods.js index e08f27c..12cb136 100644 --- a/web/resolver/shell/workflow_state_methods.js +++ b/web/resolver/shell/workflow_state_methods.js @@ -7,17 +7,28 @@ import { getSvgIcon } from "../../utils/icon_utils.js"; const log = createModuleLogger('workflow_state_methods'); export const workflowStateMethods = { - getWorkflowSignature(workflow) { + getWorkflowSignature(workflow, options = {}) { if (!workflow) return null; try { - return JSON.stringify(this.getWorkflowSignatureData(workflow)); + return JSON.stringify(this.getWorkflowSignatureData(workflow, options)); } catch (error) { console.warn('Model Resolver: workflow signature generation failed', error); return null; } }, - getWorkflowSignatureData(workflow) { + getMissingWorkflowSignature(workflow) { + return this.getWorkflowSignature(workflow, { includeStrength: false }); + }, + + isWorkflowStrengthWidgetName(name = '') { + const normalized = String(name || '').trim().replace(/[-\s]/g, '_').toLowerCase(); + return normalized === 'strength_model' + || normalized === 'strength_clip' + || /(?:^|_)(?:strength|model_strength|clip_strength)$/.test(normalized); + }, + + getWorkflowSignatureData(workflow, { includeStrength = true } = {}) { const modelExtensionPattern = /\.(ckpt|pt2?|bin|pth|safetensors|pkl|sft|onnx|gguf)(?:$|[?#])/i; const urnPattern = /^urn:air:[^:]+:[^:]+:[^:]+:\d+@\d+$/i; const urlPattern = /^https?:\/\//i; @@ -62,10 +73,12 @@ export const workflowStateMethods = { 'modelid', 'version_id', 'versionid', - 'strength', 'active', 'on' ]); + if (includeStrength) { + modelMetadataKeys.add('strength'); + } const loraStrengthNodeTypes = new Set([ 'LoraLoader', 'LoraLoaderModelOnly', @@ -176,8 +189,10 @@ export const workflowStateMethods = { }; const isRelevantScalarKey = (key = '') => { const normalizedKey = normalizeKey(key); - return loraStrengthKeys.has(normalizedKey) + return (includeStrength && ( + loraStrengthKeys.has(normalizedKey) || indexedLoraStrengthPattern.test(normalizedKey) + )) || loraStackStateKeys.has(normalizedKey) || ['active', 'on', 'model_id', 'modelid', 'version_id', 'versionid'].includes(normalizedKey); }; @@ -245,7 +260,8 @@ export const workflowStateMethods = { ); if ( - normalized === null + includeStrength + && normalized === null && loraStrengthNodeTypes.has(nodeType) && ( normalizeRelevantValue(widgetsValues[index - 1], widgetValueKey(index - 1)) !== null @@ -351,7 +367,10 @@ export const workflowStateMethods = { } }, - getWorkflowScopedQueueKey(route = this.activeWorkflowRouteKey, signature = this.activeWorkflowSignature) { + getWorkflowScopedQueueKey( + route = this.activeWorkflowRouteKey, + signature = this.activeMissingWorkflowSignature || this.activeWorkflowSignature + ) { if (route && signature) return `${route}\n${signature}`; return route || signature || null; }, @@ -598,8 +617,12 @@ export const workflowStateMethods = { const currentWorkflow = workflow || this.getCurrentWorkflow(); const nextRoute = this.getActiveWorkflowRouteKey(); const nextSignature = this.getWorkflowSignature(currentWorkflow); + const nextMissingSignature = this.getMissingWorkflowSignature(currentWorkflow); const previousKey = this.getWorkflowScopedQueueKey(); - const nextKey = this.getWorkflowScopedQueueKey(nextRoute, nextSignature); + const nextKey = this.getWorkflowScopedQueueKey( + nextRoute, + nextMissingSignature || nextSignature + ); const preserveSearchCache = Boolean(this.preserveSearchCacheAcrossNextWorkflowSync); const previousSearchCache = preserveSearchCache ? this.cloneSearchResultCache(this.searchResultCache, { @@ -624,6 +647,7 @@ export const workflowStateMethods = { if (!nextKey || nextKey === previousKey) { this.activeWorkflowRouteKey = nextRoute; this.activeWorkflowSignature = nextSignature; + this.activeMissingWorkflowSignature = nextMissingSignature; this.preserveSearchCacheAcrossNextWorkflowSync = false; return; } @@ -636,6 +660,7 @@ export const workflowStateMethods = { this.clearWorkflowScopedState(); this.activeWorkflowRouteKey = nextRoute; this.activeWorkflowSignature = nextSignature; + this.activeMissingWorkflowSignature = nextMissingSignature; this.restorePendingQueueForActiveWorkflow(); this.restoreAnalysisCacheForActiveWorkflow(); this.restoreLoadedModelsCacheForActiveWorkflow(); @@ -744,8 +769,11 @@ export const workflowStateMethods = { const workflow = this.getCurrentWorkflow(); const signature = this.getWorkflowSignature(workflow); + const missingSignature = this.getMissingWorkflowSignature(workflow); const routeChanged = currentRoute !== this.activeWorkflowRouteKey; const signatureChanged = signature && signature !== this.activeWorkflowSignature; + const missingSignatureChanged = missingSignature + && missingSignature !== this.activeMissingWorkflowSignature; const graphStillLooksOld = routeChanged && previousSignature && signature === previousSignature; if ((!signature || graphStillLooksOld) && attempt < 8) { @@ -796,6 +824,10 @@ export const workflowStateMethods = { this.syncWorkflowScopedQueue(workflow); if (this.activeTab === 'missing') { + if (!routeChanged && missingSignature && !missingSignatureChanged) { + log.debug('Model Resolver: skipping Missing Models refresh for a strength-only workflow change'); + return; + } if (this.contentElement) this.contentElement.style.overflowY = 'auto'; await this.loadWorkflowData(workflow, { preserveContent: !routeChanged && reason === 'node-widget-change'