diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 992fc0d..89a52c5 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -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')})`); diff --git a/web/resolver/model_resolver.js b/web/resolver/model_resolver.js index 2d92a83..6a87c6d 100644 --- a/web/resolver/model_resolver.js +++ b/web/resolver/model_resolver.js @@ -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), }); diff --git a/web/resolver/node_context_menu.js b/web/resolver/node_context_menu.js index 8367e63..89427ac 100644 --- a/web/resolver/node_context_menu.js +++ b/web/resolver/node_context_menu.js @@ -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