/**
* ComfyUI Model Linker Extension - Frontend
*
* Provides a menu button and dialog interface for relinking missing models in workflows.
*/
// Import ComfyUI APIs
// These paths are relative to the ComfyUI web directory
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { $el, ComfyDialog } from "../../../scripts/ui.js";
// Check if ComfyButtonGroup is available (from newer ComfyUI versions)
let ComfyButtonGroup = null;
try {
// Try to import from scripts if available
if (typeof window !== 'undefined') {
try {
// Some ComfyUI versions expose this globally
if (window.ComfyButtonGroup) {
ComfyButtonGroup = window.ComfyButtonGroup;
}
} catch (e) {
// Ignore
}
}
} catch (e) {
// Fallback if ComfyButtonGroup not available
}
class LinkerManagerDialog extends ComfyDialog {
constructor() {
super();
this.currentWorkflow = null;
this.missingModels = [];
// Create dialog element using $el
this.element = $el("div.comfy-modal", {
parent: document.body,
style: {
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "900px",
height: "700px",
maxWidth: "95vw",
maxHeight: "95vh",
backgroundColor: "var(--comfy-menu-bg, #202020)",
color: "var(--input-text, #ffffff)",
border: "2px solid var(--border-color, #555555)",
borderRadius: "8px",
padding: "0",
zIndex: "99999",
boxShadow: "0 4px 20px rgba(0,0,0,0.8)",
display: "none",
flexDirection: "column"
}
}, [
this.createHeader(),
this.createContent(),
this.createFooter()
]);
}
createHeader() {
return $el("div", {
style: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "20px 20px 10px 20px",
borderBottom: "1px solid var(--border-color)",
backgroundColor: "var(--comfy-menu-bg, #202020)"
}
}, [
$el("h2", {
textContent: "🔗 Model Linker",
style: {
margin: "0",
color: "var(--input-text)",
fontSize: "18px",
fontWeight: "600"
}
}),
$el("button", {
textContent: "×",
onclick: () => this.close(),
style: {
background: "none",
border: "none",
fontSize: "24px",
cursor: "pointer",
color: "var(--input-text)",
padding: "0",
width: "30px",
height: "30px",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center"
}
})
]);
}
createContent() {
this.contentElement = $el("div", {
id: "model-linker-content",
style: {
padding: "16px",
overflowY: "auto",
flex: "1",
minHeight: "0"
}
});
return this.contentElement;
}
createFooter() {
return $el("div", {
style: {
padding: "16px",
borderTop: "1px solid var(--border-color)",
display: "flex",
justifyContent: "flex-end",
gap: "8px"
}
}, [
$el("button", {
textContent: "Auto-Resolve 100% Matches",
onclick: () => this.autoResolve100Percent(),
className: "comfy-button",
style: {
padding: "8px 16px"
}
})
]);
}
async show() {
this.element.style.display = "flex";
await this.loadWorkflowData();
}
close() {
this.element.style.display = "none";
}
/**
* Load workflow data and display missing models
*/
async loadWorkflowData(workflow = null) {
if (!this.contentElement) return;
// Show loading state
this.contentElement.innerHTML = '
Analyzing workflow...
';
try {
// Use provided workflow, or get current workflow from ComfyUI
if (!workflow) {
workflow = this.getCurrentWorkflow();
}
if (!workflow) {
this.contentElement.innerHTML = 'No workflow loaded. Please load a workflow first.
';
return;
}
// Call analyze endpoint
const response = await api.fetchApi('/model_linker/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow })
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
this.displayMissingModels(this.contentElement, data);
} catch (error) {
console.error('Model Linker: Error loading workflow data:', error);
if (this.contentElement) {
this.contentElement.innerHTML = `Error: ${error.message}
`;
}
}
}
/**
* Get current workflow from ComfyUI
*/
getCurrentWorkflow() {
// Try to get workflow from app
if (app?.graph) {
try {
// Use ComfyUI's workflow serialization
const workflow = app.graph.serialize();
return workflow;
} catch (e) {
console.warn('Model Linker: Could not serialize workflow from graph:', e);
}
}
return null;
}
/**
* Display missing models in the dialog
*/
displayMissingModels(container, data) {
const missingModels = data.missing_models || [];
const totalMissing = data.total_missing || 0;
if (totalMissing === 0) {
container.innerHTML = '✓ No missing models found. All models are available!
';
return;
}
let html = `Found ${totalMissing} missing model(s):
`;
html += '';
// Sort missing models: those with 100% confidence matches first, then others
const sortedMissingModels = missingModels.sort((a, b) => {
const aMatches = a.matches || [];
const bMatches = b.matches || [];
// Filter to 70%+ confidence
const aFiltered = aMatches.filter(m => m.confidence >= 70);
const bFiltered = bMatches.filter(m => m.confidence >= 70);
// Check if they have 100% matches
const aHas100 = aFiltered.some(m => m.confidence === 100);
const bHas100 = bFiltered.some(m => m.confidence === 100);
// If one has 100% and the other doesn't, prioritize the one with 100%
if (aHas100 && !bHas100) return -1;
if (!aHas100 && bHas100) return 1;
// If both have 100% or neither has 100%, sort by best confidence
const aBestConf = aFiltered.length > 0 ? Math.max(...aFiltered.map(m => m.confidence)) : 0;
const bBestConf = bFiltered.length > 0 ? Math.max(...bFiltered.map(m => m.confidence)) : 0;
return bBestConf - aBestConf; // Higher confidence first
});
for (const missing of sortedMissingModels) {
html += this.renderMissingModel(missing);
}
html += '
';
container.innerHTML = html;
// Attach event listeners for resolve buttons (use sorted order)
sortedMissingModels.forEach((missing, missingIndex) => {
const allMatches = missing.matches || [];
// Filter out matches below 70% confidence threshold
const filteredMatches = allMatches.filter(m => m.confidence >= 70);
// Filter to only 100% matches if available, otherwise use filtered matches (>=70%)
const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
const matchesToUse = perfectMatches.length > 0 ? perfectMatches : filteredMatches;
matchesToUse.forEach((match, matchIndex) => {
const buttonId = `resolve-${missing.node_id}-${missing.widget_index}-${matchIndex}`;
const resolveButton = container.querySelector(`#${buttonId}`);
if (resolveButton) {
resolveButton.addEventListener('click', () => {
this.resolveModel(missing, match.model);
});
}
});
});
}
/**
* Render a single missing model entry
*/
renderMissingModel(missing) {
const allMatches = missing.matches || [];
// Filter out matches below 70% confidence threshold
const filteredMatches = allMatches.filter(m => m.confidence >= 70);
const hasMatches = filteredMatches.length > 0;
let html = ``;
// Display subgraph name as primary identifier if available, otherwise show node type
// A node type that's a UUID indicates it's a subgraph instance
const isSubgraphNode = missing.node_type && missing.node_type.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
if (missing.subgraph_name) {
// Show subgraph name as primary identifier
html += `
Subgraph: ${missing.subgraph_name} (ID: ${missing.node_id})
`;
} else if (isSubgraphNode) {
// Node type is a UUID (subgraph) but we don't have the name (shouldn't happen, but handle gracefully)
html += `
Node: Subgraph (ID: ${missing.node_id})
`;
} else {
// Regular node
html += `
Node: ${missing.node_type} (ID: ${missing.node_id})
`;
}
html += `
Missing Model: ${missing.original_path}
`;
html += `
Category: ${missing.category || 'unknown'}
`;
if (hasMatches) {
// Filter out matches below 70% confidence threshold
const filteredMatches = allMatches.filter(m => m.confidence >= 70);
// Separate 100% matches from others (from filtered list)
const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70);
// If we have 100% matches, only show those. Otherwise, show other matches sorted by confidence
const matchesToShow = perfectMatches.length > 0
? perfectMatches
: otherMatches.sort((a, b) => b.confidence - a.confidence).slice(0, 5);
html += `
Suggested Matches:
`;
html += '
';
// Sort: 100% matches first, then by confidence descending
const sortedMatches = matchesToShow.sort((a, b) => {
if (a.confidence === 100 && b.confidence !== 100) return -1;
if (a.confidence !== 100 && b.confidence === 100) return 1;
return b.confidence - a.confidence;
});
for (let matchIndex = 0; matchIndex < sortedMatches.length; matchIndex++) {
const match = sortedMatches[matchIndex];
const buttonId = `resolve-${missing.node_id}-${missing.widget_index}-${matchIndex}`;
html += ``;
html += `${match.model?.relative_path || match.filename} `;
html += `
(${match.confidence}% confidence)
`;
if (match.confidence === 100) {
html += `
Resolve
`;
}
html += ` `;
}
html += ' ';
// Add note if only showing 100% matches
if (perfectMatches.length > 0 && otherMatches.length > 0) {
html += `
Showing only 100% confidence matches. ${otherMatches.length} other match${otherMatches.length > 1 ? 'es' : ''} available.
`;
}
} else if (allMatches.length > 0 && filteredMatches.length === 0) {
// Had matches but all were below 70% threshold
html += `
No matches found above 70% confidence threshold.
`;
} else {
html += `
No matches found.
`;
}
html += '
';
return html;
}
/**
* Show a notification banner (similar to ComfyUI's "Reconnecting" banner)
*/
showNotification(message, type = 'success') {
// Create notification banner
const notification = $el("div", {
style: {
position: "fixed",
top: "0",
left: "50%",
transform: "translateX(-50%)",
backgroundColor: type === 'success' ? '#28a745' : type === 'error' ? '#dc3545' : '#007acc',
color: "#ffffff",
padding: "12px 24px",
borderRadius: "0 0 8px 8px",
fontSize: "14px",
fontWeight: "500",
zIndex: "100000",
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
display: "flex",
alignItems: "center",
gap: "12px",
minWidth: "300px",
maxWidth: "600px",
textAlign: "center",
animation: "slideDown 0.3s ease"
}
}, [
type === 'success' ? $el("span", {
textContent: "✓",
style: {
fontSize: "18px",
fontWeight: "bold"
}
}) : type === 'error' ? $el("span", {
textContent: "×",
style: {
fontSize: "18px",
fontWeight: "bold"
}
}) : null,
$el("span", {
textContent: message
}),
$el("button", {
textContent: "×",
onclick: () => {
if (notification.parentNode) {
notification.style.opacity = "0";
notification.style.transform = "translateX(-50%) translateY(-100%)";
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 300);
}
},
style: {
background: "none",
border: "none",
color: "#ffffff",
fontSize: "20px",
cursor: "pointer",
padding: "0",
marginLeft: "auto",
opacity: "0.8",
width: "24px",
height: "24px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "4px"
}
})
]);
// Add CSS animation if not already added
if (!document.getElementById('model-linker-notification-style')) {
const style = $el("style", {
id: 'model-linker-notification-style',
textContent: `
@keyframes slideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-100%);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
`
});
document.head.appendChild(style);
}
document.body.appendChild(notification);
// Auto-dismiss after 4 seconds for success, 6 seconds for errors
const dismissTime = type === 'success' ? 4000 : 6000;
setTimeout(() => {
if (notification.parentNode) {
notification.style.opacity = "0";
notification.style.transform = "translateX(-50%) translateY(-100%)";
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 300);
}
}, dismissTime);
}
/**
* Resolve a single model
*/
async resolveModel(missing, resolvedModel) {
if (!resolvedModel) {
this.showNotification('No resolved model selected', 'error');
return;
}
try {
const workflow = this.getCurrentWorkflow();
if (!workflow) {
this.showNotification('No workflow loaded', 'error');
return;
}
const resolution = {
node_id: missing.node_id,
widget_index: missing.widget_index,
resolved_path: resolvedModel.path,
category: missing.category,
resolved_model: resolvedModel,
subgraph_id: missing.subgraph_id, // Include subgraph_id for subgraph nodes
is_top_level: missing.is_top_level // True for top-level nodes, False for nodes in subgraph definitions
};
const response = await api.fetchApi('/model_linker/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflow,
resolutions: [resolution]
})
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
if (data.success) {
// Update workflow in ComfyUI
await this.updateWorkflowInComfyUI(data.workflow);
// Show success notification
const modelName = resolvedModel.relative_path || resolvedModel.filename || 'model';
this.showNotification(`✓ Model linked successfully: ${modelName}`, 'success');
// Reload dialog using the updated workflow from API response
// This ensures we're analyzing the correct updated workflow
await this.loadWorkflowData(data.workflow);
} else {
this.showNotification('Failed to resolve model: ' + (data.error || 'Unknown error'), 'error');
}
} catch (error) {
console.error('Model Linker: Error resolving model:', error);
this.showNotification('Error resolving model: ' + error.message, 'error');
}
}
/**
* Auto-resolve all 100% confidence matches
*/
async autoResolve100Percent() {
if (!this.contentElement) return;
try {
const workflow = this.getCurrentWorkflow();
if (!workflow) {
this.showNotification('No workflow loaded', 'error');
return;
}
// Analyze workflow first
const analyzeResponse = await api.fetchApi('/model_linker/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow })
});
if (!analyzeResponse.ok) {
throw new Error(`API error: ${analyzeResponse.status}`);
}
const analyzeData = await analyzeResponse.json();
const missingModels = analyzeData.missing_models || [];
// Collect all 100% matches
const resolutions = [];
for (const missing of missingModels) {
const matches = missing.matches || [];
const perfectMatch = matches.find((m) => m.confidence === 100);
if (perfectMatch && perfectMatch.model) {
resolutions.push({
node_id: missing.node_id,
widget_index: missing.widget_index,
resolved_path: perfectMatch.model.path,
category: missing.category,
resolved_model: perfectMatch.model,
subgraph_id: missing.subgraph_id, // Include subgraph_id for subgraph nodes
is_top_level: missing.is_top_level // True for top-level nodes, False for nodes in subgraph definitions
});
}
}
if (resolutions.length === 0) {
this.showNotification('No 100% confidence matches found to auto-resolve.', 'error');
return;
}
// Apply resolutions
const resolveResponse = await api.fetchApi('/model_linker/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflow,
resolutions
})
});
if (!resolveResponse.ok) {
throw new Error(`API error: ${resolveResponse.status}`);
}
const resolveData = await resolveResponse.json();
if (resolveData.success) {
// Update workflow in ComfyUI
await this.updateWorkflowInComfyUI(resolveData.workflow);
// Show success notification
this.showNotification(
`✓ Successfully linked ${resolutions.length} model${resolutions.length > 1 ? 's' : ''}!`,
'success'
);
// Reload dialog using the updated workflow from API response
// This ensures we're analyzing the correct updated workflow
await this.loadWorkflowData(resolveData.workflow);
} else {
this.showNotification('Failed to resolve models: ' + (resolveData.error || 'Unknown error'), 'error');
}
} catch (error) {
console.error('Model Linker: Error auto-resolving:', error);
this.showNotification('Error auto-resolving: ' + error.message, 'error');
}
}
/**
* Update workflow in ComfyUI's UI/memory
* Updates the current workflow in place instead of creating a new tab
*/
async updateWorkflowInComfyUI(workflow) {
if (!app || !app.graph) {
console.warn('Model Linker: Could not update workflow - app or app.graph not available');
return;
}
try {
// Method 1: Try to directly update the current graph using configure
// This is the most direct way to update in place
if (app.graph && typeof app.graph.configure === 'function') {
app.graph.configure(workflow);
return;
}
// Method 2: Try deserialize to update the graph in place
if (app.graph && typeof app.graph.deserialize === 'function') {
app.graph.deserialize(workflow);
return;
}
// Method 3: Use loadGraphData with explicit parameters to update current tab
// The key is to NOT create a new workflow - pass null or undefined for the workflow parameter
// clean=false means don't clear the graph first
// restore_view=false means don't restore the viewport
// workflow=null means update current workflow instead of creating new one
if (app.loadGraphData) {
// Try with null as 4th parameter first
await app.loadGraphData(workflow, false, false, null);
return;
}
console.warn('Model Linker: No method available to update workflow');
} catch (error) {
console.error('Model Linker: Error updating workflow in ComfyUI:', error);
// Don't throw - allow the workflow update to continue even if UI update fails
// The backend has already updated the workflow data
}
}
}
// Main extension class
class ModelLinker {
constructor() {
this.linkerButton = null;
this.buttonId = "model-linker-button";
this.dialog = null;
}
setup = () => {
// Remove any existing button
this.removeExistingButton();
// Find a visible menu element
const allMenus = document.querySelectorAll("[class*='menu']");
// Try to find a visible menu
let visibleMenu = null;
for (const menu of allMenus) {
const style = window.getComputedStyle(menu);
if (style.display !== 'none' && style.visibility !== 'hidden') {
visibleMenu = menu;
break;
}
}
// Try alternative: app.menu.settingsGroup
if (!visibleMenu && app.menu?.settingsGroup?.element) {
visibleMenu = app.menu.settingsGroup.element.parentElement;
}
// Try alternative selectors for the top bar
if (!visibleMenu) {
const alternatives = [
'header',
'.header',
'.top-bar',
'.toolbar',
'.nav',
'.navigation',
'[role="toolbar"]',
'[role="menubar"]'
];
for (const selector of alternatives) {
const element = document.querySelector(selector);
if (element) {
const style = window.getComputedStyle(element);
if (style.display !== 'none' && style.visibility !== 'hidden') {
visibleMenu = element;
break;
}
}
}
}
if (visibleMenu) {
this.createLinkerButton(visibleMenu);
} else {
this.createFloatingButton();
}
// Create dialog instance (will be created on demand)
if (!this.dialog) {
this.dialog = new LinkerManagerDialog();
}
}
removeExistingButton() {
// Remove any existing button by ID
const existingButton = document.getElementById(this.buttonId);
if (existingButton) {
existingButton.remove();
}
// Also remove the stored reference if it exists
if (this.linkerButton && this.linkerButton.parentNode) {
this.linkerButton.remove();
this.linkerButton = null;
}
}
createLinkerButton(menu) {
this.linkerButton = $el("button", {
id: this.buttonId,
textContent: "🔗 Model Linker",
title: "Open Model Linker to resolve missing models in workflow",
onclick: () => {
this.openLinkerManager();
},
style: {
backgroundColor: "var(--comfy-input-bg, #353535)",
color: "var(--input-text, #ffffff)",
border: "2px solid var(--border-color, #555555)",
padding: "8px 16px",
margin: "4px",
borderRadius: "6px",
cursor: "pointer",
fontSize: "14px",
fontWeight: "600",
display: "inline-block",
minWidth: "80px",
textAlign: "center",
zIndex: "1000",
position: "relative",
transition: "all 0.2s ease"
}
});
// Add hover effects
this.linkerButton.addEventListener("mouseenter", () => {
this.linkerButton.style.backgroundColor = "var(--comfy-input-bg-hover, #4a4a4a)";
this.linkerButton.style.borderColor = "var(--primary-color, #007acc)";
this.linkerButton.style.transform = "translateY(-1px)";
this.linkerButton.style.boxShadow = "0 2px 4px rgba(0,0,0,0.2)";
});
this.linkerButton.addEventListener("mouseleave", () => {
this.linkerButton.style.backgroundColor = "var(--comfy-input-bg, #353535)";
this.linkerButton.style.borderColor = "var(--border-color, #555555)";
this.linkerButton.style.transform = "translateY(0)";
this.linkerButton.style.boxShadow = "none";
});
// Try to insert before settings group if using app.menu
if (app.menu?.settingsGroup?.element && menu === app.menu.settingsGroup.element.parentElement) {
app.menu.settingsGroup.element.before(this.linkerButton);
} else {
menu.appendChild(this.linkerButton);
}
}
createFloatingButton() {
// Create a floating button as fallback
this.linkerButton = $el("button", {
id: this.buttonId,
textContent: "🔗 Model Linker",
title: "Open Model Linker to resolve missing models in workflow",
onclick: () => {
this.openLinkerManager();
},
style: {
position: "fixed",
top: "10px",
right: "10px",
zIndex: "10000",
backgroundColor: "var(--comfy-input-bg, #353535)",
color: "var(--input-text, #ffffff)",
border: "2px solid var(--primary-color, #007acc)",
padding: "8px 16px",
borderRadius: "6px",
cursor: "pointer",
fontSize: "14px",
fontWeight: "600",
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
transition: "all 0.2s ease"
}
});
// Add hover effects
this.linkerButton.addEventListener("mouseenter", () => {
this.linkerButton.style.backgroundColor = "var(--primary-color, #007acc)";
this.linkerButton.style.transform = "scale(1.05)";
});
this.linkerButton.addEventListener("mouseleave", () => {
this.linkerButton.style.backgroundColor = "var(--comfy-input-bg, #353535)";
this.linkerButton.style.transform = "scale(1)";
});
document.body.appendChild(this.linkerButton);
}
openLinkerManager() {
try {
if (!this.dialog) {
this.dialog = new LinkerManagerDialog();
}
this.dialog.show();
} catch (error) {
console.error("🔗 Model Linker: Error creating/showing dialog:", error);
alert("Error opening Model Linker: " + error.message);
}
}
}
const modelLinker = new ModelLinker();
// Register the extension
app.registerExtension({
name: "Model Linker",
setup: modelLinker.setup
});