frontend: resolve pending node context menu models with immediate fallback

This commit is contained in:
Dariusz L
2026-07-24 16:27:07 +02:00
parent 21e6e4069d
commit 1b97cddc6f
3 changed files with 223 additions and 5 deletions
@@ -6,7 +6,9 @@ import { html, normalizePathIdentity } from '../web/resolver/utils/html_utils.js
import { getModelCardUrl } from '../web/resolver/utils/url_utils.js';
import {
buildModelResolverNodeMenu,
getImmediateModelsForNode,
getResolvedModelsForNode,
isExistingResolvedModel,
matchesWorkflowModelReference,
toResolverContextModel,
} from '../web/resolver/node_context_menu.js';
@@ -100,6 +102,65 @@ test('node context menu includes only existing resolved models from the selected
assert.equal(models[0].widget_name, 'ckpt_name');
});
test('node context menu immediately reflects a newly selected valid widget model', () => {
const previousModel = {
node_id: 42,
widget_index: 0,
widget_name: 'ckpt_name',
original_path: 'old-model.safetensors',
full_path: 'E:\\models\\old-model.safetensors',
category: 'checkpoints',
exists: true,
is_top_level: true,
};
const node = {
id: 42,
widgets: [{
value: 'new-model.safetensors',
options: {
values: ['old-model.safetensors', 'new-model.safetensors'],
},
}],
};
const models = getImmediateModelsForNode(
{ resolved_models: [previousModel] },
node,
{ is_top_level: true }
);
const menu = buildModelResolverNodeMenu(models);
assert.equal(models.length, 1);
assert.equal(models[0].original_path, 'new-model.safetensors');
assert.equal(models[0].resolution_pending, true);
assert.equal(models[0].full_path, '');
assert.equal(menu.title, 'Model Resolver');
});
test('immediate node context model ignores a value outside the widget choices', () => {
const models = getImmediateModelsForNode({
resolved_models: [{
node_id: 7,
widget_index: 0,
original_path: 'old-model.safetensors',
full_path: 'E:\\models\\old-model.safetensors',
category: 'checkpoints',
exists: true,
is_top_level: true,
}],
}, {
id: 7,
widgets: [{
value: 'not-in-dropdown.safetensors',
options: { values: ['old-model.safetensors'] },
}],
}, {
is_top_level: true,
});
assert.deepEqual(models, []);
});
test('node context menu groups multiple model inputs and dispatches actions for the selected model', () => {
const calls = [];
const models = [
@@ -239,6 +300,34 @@ test('node context integration preserves existing menu hooks and refreshes after
assert.deepEqual(calls, ['original-menu', 'original-widget:ckpt_name', 'refresh']);
});
test('pending node context model resolves against the current workflow analysis before action', async () => {
const resolveNodeContextMenuModel = eval(`(${extractMethod(modelResolverSource, 'resolveNodeContextMenuModel')})`);
const pendingModel = {
node_id: 7,
widget_index: 0,
original_path: 'new-model.safetensors',
is_top_level: true,
resolution_pending: true,
};
const resolvedModel = {
...pendingModel,
full_path: 'E:\\models\\new-model.safetensors',
exists: true,
resolution_pending: false,
};
const resolver = {
ensureCurrentNodeContextAnalysis: async () => ({
resolved_models: [resolvedModel],
}),
};
assert.equal(
await resolveNodeContextMenuModel.call(resolver, pendingModel),
resolvedModel
);
assert.equal(isExistingResolvedModel(resolvedModel), true);
});
test('queued workflow model selection survives missing data and completes after analysis', () => {
const queueWorkflowModelReferenceSelection = eval(`(${extractMethod(missingBrowserMethodsSource, 'queueWorkflowModelReferenceSelection')})`);
const applyPendingWorkflowModelSelection = eval(`(${extractMethod(missingBrowserMethodsSource, 'applyPendingWorkflowModelSelection')})`);
+61 -4
View File
@@ -8,7 +8,10 @@ import { ResolverManagerDialog } from "./resolver_dialog.js";
import { showNotification } from "../utils/notification_utils.js";
import {
buildModelResolverNodeMenu,
getImmediateModelsForNode,
getResolvedModelsForNode,
isExistingResolvedModel,
matchesWorkflowModelReference,
toResolverContextModel,
} from "./node_context_menu.js";
@@ -420,7 +423,13 @@ export class ModelResolver {
const data = this.getCurrentNodeContextAnalysis(signature);
if (!data) {
this.scheduleNodeContextMenuAnalysis(0);
return [];
const previousData = this.nodeContextAnalysisData
|| this.dialog?.cachedAnalysisData;
return getImmediateModelsForNode(
previousData || {},
node,
this.getNodeContextScope(node)
);
}
return getResolvedModelsForNode(
@@ -430,6 +439,39 @@ export class ModelResolver {
);
}
async ensureCurrentNodeContextAnalysis() {
if (this.nodeContextAnalysisTimer) {
clearTimeout(this.nodeContextAnalysisTimer);
this.nodeContextAnalysisTimer = null;
}
for (let attempt = 0; attempt < 3; attempt += 1) {
const { signature } = this.getNodeContextWorkflowState();
const cachedData = this.getCurrentNodeContextAnalysis(signature);
if (cachedData) return cachedData;
await this.refreshNodeContextMenuAnalysis({ force: true });
}
const { signature } = this.getNodeContextWorkflowState();
return this.getCurrentNodeContextAnalysis(signature);
}
async resolveNodeContextMenuModel(model) {
if (!model?.resolution_pending) return model;
const data = await this.ensureCurrentNodeContextAnalysis();
const resolvedModel = (data?.resolved_models || [])
.filter(candidate => isExistingResolvedModel(candidate))
.find(candidate => matchesWorkflowModelReference(candidate, model));
if (!resolvedModel) {
showNotification('The selected model could not be resolved after the workflow update.', 'warning');
return null;
}
return resolvedModel;
}
configureNodeContextMenu(nodeType) {
if (!nodeType?.prototype || nodeType.prototype.__modelResolverContextMenuPatched) {
return;
@@ -442,9 +484,24 @@ export class ModelResolver {
const result = originalGetExtraMenuOptions?.apply(this, arguments);
const models = owner.getResolvedModelsForNodeContextMenu(this);
const menu = buildModelResolverNodeMenu(models, {
showInResolver: model => owner.showResolvedNodeModelInResolver(model),
showInfo: model => owner.dialog?.showModelInfo(toResolverContextModel(model)),
openContainingFolder: model => owner.dialog?.openContainingFolder(toResolverContextModel(model)),
showInResolver: async model => {
const resolvedModel = await owner.resolveNodeContextMenuModel(model);
if (resolvedModel) {
owner.showResolvedNodeModelInResolver(resolvedModel);
}
},
showInfo: async model => {
const resolvedModel = await owner.resolveNodeContextMenuModel(model);
if (resolvedModel) {
owner.dialog?.showModelInfo(toResolverContextModel(resolvedModel));
}
},
openContainingFolder: async model => {
const resolvedModel = await owner.resolveNodeContextMenuModel(model);
if (resolvedModel) {
owner.dialog?.openContainingFolder(toResolverContextModel(resolvedModel));
}
},
}, {
formatCategory: category => owner.dialog?.getCategoryDisplayName?.(category),
});
+73 -1
View File
@@ -34,6 +34,28 @@ function getResolvedModelMenuLabel(model = {}, formatCategory = null) {
return `${slot} — ${filename}`;
}
function getWidgetChoiceValue(choice) {
if (choice && typeof choice === 'object') {
return choice.value ?? choice.name ?? choice.label ?? '';
}
return choice;
}
function isCurrentWidgetSelection(widget, value) {
const normalizedValue = normalizeIdentity(value);
if (!normalizedValue || ['none', 'null', 'undefined'].includes(normalizedValue)) {
return false;
}
const choices = widget?.options?.values;
if (!Array.isArray(choices) || !choices.length) {
return true;
}
return choices.some(choice => (
normalizeIdentity(getWidgetChoiceValue(choice)) === normalizedValue
));
}
function createModelActions(model, handlers = {}) {
return [
{
@@ -107,8 +129,58 @@ export function getResolvedModelsForNode(data = {}, nodeId, scope = {}) {
});
}
export function getImmediateModelsForNode(data = {}, node = {}, scope = {}) {
const references = [
...(data.resolved_models || []),
...(data.missing_models || []),
].filter(model => (
(!model.node_type || !node?.type || model.node_type === node.type)
&& matchesWorkflowModelReference(model, {
node_id: node?.id,
is_top_level: scope.is_top_level,
subgraph_id: scope.subgraph_id,
})
));
const modelsByWidget = new Map();
for (const reference of references) {
const widgetIndex = Number(reference.widget_index);
if (!Number.isInteger(widgetIndex) || modelsByWidget.has(widgetIndex)) continue;
const widget = node?.widgets?.[widgetIndex];
const currentValue = widget?.value;
if (typeof currentValue !== 'string') continue;
if (!isCurrentWidgetSelection(widget, currentValue)) continue;
if (normalizeIdentity(currentValue) === normalizeIdentity(reference.original_path)) {
if (isExistingResolvedModel(reference)) {
modelsByWidget.set(widgetIndex, reference);
}
continue;
}
modelsByWidget.set(widgetIndex, {
...reference,
name: String(currentValue),
filename: getResolvedModelFilename({ original_path: currentValue }),
original_path: String(currentValue),
full_path: '',
path: '',
resolved_path: '',
exists: false,
resolution_pending: true,
});
}
return [...modelsByWidget.values()].sort((left, right) => (
Number(left.widget_index ?? 0) - Number(right.widget_index ?? 0)
));
}
export function buildModelResolverNodeMenu(models = [], handlers = {}, options = {}) {
const resolvedModels = models.filter(isExistingResolvedModel);
const resolvedModels = models.filter(model => (
isExistingResolvedModel(model) || model.resolution_pending === true
));
if (!resolvedModels.length) return null;
const submenuOptions = resolvedModels.length === 1