From d368a6dffc39fbf0423066e87fa22b6163eb29a2 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Fri, 24 Jul 2026 15:49:52 +0200 Subject: [PATCH] frontend: add Model Resolver node context menu action and workflow selection support --- tests/test_downloads_tab_workflow_route.mjs | 194 +++++++++++++++ web/resolver.js | 4 + web/resolver/model_resolver.js | 234 +++++++++++++++++- web/resolver/node_context_menu.js | 145 +++++++++++ web/resolver/views/missing_browser_methods.js | 22 ++ 5 files changed, 590 insertions(+), 9 deletions(-) create mode 100644 web/resolver/node_context_menu.js diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 8535ee8..32a9769 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -4,6 +4,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { html, normalizePathIdentity } from '../web/resolver/utils/html_utils.js'; import { getModelCardUrl } from '../web/resolver/utils/url_utils.js'; +import { + buildModelResolverNodeMenu, + getResolvedModelsForNode, + matchesWorkflowModelReference, + toResolverContextModel, +} from '../web/resolver/node_context_menu.js'; const projectRoot = path.resolve(import.meta.dirname, '..'); const queueMethodsSource = fs.readFileSync( @@ -43,6 +49,194 @@ const renderFormatMethodsSource = fs.readFileSync( 'utf8' ); +test('node context menu includes only existing resolved models from the selected node scope', () => { + const data = { + resolved_models: [ + { + node_id: 42, + widget_index: 0, + widget_name: 'ckpt_name', + original_path: 'models/checkpoint.safetensors', + full_path: 'E:\\models\\checkpoint.safetensors', + category: 'checkpoints', + exists: true, + is_top_level: true, + }, + { + node_id: 42, + widget_index: 1, + widget_name: 'vae_name', + original_path: 'vae.safetensors', + full_path: 'E:\\models\\vae\\vae.safetensors', + category: 'vae', + exists: false, + is_top_level: true, + }, + { + node_id: 42, + widget_index: 0, + original_path: 'nested.safetensors', + full_path: 'E:\\models\\nested.safetensors', + category: 'checkpoints', + exists: true, + is_top_level: false, + subgraph_id: 'subgraph-a', + }, + { + node_id: 99, + widget_index: 0, + original_path: 'other.safetensors', + full_path: 'E:\\models\\other.safetensors', + category: 'checkpoints', + exists: true, + is_top_level: true, + }, + ], + }; + + const models = getResolvedModelsForNode(data, 42, { is_top_level: true }); + + assert.equal(models.length, 1); + assert.equal(models[0].widget_name, 'ckpt_name'); +}); + +test('node context menu groups multiple model inputs and dispatches actions for the selected model', () => { + const calls = []; + const models = [ + { + node_id: 7, + widget_index: 0, + widget_name: 'ckpt_name', + original_path: 'model.safetensors', + full_path: 'E:\\models\\model.safetensors', + category: 'checkpoints', + exists: true, + }, + { + node_id: 7, + widget_index: 1, + widget_name: 'vae_name', + original_path: 'vae.safetensors', + full_path: 'E:\\models\\vae\\vae.safetensors', + category: 'vae', + exists: true, + }, + ]; + const menu = buildModelResolverNodeMenu(models, { + showInResolver: model => calls.push(['show', model.widget_name]), + showInfo: model => calls.push(['info', model.widget_name]), + openContainingFolder: model => calls.push(['folder', model.widget_name]), + }, { + formatCategory: category => category === 'checkpoints' ? 'Checkpoint' : category, + }); + + assert.equal(menu.title, 'Model Resolver'); + assert.equal(menu.submenu.options.length, 2); + assert.match(menu.submenu.options[0].title, /^CHECKPOINT · ckpt_name/); + assert.match(menu.submenu.options[1].title, /^VAE · vae_name/); + assert.deepEqual( + menu.submenu.options[0].submenu.options.map(option => option.content), + ['Show in Model Resolver', 'Show info', 'Open containing folder'] + ); + + menu.submenu.options[1].submenu.options[2].callback(); + assert.deepEqual(calls, [['folder', 'vae_name']]); +}); + +test('node context menu uses direct actions for one model and converts its local path for resolver actions', () => { + const model = { + node_id: 5, + widget_index: 2, + original_path: 'folder/model.gguf', + full_path: 'E:\\models\\folder\\model.gguf', + category: 'diffusion_models', + exists: true, + }; + const menu = buildModelResolverNodeMenu([model]); + const contextModel = toResolverContextModel(model); + + assert.deepEqual( + menu.submenu.options.map(option => option.content), + ['Show in Model Resolver', 'Show info', 'Open containing folder'] + ); + assert.equal(contextModel.name, 'model.gguf'); + assert.equal(contextModel.path, model.full_path); + assert.equal(contextModel.resolved_path, model.full_path); +}); + +test('workflow model reference matching distinguishes widget, path, and subgraph scope', () => { + const model = { + node_id: 11, + widget_index: 3, + original_path: 'Folder\\Model.safetensors', + is_top_level: false, + subgraph_id: 'subgraph-a', + }; + + assert.equal(matchesWorkflowModelReference(model, { + node_id: '11', + widget_index: 3, + original_path: 'folder/model.safetensors', + is_top_level: false, + subgraph_id: 'subgraph-a', + }), true); + assert.equal(matchesWorkflowModelReference(model, { + node_id: 11, + widget_index: 4, + }), false); + assert.equal(matchesWorkflowModelReference(model, { + node_id: 11, + widget_index: 3, + subgraph_id: 'subgraph-b', + }), false); +}); + +test('node context integration preserves existing menu hooks and refreshes after widget changes', () => { + const configureNodeContextMenu = eval(`(${extractMethod(modelResolverSource, 'configureNodeContextMenu')})`); + const calls = []; + class NodeType {} + NodeType.prototype.getExtraMenuOptions = function(_canvas, options) { + calls.push('original-menu'); + options.push({ content: 'Original action' }); + return options; + }; + NodeType.prototype.onWidgetChanged = function(name) { + calls.push(`original-widget:${name}`); + return 'widget-result'; + }; + + const model = { + node_id: 7, + widget_index: 0, + widget_name: 'ckpt_name', + original_path: 'model.safetensors', + full_path: 'E:\\models\\model.safetensors', + category: 'checkpoints', + exists: true, + }; + const resolver = { + getResolvedModelsForNodeContextMenu: () => [model], + scheduleNodeContextMenuAnalysis: () => calls.push('refresh'), + showResolvedNodeModelInResolver: () => {}, + dialog: { + getCategoryDisplayName: () => 'checkpoint', + showModelInfo: () => {}, + openContainingFolder: () => {}, + }, + }; + + configureNodeContextMenu.call(resolver, NodeType); + const node = new NodeType(); + const options = []; + const result = node.getExtraMenuOptions(null, options); + + assert.equal(result, options); + assert.equal(options[0].title, 'Model Resolver'); + assert.equal(options[1].content, 'Original action'); + assert.equal(node.onWidgetChanged('ckpt_name'), 'widget-result'); + assert.deepEqual(calls, ['original-menu', 'original-widget:ckpt_name', 'refresh']); +}); + test('Local Database source ignores installed local model matches before search', () => { const hasMissingSourceSearchAttempt = eval(`(${extractMethod(missingBrowserMethodsSource, 'hasMissingSourceSearchAttempt')})`); const getMissingSourceResultStatus = eval(`(${extractMethod(missingBrowserMethodsSource, 'getMissingSourceResultStatus')})`); diff --git a/web/resolver.js b/web/resolver.js index 2ea3fce..4800f8e 100644 --- a/web/resolver.js +++ b/web/resolver.js @@ -31,15 +31,19 @@ app.registerExtension({ setup: modelResolver.setup, beforeRegisterNodeDef(nodeType, nodeData) { modelResolver.configureWorkflowDependencyMarkerNodeType(nodeType, nodeData); + modelResolver.configureNodeContextMenu(nodeType); }, nodeCreated(node) { modelResolver.configureWorkflowDependencyMarkerNode(node); + modelResolver.scheduleNodeContextMenuAnalysis(); }, loadedGraphNode(node) { modelResolver.configureWorkflowDependencyMarkerNode(node); + modelResolver.scheduleNodeContextMenuAnalysis(); }, afterConfigureGraph() { modelResolver.configureWorkflowDependencyMarkerNodes(); modelResolver.checkAndOpenForMissingModels(); + modelResolver.scheduleNodeContextMenuAnalysis(500); }, }); diff --git a/web/resolver/model_resolver.js b/web/resolver/model_resolver.js index 6907826..b56f58a 100644 --- a/web/resolver/model_resolver.js +++ b/web/resolver/model_resolver.js @@ -6,6 +6,11 @@ import { logger as frontendLogger } from "../log_system/logger.js"; import { loadStylesWhenNeeded } from "../utils/css_loader.js"; import { ResolverManagerDialog } from "./resolver_dialog.js"; import { showNotification } from "../utils/notification_utils.js"; +import { + buildModelResolverNodeMenu, + getResolvedModelsForNode, + toResolverContextModel, +} from "./node_context_menu.js"; const log = createModuleLogger('model_resolver'); export const MODEL_RESOLVER_OPEN_COMMAND_ID = "ModelResolver.OpenModelResolver"; @@ -112,6 +117,10 @@ export class ModelResolver { this.workflowHashMetadataSignature = null; this.workflowHashMetadataRefreshTimer = null; this.workflowHashMetadataRefreshing = false; + this.nodeContextAnalysisData = null; + this.nodeContextAnalysisSignature = null; + this.nodeContextAnalysisPromise = null; + this.nodeContextAnalysisTimer = null; } setup = async () => { @@ -310,6 +319,219 @@ export class ModelResolver { this.handleResolverButtonClick(); } + getNodeContextWorkflowState() { + const workflow = app?.graph?.serialize?.(); + if (!workflow) return { workflow: null, signature: null }; + + const signature = this.dialog?.getWorkflowSignature?.(workflow) + || JSON.stringify(workflow); + return { workflow, signature }; + } + + getNodeContextScope(node) { + const graph = node?.graph; + const isTopLevel = !graph || graph === app?.graph; + if (isTopLevel) { + return { is_top_level: true }; + } + + const subgraphId = graph?._subgraph?.id + || graph?.subgraph?.id + || graph?._id + || graph?.id + || ''; + return { + is_top_level: false, + subgraph_id: subgraphId ? String(subgraphId) : undefined, + }; + } + + getCurrentNodeContextAnalysis(signature) { + if ( + signature + && this.dialog?.cachedWorkflowSignature === signature + && this.dialog?.cachedAnalysisData + ) { + return this.dialog.cachedAnalysisData; + } + if ( + signature + && this.nodeContextAnalysisSignature === signature + && this.nodeContextAnalysisData + ) { + return this.nodeContextAnalysisData; + } + return null; + } + + scheduleNodeContextMenuAnalysis(delay = 350) { + if (this.nodeContextAnalysisTimer) { + clearTimeout(this.nodeContextAnalysisTimer); + } + this.nodeContextAnalysisTimer = setTimeout(() => { + this.nodeContextAnalysisTimer = null; + this.refreshNodeContextMenuAnalysis(); + }, delay); + } + + async refreshNodeContextMenuAnalysis({ force = false } = {}) { + if (!this.dialog) return null; + + const { workflow, signature } = this.getNodeContextWorkflowState(); + if (!workflow || !signature) return null; + + const cachedData = this.getCurrentNodeContextAnalysis(signature); + if (!force && cachedData) return cachedData; + if (this.nodeContextAnalysisPromise) return this.nodeContextAnalysisPromise; + + const analysisPromise = this.dialog.fetchJson('/model_resolver/analyze', { + method: 'POST', + body: JSON.stringify({ workflow }), + silent: true, + }, 'Analyze workflow') + .then((data) => { + const currentState = this.getNodeContextWorkflowState(); + if (currentState.signature === signature) { + this.nodeContextAnalysisSignature = signature; + this.nodeContextAnalysisData = data; + } + return data; + }) + .catch((error) => { + log.debug('Model Resolver: node context analysis failed.', error); + return null; + }) + .finally(() => { + if (this.nodeContextAnalysisPromise === analysisPromise) { + this.nodeContextAnalysisPromise = null; + } + const currentState = this.getNodeContextWorkflowState(); + if (currentState.signature && currentState.signature !== signature) { + this.scheduleNodeContextMenuAnalysis(0); + } + }); + + this.nodeContextAnalysisPromise = analysisPromise; + return analysisPromise; + } + + getResolvedModelsForNodeContextMenu(node) { + const { signature } = this.getNodeContextWorkflowState(); + const data = this.getCurrentNodeContextAnalysis(signature); + if (!data) { + this.scheduleNodeContextMenuAnalysis(0); + return []; + } + + return getResolvedModelsForNode( + data, + node?.id, + this.getNodeContextScope(node) + ); + } + + configureNodeContextMenu(nodeType) { + if (!nodeType?.prototype || nodeType.prototype.__modelResolverContextMenuPatched) { + return; + } + + const owner = this; + const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; + const originalOnWidgetChanged = nodeType.prototype.onWidgetChanged; + nodeType.prototype.getExtraMenuOptions = function(_, options) { + 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)), + }, { + formatCategory: category => owner.dialog?.getCategoryDisplayName?.(category), + }); + + const targetOptions = Array.isArray(options) + ? options + : (Array.isArray(result) ? result : null); + if (menu && targetOptions) { + targetOptions.unshift(menu); + } + return result; + }; + nodeType.prototype.onWidgetChanged = function() { + const result = originalOnWidgetChanged?.apply(this, arguments); + owner.scheduleNodeContextMenuAnalysis(); + return result; + }; + nodeType.prototype.__modelResolverContextMenuPatched = true; + } + + waitForResolverDialogReady(timeoutMs = 2500) { + const startedAt = Date.now(); + return new Promise((resolve) => { + const check = () => { + if (this.dialog?.isVisible?.() && this.dialog?.contentElement) { + resolve(true); + return; + } + if (Date.now() - startedAt >= timeoutMs) { + resolve(false); + return; + } + setTimeout(check, 40); + }; + check(); + }); + } + + async showResolvedNodeModelInResolver(reference) { + if (!this.dialog) return; + + const { signature } = this.getNodeContextWorkflowState(); + const analysisData = this.getCurrentNodeContextAnalysis(signature); + if (signature && analysisData) { + this.dialog.cachedWorkflowSignature = signature; + this.dialog.cachedAnalysisData = analysisData; + } + + this.dialog.activeTab = 'missing'; + this.dialog.persistActiveTab?.('missing'); + this.dialog.showResolvedModels = true; + this.dialog.missingModelsTypeFilter = 'all'; + this.dialog.missingModelsTypeFilterMenuOpen = false; + + try { + localStorage.setItem(this.dialog.showResolvedModelsStorageKey, '1'); + } catch (error) { + log.debug('Model Resolver: failed to persist resolved-model visibility.', error); + } + + if (!this.dialog.isVisible?.()) { + this.activateResolverButton(); + } + if (!await this.waitForResolverDialogReady()) { + showNotification('Could not open Model Resolver.', 'error'); + return; + } + + this.dialog.switchTab?.('missing', { force: true }); + await this.dialog.loadWorkflowData?.(); + + let selected = this.dialog.selectWorkflowModelReference?.( + reference, + this.dialog.cachedAnalysisData + ); + if (!selected) { + await this.dialog.loadWorkflowData?.(null, { force: true, forceRescan: false }); + selected = this.dialog.selectWorkflowModelReference?.( + reference, + this.dialog.cachedAnalysisData + ); + } + if (!selected) { + showNotification('The selected model is no longer present in the current workflow.', 'warning'); + } + } + handleSidebarButtonClick(event) { const target = event.target instanceof Element ? event.target : null; const button = target?.closest(`.${this.sidebarTabId}-tab-button`); @@ -1114,15 +1336,9 @@ export class ModelResolver { } this.lastCheckedWorkflow = workflowHash; - // Call analyze endpoint to check for missing models - let data; - try { - data = await this.dialog.fetchJson('/model_resolver/analyze', { - method: 'POST', - body: JSON.stringify({ workflow }), - silent: true - }, 'Analyze workflow'); - } catch (error) { + // Reuse the background context-menu analysis to avoid scanning twice. + const data = await this.refreshNodeContextMenuAnalysis(); + if (!data) { console.warn('Model Resolver: Failed to analyze workflow for missing models'); return; } diff --git a/web/resolver/node_context_menu.js b/web/resolver/node_context_menu.js new file mode 100644 index 0000000..0a25b1f --- /dev/null +++ b/web/resolver/node_context_menu.js @@ -0,0 +1,145 @@ +function normalizeIdentity(value) { + return String(value ?? '').trim().replaceAll('\\', '/').toLowerCase(); +} + +function getResolvedModelPath(model = {}) { + return String(model.full_path || model.path || model.resolved_path || '').trim(); +} + +function getResolvedModelFilename(model = {}) { + const source = String( + model.name + || model.filename + || model.original_path + || model.relative_path + || getResolvedModelPath(model) + || 'Resolved model' + ); + return source.split(/[\\/]/).pop() || source; +} + +function getResolvedModelCategoryLabel(model = {}, formatCategory = null) { + const category = String(model.category || 'unknown').trim() || 'unknown'; + const formatted = typeof formatCategory === 'function' + ? formatCategory(category) + : category.replaceAll('_', ' '); + return String(formatted || category).toUpperCase(); +} + +function getResolvedModelMenuLabel(model = {}, formatCategory = null) { + const category = getResolvedModelCategoryLabel(model, formatCategory); + const widgetName = String(model.widget_name || '').trim(); + const filename = getResolvedModelFilename(model); + const slot = widgetName ? `${category} · ${widgetName}` : category; + return `${slot} — ${filename}`; +} + +function createModelActions(model, handlers = {}) { + return [ + { + content: 'Show in Model Resolver', + callback: () => handlers.showInResolver?.(model), + }, + { + content: 'Show info', + callback: () => handlers.showInfo?.(model), + }, + { + content: 'Open containing folder', + callback: () => handlers.openContainingFolder?.(model), + }, + ]; +} + +export function isExistingResolvedModel(model = {}) { + return model.exists === true && Boolean(getResolvedModelPath(model)); +} + +export function matchesWorkflowModelReference(model = {}, reference = {}) { + if (String(model.node_id ?? '') !== String(reference.node_id ?? '')) { + return false; + } + + if ( + reference.widget_index !== undefined + && reference.widget_index !== null + && String(model.widget_index ?? '') !== String(reference.widget_index) + ) { + return false; + } + + if ( + reference.is_top_level !== undefined + && reference.is_top_level !== null + && Boolean(model.is_top_level) !== Boolean(reference.is_top_level) + ) { + return false; + } + + if ( + reference.subgraph_id + && String(model.subgraph_id || '') !== String(reference.subgraph_id) + ) { + return false; + } + + if (reference.original_path) { + return normalizeIdentity(model.original_path) === normalizeIdentity(reference.original_path); + } + + return true; +} + +export function getResolvedModelsForNode(data = {}, nodeId, scope = {}) { + const nodeReference = { + node_id: nodeId, + is_top_level: scope.is_top_level, + subgraph_id: scope.subgraph_id, + }; + + return (data.resolved_models || []) + .filter(isExistingResolvedModel) + .filter(model => matchesWorkflowModelReference(model, nodeReference)) + .sort((left, right) => { + const widgetOrder = Number(left.widget_index ?? 0) - Number(right.widget_index ?? 0); + if (widgetOrder) return widgetOrder; + return getResolvedModelMenuLabel(left).localeCompare(getResolvedModelMenuLabel(right)); + }); +} + +export function buildModelResolverNodeMenu(models = [], handlers = {}, options = {}) { + const resolvedModels = models.filter(isExistingResolvedModel); + if (!resolvedModels.length) return null; + + const submenuOptions = resolvedModels.length === 1 + ? createModelActions(resolvedModels[0], handlers) + : resolvedModels.map(model => ({ + title: getResolvedModelMenuLabel(model, options.formatCategory), + has_submenu: true, + submenu: { + options: createModelActions(model, handlers), + }, + })); + + return { + title: 'Model Resolver', + has_submenu: true, + submenu: { + options: submenuOptions, + }, + }; +} + +export function toResolverContextModel(model = {}) { + const path = getResolvedModelPath(model); + const filename = getResolvedModelFilename(model); + return { + ...model, + name: model.name || filename, + filename, + path, + resolved_path: path, + relative_path: model.relative_path || model.original_path || filename, + context_scope: 'local_model', + }; +} diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index 04ccaf9..c8792c3 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -6,6 +6,7 @@ import { createFloatingTreePicker } from "../utils/tree_picker.js"; import { startSplitterDrag } from "../utils/splitter_drag.js"; import { getCivitaiModelUrl } from "../globals.js"; import { safeStorage, normalizePathIdentity } from "../utils/html_utils.js"; +import { matchesWorkflowModelReference } from "../node_context_menu.js"; export const missingBrowserMethods = { getMissingFilename(missing = {}) { @@ -507,6 +508,27 @@ export const missingBrowserMethods = { }; }, + selectWorkflowModelReference(reference = {}, data = this.cachedAnalysisData || {}) { + const selected = this.getResolvedWorkflowModels(data) + .find(model => matchesWorkflowModelReference(model, reference)); + if (!selected || !this.contentElement) return null; + + this.showResolvedModels = true; + this.missingModelsTypeFilter = 'all'; + this.missingModelsTypeFilterMenuOpen = false; + safeStorage.setItem(this.showResolvedModelsStorageKey, '1'); + this.selectedMissingModelKey = this.getMissingModelKey(selected); + this.displayMissingModels(this.contentElement, data); + + requestAnimationFrame(() => { + const row = Array.from( + this.contentElement?.querySelectorAll?.('.mr-missing-list-row') || [] + ).find(item => item.dataset.missingKey === this.selectedMissingModelKey); + row?.scrollIntoView?.({ block: 'nearest' }); + }); + return selected; + }, + isAutoDownloadModel(missing = {}) { return Boolean(missing?.auto_download_capable || missing?.auto_download_candidate); },