Refresh metadata targets with live workflow changes
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user