Skip Missing Models refresh for strength-only widget changes

This commit is contained in:
Dariusz L
2026-07-27 18:22:04 +02:00
parent 726c3a9e02
commit 92c8227bda
5 changed files with 175 additions and 13 deletions
+127 -2
View File
@@ -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')})`);
+6 -2
View File
@@ -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;
};
+1
View File
@@ -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;
@@ -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();
}
+40 -8
View File
@@ -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'