1738 lines
68 KiB
JavaScript
1738 lines
68 KiB
JavaScript
import { app } from "../../../../scripts/app.js";
|
||
import { api } from "../../../../scripts/api.js";
|
||
import { $el } from "../../../../scripts/ui.js";
|
||
import { createModuleLogger } from "../log_system/log_funcs.js";
|
||
import { logger as frontendLogger } from "../log_system/logger.js";
|
||
import { loadStylesWhenNeeded } from "../utils/css_loader.js";
|
||
import { isSidebarButtonActive } from "./utils/dom_patch_utils.js";
|
||
import { fetchJson as fetchJsonRequest } from "./utils/api_client.js";
|
||
import { ResolverManagerDialog } from "./resolver_dialog.js";
|
||
import { showNotification } from "../utils/notification_utils.js";
|
||
import { safeStorage } from "./utils/html_utils.js";
|
||
import {
|
||
serializeKeybindingCombo,
|
||
keybindingsEqual,
|
||
getComboLabel,
|
||
} from "./utils/keybinding_utils.js";
|
||
import {
|
||
buildModelResolverNodeMenu,
|
||
getImmediateModelsForNode,
|
||
getResolvedModelsForNode,
|
||
isExistingResolvedModel,
|
||
matchesWorkflowModelReference,
|
||
toResolverContextModel,
|
||
} from "./node_context_menu.js";
|
||
import {
|
||
getCustomNodeModelAdapter,
|
||
getCustomNodeModelListSignature as createCustomNodeModelListSignature,
|
||
getCustomNodeModelStrengthSignature as createCustomNodeModelStrengthSignature,
|
||
isCustomNodeModelWidget as matchesCustomNodeModelWidget,
|
||
} from "./custom_nodes/registry.js";
|
||
|
||
const log = createModuleLogger('model_resolver');
|
||
export const MODEL_RESOLVER_OPEN_COMMAND_ID = "ModelResolver.OpenModelResolver";
|
||
export const MODEL_RESOLVER_OPEN_DEFAULT_KEYBINDING = Object.freeze({
|
||
commandId: MODEL_RESOLVER_OPEN_COMMAND_ID,
|
||
combo: Object.freeze({ key: "|", ctrl: true, shift: true }),
|
||
targetElementId: "graph-canvas",
|
||
});
|
||
|
||
const OPEN_TOOLTIP_BASE = "Open Model Resolver to find or download missing workflow models.";
|
||
const KEYBINDING_NEW_SETTING_ID = "Comfy.Keybinding.NewBindings";
|
||
const KEYBINDING_UNSET_SETTING_ID = "Comfy.Keybinding.UnsetBindings";
|
||
const LEGACY_WORKFLOW_HASH_MARKER_NODE_TYPE = "ModelResolverWorkflowHashes";
|
||
const WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE = "ModelResolverDependency";
|
||
const WORKFLOW_DEPENDENCY_MARKER_DISPLAY_NAME = "Model Resolver Opener";
|
||
const WORKFLOW_DEPENDENCY_MARKER_AUX_ID = "Azornes/Comfyui-Model-Resolver";
|
||
const WORKFLOW_DEPENDENCY_MARKER_REPOSITORY = "https://github.com/Azornes/Comfyui-Model-Resolver";
|
||
const WORKFLOW_DEPENDENCY_MARKER_CNR_ID = "comfyui-model-resolver";
|
||
const WORKFLOW_DEPENDENCY_MARKER_BUTTON_NAME = "Open Model Resolver";
|
||
const WORKFLOW_DEPENDENCY_MARKER_AUTO_ID = -918273646;
|
||
const WORKFLOW_DEPENDENCY_MARKER_DEFAULT_SIZE = Object.freeze([160, 40]);
|
||
const WORKFLOW_DEPENDENCY_MARKER_MIN_SIZE = Object.freeze([160, 40]);
|
||
|
||
function getKeybindingSetting(id) {
|
||
try {
|
||
const value = app.ui?.settings?.getSettingValue?.(id);
|
||
return Array.isArray(value) ? value : [];
|
||
} catch (_error) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function applyStoredFrontendLoggingPreference() {
|
||
const stored = safeStorage.getItem('ModelResolver.frontendLogsEnabled');
|
||
if (stored !== null) {
|
||
frontendLogger.setEnabled(stored !== 'false');
|
||
}
|
||
const storedLevel = safeStorage.getItem('ModelResolver.frontendLogLevel');
|
||
if (storedLevel) {
|
||
frontendLogger.setGlobalAndModuleLevel(frontendLogger.normalizeLevel(storedLevel));
|
||
}
|
||
}
|
||
|
||
// Main extension class
|
||
export class ModelResolver {
|
||
constructor() {
|
||
this.resolverButton = null;
|
||
this.buttonGroup = null;
|
||
this.buttonId = "model-resolver-button";
|
||
this.sidebarTabId = "comfyui-model-resolver";
|
||
this.sidebarRegistered = false;
|
||
this.openTooltipTargets = new Set();
|
||
this.openTooltip = this.buildOpenTooltip();
|
||
this.openTooltipRefreshFrame = null;
|
||
this.dialog = null;
|
||
this.isCheckingMissing = false; // Prevent multiple simultaneous checks
|
||
this.lastCheckedWorkflow = null; // Track to avoid duplicate checks
|
||
this.workflowHashMetadataCache = null;
|
||
this.workflowHashMetadataSignature = null;
|
||
this.workflowHashMetadataRefreshTimer = null;
|
||
this.workflowHashMetadataRefreshing = false;
|
||
this.workflowHashMetadataRefreshPending = false;
|
||
this.workflowHashMetadataPendingWorkflow = null;
|
||
this.workflowHashMetadataPendingSignature = null;
|
||
this.workflowHashMetadataActiveSignature = null;
|
||
this.workflowHashMetadataPreparing = false;
|
||
this.nodeContextAnalysisData = null;
|
||
this.nodeContextAnalysisSignature = null;
|
||
this.nodeContextAnalysisPromise = null;
|
||
this.nodeContextAnalysisTimer = null;
|
||
this.customNodeModelAdapterStates = new WeakMap();
|
||
}
|
||
|
||
setup = async () => {
|
||
window.ModelResolver = this;
|
||
applyStoredFrontendLoggingPreference();
|
||
loadStylesWhenNeeded();
|
||
|
||
// Remove any existing button
|
||
this.removeExistingButton();
|
||
|
||
// Create dialog instance
|
||
if (!this.dialog) {
|
||
this.dialog = new ResolverManagerDialog();
|
||
window.ModelResolverDialog = this.dialog;
|
||
}
|
||
|
||
this.setupOpenShortcutTooltipTracking();
|
||
|
||
// Listen for workflow load events to auto-check for missing models
|
||
this.setupAutoOpenOnMissingModels();
|
||
this.setupWorkflowHashMetadataInjection();
|
||
this.setupActiveWorkflowChangeListeners();
|
||
|
||
const sidebarRegistered = this.registerSidebarButton();
|
||
if (!sidebarRegistered) {
|
||
await this.registerTopbarButton();
|
||
} else {
|
||
this.attachSidebarButtonToggleHandler();
|
||
}
|
||
}
|
||
|
||
registerSidebarButton() {
|
||
const registerSidebarTab = app.extensionManager?.registerSidebarTab;
|
||
if (typeof registerSidebarTab !== "function") {
|
||
return false;
|
||
}
|
||
|
||
if (this.sidebarRegistered || window.__ModelResolverSidebarRegistered) {
|
||
this.sidebarRegistered = true;
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
registerSidebarTab.call(app.extensionManager, {
|
||
id: this.sidebarTabId,
|
||
icon: "mdi mdi-link-variant",
|
||
title: "Resolver",
|
||
tooltip: this.openTooltip,
|
||
type: "custom",
|
||
render: (element) => this.renderSidebarPanel(element),
|
||
});
|
||
this.sidebarRegistered = true;
|
||
window.__ModelResolverSidebarRegistered = true;
|
||
return true;
|
||
} catch (error) {
|
||
console.warn("Model Resolver: Sidebar tab registration failed, falling back to top menu button.", error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
attachSidebarButtonToggleHandler() {
|
||
window.__ModelResolverSidebarToggleOwner = this;
|
||
if (window.__ModelResolverSidebarToggleHandlerAttached) return;
|
||
|
||
window.__ModelResolverSidebarToggleHandlerAttached = true;
|
||
window.__ModelResolverSidebarToggleHandler = (event) => {
|
||
window.__ModelResolverSidebarToggleOwner?.handleSidebarButtonClick(event);
|
||
};
|
||
document.addEventListener('click', window.__ModelResolverSidebarToggleHandler, true);
|
||
}
|
||
|
||
setupOpenShortcutTooltipTracking() {
|
||
window.__ModelResolverShortcutTooltipOwner = this;
|
||
this.refreshOpenTooltip();
|
||
|
||
if (window.__ModelResolverShortcutTooltipHandlersAttached) return;
|
||
window.__ModelResolverShortcutTooltipHandlersAttached = true;
|
||
|
||
const refreshHandler = () => {
|
||
window.__ModelResolverShortcutTooltipOwner?.scheduleOpenTooltipRefresh();
|
||
};
|
||
app.ui?.settings?.addEventListener?.(`${KEYBINDING_NEW_SETTING_ID}.change`, refreshHandler);
|
||
app.ui?.settings?.addEventListener?.(`${KEYBINDING_UNSET_SETTING_ID}.change`, refreshHandler);
|
||
window.addEventListener('storage', (event) => {
|
||
if (
|
||
event.key === KEYBINDING_NEW_SETTING_ID
|
||
|| event.key === KEYBINDING_UNSET_SETTING_ID
|
||
) {
|
||
refreshHandler();
|
||
}
|
||
});
|
||
}
|
||
|
||
scheduleOpenTooltipRefresh() {
|
||
if (this.openTooltipRefreshFrame) {
|
||
cancelAnimationFrame(this.openTooltipRefreshFrame);
|
||
}
|
||
this.openTooltipRefreshFrame = requestAnimationFrame(() => {
|
||
this.openTooltipRefreshFrame = null;
|
||
this.refreshOpenTooltip();
|
||
});
|
||
}
|
||
|
||
getOpenKeybindings() {
|
||
const byCombo = {
|
||
[serializeKeybindingCombo(MODEL_RESOLVER_OPEN_DEFAULT_KEYBINDING.combo)]: MODEL_RESOLVER_OPEN_DEFAULT_KEYBINDING,
|
||
};
|
||
|
||
for (const keybinding of getKeybindingSetting(KEYBINDING_UNSET_SETTING_ID)) {
|
||
const serializedCombo = serializeKeybindingCombo(keybinding?.combo);
|
||
if (keybindingsEqual(byCombo[serializedCombo], keybinding)) {
|
||
delete byCombo[serializedCombo];
|
||
}
|
||
}
|
||
|
||
for (const keybinding of getKeybindingSetting(KEYBINDING_NEW_SETTING_ID)) {
|
||
if (!keybinding?.combo) continue;
|
||
byCombo[serializeKeybindingCombo(keybinding.combo)] = keybinding;
|
||
}
|
||
|
||
return Object.values(byCombo)
|
||
.filter((keybinding) => keybinding?.commandId === MODEL_RESOLVER_OPEN_COMMAND_ID);
|
||
}
|
||
|
||
buildOpenTooltip() {
|
||
const shortcutLabels = this.getOpenKeybindings()
|
||
.map((keybinding) => getComboLabel(keybinding.combo))
|
||
.filter(Boolean);
|
||
|
||
if (shortcutLabels.length === 0) {
|
||
return `${OPEN_TOOLTIP_BASE} Shortcut: not assigned.`;
|
||
}
|
||
|
||
const label = shortcutLabels.length === 1 ? "Shortcut" : "Shortcuts";
|
||
return `${OPEN_TOOLTIP_BASE} ${label}: ${shortcutLabels.join(", ")}.`;
|
||
}
|
||
|
||
refreshOpenTooltip() {
|
||
this.openTooltip = this.buildOpenTooltip();
|
||
this.applyOpenTooltipToTargets();
|
||
}
|
||
|
||
trackOpenTooltipTarget(target) {
|
||
if (!(target instanceof HTMLElement)) return;
|
||
this.openTooltipTargets.add(target);
|
||
this.applyTooltipToElement(target);
|
||
}
|
||
|
||
applyTooltipToElement(target) {
|
||
if (!(target instanceof HTMLElement)) return;
|
||
if (this.dialog?.setTooltip) {
|
||
this.dialog.setTooltip(target, this.openTooltip);
|
||
} else {
|
||
target.setAttribute("data-tooltip", this.openTooltip);
|
||
target.removeAttribute("title");
|
||
}
|
||
}
|
||
|
||
applyOpenTooltipToTargets() {
|
||
const liveTargets = new Set();
|
||
for (const target of this.openTooltipTargets) {
|
||
if (!(target instanceof HTMLElement) || !target.isConnected) continue;
|
||
liveTargets.add(target);
|
||
this.applyTooltipToElement(target);
|
||
}
|
||
this.openTooltipTargets = liveTargets;
|
||
|
||
document.querySelectorAll(`.${this.sidebarTabId}-tab-button`).forEach((button) => {
|
||
if (button instanceof HTMLElement) {
|
||
this.applyTooltipToElement(button);
|
||
}
|
||
});
|
||
}
|
||
|
||
getSidebarButton() {
|
||
const button = document.querySelector(`.${this.sidebarTabId}-tab-button`);
|
||
return button instanceof HTMLElement ? button : null;
|
||
}
|
||
|
||
getVisibleResolverButton() {
|
||
const button = document.getElementById(this.buttonId);
|
||
return button instanceof HTMLElement ? button : null;
|
||
}
|
||
|
||
handleResolverButtonClick = () => {
|
||
this.openResolverManager();
|
||
}
|
||
|
||
activateResolverButton = () => {
|
||
const button = this.getSidebarButton() || this.getVisibleResolverButton();
|
||
if (button) {
|
||
button.click();
|
||
return;
|
||
}
|
||
|
||
this.handleResolverButtonClick();
|
||
}
|
||
|
||
getNodeContextWorkflowState() {
|
||
const workflow = app?.graph?.serialize?.();
|
||
if (!workflow) return { workflow: null, signature: null };
|
||
|
||
const signature = this.dialog?.getMissingWorkflowSignature?.(workflow)
|
||
|| this.dialog?.getWorkflowSignature?.(workflow)
|
||
|| JSON.stringify(workflow);
|
||
return { workflow, signature };
|
||
}
|
||
|
||
getSubgraphDefinitionIdForNode(node, workflow = null) {
|
||
const runtimeSubgraphId = node?.subgraph?.id
|
||
|| node?._subgraph?.id
|
||
|| node?.subgraph_id
|
||
|| '';
|
||
const nodeType = String(node?.type || '').trim();
|
||
const definitions = workflow?.definitions?.subgraphs;
|
||
if (Array.isArray(definitions)) {
|
||
const definition = definitions.find(item => (
|
||
String(item?.id || '') === nodeType
|
||
));
|
||
if (definition?.id) return String(definition.id);
|
||
}
|
||
return runtimeSubgraphId ? String(runtimeSubgraphId) : '';
|
||
}
|
||
|
||
getNodeContextScope(node, workflow = null) {
|
||
const graph = node?.graph;
|
||
const isTopLevel = !graph || graph === app?.graph;
|
||
const subgraphInstanceId = this.getSubgraphDefinitionIdForNode(node, workflow);
|
||
if (isTopLevel) {
|
||
return {
|
||
is_top_level: true,
|
||
subgraph_id: subgraphInstanceId || undefined,
|
||
subgraph_instance_id: subgraphInstanceId || undefined,
|
||
is_subgraph_instance: Boolean(subgraphInstanceId),
|
||
};
|
||
}
|
||
|
||
const subgraphId = graph?._subgraph?.id
|
||
|| graph?.subgraph?.id
|
||
|| graph?._id
|
||
|| graph?.id
|
||
|| '';
|
||
return {
|
||
is_top_level: false,
|
||
subgraph_id: subgraphId ? String(subgraphId) : undefined,
|
||
subgraph_instance_id: subgraphInstanceId || undefined,
|
||
is_subgraph_instance: Boolean(subgraphInstanceId),
|
||
};
|
||
}
|
||
|
||
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 workflowRefreshPending = (
|
||
this.dialog.isVisible?.()
|
||
&& (this.dialog._workflowRefreshTimer || this.dialog._workflowRefreshRetryTimer)
|
||
);
|
||
if (!force && workflowRefreshPending) {
|
||
this.scheduleNodeContextMenuAnalysis(180);
|
||
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 analysisRequest = this.dialog.getWorkflowAnalysisRequest?.(workflow, {
|
||
silent: true,
|
||
});
|
||
if (!analysisRequest?.promise) return null;
|
||
|
||
const analysisPromise = analysisRequest.promise
|
||
.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 { workflow, signature } = this.getNodeContextWorkflowState();
|
||
const scope = this.getNodeContextScope(node, workflow);
|
||
const data = this.getCurrentNodeContextAnalysis(signature);
|
||
if (!data) {
|
||
this.scheduleNodeContextMenuAnalysis(0);
|
||
const previousData = this.nodeContextAnalysisData
|
||
|| this.dialog?.cachedAnalysisData;
|
||
return getImmediateModelsForNode(
|
||
previousData || {},
|
||
node,
|
||
scope
|
||
);
|
||
}
|
||
|
||
return getResolvedModelsForNode(
|
||
data,
|
||
node?.id,
|
||
scope
|
||
);
|
||
}
|
||
|
||
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) {
|
||
const isNodeInstance = Boolean(
|
||
nodeType
|
||
&& !nodeType.prototype
|
||
&& nodeType.constructor?.prototype
|
||
);
|
||
const target = isNodeInstance ? nodeType : nodeType?.prototype;
|
||
if (!target) {
|
||
return;
|
||
}
|
||
|
||
if (isNodeInstance) {
|
||
const hasOwnMenuHook = Object.prototype.hasOwnProperty.call(target, 'getExtraMenuOptions')
|
||
|| Object.prototype.hasOwnProperty.call(target, 'getMenuOptions');
|
||
if (!hasOwnMenuHook || target.__modelResolverContextMenuInstancePatched) {
|
||
return;
|
||
}
|
||
} else if (target.__modelResolverContextMenuPatched) {
|
||
return;
|
||
}
|
||
|
||
const owner = this;
|
||
const hasOwnExtraMenuOptions = Object.prototype.hasOwnProperty.call(
|
||
target,
|
||
'getExtraMenuOptions'
|
||
);
|
||
const hasOwnMenuOptions = Object.prototype.hasOwnProperty.call(
|
||
target,
|
||
'getMenuOptions'
|
||
);
|
||
const originalGetExtraMenuOptions = target.getExtraMenuOptions;
|
||
const originalGetMenuOptions = target.getMenuOptions;
|
||
const addModelResolverMenu = (node, targetOptions) => {
|
||
if (!Array.isArray(targetOptions)) return;
|
||
if (targetOptions.some(option => (
|
||
String(option?.className || '').includes('mr-model-resolver-node-menu')
|
||
))) {
|
||
return;
|
||
}
|
||
|
||
const models = owner.getResolvedModelsForNodeContextMenu(node);
|
||
const menu = buildModelResolverNodeMenu(models, {
|
||
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),
|
||
});
|
||
|
||
if (menu) targetOptions.unshift(menu);
|
||
};
|
||
const originalOnWidgetChanged = target.onWidgetChanged;
|
||
if (!isNodeInstance || hasOwnExtraMenuOptions) {
|
||
target.getExtraMenuOptions = function(_, options) {
|
||
const result = originalGetExtraMenuOptions?.apply(this, arguments);
|
||
const targetOptions = Array.isArray(options)
|
||
? options
|
||
: (Array.isArray(result) ? result : null);
|
||
addModelResolverMenu(this, targetOptions);
|
||
return result;
|
||
};
|
||
}
|
||
// ComfyUI treats the presence of getMenuOptions as an alternate menu
|
||
// provider. Only wrap node types that already implement it; adding an
|
||
// undefined method makes the native context menu return no options.
|
||
if (typeof originalGetMenuOptions === 'function' && (!isNodeInstance || hasOwnMenuOptions)) {
|
||
target.getMenuOptions = function() {
|
||
const result = originalGetMenuOptions.apply(this, arguments);
|
||
const targetOptions = Array.isArray(result)
|
||
? result
|
||
: (Array.isArray(arguments[1]) ? arguments[1] : null);
|
||
addModelResolverMenu(this, targetOptions);
|
||
return result;
|
||
};
|
||
}
|
||
if (!isNodeInstance) {
|
||
target.onWidgetChanged = function() {
|
||
const result = originalOnWidgetChanged?.apply(this, arguments);
|
||
const widgetName = arguments[0];
|
||
const handledByCustomNodeAdapter = owner.isCustomNodeModelWidget?.(
|
||
this,
|
||
widgetName
|
||
);
|
||
if (handledByCustomNodeAdapter) {
|
||
owner.customNodeModelAdapterStates?.get(this)?.notify?.();
|
||
} else if (!owner.dialog?.isWorkflowRefreshSuppressed?.()) {
|
||
if (!owner.dialog?.isWorkflowStrengthWidgetName?.(widgetName)) {
|
||
owner.scheduleNodeContextMenuAnalysis();
|
||
}
|
||
owner.dialog?.scheduleActiveWorkflowRefresh?.('node-widget-change');
|
||
}
|
||
return result;
|
||
};
|
||
target.__modelResolverContextMenuPatched = true;
|
||
} else {
|
||
target.__modelResolverContextMenuInstancePatched = true;
|
||
}
|
||
}
|
||
|
||
isCustomNodeModelWidget(node, widgetName = '') {
|
||
return matchesCustomNodeModelWidget(node, widgetName);
|
||
}
|
||
|
||
getCustomNodeModelListSignature(node) {
|
||
return createCustomNodeModelListSignature(node);
|
||
}
|
||
|
||
getCustomNodeModelStrengthSignature(node) {
|
||
return createCustomNodeModelStrengthSignature(node);
|
||
}
|
||
|
||
configureCustomNodeModelAdapter(node) {
|
||
const adapter = getCustomNodeModelAdapter(node);
|
||
if (!adapter || !Array.isArray(node?.widgets)) return;
|
||
|
||
const owner = this;
|
||
if (!(this.customNodeModelAdapterStates instanceof WeakMap)) {
|
||
this.customNodeModelAdapterStates = new WeakMap();
|
||
}
|
||
let state = this.customNodeModelAdapterStates.get(node);
|
||
if (!state) {
|
||
state = {
|
||
signature: this.getCustomNodeModelListSignature(node),
|
||
strengthSignature: this.getCustomNodeModelStrengthSignature(node),
|
||
};
|
||
this.customNodeModelAdapterStates.set(node, state);
|
||
} else {
|
||
state.signature = this.getCustomNodeModelListSignature(node);
|
||
state.strengthSignature = this.getCustomNodeModelStrengthSignature(node);
|
||
}
|
||
|
||
const notifyIfModelsChanged = () => {
|
||
const nextSignature = owner.getCustomNodeModelListSignature(node);
|
||
const nextStrengthSignature = owner.getCustomNodeModelStrengthSignature(node);
|
||
const modelListChanged = nextSignature !== state.signature;
|
||
const strengthChanged = nextStrengthSignature !== state.strengthSignature;
|
||
state.signature = nextSignature;
|
||
state.strengthSignature = nextStrengthSignature;
|
||
if (owner.dialog?.isWorkflowRefreshSuppressed?.()) return;
|
||
|
||
if (
|
||
modelListChanged
|
||
) {
|
||
owner.scheduleNodeContextMenuAnalysis();
|
||
owner.dialog?.scheduleActiveWorkflowRefresh?.('node-widget-change');
|
||
} else if (strengthChanged) {
|
||
owner.dialog?.updateLoadedModelStrengthsFromNode?.(node);
|
||
}
|
||
};
|
||
state.notify = notifyIfModelsChanged;
|
||
adapter.observe(node, notifyIfModelsChanged);
|
||
}
|
||
|
||
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();
|
||
});
|
||
}
|
||
|
||
waitForWorkflowModelSelection(request, timeoutMs = 120000) {
|
||
const startedAt = Date.now();
|
||
return new Promise((resolve) => {
|
||
const check = () => {
|
||
if (!request || request.status !== 'pending') {
|
||
resolve(request || null);
|
||
return;
|
||
}
|
||
if (Date.now() - startedAt >= timeoutMs) {
|
||
request.status = 'timeout';
|
||
if (this.dialog?.pendingWorkflowModelSelection === request) {
|
||
this.dialog.pendingWorkflowModelSelection = null;
|
||
}
|
||
resolve(request);
|
||
return;
|
||
}
|
||
setTimeout(check, 50);
|
||
};
|
||
check();
|
||
});
|
||
}
|
||
|
||
async showResolvedNodeModelInResolver(reference) {
|
||
if (!this.dialog) return;
|
||
|
||
const wasVisible = Boolean(this.dialog.isVisible?.());
|
||
const wasOnMissingTab = this.dialog.activeTab === 'missing';
|
||
const { signature } = this.getNodeContextWorkflowState();
|
||
const analysisData = this.getCurrentNodeContextAnalysis(signature);
|
||
if (signature && analysisData) {
|
||
this.dialog.cachedWorkflowSignature = signature;
|
||
this.dialog.cachedAnalysisData = analysisData;
|
||
}
|
||
|
||
this.dialog.persistActiveTab?.('missing');
|
||
|
||
if (wasVisible && wasOnMissingTab) {
|
||
const selected = this.dialog.selectWorkflowModelReference?.(
|
||
reference,
|
||
analysisData || this.dialog.cachedAnalysisData,
|
||
{ preferExistingBrowser: true }
|
||
);
|
||
if (selected) return;
|
||
}
|
||
|
||
this.dialog.showResolvedModels = true;
|
||
this.dialog.missingModelsTypeFilter = 'all';
|
||
this.dialog.missingModelsTypeFilterMenuOpen = false;
|
||
safeStorage.setItem(this.dialog.showResolvedModelsStorageKey, '1');
|
||
|
||
const selectionRequest = this.dialog.queueWorkflowModelReferenceSelection?.(reference);
|
||
if (!wasVisible) {
|
||
this.dialog.activeTab = 'missing';
|
||
this.activateResolverButton();
|
||
}
|
||
if (!await this.waitForResolverDialogReady()) {
|
||
if (selectionRequest?.status === 'pending') {
|
||
selectionRequest.status = 'open-failed';
|
||
}
|
||
if (this.dialog.pendingWorkflowModelSelection === selectionRequest) {
|
||
this.dialog.pendingWorkflowModelSelection = null;
|
||
}
|
||
showNotification('Could not open Model Resolver.', 'error');
|
||
return;
|
||
}
|
||
|
||
if (wasVisible && !wasOnMissingTab) {
|
||
await this.dialog.switchTab?.('missing', { force: true });
|
||
} else if (wasVisible) {
|
||
await this.dialog.loadWorkflowData?.();
|
||
}
|
||
|
||
const completedRequest = await this.waitForWorkflowModelSelection(selectionRequest);
|
||
if (completedRequest?.status === 'timeout') {
|
||
showNotification('Could not select the model after workflow analysis completed.', 'warning');
|
||
}
|
||
}
|
||
|
||
handleSidebarButtonClick(event) {
|
||
const target = event.target instanceof Element ? event.target : null;
|
||
const button = target?.closest(`.${this.sidebarTabId}-tab-button`);
|
||
if (!(button instanceof HTMLElement)) return;
|
||
if (!this.dialog?.isVisible()) return;
|
||
|
||
const wasDocked = this.dialog.docked;
|
||
this.dialog.close({ collapseSidebar: false });
|
||
|
||
if (!wasDocked) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
event.stopImmediatePropagation?.();
|
||
|
||
requestAnimationFrame(() => {
|
||
if (isSidebarButtonActive(button)) {
|
||
button.click();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
renderSidebarPanel(element) {
|
||
element.style.height = "100%";
|
||
element.classList.add("mr-sidebar-dock-panel");
|
||
|
||
if (!this.dialog) {
|
||
this.dialog = new ResolverManagerDialog();
|
||
window.ModelResolverDialog = this.dialog;
|
||
}
|
||
this.dialog.rememberDockDropPreviewWidth?.(element);
|
||
|
||
if (this.dialog.shouldOpenFromSidebarFloating()) {
|
||
if (this.dialog.isVisible() && !this.dialog.docked) {
|
||
this.dialog.close();
|
||
this.dialog.closeComfySidebar(element);
|
||
return;
|
||
}
|
||
|
||
this.openResolverManager({
|
||
forceFloating: true,
|
||
closeSidebarContainer: element,
|
||
});
|
||
return;
|
||
}
|
||
|
||
element.replaceChildren();
|
||
this.openResolverManager({ dockContainer: element });
|
||
}
|
||
|
||
async registerTopbarButton() {
|
||
// Try to use new ComfyUI button system (like ComfyUI Manager does)
|
||
try {
|
||
// Dynamic imports for ComfyUI's button components
|
||
const { ComfyButtonGroup } = await import("../../../../scripts/ui/components/buttonGroup.js");
|
||
const { ComfyButton } = await import("../../../../scripts/ui/components/button.js");
|
||
|
||
// Create button group with Model Resolver button
|
||
const ModelResolverButton = new ComfyButton({
|
||
icon: "link-variant",
|
||
action: this.handleResolverButtonClick,
|
||
content: "Model Resolver",
|
||
classList: "comfyui-button comfyui-menu-mobile-collapse"
|
||
}).element;
|
||
ModelResolverButton.id = this.buttonId;
|
||
this.trackOpenTooltipTarget(ModelResolverButton);
|
||
this.buttonGroup = new ComfyButtonGroup(
|
||
ModelResolverButton
|
||
);
|
||
|
||
// Insert before settings group in the menu
|
||
app.menu?.settingsGroup.element.before(this.buttonGroup.element);
|
||
} catch (_e) {
|
||
// Fallback for older ComfyUI versions without the new button system
|
||
log.debug('Model Resolver: New button system not available, using floating button fallback.');
|
||
this.createFloatingButton();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Setup auto-open functionality when workflow is loaded with missing models
|
||
*/
|
||
setupAutoOpenOnMissingModels() {
|
||
// Watch for ComfyUI's Missing Models popup and inject our button
|
||
this.setupMissingModelsPopupObserver();
|
||
|
||
log.debug('Model Resolver: Missing models popup button injection enabled');
|
||
}
|
||
|
||
setupActiveWorkflowChangeListeners() {
|
||
if (window.__ModelResolverWorkflowChangeHandlers) {
|
||
for (const { target, event, handler, options } of window.__ModelResolverWorkflowChangeHandlers) {
|
||
target?.removeEventListener?.(event, handler, options);
|
||
}
|
||
}
|
||
|
||
if (!window.__ModelResolverHistoryPatched) {
|
||
const originalPushState = history.pushState;
|
||
const originalReplaceState = history.replaceState;
|
||
history.pushState = function(...args) {
|
||
const result = originalPushState.apply(this, args);
|
||
window.dispatchEvent(new Event('model-resolver-locationchange'));
|
||
return result;
|
||
};
|
||
history.replaceState = function(...args) {
|
||
const result = originalReplaceState.apply(this, args);
|
||
window.dispatchEvent(new Event('model-resolver-locationchange'));
|
||
return result;
|
||
};
|
||
window.__ModelResolverHistoryPatched = true;
|
||
}
|
||
|
||
if (!window.__ModelResolverLoadGraphDataPatched && typeof app.loadGraphData === 'function') {
|
||
const originalLoadGraphData = app.loadGraphData;
|
||
app.loadGraphData = function(...args) {
|
||
if (args[0] && typeof args[0] === 'object') {
|
||
window.__ModelResolverWorkflowHashMetadataOwner?.removeLegacyWorkflowHashMarkerNode(args[0]);
|
||
}
|
||
const result = originalLoadGraphData.apply(this, args);
|
||
Promise.resolve(result).then(() => {
|
||
setTimeout(() => {
|
||
window.dispatchEvent(new Event('model-resolver-active-workflowchange'));
|
||
}, 0);
|
||
}, () => {
|
||
setTimeout(() => {
|
||
window.dispatchEvent(new Event('model-resolver-active-workflowchange'));
|
||
}, 0);
|
||
});
|
||
return result;
|
||
};
|
||
window.__ModelResolverLoadGraphDataPatched = true;
|
||
}
|
||
|
||
window.__ModelResolverWorkflowChangeOwner = this;
|
||
|
||
const routeHandler = () => {
|
||
window.__ModelResolverWorkflowChangeOwner?.handleActiveWorkflowRouteChange('route-change');
|
||
};
|
||
const activeWorkflowHandler = () => {
|
||
window.__ModelResolverWorkflowChangeOwner?.handleActiveWorkflowRouteChange('active-workflow-change');
|
||
};
|
||
const documentClickHandler = (event) => {
|
||
const owner = window.__ModelResolverWorkflowChangeOwner;
|
||
const target = event.target instanceof Element ? event.target : null;
|
||
if (target?.closest('#model-resolver-modal, .model-resolver-backdrop')) return;
|
||
if (!owner?.dialog?.isVisible()) return;
|
||
if (!owner.isLikelyWorkflowTabClickTarget(target)) return;
|
||
|
||
setTimeout(() => {
|
||
if (window.__ModelResolverWorkflowChangeOwner === owner) {
|
||
owner.handleActiveWorkflowRouteChange('workflow-tab-click');
|
||
}
|
||
}, 0);
|
||
};
|
||
const focusHandler = () => {
|
||
window.__ModelResolverWorkflowChangeOwner?.handleActiveWorkflowRouteChange('window-focus');
|
||
};
|
||
const visibilityHandler = () => {
|
||
if (document.visibilityState === 'visible') {
|
||
window.__ModelResolverWorkflowChangeOwner?.handleActiveWorkflowRouteChange('visibility-change');
|
||
}
|
||
};
|
||
|
||
window.addEventListener('hashchange', routeHandler);
|
||
window.addEventListener('popstate', routeHandler);
|
||
window.addEventListener('model-resolver-locationchange', routeHandler);
|
||
window.addEventListener('model-resolver-active-workflowchange', activeWorkflowHandler);
|
||
document.addEventListener('click', documentClickHandler, true);
|
||
window.addEventListener('focus', focusHandler);
|
||
document.addEventListener('visibilitychange', visibilityHandler);
|
||
|
||
window.__ModelResolverWorkflowChangeHandlers = [
|
||
{ target: window, event: 'hashchange', handler: routeHandler },
|
||
{ target: window, event: 'popstate', handler: routeHandler },
|
||
{ target: window, event: 'model-resolver-locationchange', handler: routeHandler },
|
||
{ target: window, event: 'model-resolver-active-workflowchange', handler: activeWorkflowHandler },
|
||
{ target: document, event: 'click', handler: documentClickHandler, options: true },
|
||
{ target: window, event: 'focus', handler: focusHandler },
|
||
{ target: document, event: 'visibilitychange', handler: visibilityHandler }
|
||
];
|
||
}
|
||
|
||
isLikelyWorkflowTabClickTarget(target) {
|
||
if (!(target instanceof Element)) return false;
|
||
|
||
const tab = target.closest([
|
||
'[data-workflow-id]',
|
||
'[data-workflow-name]',
|
||
'[data-tab-id*="workflow" i]',
|
||
'[aria-controls*="workflow" i]',
|
||
'[class*="workflow"][class*="tab" i]',
|
||
'[class*="tab"][class*="workflow" i]',
|
||
'[class*="workflow"][class*="item" i]'
|
||
].join(','));
|
||
if (tab) return true;
|
||
|
||
const roleTab = target.closest('[role="tab"]');
|
||
if (!roleTab) return false;
|
||
|
||
const text = roleTab.textContent?.trim() || '';
|
||
const aria = roleTab.getAttribute('aria-label') || roleTab.getAttribute('title') || '';
|
||
return /workflow|unsaved|untitled/i.test(`${text} ${aria}`);
|
||
}
|
||
|
||
handleActiveWorkflowRouteChange(reason = 'workflow-change') {
|
||
this.scheduleWorkflowHashMetadataRefresh();
|
||
if (!this.dialog?.isVisible()) return;
|
||
this.dialog.scheduleActiveWorkflowRefresh(reason);
|
||
}
|
||
|
||
isWorkflowHashMetadataEnabled() {
|
||
return safeStorage.getItem('ModelResolver.workflowHashMetadataEnabled') !== 'false';
|
||
}
|
||
|
||
isWorkflowDependencyMarkerEnabled() {
|
||
return safeStorage.getItem('ModelResolver.workflowDependencyMarkerEnabled') === 'true';
|
||
}
|
||
|
||
setupWorkflowHashMetadataInjection() {
|
||
window.__ModelResolverWorkflowHashMetadataOwner = this;
|
||
if (window.__ModelResolverWorkflowHashMetadataPatched) {
|
||
this.scheduleWorkflowHashMetadataRefresh();
|
||
this.configureWorkflowDependencyMarkerNodes();
|
||
return;
|
||
}
|
||
|
||
if (!app?.graph || typeof app.graph.serialize !== 'function') return;
|
||
|
||
const originalSerialize = app.graph.serialize;
|
||
app.graph.serialize = function(...args) {
|
||
const workflow = originalSerialize.apply(this, args);
|
||
window.__ModelResolverWorkflowHashMetadataOwner?.removeLegacyWorkflowHashMarkerNode(workflow);
|
||
window.__ModelResolverWorkflowHashMetadataOwner?.configureSerializedWorkflowDependencyMarkerNodes(workflow);
|
||
window.__ModelResolverWorkflowHashMetadataOwner?.injectWorkflowHashMetadata(workflow);
|
||
window.__ModelResolverWorkflowHashMetadataOwner?.injectWorkflowDependencyMarker(workflow);
|
||
window.__ModelResolverWorkflowHashMetadataOwner?.scheduleWorkflowHashMetadataRefresh(workflow);
|
||
return workflow;
|
||
};
|
||
window.__ModelResolverWorkflowHashMetadataPatched = true;
|
||
this.configureWorkflowDependencyMarkerNodes();
|
||
this.scheduleWorkflowHashMetadataRefresh();
|
||
}
|
||
|
||
removeLegacyWorkflowHashMarkerNode(workflow) {
|
||
if (!workflow || !Array.isArray(workflow.nodes)) return workflow;
|
||
workflow.nodes = workflow.nodes.filter((node) => node?.type !== LEGACY_WORKFLOW_HASH_MARKER_NODE_TYPE);
|
||
return workflow;
|
||
}
|
||
|
||
isWorkflowDependencyMarkerNode(node) {
|
||
if (!node) return false;
|
||
const candidates = [
|
||
node.type,
|
||
node.comfyClass,
|
||
node.comfy_class,
|
||
node.constructor?.ComfyClass,
|
||
node.constructor?.comfyClass,
|
||
node.constructor?.nodeData?.name,
|
||
];
|
||
return candidates.some((candidate) => candidate === WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE);
|
||
}
|
||
|
||
isWorkflowDependencyMarkerNodeType(nodeType, nodeData) {
|
||
const candidates = [
|
||
nodeData?.name,
|
||
nodeData?.node_id,
|
||
nodeData?.class_type,
|
||
nodeType?.ComfyClass,
|
||
nodeType?.comfyClass,
|
||
nodeType?.type,
|
||
nodeType?.prototype?.comfyClass,
|
||
];
|
||
return candidates.some((candidate) => candidate === WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE);
|
||
}
|
||
|
||
configureWorkflowDependencyMarkerNodeType(nodeType, nodeData) {
|
||
if (!this.isWorkflowDependencyMarkerNodeType(nodeType, nodeData)) return;
|
||
const proto = nodeType?.prototype;
|
||
if (!proto || proto.__modelResolverDependencyMarkerTypePatched) return;
|
||
|
||
const resolver = this;
|
||
const originalOnNodeCreated = proto.onNodeCreated;
|
||
proto.onNodeCreated = function(...args) {
|
||
const result = originalOnNodeCreated?.apply(this, args);
|
||
resolver.configureWorkflowDependencyMarkerNode(this);
|
||
return result;
|
||
};
|
||
|
||
const originalComputeSize = proto.computeSize;
|
||
proto.computeSize = function(...args) {
|
||
originalComputeSize?.apply(this, args);
|
||
return [...WORKFLOW_DEPENDENCY_MARKER_MIN_SIZE];
|
||
};
|
||
|
||
proto.__modelResolverDependencyMarkerTypePatched = true;
|
||
}
|
||
|
||
applyWorkflowDependencyMarkerSize(node) {
|
||
if (!Array.isArray(node?.size)) return;
|
||
|
||
const width = Number(node.size[0]);
|
||
const height = Number(node.size[1]);
|
||
if (!Number.isFinite(width) || !Number.isFinite(height)) {
|
||
node.size = [...WORKFLOW_DEPENDENCY_MARKER_DEFAULT_SIZE];
|
||
return;
|
||
}
|
||
|
||
if (node.__modelResolverDependencySizeInitialized) return;
|
||
|
||
const [defaultWidth, defaultHeight] = WORKFLOW_DEPENDENCY_MARKER_DEFAULT_SIZE;
|
||
const shouldCompact =
|
||
width > defaultWidth + 25
|
||
|| height > defaultHeight + 16;
|
||
|
||
if (shouldCompact) {
|
||
const compactSize = [
|
||
Math.min(width, defaultWidth),
|
||
Math.min(height, defaultHeight),
|
||
];
|
||
if (typeof node.setSize === 'function') {
|
||
node.setSize(compactSize);
|
||
} else {
|
||
node.size = compactSize;
|
||
}
|
||
}
|
||
|
||
node.__modelResolverDependencySizeInitialized = true;
|
||
}
|
||
|
||
configureWorkflowDependencyMarkerNode(node) {
|
||
if (!this.isWorkflowDependencyMarkerNode(node)) return node;
|
||
|
||
node.properties = node.properties && typeof node.properties === 'object' ? node.properties : {};
|
||
delete node.properties.cnr_id;
|
||
node.properties.aux_id = WORKFLOW_DEPENDENCY_MARKER_AUX_ID;
|
||
node.properties.repository = WORKFLOW_DEPENDENCY_MARKER_REPOSITORY;
|
||
node.properties.registry_id = WORKFLOW_DEPENDENCY_MARKER_CNR_ID;
|
||
node.properties.purpose = "Declares Model Resolver as an intentional workflow dependency.";
|
||
node.properties["Node name for S&R"] = WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE;
|
||
|
||
node.widgets = Array.isArray(node.widgets)
|
||
? node.widgets.filter((widget) => widget?.name !== 'note')
|
||
: [];
|
||
const existingOpenButton = node.widgets.find(
|
||
(widget) => widget?.__modelResolverOpenButton || widget?.name === WORKFLOW_DEPENDENCY_MARKER_BUTTON_NAME
|
||
);
|
||
if (existingOpenButton) {
|
||
existingOpenButton.callback = () => this.activateResolverButton();
|
||
existingOpenButton.value = WORKFLOW_DEPENDENCY_MARKER_BUTTON_NAME;
|
||
existingOpenButton.__modelResolverOpenButton = true;
|
||
existingOpenButton.options = existingOpenButton.options && typeof existingOpenButton.options === 'object'
|
||
? existingOpenButton.options
|
||
: {};
|
||
existingOpenButton.options.serialize = false;
|
||
} else if (typeof node.addWidget === 'function') {
|
||
const button = node.addWidget(
|
||
"button",
|
||
WORKFLOW_DEPENDENCY_MARKER_BUTTON_NAME,
|
||
WORKFLOW_DEPENDENCY_MARKER_BUTTON_NAME,
|
||
() => this.activateResolverButton(),
|
||
{ serialize: false }
|
||
);
|
||
if (button) {
|
||
button.__modelResolverOpenButton = true;
|
||
button.options = button.options && typeof button.options === 'object' ? button.options : {};
|
||
button.options.serialize = false;
|
||
}
|
||
}
|
||
|
||
this.applyWorkflowDependencyMarkerSize(node);
|
||
|
||
if (!node.__modelResolverDependencySerializePatched) {
|
||
const originalOnSerialize = node.onSerialize;
|
||
node.onSerialize = function(serialized) {
|
||
const result = typeof originalOnSerialize === 'function'
|
||
? originalOnSerialize.call(this, serialized)
|
||
: undefined;
|
||
window.__ModelResolverWorkflowHashMetadataOwner?.configureSerializedDependencyMarkerNode(serialized);
|
||
return result;
|
||
};
|
||
node.__modelResolverDependencySerializePatched = true;
|
||
}
|
||
|
||
node.graph?.setDirtyCanvas?.(true, true);
|
||
app.canvas?.setDirty?.(true, true);
|
||
return node;
|
||
}
|
||
|
||
configureWorkflowDependencyMarkerNodes(graph = app?.graph) {
|
||
const nodes = graph?._nodes || graph?.nodes || [];
|
||
if (!Array.isArray(nodes)) return;
|
||
for (const node of nodes) {
|
||
this.configureWorkflowDependencyMarkerNode(node);
|
||
}
|
||
}
|
||
|
||
configureSerializedDependencyMarkerNode(node) {
|
||
if (!node || node.type !== WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE) return node;
|
||
|
||
node.properties = node.properties && typeof node.properties === 'object' ? node.properties : {};
|
||
delete node.properties.cnr_id;
|
||
node.properties.aux_id = WORKFLOW_DEPENDENCY_MARKER_AUX_ID;
|
||
node.properties.repository = WORKFLOW_DEPENDENCY_MARKER_REPOSITORY;
|
||
node.properties.registry_id = WORKFLOW_DEPENDENCY_MARKER_CNR_ID;
|
||
node.properties.purpose = "Declares Model Resolver as an intentional workflow dependency.";
|
||
node.properties["Node name for S&R"] = WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE;
|
||
|
||
node.widgets_values = [];
|
||
|
||
return node;
|
||
}
|
||
|
||
configureSerializedWorkflowDependencyMarkerNodes(workflow) {
|
||
if (!workflow || !Array.isArray(workflow.nodes)) return workflow;
|
||
for (const node of workflow.nodes) {
|
||
this.configureSerializedDependencyMarkerNode(node);
|
||
}
|
||
return workflow;
|
||
}
|
||
|
||
findWorkflowDependencyMarkerNode(graph = app?.graph) {
|
||
const nodes = graph?._nodes || graph?.nodes || [];
|
||
return Array.isArray(nodes)
|
||
? nodes.find((node) => this.isWorkflowDependencyMarkerNode(node)) || null
|
||
: null;
|
||
}
|
||
|
||
getDependencyMarkerGraphPosition() {
|
||
const visibleArea = app?.canvas?.ds?.visible_area;
|
||
if (Array.isArray(visibleArea) && visibleArea.length >= 4) {
|
||
const [x, y, w] = visibleArea;
|
||
const dpi = Math.max(window.devicePixelRatio || 1, 1);
|
||
return [x + Math.max(24, (w - 360) / dpi / 2), y + 48];
|
||
}
|
||
return [0, 0];
|
||
}
|
||
|
||
addWorkflowDependencyMarkerNode() {
|
||
const graph = app?.graph;
|
||
const liteGraph = window.LiteGraph;
|
||
if (!graph || typeof liteGraph?.createNode !== 'function') {
|
||
showNotification("Could not access the current workflow graph.", "error");
|
||
return null;
|
||
}
|
||
|
||
const existing = this.findWorkflowDependencyMarkerNode(graph);
|
||
if (existing) {
|
||
this.configureWorkflowDependencyMarkerNode(existing);
|
||
app.canvas?.centerOnNode?.(existing);
|
||
showNotification("Model Resolver opener node is already in this workflow.", "info");
|
||
return existing;
|
||
}
|
||
|
||
const node = liteGraph.createNode(
|
||
WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE,
|
||
WORKFLOW_DEPENDENCY_MARKER_DISPLAY_NAME,
|
||
{ pos: this.getDependencyMarkerGraphPosition() }
|
||
);
|
||
if (!node) {
|
||
showNotification("Model Resolver opener node is not available. Restart ComfyUI after updating the extension.", "error");
|
||
return null;
|
||
}
|
||
|
||
node.size = [...WORKFLOW_DEPENDENCY_MARKER_DEFAULT_SIZE];
|
||
this.configureWorkflowDependencyMarkerNode(node);
|
||
graph.add(node);
|
||
app.canvas?.centerOnNode?.(node);
|
||
graph.setDirtyCanvas?.(true, true);
|
||
app.canvas?.setDirty?.(true, true);
|
||
showNotification("Model Resolver opener node added.", "success");
|
||
return node;
|
||
}
|
||
|
||
getWorkflowDependencyMarkerPosition(workflow) {
|
||
const nodes = Array.isArray(workflow?.nodes)
|
||
? workflow.nodes.filter((node) => node?.type !== WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE && Array.isArray(node?.pos))
|
||
: [];
|
||
if (!nodes.length) return [0, 0];
|
||
|
||
const xs = nodes.map((node) => Number(node.pos[0])).filter(Number.isFinite);
|
||
const ys = nodes.map((node) => Number(node.pos[1])).filter(Number.isFinite);
|
||
if (!xs.length || !ys.length) return [0, 0];
|
||
|
||
return [Math.min(...xs), Math.min(...ys) - 170];
|
||
}
|
||
|
||
getWorkflowDependencyMarkerId(workflow) {
|
||
const usedIds = new Set(
|
||
(Array.isArray(workflow?.nodes) ? workflow.nodes : [])
|
||
.map((node) => Number(node?.id))
|
||
.filter(Number.isFinite)
|
||
);
|
||
let id = WORKFLOW_DEPENDENCY_MARKER_AUTO_ID;
|
||
while (usedIds.has(id)) id -= 1;
|
||
return id;
|
||
}
|
||
|
||
createSerializedWorkflowDependencyMarkerNode(workflow) {
|
||
return this.configureSerializedDependencyMarkerNode({
|
||
id: this.getWorkflowDependencyMarkerId(workflow),
|
||
type: WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE,
|
||
pos: this.getWorkflowDependencyMarkerPosition(workflow),
|
||
size: [...WORKFLOW_DEPENDENCY_MARKER_DEFAULT_SIZE],
|
||
flags: {},
|
||
order: -1,
|
||
mode: 0,
|
||
inputs: [],
|
||
outputs: [],
|
||
properties: {},
|
||
widgets_values: [],
|
||
});
|
||
}
|
||
|
||
injectWorkflowDependencyMarker(workflow) {
|
||
if (!workflow || typeof workflow !== 'object') return workflow;
|
||
if (!this.isWorkflowDependencyMarkerEnabled()) return workflow;
|
||
|
||
workflow.nodes = Array.isArray(workflow.nodes) ? workflow.nodes : [];
|
||
const existing = workflow.nodes.find((node) => node?.type === WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE);
|
||
if (existing) {
|
||
this.configureSerializedDependencyMarkerNode(existing);
|
||
return workflow;
|
||
}
|
||
|
||
workflow.nodes.push(this.createSerializedWorkflowDependencyMarkerNode(workflow));
|
||
return workflow;
|
||
}
|
||
|
||
injectWorkflowHashMetadata(workflow) {
|
||
if (!workflow || typeof workflow !== 'object') return workflow;
|
||
if (!this.isWorkflowHashMetadataEnabled()) return workflow;
|
||
|
||
const cache = this.workflowHashMetadataCache;
|
||
if (!cache || !Array.isArray(cache.models) || !cache.models.length) return workflow;
|
||
|
||
workflow.extra = workflow.extra && typeof workflow.extra === 'object' ? workflow.extra : {};
|
||
workflow.extra.model_resolver_hashes = {
|
||
version: 1,
|
||
source: 'comfyui-model-resolver',
|
||
models: cache.models,
|
||
updated_at: cache.updated_at || Date.now()
|
||
};
|
||
return workflow;
|
||
}
|
||
|
||
getWorkflowHashMetadataSignature(workflow) {
|
||
if (!workflow || typeof workflow !== 'object') return '';
|
||
|
||
const dependencySignature = this.dialog?.getMissingWorkflowSignature?.(workflow)
|
||
|| this.dialog?.getWorkflowSignature?.(workflow);
|
||
if (dependencySignature) return dependencySignature;
|
||
|
||
const normalizeNode = (node = {}) => ({
|
||
id: node.id,
|
||
type: node.type,
|
||
mode: node.mode,
|
||
widgets_values: node.widgets_values,
|
||
inputs: Array.isArray(node.inputs)
|
||
? node.inputs.map(input => ({
|
||
name: input?.name,
|
||
type: input?.type,
|
||
link: input?.link,
|
||
widget: input?.widget
|
||
}))
|
||
: [],
|
||
outputs: Array.isArray(node.outputs)
|
||
? node.outputs.map(output => ({
|
||
name: output?.name,
|
||
type: output?.type,
|
||
links: output?.links
|
||
}))
|
||
: [],
|
||
properties: node.properties
|
||
});
|
||
const normalizeDefinition = (definition = {}) => ({
|
||
id: definition.id,
|
||
name: definition.name,
|
||
nodes: Array.isArray(definition.nodes)
|
||
? definition.nodes.map(normalizeNode)
|
||
: [],
|
||
links: Array.isArray(definition.links) ? definition.links : []
|
||
});
|
||
const definitions = workflow.definitions && typeof workflow.definitions === 'object'
|
||
? Object.fromEntries(Object.entries(workflow.definitions).map(([key, value]) => [
|
||
key,
|
||
Array.isArray(value)
|
||
? value.map(normalizeDefinition)
|
||
: normalizeDefinition(value)
|
||
]))
|
||
: {};
|
||
|
||
try {
|
||
return JSON.stringify({
|
||
nodes: Array.isArray(workflow.nodes)
|
||
? workflow.nodes.map(normalizeNode)
|
||
: [],
|
||
links: Array.isArray(workflow.links) ? workflow.links : [],
|
||
definitions
|
||
});
|
||
} catch (error) {
|
||
log.debug('Model Resolver: workflow hash signature generation failed', error);
|
||
return '';
|
||
}
|
||
}
|
||
|
||
armWorkflowHashMetadataRefresh(delay = 800) {
|
||
if (this.workflowHashMetadataRefreshTimer) {
|
||
clearTimeout(this.workflowHashMetadataRefreshTimer);
|
||
}
|
||
this.workflowHashMetadataRefreshTimer = setTimeout(() => {
|
||
this.workflowHashMetadataRefreshTimer = null;
|
||
this.flushWorkflowHashMetadataRefresh();
|
||
}, delay);
|
||
}
|
||
|
||
scheduleWorkflowHashMetadataRefresh(workflow = null) {
|
||
if (!this.isWorkflowHashMetadataEnabled() || this.workflowHashMetadataPreparing) return;
|
||
|
||
const signature = workflow
|
||
? this.getWorkflowHashMetadataSignature(workflow)
|
||
: null;
|
||
if (
|
||
signature
|
||
&& (
|
||
signature === this.workflowHashMetadataSignature
|
||
|| signature === this.workflowHashMetadataActiveSignature
|
||
|| (
|
||
this.workflowHashMetadataRefreshPending
|
||
&& signature === this.workflowHashMetadataPendingSignature
|
||
)
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
this.workflowHashMetadataRefreshPending = true;
|
||
this.workflowHashMetadataPendingWorkflow = workflow;
|
||
this.workflowHashMetadataPendingSignature = signature;
|
||
this.armWorkflowHashMetadataRefresh();
|
||
}
|
||
|
||
async flushWorkflowHashMetadataRefresh() {
|
||
if (!this.workflowHashMetadataRefreshPending) return;
|
||
if (this.workflowHashMetadataRefreshing) {
|
||
this.armWorkflowHashMetadataRefresh(200);
|
||
return;
|
||
}
|
||
|
||
const workflow = this.workflowHashMetadataPendingWorkflow;
|
||
const signature = this.workflowHashMetadataPendingSignature;
|
||
this.workflowHashMetadataRefreshPending = false;
|
||
this.workflowHashMetadataPendingWorkflow = null;
|
||
this.workflowHashMetadataPendingSignature = null;
|
||
await this.refreshWorkflowHashMetadata(workflow, signature);
|
||
}
|
||
|
||
async refreshWorkflowHashMetadata(workflow = null, scheduledSignature = null) {
|
||
if (this.workflowHashMetadataRefreshing || !this.isWorkflowHashMetadataEnabled()) return;
|
||
|
||
let currentWorkflow = workflow;
|
||
if (!currentWorkflow) {
|
||
this.workflowHashMetadataPreparing = true;
|
||
try {
|
||
currentWorkflow = app?.graph?.serialize?.();
|
||
} finally {
|
||
this.workflowHashMetadataPreparing = false;
|
||
}
|
||
}
|
||
if (!currentWorkflow) return;
|
||
|
||
const signature = scheduledSignature
|
||
|| this.getWorkflowHashMetadataSignature(currentWorkflow);
|
||
if (signature && signature === this.workflowHashMetadataSignature && this.workflowHashMetadataCache) {
|
||
return;
|
||
}
|
||
|
||
this.workflowHashMetadataRefreshing = true;
|
||
this.workflowHashMetadataActiveSignature = signature;
|
||
try {
|
||
const data = await fetchJsonRequest('/model_resolver/workflow-model-hashes', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ workflow: currentWorkflow }),
|
||
silent: true,
|
||
}, 'Refresh workflow hash metadata', {
|
||
apiClient: api,
|
||
notify: null,
|
||
logError: null,
|
||
throwOnHttpError: false,
|
||
});
|
||
if (!data) return;
|
||
if (!data?.enabled) {
|
||
this.workflowHashMetadataCache = null;
|
||
this.workflowHashMetadataSignature = signature;
|
||
return;
|
||
}
|
||
this.workflowHashMetadataCache = {
|
||
models: Array.isArray(data.models) ? data.models : [],
|
||
by_path: data.by_path || {},
|
||
by_node: data.by_node || {},
|
||
updated_at: Date.now()
|
||
};
|
||
this.workflowHashMetadataSignature = signature;
|
||
} catch (error) {
|
||
log.debug('Model Resolver: workflow hash metadata refresh failed', error);
|
||
} finally {
|
||
this.workflowHashMetadataRefreshing = false;
|
||
this.workflowHashMetadataActiveSignature = null;
|
||
if (
|
||
this.workflowHashMetadataRefreshPending
|
||
&& !this.workflowHashMetadataRefreshTimer
|
||
) {
|
||
this.armWorkflowHashMetadataRefresh(200);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Setup MutationObserver to detect ComfyUI's Missing Models popup and inject our button
|
||
*/
|
||
setupMissingModelsPopupObserver() {
|
||
const observer = new MutationObserver((mutations) => {
|
||
for (const mutation of mutations) {
|
||
for (const node of mutation.addedNodes) {
|
||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||
this.checkAndInjectButton(node);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Observe the entire document for added nodes
|
||
observer.observe(document.body, {
|
||
childList: true,
|
||
subtree: true
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Check if a node is the Missing Models popup and inject our buttons
|
||
*/
|
||
checkAndInjectButton(node) {
|
||
// Look for the Missing Models popup by finding elements with "Missing Models" text
|
||
const findMissingModelsDialog = (element) => {
|
||
// Check if this element or its children contain "Missing Models" heading
|
||
const headings = element.querySelectorAll ? element.querySelectorAll('h2, h3, [class*="title"], [class*="header"]') : [];
|
||
for (const heading of headings) {
|
||
if (heading.textContent?.includes('Missing Models')) {
|
||
return element;
|
||
}
|
||
}
|
||
// Also check text content directly
|
||
if (element.textContent?.includes('Missing Models') &&
|
||
element.textContent?.includes('following models were not found')) {
|
||
return element;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const dialog = findMissingModelsDialog(node);
|
||
if (!dialog) return;
|
||
|
||
// Check if we already injected buttons
|
||
if (dialog.querySelector('#model-resolver-btn-container')) return;
|
||
|
||
// Find a suitable place to inject the button
|
||
const injectButtons = () => {
|
||
// Auto-resolve button (green)
|
||
const autoResolveBtn = document.createElement('button');
|
||
autoResolveBtn.id = 'model-resolver-btn-container'; // Use this ID to prevent duplicate injection
|
||
autoResolveBtn.className = 'mr-popup-auto-resolve-btn';
|
||
autoResolveBtn.textContent = '🔗 Auto-resolve 100%';
|
||
this.dialog?.setTooltip(autoResolveBtn, 'Link every missing model that already has an exact local match, then open Model Resolver for the rest.');
|
||
autoResolveBtn.addEventListener('click', async () => {
|
||
await this.handleAutoResolveInPopup(dialog, autoResolveBtn);
|
||
});
|
||
|
||
// Find the "Don't show this again" checkbox row and add button next to it
|
||
const checkbox = dialog.querySelector('input[type="checkbox"]');
|
||
if (checkbox) {
|
||
const checkboxRow = checkbox.closest('label') || checkbox.parentElement;
|
||
if (checkboxRow && checkboxRow.parentElement) {
|
||
// Make the parent a flex container to align checkbox and button
|
||
checkboxRow.parentElement.classList.add('mr-popup-inline-actions');
|
||
// Insert button at the beginning (left side)
|
||
checkboxRow.parentElement.insertBefore(autoResolveBtn, checkboxRow);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Fallback: Find the list of models and insert before it
|
||
const modelList = dialog.querySelector('[style*="overflow"]') ||
|
||
dialog.querySelector('[class*="list"]') ||
|
||
dialog.querySelector('[class*="content"]');
|
||
|
||
if (modelList) {
|
||
// Create a wrapper and insert before the model list
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'mr-popup-actions-wrap';
|
||
wrapper.appendChild(autoResolveBtn);
|
||
modelList.parentElement?.insertBefore(wrapper, modelList);
|
||
} else {
|
||
// Find after the description text
|
||
const allElements = dialog.querySelectorAll('*');
|
||
for (const el of allElements) {
|
||
if (el.textContent?.includes('following models were not found') &&
|
||
el.children.length === 0) {
|
||
el.parentElement?.insertBefore(autoResolveBtn, el.nextSibling);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
log.debug('Model Resolver: Injected buttons into Missing Models popup');
|
||
};
|
||
|
||
// Small delay to ensure popup is fully rendered
|
||
setTimeout(injectButtons, 100);
|
||
}
|
||
|
||
/**
|
||
* Handle auto-resolve in the popup - resolve 100% matches and open Model Resolver for remaining
|
||
*/
|
||
async handleAutoResolveInPopup(dialog, button) {
|
||
button.textContent = '⏳ Resolving...';
|
||
button.disabled = true;
|
||
|
||
// Close the popup first
|
||
const closeBtn = dialog.querySelector('button[class*="close"]') ||
|
||
dialog.querySelector('svg')?.closest('button') ||
|
||
Array.from(dialog.querySelectorAll('button')).find(b =>
|
||
b.textContent === '×' || b.innerHTML.includes('×') || b.innerHTML.includes('close'));
|
||
|
||
if (closeBtn) {
|
||
closeBtn.click();
|
||
}
|
||
|
||
// Small delay to let popup close
|
||
await new Promise(r => setTimeout(r, 200));
|
||
|
||
// Create dialog if needed
|
||
if (!this.dialog) {
|
||
this.dialog = new ResolverManagerDialog();
|
||
window.ModelResolverDialog = this.dialog;
|
||
}
|
||
|
||
// Run auto-resolve for 100% matches - returns the updated workflow
|
||
const updatedWorkflow = await this.dialog.autoResolve100Percent();
|
||
|
||
return updatedWorkflow;
|
||
}
|
||
|
||
/**
|
||
* Check if auto-open preference is enabled
|
||
*/
|
||
isAutoOpenEnabled() {
|
||
return safeStorage.getItem('ModelResolver.autoOpenOnMissing') === 'true';
|
||
}
|
||
|
||
openResolverForDetectedMissingModels() {
|
||
if (this.dialog?.isVisible?.()) {
|
||
this.dialog.scheduleActiveWorkflowRefresh?.('auto-open-missing-models');
|
||
return;
|
||
}
|
||
|
||
this.activateResolverButton();
|
||
}
|
||
|
||
|
||
/**
|
||
* Check for missing models and auto-open dialog if any are found
|
||
*/
|
||
async checkAndOpenForMissingModels() {
|
||
// Check if auto-open is enabled
|
||
if (!this.isAutoOpenEnabled()) {
|
||
return;
|
||
}
|
||
|
||
// Prevent multiple simultaneous checks
|
||
if (this.isCheckingMissing) {
|
||
return;
|
||
}
|
||
|
||
this.isCheckingMissing = true;
|
||
|
||
try {
|
||
// Small delay to let workflow fully load
|
||
await new Promise(r => setTimeout(r, 500));
|
||
|
||
// Get current workflow
|
||
const workflow = app?.graph?.serialize();
|
||
if (!workflow) {
|
||
return;
|
||
}
|
||
|
||
// Create a simple hash to detect if workflow changed
|
||
const workflowHash = JSON.stringify(workflow.nodes?.map(n => n.type + ':' + JSON.stringify(n.widgets_values || [])));
|
||
|
||
// Skip if we already checked this exact workflow
|
||
if (this.lastCheckedWorkflow === workflowHash) {
|
||
return;
|
||
}
|
||
this.lastCheckedWorkflow = workflowHash;
|
||
|
||
// 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;
|
||
}
|
||
|
||
// Auto-open dialog if there are missing models
|
||
if (data.total_missing > 0) {
|
||
log.debug(`Model Resolver: Found ${data.total_missing} missing model(s), opening dialog...`);
|
||
this.openResolverForDetectedMissingModels();
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Model Resolver: Error checking for missing models:', error);
|
||
} finally {
|
||
this.isCheckingMissing = false;
|
||
}
|
||
}
|
||
|
||
removeExistingButton() {
|
||
// Remove any existing button by ID
|
||
const existingButton = document.getElementById(this.buttonId);
|
||
if (existingButton) {
|
||
existingButton.remove();
|
||
}
|
||
|
||
document.querySelectorAll('.comfyui-button-group button.comfyui-button').forEach((button) => {
|
||
const label = button.querySelector('span')?.textContent?.trim() || button.textContent?.trim();
|
||
if (label === 'Model Resolver') {
|
||
button.closest('.comfyui-button-group')?.remove();
|
||
}
|
||
});
|
||
|
||
// Remove button group if it exists
|
||
if (this.buttonGroup?.element?.parentNode) {
|
||
this.buttonGroup.element.remove();
|
||
this.buttonGroup = null;
|
||
}
|
||
|
||
// Also remove the stored reference if it exists
|
||
if (this.resolverButton && this.resolverButton.parentNode) {
|
||
this.resolverButton.remove();
|
||
this.resolverButton = null;
|
||
}
|
||
}
|
||
|
||
createFloatingButton() {
|
||
// Create a floating button as fallback for legacy ComfyUI versions
|
||
this.resolverButton = $el("button", {
|
||
id: this.buttonId,
|
||
textContent: "🔗 Model Resolver",
|
||
onclick: this.handleResolverButtonClick,
|
||
className: "model-resolver-floating-button"
|
||
});
|
||
|
||
document.body.appendChild(this.resolverButton);
|
||
this.trackOpenTooltipTarget(this.resolverButton);
|
||
}
|
||
|
||
async openResolverManager(options = {}) {
|
||
try {
|
||
if (!this.dialog) {
|
||
this.dialog = new ResolverManagerDialog();
|
||
window.ModelResolverDialog = this.dialog;
|
||
}
|
||
if (options.dockContainer && !options.forceFloating) {
|
||
await this.dialog.showDocked(options.dockContainer, options.workflow || null);
|
||
return;
|
||
}
|
||
|
||
const wasDocked = this.dialog.docked;
|
||
const showPromise = this.dialog.show(options.workflow || null);
|
||
|
||
if (options.closeSidebarContainer && !wasDocked) {
|
||
this.dialog.closeComfySidebar(options.closeSidebarContainer);
|
||
}
|
||
|
||
await showPromise;
|
||
} catch (error) {
|
||
console.error("🔗 Model Resolver: Error creating/showing dialog:", error);
|
||
showNotification("Error opening Model Resolver: " + error.message, "error");
|
||
}
|
||
}
|
||
}
|