frontend: add Model Resolver node context menu action and workflow selection support

This commit is contained in:
Dariusz L
2026-07-24 15:49:52 +02:00
parent 1ee4c6ce4c
commit d368a6dffc
5 changed files with 590 additions and 9 deletions
+194
View File
@@ -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')})`);
+4
View File
@@ -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);
},
});
+225 -9
View File
@@ -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;
}
+145
View File
@@ -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',
};
}
@@ -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);
},