frontend: preserve missing models browser state during apply and avoid duplicate workflow refreshes
This commit is contained in:
@@ -1757,91 +1757,83 @@ test('Apply defers model catalog refresh until after the linking path returns',
|
||||
assert.deepEqual(calls, ['catalog', 'refresh:node-widget-change']);
|
||||
});
|
||||
|
||||
test('background Apply validation uses the Missing Models workflow signature', async () => {
|
||||
const refreshAnalysisInBackground = eval(
|
||||
`(${extractMethod(queueMethodsSource, 'refreshAnalysisInBackground')})`
|
||||
test('Apply does not schedule a duplicate workflow refresh when analysis is already current', async () => {
|
||||
const scheduleComfyModelCatalogRefreshAfterApply = eval(
|
||||
`(${extractMethod(workflowUpdateMethodsSource, 'scheduleComfyModelCatalogRefreshAfterApply')})`
|
||||
);
|
||||
const workflow = { nodes: [{ id: 7, widgets_values: ['linked.safetensors', 1.25] }] };
|
||||
const returnedData = { missing_models: [], resolved_models: [{ node_id: 7 }] };
|
||||
const calls = [];
|
||||
let finish;
|
||||
const finished = new Promise(resolve => {
|
||||
finish = resolve;
|
||||
});
|
||||
const workflow = { nodes: [{ id: 7, widgets_values: ['linked.safetensors'] }] };
|
||||
const dialog = {
|
||||
activeWorkflowSignature: 'different-full-signature',
|
||||
activeMissingWorkflowSignature: 'linked-missing-signature',
|
||||
cachedWorkflowSignature: null,
|
||||
cachedAnalysisData: null,
|
||||
activeTab: 'loaded',
|
||||
runWithWorkflowRefreshSuppressed: async callback => callback(),
|
||||
cachedWorkflowSignature: 'current-signature',
|
||||
cachedAnalysisData: { missing_models: [], resolved_models: [] },
|
||||
getMissingWorkflowSignature(value) {
|
||||
assert.equal(value, workflow);
|
||||
return 'linked-missing-signature';
|
||||
return 'current-signature';
|
||||
},
|
||||
async fetchJson(url) {
|
||||
calls.push(url);
|
||||
return returnedData;
|
||||
async refreshComfyModelCatalogAfterApply() {
|
||||
calls.push('catalog');
|
||||
return true;
|
||||
},
|
||||
applyResolvedSelectionAliasesToAnalysisData(data) {
|
||||
calls.push(data);
|
||||
},
|
||||
saveAnalysisCacheForActiveWorkflow() {
|
||||
calls.push('save');
|
||||
scheduleActiveWorkflowRefresh() {
|
||||
calls.push('refresh');
|
||||
},
|
||||
};
|
||||
|
||||
await refreshAnalysisInBackground.call(dialog, workflow);
|
||||
|
||||
assert.equal(dialog.cachedWorkflowSignature, 'linked-missing-signature');
|
||||
assert.equal(dialog.cachedAnalysisData, returnedData);
|
||||
assert.deepEqual(calls, [
|
||||
'/model_resolver/analyze',
|
||||
returnedData,
|
||||
'save',
|
||||
]);
|
||||
scheduleComfyModelCatalogRefreshAfterApply.call(dialog, workflow, []);
|
||||
setTimeout(() => finish(), 0);
|
||||
await finished;
|
||||
assert.deepEqual(calls, ['catalog']);
|
||||
});
|
||||
|
||||
test('Apply selected model preserves Missing Models browser geometry during both refresh stages', async () => {
|
||||
const applyOptimisticAnalysisData = eval(
|
||||
`(${extractMethod(queueMethodsSource, 'applyOptimisticAnalysisData')})`
|
||||
test('Apply reuses the content-preserving workflow analysis path', () => {
|
||||
const applyPendingResolutionList = extractMethod(
|
||||
queueMethodsSource,
|
||||
'applyPendingResolutionList'
|
||||
);
|
||||
const refreshAnalysisInBackground = eval(
|
||||
`(${extractMethod(queueMethodsSource, 'refreshAnalysisInBackground')})`
|
||||
|
||||
assert.match(
|
||||
applyPendingResolutionList,
|
||||
/loadWorkflowData\?\.\(data\.workflow,\s*\{\s*preserveContent: true/
|
||||
);
|
||||
const contentElement = {};
|
||||
const workflow = { nodes: [{ id: 7, widgets_values: ['linked.safetensors'] }] };
|
||||
const optimisticData = { missing_models: [], resolved_models: [{ node_id: 7, optimistic: true }] };
|
||||
const analyzedData = { missing_models: [], resolved_models: [{ node_id: 7, optimistic: false }] };
|
||||
const renderCalls = [];
|
||||
assert.doesNotMatch(applyPendingResolutionList, /applyOptimisticAnalysisData\(/);
|
||||
assert.doesNotMatch(applyPendingResolutionList, /refreshAnalysisInBackground\(/);
|
||||
});
|
||||
|
||||
test('Apply keeps the workflow slot key when resolved path identity changes', () => {
|
||||
const doesResolvedModelMatchAlias = eval(
|
||||
`(${extractMethod(queueMethodsSource, 'doesResolvedModelMatchAlias')})`
|
||||
);
|
||||
const applyResolvedSelectionAliasesToAnalysisData = eval(
|
||||
`(${extractMethod(queueMethodsSource, 'applyResolvedSelectionAliasesToAnalysisData')})`
|
||||
);
|
||||
const data = {
|
||||
resolved_models: [{
|
||||
node_id: 7,
|
||||
widget_index: 0,
|
||||
category: 'loras',
|
||||
missing_key: 'backend-path-key',
|
||||
original_path: 'different/backend/path.safetensors',
|
||||
}],
|
||||
};
|
||||
const dialog = {
|
||||
activeTab: 'missing',
|
||||
contentElement,
|
||||
activeMissingWorkflowSignature: 'linked-missing-signature',
|
||||
cachedWorkflowSignature: null,
|
||||
cachedAnalysisData: null,
|
||||
applyResolvedSelectionAliasesToAnalysisData() {},
|
||||
getMissingWorkflowSignature() {
|
||||
return 'linked-missing-signature';
|
||||
},
|
||||
getCurrentWorkflow() {
|
||||
return workflow;
|
||||
},
|
||||
cloneAnalysisData(data) {
|
||||
return data;
|
||||
},
|
||||
saveAnalysisCacheForActiveWorkflow() {},
|
||||
displayMissingModels(...args) {
|
||||
renderCalls.push(args);
|
||||
},
|
||||
reconnectActiveDownloads() {},
|
||||
async fetchJson() {
|
||||
return analyzedData;
|
||||
},
|
||||
appliedResolvedSelectionAliases: new Map([
|
||||
['stable-slot-key', {
|
||||
category: 'loras',
|
||||
refs: [{ node_id: 7, widget_index: 0 }],
|
||||
}],
|
||||
]),
|
||||
doesResolvedModelMatchAlias,
|
||||
};
|
||||
|
||||
applyOptimisticAnalysisData.call(dialog, optimisticData, workflow);
|
||||
await refreshAnalysisInBackground.call(dialog, workflow);
|
||||
applyResolvedSelectionAliasesToAnalysisData.call(dialog, data);
|
||||
|
||||
assert.deepEqual(renderCalls, [
|
||||
[contentElement, optimisticData, { preserveBrowser: true }],
|
||||
[contentElement, analyzedData, { preserveBrowser: true }],
|
||||
]);
|
||||
assert.equal(data.resolved_models[0].missing_key, 'stable-slot-key');
|
||||
assert.equal(dialog.appliedResolvedSelectionAliases.size, 0);
|
||||
});
|
||||
|
||||
test('background Loaded Models refresh keeps the current view until new data is ready', async () => {
|
||||
@@ -2150,6 +2142,14 @@ test('content-preserving Missing Models refresh patches the browser instead of c
|
||||
missingBrowserMethodsSource,
|
||||
'patchMissingModelsBrowserElement'
|
||||
);
|
||||
const patchMissingModelRowElement = extractMethod(
|
||||
missingBrowserMethodsSource,
|
||||
'patchMissingModelRowElement'
|
||||
);
|
||||
const setupMissingModelsVirtualizer = extractMethod(
|
||||
missingBrowserMethodsSource,
|
||||
'setupMissingModelsVirtualizer'
|
||||
);
|
||||
const displayMissingModels = extractMethod(
|
||||
missingBrowserMethodsSource,
|
||||
'displayMissingModels'
|
||||
@@ -2157,7 +2157,23 @@ test('content-preserving Missing Models refresh patches the browser instead of c
|
||||
|
||||
assert.match(
|
||||
patchMissingModelsBrowserElement,
|
||||
/currentRow\?\.outerHTML === nextRow\.outerHTML/
|
||||
/patchMissingModelRowElement\(currentRow, nextRow\)/
|
||||
);
|
||||
assert.match(
|
||||
patchMissingModelRowElement,
|
||||
/currentRow\.innerHTML = nextRow\.innerHTML/
|
||||
);
|
||||
assert.match(
|
||||
patchMissingModelRowElement,
|
||||
/_wiredMissingModelRows\?\.delete\?\./
|
||||
);
|
||||
assert.match(
|
||||
setupMissingModelsVirtualizer,
|
||||
/patchMissingModelRowElement\(currentRow, nextRow\)/
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
setupMissingModelsVirtualizer,
|
||||
/rowsHost\.innerHTML/
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
patchMissingModelsBrowserElement,
|
||||
|
||||
@@ -2114,6 +2114,7 @@ export const queueMethods = {
|
||||
return null;
|
||||
}
|
||||
|
||||
let applyRefreshSuppressionStarted = false;
|
||||
try {
|
||||
const appliedSelections = this.clonePendingResolutions?.(list) || JSON.parse(JSON.stringify(list));
|
||||
const applyResolutions = this.expandPendingResolutionsForApply(appliedSelections);
|
||||
@@ -2132,7 +2133,8 @@ export const queueMethods = {
|
||||
body: JSON.stringify({ workflow, resolutions: applyResolutions })
|
||||
}, 'Apply resolutions');
|
||||
if (data.success) {
|
||||
const optimisticData = this.getOptimisticAnalysisDataAfterApply(appliedSelections);
|
||||
this.beginApplyWorkflowRefreshSuppression?.();
|
||||
applyRefreshSuppressionStarted = true;
|
||||
await this.updateWorkflowInComfyUI(data.workflow, applyResolutions);
|
||||
this.rememberAppliedResolvedSelections(appliedSelections);
|
||||
|
||||
@@ -2153,21 +2155,34 @@ export const queueMethods = {
|
||||
this.updateApplyPendingButton();
|
||||
this.updateQueuePanel();
|
||||
|
||||
this.applyOptimisticAnalysisData(optimisticData, data.workflow);
|
||||
|
||||
const selectionCount = appliedSelections.length;
|
||||
const refText = applyResolutions.length > selectionCount
|
||||
? ` (${applyResolutions.length} references)`
|
||||
: '';
|
||||
this.showNotification(`✓ Linked ${selectionCount} selection${selectionCount > 1 ? 's' : ''}${refText}`, 'success');
|
||||
this.scheduleComfyModelCatalogRefreshAfterApply?.(data.workflow, applyResolutions);
|
||||
this.refreshAnalysisInBackground(data.workflow, this.getMissingWorkflowSignature(data.workflow));
|
||||
const releaseApplyRefreshSuppression = () => {
|
||||
if (!applyRefreshSuppressionStarted) return;
|
||||
applyRefreshSuppressionStarted = false;
|
||||
this.endApplyWorkflowRefreshSuppression?.();
|
||||
};
|
||||
const workflowRefresh = this.loadWorkflowData?.(data.workflow, {
|
||||
preserveContent: true
|
||||
});
|
||||
Promise.resolve(workflowRefresh).then(
|
||||
releaseApplyRefreshSuppression,
|
||||
releaseApplyRefreshSuppression
|
||||
);
|
||||
return data.workflow || null;
|
||||
} else {
|
||||
this.showNotification('Failed to apply selections: ' + (data.error || 'Unknown error'), 'error');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
if (applyRefreshSuppressionStarted) {
|
||||
applyRefreshSuppressionStarted = false;
|
||||
this.endApplyWorkflowRefreshSuppression?.();
|
||||
}
|
||||
console.error('Model Resolver: applyPendingResolutions error', e);
|
||||
this.showNotification('Error applying selections: ' + e.message, 'error');
|
||||
return null;
|
||||
@@ -2275,43 +2290,6 @@ export const queueMethods = {
|
||||
return (this.missingModels || []).filter(missing => activeKeys.has(this.getMissingModelKey(missing)));
|
||||
},
|
||||
|
||||
getResolvedSelectionPathCandidates(selection = {}) {
|
||||
const model = selection.resolved_model || {};
|
||||
return [
|
||||
model.relative_path,
|
||||
model.filename,
|
||||
model.name,
|
||||
model.path,
|
||||
model.resolved_path,
|
||||
selection.resolved_path
|
||||
].filter(Boolean);
|
||||
},
|
||||
|
||||
normalizeResolvedSelectionToken(value = '', { basename = false, stem = false } = {}) {
|
||||
let text = this.normalizePathToForward(value).toLowerCase();
|
||||
if (!text) return '';
|
||||
if (basename) {
|
||||
text = text.split('/').pop() || text;
|
||||
}
|
||||
if (stem) {
|
||||
text = this.stripModelExtension(text);
|
||||
}
|
||||
return text;
|
||||
},
|
||||
|
||||
getResolvedSelectionTokenSet(values = []) {
|
||||
const tokens = new Set();
|
||||
for (const value of values) {
|
||||
const normalized = this.normalizeResolvedSelectionToken(value);
|
||||
const basename = this.normalizeResolvedSelectionToken(value, { basename: true });
|
||||
const stem = this.normalizeResolvedSelectionToken(value, { basename: true, stem: true });
|
||||
if (normalized) tokens.add(normalized);
|
||||
if (basename) tokens.add(basename);
|
||||
if (stem) tokens.add(stem);
|
||||
}
|
||||
return tokens;
|
||||
},
|
||||
|
||||
getResolvedSelectionRefs(selection = {}) {
|
||||
const refs = Array.isArray(selection.node_refs) && selection.node_refs.length
|
||||
? selection.node_refs
|
||||
@@ -2337,44 +2315,41 @@ export const queueMethods = {
|
||||
key,
|
||||
category: selection.category || '',
|
||||
searchKey: selection.missing_search_key || selection.search_key || '',
|
||||
refs: this.getResolvedSelectionRefs(selection),
|
||||
tokens: this.getResolvedSelectionTokenSet(this.getResolvedSelectionPathCandidates(selection))
|
||||
refs: this.getResolvedSelectionRefs(selection)
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
doesResolvedModelMatchAlias(model = {}, alias = {}) {
|
||||
const refs = Array.isArray(alias.refs) ? alias.refs : [];
|
||||
const modelNodeId = model.node_id ?? '';
|
||||
const modelWidgetIndex = model.widget_index ?? '';
|
||||
const modelSubgraphId = model.subgraph_id ?? '';
|
||||
const modelScope = model.is_top_level !== false;
|
||||
const modelNestedKey = model.nested_key || '';
|
||||
const refMatch = refs.some(ref => (
|
||||
String(ref.node_id ?? '') === String(modelNodeId)
|
||||
&& String(ref.widget_index ?? '') === String(modelWidgetIndex)
|
||||
&& String(ref.subgraph_id ?? '') === String(modelSubgraphId)
|
||||
&& (ref.is_top_level !== false) === modelScope
|
||||
&& String(ref.nested_key || '') === String(modelNestedKey)
|
||||
));
|
||||
const modelRefs = [
|
||||
model,
|
||||
...(Array.isArray(model.all_node_refs) ? model.all_node_refs : [])
|
||||
];
|
||||
const refMatch = modelRefs.some(modelRef => {
|
||||
const modelNodeId = modelRef?.node_id ?? '';
|
||||
const modelWidgetIndex = modelRef?.widget_index ?? '';
|
||||
const modelSubgraphId = modelRef?.subgraph_id ?? '';
|
||||
const modelScope = modelRef?.is_top_level !== false;
|
||||
const modelNestedKey = modelRef?.nested_key || '';
|
||||
return refs.some(ref => (
|
||||
String(ref.node_id ?? '') === String(modelNodeId)
|
||||
&& String(ref.widget_index ?? '') === String(modelWidgetIndex)
|
||||
&& String(ref.subgraph_id ?? '') === String(modelSubgraphId)
|
||||
&& (ref.is_top_level !== false) === modelScope
|
||||
&& String(ref.nested_key || '') === String(modelNestedKey)
|
||||
));
|
||||
});
|
||||
if (!refMatch) return false;
|
||||
|
||||
const aliasCategory = String(alias.category || '').toLowerCase();
|
||||
const modelCategory = String(model.category || '').toLowerCase();
|
||||
if (aliasCategory && modelCategory && aliasCategory !== modelCategory) return false;
|
||||
|
||||
const modelTokens = this.getResolvedSelectionTokenSet([
|
||||
model.original_path,
|
||||
model.name,
|
||||
model.filename,
|
||||
model.relative_path,
|
||||
model.full_path,
|
||||
model.path
|
||||
]);
|
||||
for (const token of alias.tokens || []) {
|
||||
if (modelTokens.has(token)) return true;
|
||||
}
|
||||
return false;
|
||||
// Workflow position is the stable identity after Apply. The backend may
|
||||
// return a resolved model with a different path representation, so the
|
||||
// matching slot is sufficient to restore the optimistic missing_key.
|
||||
return true;
|
||||
},
|
||||
|
||||
applyResolvedSelectionAliasesToAnalysisData(data = null) {
|
||||
@@ -2386,7 +2361,6 @@ export const queueMethods = {
|
||||
|
||||
const matchedKeys = new Set();
|
||||
for (const model of resolvedModels) {
|
||||
if (model?.missing_key) continue;
|
||||
for (const [key, alias] of this.appliedResolvedSelectionAliases.entries()) {
|
||||
if (matchedKeys.has(key)) continue;
|
||||
if (!this.doesResolvedModelMatchAlias(model, alias)) continue;
|
||||
@@ -2405,141 +2379,6 @@ export const queueMethods = {
|
||||
return data;
|
||||
},
|
||||
|
||||
buildOptimisticResolvedModel(selection = {}, sourceMissing = null) {
|
||||
const resolvedModel = selection.resolved_model || {};
|
||||
const resolvedRelativePath = resolvedModel.relative_path
|
||||
|| resolvedModel.filename
|
||||
|| selection.resolved_path
|
||||
|| sourceMissing?.original_path
|
||||
|| selection.original_path
|
||||
|| '';
|
||||
|
||||
return {
|
||||
...(sourceMissing || {}),
|
||||
missing_key: this.getResolutionQueueKey(selection),
|
||||
missing_search_key: selection.missing_search_key
|
||||
|| sourceMissing?.missing_search_key
|
||||
|| (sourceMissing ? this.getMissingSearchKey?.(sourceMissing) : ''),
|
||||
__isOptimisticResolved: true,
|
||||
node_id: selection.node_id ?? sourceMissing?.node_id,
|
||||
widget_index: selection.widget_index ?? sourceMissing?.widget_index,
|
||||
category: selection.category || sourceMissing?.category || resolvedModel.category || 'unknown',
|
||||
original_path: resolvedRelativePath,
|
||||
name: resolvedRelativePath,
|
||||
filename: resolvedModel.filename || this.getFilenameFromPath(resolvedRelativePath) || resolvedRelativePath,
|
||||
relative_path: resolvedRelativePath,
|
||||
full_path: resolvedModel.path || selection.resolved_path || sourceMissing?.full_path || '',
|
||||
path: resolvedModel.path || selection.resolved_path || '',
|
||||
matches: [{
|
||||
confidence: 100,
|
||||
match_type: 'exact',
|
||||
filename: resolvedModel.filename || resolvedRelativePath,
|
||||
path: resolvedModel.path || selection.resolved_path || '',
|
||||
model: {
|
||||
...resolvedModel,
|
||||
path: resolvedModel.path || selection.resolved_path || '',
|
||||
relative_path: resolvedRelativePath,
|
||||
filename: resolvedModel.filename || resolvedRelativePath
|
||||
}
|
||||
}]
|
||||
};
|
||||
},
|
||||
|
||||
getOptimisticAnalysisDataAfterApply(appliedSelections = []) {
|
||||
const missingModels = Array.isArray(this.missingModels) ? this.missingModels : [];
|
||||
const appliedKeys = new Set(
|
||||
(Array.isArray(appliedSelections) ? appliedSelections : [])
|
||||
.map(selection => this.getResolutionQueueKey(selection))
|
||||
);
|
||||
if (!appliedKeys.size) return null;
|
||||
|
||||
const sourceData = this.cachedAnalysisData || {
|
||||
missing_models: missingModels,
|
||||
total_missing: missingModels.length
|
||||
};
|
||||
const sourceMissing = Array.isArray(sourceData.missing_models)
|
||||
? sourceData.missing_models
|
||||
: missingModels;
|
||||
const nextMissing = sourceMissing.filter(missing => !appliedKeys.has(this.getMissingModelKey(missing)));
|
||||
const currentResolved = Array.isArray(sourceData.resolved_models) ? sourceData.resolved_models : [];
|
||||
const optimisticResolved = [];
|
||||
for (const selection of Array.isArray(appliedSelections) ? appliedSelections : []) {
|
||||
const key = this.getResolutionQueueKey(selection);
|
||||
const source = sourceMissing.find(missing => this.getMissingModelKey(missing) === key)
|
||||
|| missingModels.find(missing => this.getMissingModelKey(missing) === key)
|
||||
|| null;
|
||||
optimisticResolved.push(this.buildOptimisticResolvedModel(selection, source));
|
||||
}
|
||||
const optimisticKeys = new Set(optimisticResolved.map(model => model.missing_key).filter(Boolean));
|
||||
const nextResolved = [
|
||||
...currentResolved.filter(model => !optimisticKeys.has(this.getMissingModelKey(model))),
|
||||
...optimisticResolved
|
||||
];
|
||||
|
||||
return {
|
||||
...sourceData,
|
||||
missing_models: nextMissing,
|
||||
resolved_models: nextResolved,
|
||||
total_missing: nextMissing.length
|
||||
};
|
||||
},
|
||||
|
||||
applyOptimisticAnalysisData(data, workflow = null) {
|
||||
if (!data) return false;
|
||||
|
||||
this.applyResolvedSelectionAliasesToAnalysisData(data);
|
||||
const workflowSignature = this.getMissingWorkflowSignature(workflow || this.getCurrentWorkflow());
|
||||
if (workflowSignature) {
|
||||
this.cachedWorkflowSignature = workflowSignature;
|
||||
}
|
||||
this.cachedAnalysisData = this.cloneAnalysisData?.(data) || data;
|
||||
this.saveAnalysisCacheForActiveWorkflow?.();
|
||||
|
||||
if (this.activeTab === 'missing' && this.contentElement) {
|
||||
this.displayMissingModels(
|
||||
this.contentElement,
|
||||
data,
|
||||
{ preserveBrowser: true }
|
||||
);
|
||||
this.reconnectActiveDownloads?.();
|
||||
} else {
|
||||
this.missingModels = Array.isArray(data.missing_models) ? data.missing_models : [];
|
||||
this.updateBatchFooterButtons?.();
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
async refreshAnalysisInBackground(workflow, expectedSignature = null) {
|
||||
if (!workflow) return;
|
||||
|
||||
const signature = expectedSignature || this.getMissingWorkflowSignature(workflow);
|
||||
try {
|
||||
const data = await this.fetchJson('/model_resolver/analyze', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ workflow }),
|
||||
silent: true
|
||||
}, 'Background analysis');
|
||||
|
||||
if (signature && this.activeMissingWorkflowSignature !== signature) return;
|
||||
this.applyResolvedSelectionAliasesToAnalysisData(data);
|
||||
this.cachedWorkflowSignature = signature || this.cachedWorkflowSignature;
|
||||
this.cachedAnalysisData = data;
|
||||
this.saveAnalysisCacheForActiveWorkflow?.();
|
||||
|
||||
if (this.activeTab === 'missing' && this.contentElement && !this._analysisProgressToken) {
|
||||
this.displayMissingModels(
|
||||
this.contentElement,
|
||||
data,
|
||||
{ preserveBrowser: true }
|
||||
);
|
||||
this.reconnectActiveDownloads?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Model Resolver: background analysis refresh failed:', error);
|
||||
}
|
||||
},
|
||||
|
||||
previewFooterMenuModels(models = []) {
|
||||
const keys = new Set(
|
||||
(Array.isArray(models) ? models : [])
|
||||
|
||||
@@ -2695,7 +2695,11 @@ export const searchPanelMethods = {
|
||||
|
||||
Promise.allSettled(refreshTasks).then(() => {
|
||||
if (!container.isConnected || this.activeTab !== 'missing') return;
|
||||
this.displayMissingModels(container, data);
|
||||
this.displayMissingModels(
|
||||
container,
|
||||
this.cachedAnalysisData || data,
|
||||
{ preserveBrowser: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -770,8 +770,32 @@ export const workflowStateMethods = {
|
||||
this.updateQueueVisibility();
|
||||
},
|
||||
|
||||
beginApplyWorkflowRefreshSuppression() {
|
||||
this._applyWorkflowRefreshSuppressionDepth = Number(
|
||||
this._applyWorkflowRefreshSuppressionDepth || 0
|
||||
) + 1;
|
||||
this._workflowRefreshGeneration = (this._workflowRefreshGeneration || 0) + 1;
|
||||
if (this._workflowRefreshTimer) {
|
||||
clearTimeout(this._workflowRefreshTimer);
|
||||
this._workflowRefreshTimer = null;
|
||||
}
|
||||
if (this._workflowRefreshRetryTimer) {
|
||||
clearTimeout(this._workflowRefreshRetryTimer);
|
||||
this._workflowRefreshRetryTimer = null;
|
||||
}
|
||||
this._workflowDataLoadToken = null;
|
||||
this._analysisProgressToken = null;
|
||||
},
|
||||
|
||||
endApplyWorkflowRefreshSuppression() {
|
||||
this._applyWorkflowRefreshSuppressionDepth = Math.max(
|
||||
0,
|
||||
Number(this._applyWorkflowRefreshSuppressionDepth || 0) - 1
|
||||
);
|
||||
},
|
||||
|
||||
scheduleActiveWorkflowRefresh(reason = 'workflow-change') {
|
||||
if (!this.isVisible()) return;
|
||||
if (!this.isVisible() || Number(this._applyWorkflowRefreshSuppressionDepth || 0) > 0) return;
|
||||
|
||||
const generation = (this._workflowRefreshGeneration || 0) + 1;
|
||||
this._workflowRefreshGeneration = generation;
|
||||
@@ -808,7 +832,10 @@ export const workflowStateMethods = {
|
||||
candidateRoute = null,
|
||||
candidateSignature = null
|
||||
} = {}) {
|
||||
if (!this.isVisible()) return;
|
||||
if (
|
||||
!this.isVisible()
|
||||
|| Number(this._applyWorkflowRefreshSuppressionDepth || 0) > 0
|
||||
) return;
|
||||
if (generation !== this._workflowRefreshGeneration) return;
|
||||
|
||||
const currentRoute = this.getActiveWorkflowRouteKey();
|
||||
|
||||
@@ -22,7 +22,13 @@ export const workflowUpdateMethods = {
|
||||
this.runWithWorkflowRefreshSuppressed(
|
||||
() => this.refreshComfyModelCatalogAfterApply(workflow, resolutions)
|
||||
).then(refreshed => {
|
||||
if (refreshed) {
|
||||
const workflowSignature = this.getMissingWorkflowSignature?.(workflow);
|
||||
const hasCurrentAnalysis = Boolean(
|
||||
workflowSignature
|
||||
&& this.cachedWorkflowSignature === workflowSignature
|
||||
&& this.cachedAnalysisData
|
||||
);
|
||||
if (refreshed && !hasCurrentAnalysis) {
|
||||
this.scheduleActiveWorkflowRefresh?.('node-widget-change');
|
||||
}
|
||||
}).catch(error => {
|
||||
|
||||
@@ -528,8 +528,28 @@ export const missingBrowserMethods = {
|
||||
return MISSING_ROW_FALLBACK_HEIGHT;
|
||||
},
|
||||
|
||||
getMissingModelRowSlotKeys(missing = {}) {
|
||||
const knownKeys = this.getMissingModelWorkflowSlotKeys?.(missing);
|
||||
if (Array.isArray(knownKeys) && knownKeys.length) return knownKeys;
|
||||
|
||||
const references = [
|
||||
missing,
|
||||
...(Array.isArray(missing.all_node_refs) ? missing.all_node_refs : [])
|
||||
];
|
||||
return references
|
||||
.filter(reference => reference?.node_id !== undefined && reference?.node_id !== null)
|
||||
.map(reference => [
|
||||
reference.is_top_level !== false ? 'T' : 'F',
|
||||
reference.subgraph_id || '',
|
||||
reference.node_id,
|
||||
reference.widget_index ?? '',
|
||||
reference.nested_key || ''
|
||||
].map(value => encodeURIComponent(String(value))).join(':'));
|
||||
},
|
||||
|
||||
renderMissingModelListRow(missing, index, totalCount, selectedKey) {
|
||||
const key = this.getMissingModelKey(missing);
|
||||
const slotKeys = this.getMissingModelRowSlotKeys(missing);
|
||||
const isSelected = key === selectedKey;
|
||||
const isBatchSelected = this.batchSelectedMissingKeys?.has(key);
|
||||
const isResolved = this.isMissingModelResolved(missing);
|
||||
@@ -548,6 +568,7 @@ export const missingBrowserMethods = {
|
||||
<div role="button" tabindex="0"
|
||||
class="mr-missing-list-row ${isSelected ? 'is-selected' : ''} ${isBatchSelected ? 'is-batch-selected' : ''} ${isResolved ? 'is-resolved' : ''}"
|
||||
data-missing-key="${this.escapeHtml(key)}"
|
||||
data-missing-slot-keys="${this.escapeHtml(slotKeys.join('|'))}"
|
||||
data-missing-index="${index}"
|
||||
aria-posinset="${index + 1}"
|
||||
aria-setsize="${totalCount}">
|
||||
@@ -636,7 +657,39 @@ export const missingBrowserMethods = {
|
||||
state.start = nextStart;
|
||||
state.end = nextEnd;
|
||||
rowsHost.style.transform = `translateY(${Math.round(nextStart * rowHeight)}px)`;
|
||||
rowsHost.innerHTML = state.models
|
||||
const currentRows = new Map(
|
||||
Array.from(rowsHost.querySelectorAll('.mr-missing-list-row'))
|
||||
.map(row => [row.dataset.missingKey || '', row])
|
||||
.filter(([key]) => key)
|
||||
);
|
||||
const currentRowsBySlot = new Map();
|
||||
const currentRowsByIndex = new Map();
|
||||
currentRows.forEach((row) => {
|
||||
const index = row.dataset.missingIndex || '';
|
||||
if (index && !currentRowsByIndex.has(index)) currentRowsByIndex.set(index, row);
|
||||
String(row.dataset.missingSlotKeys || '')
|
||||
.split('|')
|
||||
.filter(Boolean)
|
||||
.forEach(slotKey => {
|
||||
if (!currentRowsBySlot.has(slotKey)) currentRowsBySlot.set(slotKey, row);
|
||||
});
|
||||
});
|
||||
const retainedRows = new Set();
|
||||
const findCurrentRow = (nextRow) => {
|
||||
const byKey = currentRows.get(nextRow.dataset.missingKey || '');
|
||||
if (byKey && !retainedRows.has(byKey)) return byKey;
|
||||
|
||||
const bySlot = String(nextRow.dataset.missingSlotKeys || '')
|
||||
.split('|')
|
||||
.map(slotKey => currentRowsBySlot.get(slotKey))
|
||||
.find(row => row && !retainedRows.has(row));
|
||||
if (bySlot) return bySlot;
|
||||
|
||||
const byIndex = currentRowsByIndex.get(nextRow.dataset.missingIndex || '');
|
||||
return byIndex && !retainedRows.has(byIndex) ? byIndex : null;
|
||||
};
|
||||
const nextRowsTemplate = document.createElement('template');
|
||||
nextRowsTemplate.innerHTML = state.models
|
||||
.slice(nextStart, nextEnd)
|
||||
.map((missing, offset) => this.renderMissingModelListRow(
|
||||
missing,
|
||||
@@ -645,6 +698,21 @@ export const missingBrowserMethods = {
|
||||
this.selectedMissingModelKey
|
||||
))
|
||||
.join('');
|
||||
const nextRows = Array.from(
|
||||
nextRowsTemplate.content.querySelectorAll('.mr-missing-list-row')
|
||||
);
|
||||
|
||||
nextRows.forEach((nextRow) => {
|
||||
const currentRow = findCurrentRow(nextRow);
|
||||
const row = currentRow
|
||||
? this.patchMissingModelRowElement(currentRow, nextRow)
|
||||
: nextRow;
|
||||
retainedRows.add(row);
|
||||
rowsHost.appendChild(row);
|
||||
});
|
||||
currentRows.forEach((row) => {
|
||||
if (!retainedRows.has(row)) row.remove();
|
||||
});
|
||||
this.wireVisibleMissingModelRows(container, state.data, state.models);
|
||||
};
|
||||
|
||||
@@ -862,11 +930,36 @@ export const missingBrowserMethods = {
|
||||
.map(row => [row.dataset.missingKey || '', row])
|
||||
.filter(([key]) => key)
|
||||
);
|
||||
const currentRowsBySlot = new Map();
|
||||
const currentRowsByIndex = new Map();
|
||||
currentRows.forEach((row) => {
|
||||
const index = row.dataset.missingIndex || '';
|
||||
if (index && !currentRowsByIndex.has(index)) currentRowsByIndex.set(index, row);
|
||||
String(row.dataset.missingSlotKeys || '')
|
||||
.split('|')
|
||||
.filter(Boolean)
|
||||
.forEach(slotKey => {
|
||||
if (!currentRowsBySlot.has(slotKey)) currentRowsBySlot.set(slotKey, row);
|
||||
});
|
||||
});
|
||||
const retainedRows = new Set();
|
||||
const findCurrentRow = (nextRow) => {
|
||||
const byKey = currentRows.get(nextRow.dataset.missingKey || '');
|
||||
if (byKey && !retainedRows.has(byKey)) return byKey;
|
||||
|
||||
const bySlot = String(nextRow.dataset.missingSlotKeys || '')
|
||||
.split('|')
|
||||
.map(slotKey => currentRowsBySlot.get(slotKey))
|
||||
.find(row => row && !retainedRows.has(row));
|
||||
if (bySlot) return bySlot;
|
||||
|
||||
const byIndex = currentRowsByIndex.get(nextRow.dataset.missingIndex || '');
|
||||
return byIndex && !retainedRows.has(byIndex) ? byIndex : null;
|
||||
};
|
||||
nextRowsHost.querySelectorAll('.mr-missing-list-row').forEach((nextRow) => {
|
||||
const currentRow = currentRows.get(nextRow.dataset.missingKey || '');
|
||||
const row = currentRow?.outerHTML === nextRow.outerHTML
|
||||
? currentRow
|
||||
const currentRow = findCurrentRow(nextRow);
|
||||
const row = currentRow
|
||||
? this.patchMissingModelRowElement(currentRow, nextRow)
|
||||
: nextRow;
|
||||
retainedRows.add(row);
|
||||
if (currentRowsHost === currentList) {
|
||||
@@ -898,6 +991,29 @@ export const missingBrowserMethods = {
|
||||
return true;
|
||||
},
|
||||
|
||||
patchMissingModelRowElement(currentRow, nextRow) {
|
||||
if (!currentRow || !nextRow || currentRow === nextRow) return currentRow || nextRow;
|
||||
if (currentRow.outerHTML === nextRow.outerHTML) return currentRow;
|
||||
|
||||
const nextAttributeNames = new Set(
|
||||
Array.from(nextRow.attributes || [], attribute => attribute.name)
|
||||
);
|
||||
for (const attribute of Array.from(nextRow.attributes || [])) {
|
||||
if (currentRow.getAttribute(attribute.name) !== attribute.value) {
|
||||
currentRow.setAttribute(attribute.name, attribute.value);
|
||||
}
|
||||
}
|
||||
for (const attribute of Array.from(currentRow.attributes || [])) {
|
||||
if (!nextAttributeNames.has(attribute.name)) {
|
||||
currentRow.removeAttribute(attribute.name);
|
||||
}
|
||||
}
|
||||
|
||||
currentRow.innerHTML = nextRow.innerHTML;
|
||||
this._wiredMissingModelRows?.delete?.(currentRow);
|
||||
return currentRow;
|
||||
},
|
||||
|
||||
wireVisibleMissingModelRows(container, data, sortedMissingModels) {
|
||||
const getCurrentData = () => this.cachedAnalysisData || data;
|
||||
const getCurrentMissingModels = () => (
|
||||
@@ -2190,6 +2306,7 @@ export const missingBrowserMethods = {
|
||||
*/
|
||||
displayMissingModels(container, data, options = {}) {
|
||||
const selectionOnly = !!options.selectionOnly;
|
||||
const preserveBrowser = !!options.preserveBrowser;
|
||||
const listScrollSnapshot = this.getMissingListScrollSnapshot(container);
|
||||
const previousMissingModels = Array.isArray(this.missingModels)
|
||||
? [...this.missingModels]
|
||||
@@ -2335,7 +2452,11 @@ export const missingBrowserMethods = {
|
||||
if (needsDownloadDirectories || needsDownloadRootDirectories) {
|
||||
const renderToken = `download-dirs-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
this._downloadDirectoriesRenderToken = renderToken;
|
||||
container.innerHTML = this.renderStatusMessage('Loading model folders...', 'info');
|
||||
const keepCurrentBrowser = preserveBrowser
|
||||
&& Boolean(container.querySelector('.mr-missing-browser'));
|
||||
if (!keepCurrentBrowser) {
|
||||
container.innerHTML = this.renderStatusMessage('Loading model folders...', 'info');
|
||||
}
|
||||
|
||||
const loaders = [];
|
||||
if (needsDownloadDirectories) {
|
||||
@@ -2351,7 +2472,9 @@ export const missingBrowserMethods = {
|
||||
this.activeTab === 'missing' &&
|
||||
container.isConnected
|
||||
) {
|
||||
this.displayMissingModels(container, data);
|
||||
this.displayMissingModels(container, data, {
|
||||
preserveBrowser: keepCurrentBrowser
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user