frontend: resolve pending node context menu models with immediate fallback
This commit is contained in:
@@ -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')})`);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user