Refresh metadata targets with live workflow changes

This commit is contained in:
Dariusz L
2026-09-14 00:13:54 +02:00
parent 552ef683da
commit 1672460801
7 changed files with 214 additions and 9 deletions
@@ -1725,6 +1725,30 @@ test('rgthree dynamic lora widgets bypass the generic full workflow refresh', ()
]);
});
test('removed workflow nodes notify the live workflow refresh mechanism', () => {
const configureWorkflowNodeLifecycle = eval(
`(${extractMethod(modelResolverSource, 'configureWorkflowNodeLifecycle')})`
);
const calls = [];
const node = {
onRemoved(value) {
calls.push(`native:${value}`);
return 'removed';
},
};
const resolver = {
dialog: {
scheduleActiveWorkflowRefresh: reason => calls.push(reason),
},
};
configureWorkflowNodeLifecycle.call(resolver, node);
assert.equal(node.onRemoved('node'), 'removed');
assert.deepEqual(calls, ['native:node', 'node-removed']);
assert.equal(node.__modelResolverWorkflowLifecyclePatched, true);
});
test('LoRA Manager text onWidgetChanged waits for a confirmed model-list change', () => {
const configureNodeContextMenu = eval(`(${extractMethod(modelResolverSource, 'configureNodeContextMenu')})`);
const calls = [];
@@ -2928,6 +2952,44 @@ test('node widget changes request a content-preserving Missing Models refresh',
assert.equal(preserveSearchCacheAtSync, true);
});
test('metadata workflow changes refresh transfer targets without showing a loading replacement', async () => {
const log = { debug() {} };
const refreshForActiveWorkflowChange = eval(
`(${extractMethod(workflowStateMethodsSource, 'refreshForActiveWorkflowChange')})`
);
const workflow = { nodes: [{ id: 3, type: 'CLIPLoader' }] };
let openArguments = null;
const dialog = {
_workflowRefreshGeneration: 1,
activeWorkflowRouteKey: 'workflow-a',
activeWorkflowSignature: 'old-signature',
activeMissingWorkflowSignature: 'old-missing-signature',
activeTab: 'metadata',
contentElement: { style: {} },
isVisible: () => true,
getActiveWorkflowRouteKey: () => 'workflow-a',
getCurrentWorkflow: () => workflow,
getWorkflowSignature: () => 'new-signature',
getMissingWorkflowSignature: () => 'new-missing-signature',
syncWorkflowScopedQueue() {},
async openMetadataTransfer(...args) {
openArguments = args;
},
};
await refreshForActiveWorkflowChange.call(dialog, {
reason: 'node-created',
expectedRoute: 'workflow-a',
previousSignature: 'old-signature',
attempt: 8,
generation: 1,
candidateRoute: 'workflow-a',
candidateSignature: 'new-signature',
});
assert.deepEqual(openArguments, [{ preserveContent: true }]);
});
test('node widget refresh retries while ComfyUI still serializes the previous workflow', async () => {
const log = { debug() {} };
const refreshForActiveWorkflowChange = eval(
+10
View File
@@ -61,4 +61,14 @@ test('frontend entrypoint registers the ComfyUI extension contract', () => {
2,
'dynamically created and loaded node types must receive the widget-change hook'
);
assert.match(
resolverSource,
/modelResolver\.dialog\?\.scheduleActiveWorkflowRefresh\?\.\('node-created'\)/,
'new nodes must notify the live workflow refresh mechanism'
);
assert.match(
modelResolverSource,
/configureWorkflowNodeLifecycle\(node\)/,
'workflow nodes must receive removal tracking'
);
});
+84
View File
@@ -250,6 +250,90 @@ test('metadata transfer stores a fresh target scan in the shared Loaded Models c
assert.equal(saveCount, 1);
});
test('metadata transfer refresh keeps the current panel visible while scanning a changed workflow', async () => {
const oldTargetGroup = {
category: 'text_encoders',
nodes: [{
nodeKey: 'top:1',
nodeId: 1,
nodeType: 'CLIPLoader',
refs: [],
}],
};
const sourceModel = {
category: 'text_encoders',
original_path: 'imported.safetensors',
};
let resolveFetch;
let renderCount = 0;
const context = {
...imageMetadataMethods,
imageInspectorState: {
requestToken: 'source-request',
loadedModels: { loaded_models: [sourceModel] },
transfer: {
open: true,
workflowSignature: 'old-workflow',
targetModels: { loaded_models: [] },
targetGroups: [oldTargetGroup],
selectedNodeKeys: new Set(['top:1']),
collapsedCategories: new Set(['text_encoders']),
sourceSelections: {},
},
},
getCurrentWorkflow: () => ({ signature: 'new-workflow' }),
getWorkflowSignature: workflow => workflow.signature,
syncWorkflowScopedQueue() {},
getCachedLoadedModelsForSignature() {
return null;
},
getMetadataTransferAcceptedFileTypes() {
return [];
},
fetchJson: () => new Promise(resolve => {
resolveFetch = resolve;
}),
saveLoadedModelsCacheForActiveWorkflow() {},
renderImageMetadataResult() {
renderCount += 1;
},
};
const refreshPromise = context.openMetadataTransfer({ preserveContent: true });
await new Promise(resolve => setTimeout(resolve, 0));
assert.equal(renderCount, 0);
assert.equal(context.imageInspectorState.transfer.refreshing, true);
assert.deepEqual(context.imageInspectorState.transfer.targetGroups, [oldTargetGroup]);
assert.deepEqual(
Array.from(context.imageInspectorState.transfer.selectedNodeKeys),
['top:1']
);
resolveFetch({
loaded_models: [{
category: 'text_encoders',
node_id: 1,
node_type: 'CLIPLoader',
widget_index: 0,
original_path: 'current.safetensors',
active: true,
connected: true,
is_top_level: true,
}],
total: 1,
});
await refreshPromise;
assert.equal(renderCount, 1);
assert.equal(context.imageInspectorState.transfer.refreshing, false);
assert.equal(context.imageInspectorState.transfer.targetGroups[0].nodes[0].nodeKey, 'top:1');
assert.deepEqual(
Array.from(context.imageInspectorState.transfer.selectedNodeKeys),
['top:1']
);
});
test('metadata transfer uses the shared canonical category aliases', () => {
const context = createTransferContext();
const state = createTransferState([
+3
View File
@@ -39,6 +39,8 @@ app.registerExtension({
queueMicrotask(() => modelResolver.configureNodeContextMenu(node));
modelResolver.configureWorkflowDependencyMarkerNode(node);
modelResolver.configureCustomNodeModelAdapter(node);
modelResolver.configureWorkflowNodeLifecycle(node);
modelResolver.dialog?.scheduleActiveWorkflowRefresh?.('node-created');
},
loadedGraphNode(node) {
modelResolver.configureNodeContextMenu(node?.constructor);
@@ -46,6 +48,7 @@ app.registerExtension({
queueMicrotask(() => modelResolver.configureNodeContextMenu(node));
modelResolver.configureWorkflowDependencyMarkerNode(node);
modelResolver.configureCustomNodeModelAdapter(node);
modelResolver.configureWorkflowNodeLifecycle(node);
},
afterConfigureGraph() {
modelResolver.handleGraphConfigured();
+26
View File
@@ -667,6 +667,32 @@ export class ModelResolver {
adapter.observe(node, notifyIfModelsChanged);
}
configureWorkflowNodeLifecycle(node) {
if (
!node
|| typeof node !== 'object'
|| node.__modelResolverWorkflowLifecyclePatched
|| typeof node.onRemoved !== 'function'
) return;
const owner = this;
const originalOnRemoved = node.onRemoved;
try {
node.onRemoved = function(...args) {
let result;
try {
result = originalOnRemoved.apply(this, args);
} finally {
owner.dialog?.scheduleActiveWorkflowRefresh?.('node-removed');
}
return result;
};
node.__modelResolverWorkflowLifecyclePatched = true;
} catch (_error) {
log.debug('Model Resolver: could not attach workflow lifecycle tracking to a node.');
}
}
waitForResolverDialogReady(timeoutMs = 2500) {
const startedAt = Date.now();
return new Promise((resolve) => {
+4 -2
View File
@@ -872,7 +872,7 @@ export const workflowStateMethods = {
&& missingSignature !== this.activeMissingWorkflowSignature;
const graphStillLooksOld = routeChanged && previousSignature && signature === previousSignature;
const workflowChangeStillPending = (
reason === 'node-widget-change'
String(reason || '').startsWith('node-')
&& !routeChanged
&& !signatureChanged
);
@@ -940,7 +940,9 @@ export const workflowStateMethods = {
});
} else if (this.activeTab === 'metadata') {
if (this.contentElement) this.contentElement.style.overflowY = 'auto';
await this.openMetadataTransfer?.();
await this.openMetadataTransfer?.({
preserveContent: !routeChanged && String(reason || '').startsWith('node-'),
});
}
}
};
@@ -1346,6 +1346,7 @@ export const imageMetadataMethods = {
const hasTransferableTarget = previewRows.length > 0;
const applyDisabled = (
transfer.applying
|| transfer.refreshing
|| !sourceEntries.length
|| !selectedTargetCount
|| !hasTransferableTarget
@@ -1400,7 +1401,7 @@ export const imageMetadataMethods = {
`;
},
async openMetadataTransfer({ force = false } = {}) {
async openMetadataTransfer({ force = false, preserveContent = false } = {}) {
const state = getImageInspectorState(this);
const imageRequestToken = state.requestToken;
const workflow = this.getCurrentWorkflow?.();
@@ -1409,10 +1410,17 @@ export const imageMetadataMethods = {
: '';
if (workflow) this.syncWorkflowScopedQueue?.(workflow);
const previousTransfer = state.transfer;
const keepCurrentTransferContent = Boolean(preserveContent && previousTransfer);
const preserveSelection = Boolean(
workflowSignature
&& previousTransfer?.workflowSignature
&& previousTransfer.workflowSignature === workflowSignature
previousTransfer
&& (
preserveContent
|| (
workflowSignature
&& previousTransfer.workflowSignature
&& previousTransfer.workflowSignature === workflowSignature
)
)
);
const previousSourceSelections = previousTransfer?.sourceSelections;
const savedPreferences = this.getMetadataTransferPreferences();
@@ -1426,7 +1434,8 @@ export const imageMetadataMethods = {
const token = `transfer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
state.transfer = {
open: true,
loading: Boolean(workflow),
loading: Boolean(workflow) && !keepCurrentTransferContent,
refreshing: Boolean(workflow) && keepCurrentTransferContent,
applying: false,
error: '',
mode: normalizeTransferMode(previousTransfer?.mode ?? savedPreferences.mode),
@@ -1444,7 +1453,14 @@ export const imageMetadataMethods = {
: new Set(),
sourceSelections,
};
this.renderImageMetadataResult();
if (keepCurrentTransferContent) {
state.transfer.targetModels = previousTransfer.targetModels || null;
state.transfer.targetGroups = Array.isArray(previousTransfer.targetGroups)
? previousTransfer.targetGroups
: [];
} else {
this.renderImageMetadataResult();
}
if (!workflow) {
state.transfer.loading = false;
@@ -1479,6 +1495,7 @@ export const imageMetadataMethods = {
this.saveLoadedModelsCacheForActiveWorkflow?.();
}
state.transfer.loading = false;
state.transfer.refreshing = false;
state.transfer.targetModels = targetModels;
state.transfer.targetGroups = this.getMetadataTransferTargetGroups(targetModels);
const sourceEntries = this.getMetadataTransferSourceModels(state.loadedModels);
@@ -1505,6 +1522,7 @@ export const imageMetadataMethods = {
|| state.transfer?.requestToken !== token
) return null;
state.transfer.loading = false;
state.transfer.refreshing = false;
state.transfer.error = error?.message || 'Could not scan the current workflow.';
this.renderImageMetadataResult();
return null;
@@ -1562,7 +1580,7 @@ export const imageMetadataMethods = {
async applyMetadataTransfer() {
const state = getImageInspectorState(this);
const transfer = state.transfer;
if (!transfer || transfer.loading || transfer.applying) return null;
if (!transfer || transfer.loading || transfer.refreshing || transfer.applying) return null;
const mode = normalizeTransferMode(transfer.mode);
const activityScope = normalizeTransferActivityScope(transfer.activityScope);