Backend: add explicit URN handling to resolve CivitAI model/version into a direct download source; normalize and accept a `sources` parameter (local, huggingface, civitai, all) and drive per-source searching. Reorder and tighten search logic (local/popular, model-list, HuggingFace, CivitAI) and include `searched_sources` in results and improved logging. Frontend: add per-missing-item search result cache and state, UI buttons to pick search sources, merge incremental results instead of overwriting, show selected-source helper text, pass selected source to the server, clear cache on refresh, and improve search status messages and UX. Overall: enables targeted online searches, preserves previous results, and surfaces direct CivitAI downloads for URNs.
5196 lines
211 KiB
JavaScript
5196 lines
211 KiB
JavaScript
/**
|
||
* 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";
|
||
|
||
class LinkerManagerDialog extends ComfyDialog {
|
||
constructor() {
|
||
super();
|
||
this.currentWorkflow = null;
|
||
this.missingModels = [];
|
||
this.allModels = null; // list of all available models for dropdown
|
||
this.pendingResolutions = [];
|
||
this.pendingIndex = new Map(); // key -> index in pendingResolutions
|
||
this.activeDownloads = {}; // Track active downloads
|
||
this.searchResultCache = new Map();
|
||
this.boundHandleOutsideClick = this.handleOutsideClick.bind(this);
|
||
this.activeTab = 'missing'; // Default tab
|
||
this.fullscreen = false;
|
||
this._dragging = false;
|
||
this._dragStart = null;
|
||
|
||
// Inject global styles for the redesigned UI
|
||
this.injectStyles();
|
||
|
||
// Create backdrop overlay for click-outside-to-close
|
||
this.backdrop = $el("div.model-linker-backdrop", {
|
||
parent: document.body,
|
||
style: {
|
||
position: "fixed",
|
||
top: "0",
|
||
left: "0",
|
||
width: "100vw",
|
||
height: "100vh",
|
||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||
zIndex: "99998",
|
||
display: "none"
|
||
}
|
||
});
|
||
|
||
// Create context menu for model chips
|
||
this.contextMenu = $el("div.ml-context-menu", {
|
||
parent: document.body,
|
||
style: {
|
||
position: "fixed",
|
||
display: "none",
|
||
zIndex: "100001"
|
||
}
|
||
}, [
|
||
$el("div.ml-context-menu-item", {
|
||
onclick: () => this.handleContextMenuAction('showInfo'),
|
||
style: { cursor: "pointer" }
|
||
}, [
|
||
$el("span.ml-context-menu-item-icon", { textContent: "ℹ" }),
|
||
$el("span", { textContent: "Show Info" })
|
||
]),
|
||
$el("div.ml-context-menu-divider"),
|
||
$el("div.ml-context-menu-item", {
|
||
onclick: () => this.handleContextMenuAction('civitai'),
|
||
style: { cursor: "pointer" }
|
||
}, [
|
||
$el("span.ml-context-menu-item-icon", { textContent: "🌐" }),
|
||
$el("span", { textContent: "Open in CivitAI" })
|
||
])
|
||
]);
|
||
|
||
// Selected model for context menu
|
||
this._contextMenuModel = null;
|
||
|
||
// Create dialog element using $el
|
||
this.element = $el("div.comfy-modal", {
|
||
id: "model-linker-modal",
|
||
parent: document.body,
|
||
style: {
|
||
position: "fixed",
|
||
top: "50%",
|
||
left: "50%",
|
||
transform: "translate(-50%, -50%)",
|
||
width: "1100px",
|
||
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",
|
||
resize: "both",
|
||
overflow: "hidden",
|
||
minWidth: "640px",
|
||
minHeight: "420px"
|
||
}
|
||
}, [
|
||
this.createHeader(),
|
||
this.createContent(),
|
||
this.createFooter()
|
||
]);
|
||
|
||
// Add click listener to backdrop
|
||
this.backdrop.addEventListener('click', () => this.close());
|
||
|
||
// Add click listener to hide context menu when clicking outside
|
||
this.boundHandleContextMenuClick = (e) => this.handleContextMenuOutsideClick(e);
|
||
document.addEventListener('click', this.boundHandleContextMenuClick);
|
||
}
|
||
|
||
/**
|
||
* Build stable cache key for a missing model entry
|
||
*/
|
||
getMissingSearchKey(missing) {
|
||
return `${missing.node_id}:${missing.widget_index}`;
|
||
}
|
||
|
||
/**
|
||
* Get or initialize search state for a missing model entry
|
||
*/
|
||
getSearchState(missing) {
|
||
const key = this.getMissingSearchKey(missing);
|
||
if (!this.searchResultCache.has(key)) {
|
||
this.searchResultCache.set(key, {
|
||
selectedSource: 'all',
|
||
results: {
|
||
popular: null,
|
||
model_list: null,
|
||
huggingface: null,
|
||
civitai: null
|
||
},
|
||
lastAttemptSources: [],
|
||
lastAttemptFound: null
|
||
});
|
||
}
|
||
return this.searchResultCache.get(key);
|
||
}
|
||
|
||
/**
|
||
* Merge new search results into cached per-source results.
|
||
* Empty responses do not delete previously found results.
|
||
*/
|
||
mergeSearchResults(existingResults = {}, newResults = {}) {
|
||
return {
|
||
popular: newResults.popular || existingResults.popular || null,
|
||
model_list: newResults.model_list || existingResults.model_list || null,
|
||
huggingface: newResults.huggingface || existingResults.huggingface || null,
|
||
civitai: newResults.civitai || existingResults.civitai || null
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Return true when at least one downloadable source was found
|
||
*/
|
||
hasSearchResults(data = {}) {
|
||
return !!(data.popular || data.model_list || data.huggingface || data.civitai);
|
||
}
|
||
|
||
/**
|
||
* Convert source ids to readable labels
|
||
*/
|
||
getSearchSourceLabel(source) {
|
||
const labels = {
|
||
all: 'Everything',
|
||
local: 'Local Database',
|
||
huggingface: 'HuggingFace',
|
||
civitai: 'CivitAI'
|
||
};
|
||
return labels[source] || source;
|
||
}
|
||
|
||
/**
|
||
* Update source selector buttons and helper text for one card
|
||
*/
|
||
syncSearchSourceUi(missing, container) {
|
||
if (!container) return;
|
||
|
||
const state = this.getSearchState(missing);
|
||
const buttons = container.querySelectorAll('.ml-search-source-btn');
|
||
buttons.forEach(btn => {
|
||
const isActive = btn.dataset.searchSource === state.selectedSource;
|
||
btn.classList.toggle('ml-btn-primary', isActive);
|
||
btn.classList.toggle('ml-btn-secondary', !isActive);
|
||
});
|
||
|
||
const infoEl = container.querySelector(`#search-source-info-${missing.node_id}-${missing.widget_index}`);
|
||
if (infoEl) {
|
||
infoEl.textContent = `Selected source: ${this.getSearchSourceLabel(state.selectedSource)}`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set current search source for one card
|
||
*/
|
||
setSearchSource(missing, source, container) {
|
||
const state = this.getSearchState(missing);
|
||
state.selectedSource = source || 'all';
|
||
this.syncSearchSourceUi(missing, container);
|
||
}
|
||
|
||
/**
|
||
* Handle click outside context menu to hide it
|
||
*/
|
||
handleContextMenuOutsideClick(e) {
|
||
if (!this.contextMenu) return;
|
||
if (this.contextMenu.style.display === 'none') return;
|
||
|
||
// Check if click is outside the context menu
|
||
if (!this.contextMenu.contains(e.target)) {
|
||
this.hideContextMenu();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show context menu at the specified position
|
||
*/
|
||
showContextMenu(x, y, model) {
|
||
if (!this.contextMenu) return;
|
||
|
||
this._contextMenuModel = model;
|
||
|
||
// Position the menu
|
||
this.contextMenu.style.left = `${x}px`;
|
||
this.contextMenu.style.top = `${y}px`;
|
||
this.contextMenu.style.display = 'block';
|
||
|
||
// Adjust position if menu would go off screen
|
||
const rect = this.contextMenu.getBoundingClientRect();
|
||
if (rect.right > window.innerWidth) {
|
||
this.contextMenu.style.left = `${window.innerWidth - rect.width - 10}px`;
|
||
}
|
||
if (rect.bottom > window.innerHeight) {
|
||
this.contextMenu.style.top = `${window.innerHeight - rect.height - 10}px`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Hide context menu
|
||
*/
|
||
hideContextMenu() {
|
||
if (!this.contextMenu) return;
|
||
this.contextMenu.style.display = 'none';
|
||
this._contextMenuModel = null;
|
||
}
|
||
|
||
/**
|
||
* Handle context menu item click
|
||
*/
|
||
handleContextMenuAction(action) {
|
||
const model = this._contextMenuModel;
|
||
this.hideContextMenu();
|
||
|
||
if (!model) return;
|
||
|
||
if (action === 'civitai') {
|
||
this.openInCivitAI(model);
|
||
} else if (action === 'showInfo') {
|
||
this.showModelInfo(model);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Open model in CivitAI
|
||
*/
|
||
async openInCivitAI(model) {
|
||
if (!model) return;
|
||
|
||
const name = model.name || model.original_path?.split(/[\/\\]/).pop() || '';
|
||
if (!name) return;
|
||
|
||
try {
|
||
// Search CivitAI for this model using hash (pass resolved_path for hash lookup)
|
||
const response = await api.fetchApi('/model_linker/civitai-search', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
filename: name,
|
||
category: model.category,
|
||
resolved_path: model.resolved_path || ''
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
this.showNotification('Nie znaleziono modelu na CivitAI', 'error');
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
if (data.url) {
|
||
window.open(data.url, '_blank');
|
||
} else {
|
||
// Try direct search on CivitAI
|
||
const searchName = name.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
|
||
const searchUrl = `https://civitai.com/search?q=${encodeURIComponent(searchName)}`;
|
||
window.open(searchUrl, '_blank');
|
||
}
|
||
} catch (e) {
|
||
console.error('Model Linker: Error searching CivitAI:', e);
|
||
// Fall back to direct search
|
||
const searchName = name.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
|
||
const searchUrl = `https://civitai.com/search?q=${encodeURIComponent(searchName)}`;
|
||
window.open(searchUrl, '_blank');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show model info dialog (similar to rgthree's RgthreeLoraInfoDialog)
|
||
*/
|
||
async showModelInfo(model) {
|
||
if (!model) return;
|
||
|
||
const name = model.name || model.original_path?.split(/[\/\\]/).pop() || '';
|
||
if (!name) return;
|
||
|
||
// Create and show the info dialog
|
||
this.showModelInfoDialog(name, model);
|
||
}
|
||
|
||
/**
|
||
* Show the model info dialog
|
||
*/
|
||
showModelInfoDialog(loraName, modelData) {
|
||
// Create info dialog element
|
||
const dialog = this.createInfoDialog(loraName, modelData);
|
||
|
||
// Show the dialog
|
||
document.body.appendChild(dialog);
|
||
|
||
// Add close handlers
|
||
const closeBtn = dialog.querySelector('.ml-info-dialog-close');
|
||
const footerCloseBtn = dialog.querySelector('.ml-info-dialog-close-btn');
|
||
const backdrop = dialog.querySelector('.ml-info-dialog-backdrop');
|
||
|
||
if (closeBtn) {
|
||
closeBtn.addEventListener('click', () => this.closeInfoDialog(dialog));
|
||
}
|
||
if (footerCloseBtn) {
|
||
footerCloseBtn.addEventListener('click', () => this.closeInfoDialog(dialog));
|
||
}
|
||
if (backdrop) {
|
||
backdrop.addEventListener('click', (e) => {
|
||
// Only close if clicking backdrop itself, not its children
|
||
if (e.target === backdrop) {
|
||
this.closeInfoDialog(dialog);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Fetch CivitAI info
|
||
this.fetchModelInfoForDialog(loraName, modelData, dialog);
|
||
}
|
||
|
||
/**
|
||
* Create the info dialog element
|
||
*/
|
||
createInfoDialog(loraName, modelData) {
|
||
const loraDisplayName = loraName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
|
||
|
||
const dialog = document.createElement('div');
|
||
dialog.className = 'ml-info-dialog-backdrop';
|
||
dialog.innerHTML = `
|
||
<div class="ml-info-dialog">
|
||
<div class="ml-info-dialog-header">
|
||
<h3 class="ml-info-dialog-title">${loraDisplayName}</h3>
|
||
<button class="ml-info-dialog-close">×</button>
|
||
</div>
|
||
<div class="ml-info-dialog-content">
|
||
<div class="ml-info-dialog-loading">Loading...</div>
|
||
<div class="ml-info-dialog-body" style="display: none;">
|
||
<div class="ml-info-area">
|
||
<span class="ml-info-tag ml-info-type"></span>
|
||
<span class="ml-info-tag ml-info-basemodel"></span>
|
||
</div>
|
||
<table class="ml-info-table">
|
||
<tbody>
|
||
<tr class="ml-info-file-row">
|
||
<td><span>File <span class="ml-info-help" title=""></span></span></td>
|
||
<td><span class="ml-info-file"></span></td>
|
||
</tr>
|
||
<tr class="ml-info-hash-row">
|
||
<td><span>Hash (sha256) <span class="ml-info-help" title=""></span></span></td>
|
||
<td><span class="ml-info-hash"></span></td>
|
||
</tr>
|
||
<tr class="ml-info-civitai-row">
|
||
<td><span>CivitAI <span class="ml-info-help" title=""></span></span></td>
|
||
<td><span class="ml-info-civitai-link"></span></td>
|
||
</tr>
|
||
<tr class="ml-info-name-row">
|
||
<td><span>Name <span class="ml-info-help" title="The name for display."></span></span></td>
|
||
<td><span class="ml-info-name"></span></td>
|
||
</tr>
|
||
<tr class="ml-info-basemodel-row">
|
||
<td><span>Base Model <span class="ml-info-help" title=""></span></span></td>
|
||
<td><span class="ml-info-base-model"></span></td>
|
||
</tr>
|
||
<tr class="ml-info-trainedwords-row" style="display: none;">
|
||
<td><span>Trained Words <span class="ml-info-help" title="Trigger words/phrases for this Lora."></span></span></td>
|
||
<td><span class="ml-info-trained-words"></span></td>
|
||
</tr>
|
||
<tr class="ml-info-clipskip-row" style="display: none;">
|
||
<td><span>Clip Skip <span class="ml-info-help" title="Recommended clip skip value."></span></span></td>
|
||
<td><span class="ml-info-clip-skip"></span></td>
|
||
</tr>
|
||
<tr class="ml-info-description-row" style="display: none;">
|
||
<td><span>Description <span class="ml-info-help" title="Model description from CivitAI."></span></span></td>
|
||
<td><span class="ml-info-description"></span></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<div class="ml-info-images"></div>
|
||
</div>
|
||
</div>
|
||
<div class="ml-info-dialog-footer">
|
||
<button class="ml-btn ml-btn-secondary ml-info-dialog-close-btn">Close</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
return dialog;
|
||
}
|
||
|
||
/**
|
||
* Fetch model info and update the dialog
|
||
*/
|
||
async fetchModelInfoForDialog(loraName, modelData, dialog) {
|
||
try {
|
||
const response = await api.fetchApi('/model_linker/civitai-search', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
filename: loraName,
|
||
category: modelData?.category || '',
|
||
resolved_path: modelData?.resolved_path || ''
|
||
})
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
this.updateInfoDialogWithData(dialog, data);
|
||
} else {
|
||
this.updateInfoDialogError(dialog, 'Model not found on CivitAI');
|
||
}
|
||
} catch (e) {
|
||
console.error('Model Linker: Error fetching model info:', e);
|
||
this.updateInfoDialogError(dialog, 'Error fetching info');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update the info dialog with data
|
||
*/
|
||
updateInfoDialogWithData(dialog, data) {
|
||
const loadingDiv = dialog.querySelector('.ml-info-dialog-loading');
|
||
const bodyDiv = dialog.querySelector('.ml-info-dialog-body');
|
||
|
||
if (loadingDiv) loadingDiv.style.display = 'none';
|
||
if (bodyDiv) bodyDiv.style.display = 'block';
|
||
|
||
if (!data) {
|
||
this.updateInfoDialogError(dialog, 'No data received');
|
||
return;
|
||
}
|
||
|
||
// Update title
|
||
const titleEl = dialog.querySelector('.ml-info-dialog-title');
|
||
if (titleEl) {
|
||
const modelName = data.model_name || data.modelName || 'Unknown Model';
|
||
const versionName = data.version_name || data.versionName || '';
|
||
titleEl.textContent = versionName ? `${modelName} - ${versionName}` : modelName;
|
||
}
|
||
|
||
// Update type tag
|
||
const typeTag = dialog.querySelector('.ml-info-type');
|
||
if (typeTag) {
|
||
const modelType = data.model_type || data.modelType || '';
|
||
typeTag.textContent = modelType.toUpperCase();
|
||
typeTag.className = `ml-info-tag ml-info-type -type-${modelType.toLowerCase()}`;
|
||
}
|
||
|
||
// Update base model tag
|
||
const baseModelTag = dialog.querySelector('.ml-info-basemodel');
|
||
if (baseModelTag) {
|
||
const baseModel = data.base_model || data.baseModel || '';
|
||
baseModelTag.textContent = baseModel || '';
|
||
if (baseModel) {
|
||
baseModelTag.style.display = '';
|
||
baseModelTag.className = `ml-info-tag ml-info-basemodel -basemodel-${baseModel.toLowerCase().replace(/\s+/g, '-')}`;
|
||
} else {
|
||
baseModelTag.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Update file
|
||
const fileEl = dialog.querySelector('.ml-info-file');
|
||
if (fileEl && data.filename) {
|
||
fileEl.textContent = data.filename;
|
||
}
|
||
|
||
// Update hash
|
||
const hashEl = dialog.querySelector('.ml-info-hash');
|
||
if (hashEl) {
|
||
hashEl.textContent = data.sha256 || data.hash || '';
|
||
}
|
||
|
||
// Update CivitAI link
|
||
const civitaiLinkEl = dialog.querySelector('.ml-info-civitai-link');
|
||
if (civitaiLinkEl) {
|
||
if (data.url || data.version_url) {
|
||
const url = data.version_url || data.url;
|
||
civitaiLinkEl.innerHTML = `
|
||
<a href="${url}" target="_blank" class="ml-info-link">
|
||
<svg viewBox="0 0 178 178" class="ml-info-civitai-logo">
|
||
<defs>
|
||
<linearGradient id="bgblue" gradientUnits="userSpaceOnUse" x1="89.3" y1="-665.5" x2="89.3" y2="-841.1" gradientTransform="matrix(1 0 0 -1 0 -664)">
|
||
<stop offset="0" style="stop-color:#1284F7"></stop>
|
||
<stop offset="1" style="stop-color:#0A20C9"></stop>
|
||
</linearGradient>
|
||
</defs>
|
||
<path fill="#000" d="M13.3,45.4v87.7l76,43.9l76-43.9V45.4l-76-43.9L13.3,45.4z"></path>
|
||
<path style="fill:url(#bgblue);" d="M89.3,29.2l52,30v60l-52,30l-52-30v-60L89.3,29.2 M89.3,1.5l-76,43.9v87.8l76,43.9l76-43.9V45.4L89.3,1.5z"></path>
|
||
<path fill="#FFF" d="M104.1,97.2l-14.9,8.5l-14.9-8.5v-17l14.9-8.5l14.9,8.5h18.2V69.7l-33-19l-33,19v38.1l33,19l33-19V97.2H104.1z"></path>
|
||
</svg>
|
||
View on Civitai
|
||
</a>
|
||
`;
|
||
} else {
|
||
const searchName = data.model_name || data.modelName || 'Unknown';
|
||
civitaiLinkEl.innerHTML = `
|
||
<span class="ml-info-not-found">Model not found</span>
|
||
<a href="https://civitai.com/search?q=${encodeURIComponent(searchName)}" target="_blank" class="ml-info-link">
|
||
🔍 Search on CivitAI
|
||
</a>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// Update name
|
||
const nameEl = dialog.querySelector('.ml-info-name');
|
||
if (nameEl) {
|
||
nameEl.textContent = data.model_name || data.modelName || '';
|
||
}
|
||
|
||
// Update base model row
|
||
const baseModelRowEl = dialog.querySelector('.ml-info-base-model');
|
||
if (baseModelRowEl) {
|
||
const baseModel = data.base_model || data.baseModel || '';
|
||
baseModelRowEl.textContent = baseModel;
|
||
const row = baseModelRowEl.closest('tr');
|
||
if (row && baseModel) {
|
||
row.style.display = '';
|
||
} else if (row) {
|
||
row.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Update trained words
|
||
const trainedWordsEl = dialog.querySelector('.ml-info-trained-words');
|
||
if (trainedWordsEl) {
|
||
const words = data.trained_words || data.trainedWords || [];
|
||
if (Array.isArray(words) && words.length > 0) {
|
||
trainedWordsEl.textContent = words.join(', ');
|
||
const row = trainedWordsEl.closest('tr');
|
||
if (row) row.style.display = '';
|
||
} else {
|
||
const row = trainedWordsEl.closest('tr');
|
||
if (row) row.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Update clip skip
|
||
const clipSkipEl = dialog.querySelector('.ml-info-clip-skip');
|
||
if (clipSkipEl) {
|
||
const clipSkip = data.clip_skip || data.clipSkip;
|
||
if (clipSkip && clipSkip !== 'None') {
|
||
clipSkipEl.textContent = clipSkip;
|
||
const row = clipSkipEl.closest('tr');
|
||
if (row) row.style.display = '';
|
||
} else {
|
||
const row = clipSkipEl.closest('tr');
|
||
if (row) row.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Update description
|
||
const descEl = dialog.querySelector('.ml-info-description');
|
||
if (descEl) {
|
||
const desc = data.description || data.model_description || data.modelDescription || '';
|
||
if (desc) {
|
||
descEl.textContent = desc.substring(0, 200) + (desc.length > 200 ? '...' : '');
|
||
const row = descEl.closest('tr');
|
||
if (row) row.style.display = '';
|
||
} else {
|
||
const row = descEl.closest('tr');
|
||
if (row) row.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Update images
|
||
this.updateInfoDialogImages(dialog, data.images || data.modelImages || []);
|
||
}
|
||
|
||
/**
|
||
* Update images in the info dialog
|
||
*/
|
||
updateInfoDialogImages(dialog, images) {
|
||
const imagesContainer = dialog.querySelector('.ml-info-images');
|
||
if (!imagesContainer || !images.length) return;
|
||
|
||
let imagesHtml = '<div class="ml-info-images-header">Example Images</div><div class="ml-info-images-grid">';
|
||
|
||
for (const img of images.slice(0, 6)) {
|
||
if (!img.url) continue;
|
||
|
||
let caption = '';
|
||
if (img.civitaiUrl) {
|
||
caption += `<a href="${img.civitaiUrl}" target="_blank" class="ml-info-image-link">civitai</a>`;
|
||
}
|
||
if (img.seed) caption += `<span><label>seed</label> ${img.seed}</span>`;
|
||
if (img.steps) caption += `<span><label>steps</label> ${img.steps}</span>`;
|
||
if (img.cfg) caption += `<span><label>cfg</label> ${img.cfg}</span>`;
|
||
if (img.sampler) caption += `<span><label>sampler</label> ${img.sampler}</span>`;
|
||
if (img.positive) caption += `<span><label>positive</label> ${img.positive.substring(0, 100)}${img.positive.length > 100 ? '...' : ''}</span>`;
|
||
|
||
imagesHtml += `
|
||
<div class="ml-info-image-item">
|
||
<figure>
|
||
<img src="${img.url}" alt="Example" loading="lazy" />
|
||
<figcaption>${caption}</figcaption>
|
||
</figure>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
imagesHtml += '</div>';
|
||
imagesContainer.innerHTML = imagesHtml;
|
||
}
|
||
|
||
/**
|
||
* Update the info dialog with error
|
||
*/
|
||
updateInfoDialogError(dialog, message) {
|
||
const civitaiLink = dialog.querySelector('.ml-info-civitai-link');
|
||
if (civitaiLink) {
|
||
civitaiLink.innerHTML = `<span class="ml-info-error">${message}</span>`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Close the info dialog
|
||
*/
|
||
closeInfoDialog(dialog) {
|
||
if (dialog && dialog.parentNode) {
|
||
dialog.parentNode.removeChild(dialog);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Inject global CSS styles for the Model Linker UI
|
||
*/
|
||
injectStyles() {
|
||
// Only inject once
|
||
if (document.getElementById('model-linker-styles')) return;
|
||
|
||
const styles = document.createElement('style');
|
||
styles.id = 'model-linker-styles';
|
||
styles.textContent = `
|
||
/* CSS Variables for Model Linker */
|
||
:root {
|
||
--ml-bg: #222;
|
||
--ml-card-bg: #2a2a2a;
|
||
--ml-card-bg-alt: #252525;
|
||
--ml-border: #3a3a3a;
|
||
--ml-text: #e0e0e0;
|
||
--ml-text-muted: #888;
|
||
--ml-text-dim: #666;
|
||
--ml-accent: #4CAF50;
|
||
--ml-accent-hover: #45a049;
|
||
--ml-confidence-high: #4CAF50;
|
||
--ml-confidence-medium: #FFC107;
|
||
--ml-confidence-low: #f44336;
|
||
--ml-link-color: #8ab4f8;
|
||
}
|
||
|
||
/* Card Styles */
|
||
.ml-card {
|
||
background: var(--ml-card-bg);
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
margin-bottom: 12px;
|
||
transition: background 0.2s ease;
|
||
}
|
||
.ml-card:nth-child(even) {
|
||
background: var(--ml-card-bg-alt);
|
||
}
|
||
.ml-card:hover {
|
||
background: #303030;
|
||
}
|
||
|
||
/* Card Header */
|
||
.ml-card-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.ml-card-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--ml-text);
|
||
margin: 0;
|
||
word-break: break-word;
|
||
flex: 1;
|
||
}
|
||
.ml-node-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 3px 8px;
|
||
background: #3a3a3a;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
color: var(--ml-text-muted);
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
.ml-category-chip {
|
||
display: inline-flex;
|
||
padding: 2px 6px;
|
||
background: #444;
|
||
border-radius: 3px;
|
||
font-size: 10px;
|
||
color: var(--ml-text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
/* Two-Column Layout */
|
||
.ml-columns {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 16px;
|
||
}
|
||
@media (max-width: 700px) {
|
||
.ml-columns {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
.ml-column {
|
||
min-width: 0;
|
||
}
|
||
.ml-column-header {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--ml-text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-bottom: 8px;
|
||
padding-bottom: 6px;
|
||
border-bottom: 1px solid var(--ml-border);
|
||
}
|
||
|
||
/* Filename Chips */
|
||
.ml-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 4px 10px;
|
||
background: #3a3a3a;
|
||
border-radius: 6px;
|
||
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
|
||
font-size: 12px;
|
||
color: var(--ml-text);
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.ml-chip:hover {
|
||
background: #444;
|
||
}
|
||
|
||
/* Confidence Badges */
|
||
.ml-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 2px 8px;
|
||
border-radius: 12px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: white;
|
||
min-width: 42px;
|
||
text-align: center;
|
||
}
|
||
.ml-badge-high {
|
||
background: var(--ml-confidence-high);
|
||
}
|
||
.ml-badge-medium {
|
||
background: var(--ml-confidence-medium);
|
||
color: #333;
|
||
}
|
||
.ml-badge-low {
|
||
background: var(--ml-confidence-low);
|
||
}
|
||
|
||
/* Match Row */
|
||
.ml-match-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px;
|
||
border-radius: 6px;
|
||
margin-bottom: 6px;
|
||
background: rgba(255,255,255,0.02);
|
||
transition: background 0.15s ease;
|
||
}
|
||
.ml-match-row:hover {
|
||
background: rgba(255,255,255,0.05);
|
||
}
|
||
.ml-match-row.ml-best-match {
|
||
background: rgba(76, 175, 80, 0.1);
|
||
border-left: 3px solid var(--ml-confidence-high);
|
||
padding-left: 10px;
|
||
}
|
||
.ml-match-filename {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
|
||
font-size: 12px;
|
||
color: var(--ml-text);
|
||
}
|
||
|
||
/* Buttons */
|
||
.ml-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
padding: 6px 12px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.15s ease;
|
||
white-space: nowrap;
|
||
}
|
||
.ml-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
.ml-btn-primary {
|
||
background: var(--ml-accent);
|
||
color: white;
|
||
}
|
||
.ml-btn-primary:hover:not(:disabled) {
|
||
background: var(--ml-accent-hover);
|
||
}
|
||
.ml-btn-secondary {
|
||
background: transparent;
|
||
color: var(--ml-text);
|
||
border: 1px solid var(--ml-border);
|
||
}
|
||
.ml-btn-secondary:hover:not(:disabled) {
|
||
background: rgba(255,255,255,0.05);
|
||
border-color: #555;
|
||
}
|
||
.ml-btn-link {
|
||
background: #2196F3;
|
||
color: white;
|
||
}
|
||
.ml-btn-link:hover:not(:disabled) {
|
||
background: #1976D2;
|
||
}
|
||
.ml-btn-download {
|
||
background: var(--ml-accent);
|
||
color: white;
|
||
}
|
||
.ml-btn-download:hover:not(:disabled) {
|
||
background: var(--ml-accent-hover);
|
||
}
|
||
.ml-btn-danger {
|
||
background: #f44336;
|
||
color: white;
|
||
}
|
||
.ml-btn-danger:hover:not(:disabled) {
|
||
background: #d32f2f;
|
||
}
|
||
.ml-btn-sm {
|
||
padding: 4px 8px;
|
||
font-size: 11px;
|
||
}
|
||
.ml-btn-icon {
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* Download Section */
|
||
.ml-download-section {
|
||
padding: 12px;
|
||
background: rgba(76, 175, 80, 0.05);
|
||
border-radius: 6px;
|
||
border: 1px dashed var(--ml-border);
|
||
}
|
||
.ml-download-info {
|
||
font-size: 12px;
|
||
color: var(--ml-text-muted);
|
||
margin-top: 6px;
|
||
}
|
||
.ml-download-source {
|
||
color: var(--ml-accent);
|
||
font-weight: 500;
|
||
}
|
||
.ml-download-size {
|
||
color: var(--ml-text-dim);
|
||
margin-left: 8px;
|
||
}
|
||
|
||
/* Status Messages */
|
||
.ml-status {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
padding: 10px 14px;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
margin-top: 8px;
|
||
}
|
||
.ml-status-icon {
|
||
font-size: 16px;
|
||
flex-shrink: 0;
|
||
}
|
||
.ml-status-error {
|
||
background: rgba(244, 67, 54, 0.1);
|
||
border: 1px solid rgba(244, 67, 54, 0.3);
|
||
color: #ef9a9a;
|
||
}
|
||
.ml-status-success {
|
||
background: rgba(76, 175, 80, 0.1);
|
||
border: 1px solid rgba(76, 175, 80, 0.3);
|
||
color: #a5d6a7;
|
||
}
|
||
.ml-status-info {
|
||
background: rgba(33, 150, 243, 0.1);
|
||
border: 1px solid rgba(33, 150, 243, 0.3);
|
||
color: #90caf9;
|
||
}
|
||
.ml-status-warning {
|
||
background: rgba(255, 152, 0, 0.1);
|
||
border: 1px solid rgba(255, 152, 0, 0.3);
|
||
color: #ffcc80;
|
||
}
|
||
|
||
/* Progress Bar */
|
||
.ml-progress-container {
|
||
margin-top: 8px;
|
||
}
|
||
.ml-progress-bar {
|
||
height: 6px;
|
||
background: #333;
|
||
border-radius: 3px;
|
||
overflow: hidden;
|
||
}
|
||
.ml-progress-fill {
|
||
height: 100%;
|
||
background: var(--ml-accent);
|
||
transition: width 0.3s ease;
|
||
}
|
||
.ml-progress-text {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-top: 6px;
|
||
font-size: 11px;
|
||
color: var(--ml-text-muted);
|
||
}
|
||
|
||
/* Scrollbar */
|
||
.ml-scrollable {
|
||
overflow-y: auto;
|
||
}
|
||
.ml-scrollable::-webkit-scrollbar {
|
||
width: 8px;
|
||
}
|
||
.ml-scrollable::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
.ml-scrollable::-webkit-scrollbar-thumb {
|
||
background: #444;
|
||
border-radius: 4px;
|
||
}
|
||
.ml-scrollable::-webkit-scrollbar-thumb:hover {
|
||
background: #555;
|
||
}
|
||
|
||
/* Footer */
|
||
.ml-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 16px 20px;
|
||
background: linear-gradient(to top, var(--ml-bg) 0%, var(--ml-bg) 70%, transparent 100%);
|
||
border-top: 1px solid var(--ml-border);
|
||
}
|
||
|
||
/* Tabs */
|
||
.ml-tabs {
|
||
display: flex;
|
||
gap: 4px;
|
||
padding: 0 20px;
|
||
margin-bottom: 0;
|
||
background-color: var(--ml-bg, #222);
|
||
border-bottom: 1px solid var(--ml-border);
|
||
}
|
||
.ml-tab {
|
||
padding: 12px 20px;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--ml-text-muted, #888);
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
transition: all 0.2s ease;
|
||
}
|
||
.ml-tab:hover {
|
||
color: var(--ml-text, #e0e0e0);
|
||
background: rgba(255,255,255,0.05);
|
||
}
|
||
.ml-tab.ml-tab-active {
|
||
color: var(--ml-accent, #4CAF50);
|
||
border-bottom-color: var(--ml-accent, #4CAF50);
|
||
}
|
||
|
||
/* Loaded Models Tab */
|
||
.ml-loaded-models {
|
||
padding: 0;
|
||
}
|
||
.ml-loaded-models-header {
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--ml-border);
|
||
}
|
||
.ml-loaded-models-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--ml-text);
|
||
margin: 0 0 8px 0;
|
||
}
|
||
.ml-loaded-models-subtitle {
|
||
font-size: 12px;
|
||
color: var(--ml-text-muted);
|
||
}
|
||
.ml-models-list {
|
||
padding: 12px 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.ml-model-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 6px 12px;
|
||
background: #3a3a3a;
|
||
border-radius: 6px;
|
||
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
|
||
font-size: 12px;
|
||
color: var(--ml-text);
|
||
}
|
||
.ml-model-chip-category {
|
||
padding: 2px 6px;
|
||
background: #555;
|
||
border-radius: 4px;
|
||
font-size: 10px;
|
||
color: #aaa;
|
||
margin-left: 8px;
|
||
text-transform: uppercase;
|
||
}
|
||
.ml-model-chip-strength {
|
||
padding: 2px 6px;
|
||
background: #4CAF50;
|
||
border-radius: 4px;
|
||
font-size: 10px;
|
||
color: white;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
/* No matches text */
|
||
.ml-no-matches {
|
||
color: var(--ml-text-muted);
|
||
font-size: 12px;
|
||
font-style: italic;
|
||
padding: 8px 0;
|
||
}
|
||
|
||
/* Link styling */
|
||
.ml-link {
|
||
color: var(--ml-link-color);
|
||
text-decoration: none;
|
||
}
|
||
.ml-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
/* Context menu */
|
||
.ml-context-menu {
|
||
position: fixed;
|
||
background: var(--comfy-menu-bg, #202020);
|
||
border: 1px solid var(--border-color, #555555);
|
||
border-radius: 6px;
|
||
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||
z-index: 100001;
|
||
min-width: 160px;
|
||
overflow: hidden;
|
||
}
|
||
.ml-context-menu-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
font-size: 12px;
|
||
color: var(--input-text, #e0e0e0);
|
||
cursor: pointer;
|
||
transition: background 0.15s ease;
|
||
}
|
||
.ml-context-menu-item:hover {
|
||
background: rgba(255,255,255,0.1);
|
||
}
|
||
.ml-context-menu-item-icon {
|
||
font-size: 14px;
|
||
width: 20px;
|
||
text-align: center;
|
||
}
|
||
.ml-context-menu-divider {
|
||
height: 1px;
|
||
background: var(--border-color, #555555);
|
||
margin: 4px 0;
|
||
}
|
||
|
||
/* Model Info Dialog (similar to rgthree's RgthreeLoraInfoDialog) */
|
||
.ml-info-dialog-backdrop {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100vw;
|
||
height: 100vh;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
z-index: 100002;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.ml-info-dialog {
|
||
background: var(--comfy-menu-bg, #202020);
|
||
border: 2px solid var(--border-color, #555555);
|
||
border-radius: 8px;
|
||
width: 500px;
|
||
max-width: 90vw;
|
||
max-height: 80vh;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||
}
|
||
.ml-info-dialog-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--border-color, #555555);
|
||
background: rgba(0,0,0,0.2);
|
||
}
|
||
.ml-info-dialog-header h3 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
color: var(--input-text, #e0e0e0);
|
||
}
|
||
.ml-info-dialog-close {
|
||
background: none;
|
||
border: none;
|
||
font-size: 24px;
|
||
color: var(--input-text, #e0e0e0);
|
||
cursor: pointer;
|
||
padding: 0;
|
||
width: 30px;
|
||
height: 30px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 4px;
|
||
}
|
||
.ml-info-dialog-close:hover {
|
||
background: rgba(255,255,255,0.1);
|
||
}
|
||
.ml-info-dialog-content {
|
||
padding: 16px 20px;
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
}
|
||
.ml-info-dialog-loading {
|
||
text-align: center;
|
||
color: var(--ml-text-muted, #888);
|
||
padding: 20px;
|
||
}
|
||
.ml-info-section {
|
||
margin-bottom: 16px;
|
||
padding-bottom: 16px;
|
||
border-bottom: 1px solid var(--border-color, #555555);
|
||
}
|
||
.ml-info-section-header {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--ml-text-muted, #888);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.ml-info-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 6px 0;
|
||
}
|
||
.ml-info-label {
|
||
color: var(--ml-text-muted, #888);
|
||
font-size: 13px;
|
||
}
|
||
.ml-info-value {
|
||
color: var(--input-text, #e0e0e0);
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
.ml-info-value.ml-info-active {
|
||
color: #4CAF50;
|
||
}
|
||
.ml-info-value.ml-info-inactive {
|
||
color: #888;
|
||
}
|
||
.ml-info-section-civitai {
|
||
border-bottom: none;
|
||
margin-bottom: 0;
|
||
padding-bottom: 0;
|
||
}
|
||
.ml-info-civitai-link {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.ml-info-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 12px;
|
||
background: #2196F3;
|
||
color: white;
|
||
border-radius: 6px;
|
||
text-decoration: none;
|
||
font-size: 13px;
|
||
}
|
||
.ml-info-link:hover {
|
||
background: #1976D2;
|
||
}
|
||
.ml-info-loading-small {
|
||
color: var(--ml-text-muted, #888);
|
||
font-size: 12px;
|
||
font-style: italic;
|
||
}
|
||
.ml-info-not-found {
|
||
color: #888;
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* Info Area (type/base model tags) */
|
||
.ml-info-area {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.ml-info-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 4px 10px;
|
||
background: rgba(255,255,255,0.1);
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--input-text, #e0e0e0);
|
||
}
|
||
.ml-info-tag.-type-lora { background: rgba(33,150,243,0.2); color: #64B5F6; }
|
||
.ml-info-tag.-type-checkpoint { background: rgba(76,175,80,0.2); color: #81C784; }
|
||
.ml-info-tag.-type-vae { background: rgba(156,39,176,0.2); color: #BA68C8; }
|
||
.ml-info-tag.-basemodel-sd1 { background: rgba(244,67,54,0.2); color: #EF9A9A; }
|
||
.ml-info-tag.-basemodel-sdxl { background: rgba(33,150,243,0.2); color: #64B5F6; }
|
||
.ml-info-tag.-basemodel-sdxl-turbo { background: rgba(33,150,243,0.2); color: #64B5F6; }
|
||
.ml-info-tag.-basemodel-flux.1 { background: rgba(255,152,0,0.2); color: #FFB74D; }
|
||
.ml-info-tag.-basemodel-flux.1-d { background: rgba(255,152,0,0.2); color: #FFB74D; }
|
||
|
||
/* Info Table */
|
||
.ml-info-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin-bottom: 16px;
|
||
}
|
||
.ml-info-table tr {
|
||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||
}
|
||
.ml-info-table tr:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.ml-info-table td {
|
||
padding: 8px 4px;
|
||
font-size: 13px;
|
||
vertical-align: top;
|
||
}
|
||
.ml-info-table td:first-child {
|
||
color: var(--ml-text-muted, #888);
|
||
white-space: nowrap;
|
||
width: 140px;
|
||
}
|
||
.ml-info-table td:last-child {
|
||
color: var(--input-text, #e0e0e0);
|
||
word-break: break-word;
|
||
}
|
||
.ml-info-help {
|
||
display: inline-block;
|
||
width: 14px;
|
||
height: 14px;
|
||
background: rgba(255,255,255,0.1);
|
||
border-radius: 50%;
|
||
font-size: 10px;
|
||
text-align: center;
|
||
line-height: 14px;
|
||
cursor: help;
|
||
margin-left: 4px;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
/* Images Section */
|
||
.ml-info-images {
|
||
margin-top: 16px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid var(--border-color, #555555);
|
||
}
|
||
.ml-info-images-header {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--ml-text-muted, #888);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.ml-info-images-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
.ml-info-image-item {
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
background: rgba(0,0,0,0.2);
|
||
}
|
||
.ml-info-image-item img {
|
||
width: 100%;
|
||
height: auto;
|
||
display: block;
|
||
}
|
||
.ml-info-image-item figcaption {
|
||
padding: 8px;
|
||
font-size: 11px;
|
||
color: var(--ml-text-muted, #888);
|
||
}
|
||
.ml-info-image-item figcaption span {
|
||
display: block;
|
||
margin-bottom: 2px;
|
||
}
|
||
.ml-info-image-item figcaption span label {
|
||
font-weight: 600;
|
||
}
|
||
.ml-info-image-link {
|
||
color: #2196F3;
|
||
text-decoration: none;
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
/* CivitAI Logo */
|
||
.ml-info-civitai-logo {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
/* Footer */
|
||
.ml-info-dialog-footer {
|
||
padding: 12px 20px;
|
||
border-top: 1px solid var(--border-color, #555555);
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
}
|
||
.ml-info-dialog-close-btn {
|
||
padding: 8px 16px;
|
||
}
|
||
.ml-info-error {
|
||
color: #f44336;
|
||
font-size: 12px;
|
||
}
|
||
`;
|
||
|
||
document.head.appendChild(styles);
|
||
}
|
||
|
||
/**
|
||
* Get a colored confidence badge HTML
|
||
* @param {number} confidence - Confidence percentage (0-100)
|
||
* @returns {string} HTML for the badge
|
||
*/
|
||
getConfidenceBadge(confidence) {
|
||
let badgeClass;
|
||
if (confidence >= 95) {
|
||
badgeClass = 'ml-badge-high';
|
||
} else if (confidence >= 70) {
|
||
badgeClass = 'ml-badge-medium';
|
||
} else {
|
||
badgeClass = 'ml-badge-low';
|
||
}
|
||
return `<span class="ml-badge ${badgeClass}">${confidence}%</span>`;
|
||
}
|
||
|
||
/**
|
||
* Format a filename with smart truncation
|
||
* @param {string} path - Full path or filename
|
||
* @param {number} maxLength - Maximum display length
|
||
* @returns {object} { display: truncated name, full: full name }
|
||
*/
|
||
formatFilename(path, maxLength = 50) {
|
||
if (!path) return { display: 'Unknown', full: 'Unknown' };
|
||
|
||
// Extract just the filename from path
|
||
const filename = path.split(/[\/\\]/).pop() || path;
|
||
|
||
if (filename.length <= maxLength) {
|
||
return { display: filename, full: filename };
|
||
}
|
||
|
||
// Smart truncation: keep extension visible
|
||
const lastDot = filename.lastIndexOf('.');
|
||
const ext = lastDot > 0 ? filename.slice(lastDot) : '';
|
||
const name = lastDot > 0 ? filename.slice(0, lastDot) : filename;
|
||
|
||
// Calculate how much of the name we can show
|
||
const availableLength = maxLength - ext.length - 3; // 3 for "..."
|
||
if (availableLength < 8) {
|
||
// Too short, just truncate at the end
|
||
return { display: filename.slice(0, maxLength - 3) + '...', full: filename };
|
||
}
|
||
|
||
// Truncate middle of name
|
||
const frontLength = Math.ceil(availableLength / 2);
|
||
const backLength = Math.floor(availableLength / 2);
|
||
const truncated = name.slice(0, frontLength) + '...' + name.slice(-backLength) + ext;
|
||
|
||
return { display: truncated, full: filename };
|
||
}
|
||
|
||
/**
|
||
* Format a path showing directory context
|
||
* @param {string} path - Full relative path
|
||
* @param {number} maxLength - Maximum display length
|
||
* @returns {object} { display: formatted path, full: full path }
|
||
*/
|
||
formatPath(path, maxLength = 60) {
|
||
if (!path) return { display: 'Unknown', full: 'Unknown' };
|
||
|
||
if (path.length <= maxLength) {
|
||
return { display: path, full: path };
|
||
}
|
||
|
||
// Try to show meaningful parts: first dir + filename
|
||
const parts = path.split(/[\/\\]/);
|
||
const filename = parts.pop() || '';
|
||
const firstDir = parts[0] || '';
|
||
|
||
if (parts.length === 0) {
|
||
// Just a filename
|
||
return this.formatFilename(path, maxLength);
|
||
}
|
||
|
||
// Show first directory + ... + filename
|
||
const formatted = firstDir + '\\...' + (filename.length > 40 ? this.formatFilename(filename, 40).display : filename);
|
||
|
||
if (formatted.length <= maxLength) {
|
||
return { display: formatted, full: path };
|
||
}
|
||
|
||
// Still too long, just truncate
|
||
return { display: path.slice(0, maxLength - 3) + '...', full: path };
|
||
}
|
||
|
||
/**
|
||
* Render a status message with icon
|
||
* @param {string} message - Message text
|
||
* @param {string} type - 'error' | 'success' | 'info' | 'warning'
|
||
* @returns {string} HTML for status message
|
||
*/
|
||
renderStatusMessage(message, type = 'info') {
|
||
const icons = {
|
||
error: '⚠',
|
||
success: '✓',
|
||
info: 'ℹ',
|
||
warning: '⚡'
|
||
};
|
||
const icon = icons[type] || icons.info;
|
||
|
||
return `
|
||
<div class="ml-status ml-status-${type}">
|
||
<span class="ml-status-icon">${icon}</span>
|
||
<span>${message}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Render a progress bar
|
||
* @param {number} percent - Progress percentage (0-100)
|
||
* @param {string} leftText - Text on the left
|
||
* @param {string} rightText - Text on the right
|
||
* @returns {string} HTML for progress bar
|
||
*/
|
||
renderProgressBar(percent, leftText = '', rightText = '') {
|
||
return `
|
||
<div class="ml-progress-container">
|
||
<div class="ml-progress-bar">
|
||
<div class="ml-progress-fill" style="width: ${percent}%"></div>
|
||
</div>
|
||
<div class="ml-progress-text">
|
||
<span>${leftText}</span>
|
||
<span>${rightText}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Handle clicks outside the dialog
|
||
*/
|
||
handleOutsideClick(e) {
|
||
// Close if click is on the backdrop (not on the dialog itself)
|
||
if (e.target === this.backdrop) {
|
||
this.close();
|
||
}
|
||
}
|
||
|
||
createHeader() {
|
||
// Create tabs
|
||
this.missingTab = $el("button.ml-tab.ml-tab-active", {
|
||
textContent: "Missing Models",
|
||
onclick: () => this.switchTab('missing')
|
||
});
|
||
|
||
this.loadedTab = $el("button.ml-tab", {
|
||
textContent: "Loaded Models",
|
||
onclick: () => this.switchTab('loaded')
|
||
});
|
||
|
||
return $el("div", {
|
||
style: {
|
||
display: "flex",
|
||
flexDirection: "column"
|
||
}
|
||
}, [
|
||
$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("div", { style: { display: "flex", gap: "8px", alignItems: "center" } }, [
|
||
$el("div", {
|
||
id: "model-linker-drag-handle",
|
||
title: "Drag window",
|
||
ondragstart: (e) => e.preventDefault(),
|
||
style: {
|
||
cursor: "grab",
|
||
userSelect: "none",
|
||
border: "1px solid var(--border-color)",
|
||
borderRadius: "4px",
|
||
padding: "0 6px",
|
||
height: "24px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
opacity: "0.9"
|
||
}
|
||
}, [
|
||
$el("span", { textContent: "⠿" })
|
||
]),
|
||
$el("h2", {
|
||
textContent: "🔗 Model Linker",
|
||
style: {
|
||
margin: "0",
|
||
color: "var(--input-text)",
|
||
fontSize: "18px",
|
||
fontWeight: "600"
|
||
}
|
||
})
|
||
]),
|
||
$el("div", { style: { display: "flex", gap: "8px", alignItems: "center" } }, [
|
||
$el("button", {
|
||
id: "model-linker-fullscreen-toggle",
|
||
title: "Toggle full screen",
|
||
textContent: "⛶",
|
||
onclick: () => this.toggleFullScreen(),
|
||
style: {
|
||
background: "none",
|
||
border: "1px solid var(--border-color)",
|
||
fontSize: "16px",
|
||
cursor: "pointer",
|
||
color: "var(--input-text)",
|
||
padding: "2px 8px",
|
||
minWidth: "32px",
|
||
height: "30px",
|
||
borderRadius: "4px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center"
|
||
}
|
||
}),
|
||
$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"
|
||
}
|
||
})
|
||
])
|
||
]),
|
||
$el("div.ml-tabs", {}, [
|
||
this.missingTab,
|
||
this.loadedTab
|
||
])
|
||
]);
|
||
}
|
||
|
||
// Toggle full screen mode for the dialog
|
||
toggleFullScreen() {
|
||
this.setFullScreen(!this.fullscreen);
|
||
}
|
||
|
||
setFullScreen(enable) {
|
||
this.fullscreen = !!enable;
|
||
const el = this.element;
|
||
if (!el) return;
|
||
const btn = document.getElementById('model-linker-fullscreen-toggle');
|
||
if (enable) {
|
||
// Save current size
|
||
try {
|
||
const rect = el.getBoundingClientRect();
|
||
localStorage.setItem('model_linker_modal_size_before_fs', JSON.stringify({ w: Math.round(rect.width), h: Math.round(rect.height) }));
|
||
} catch (e) {}
|
||
el.style.top = '0';
|
||
el.style.left = '0';
|
||
el.style.transform = 'none';
|
||
el.style.width = '100vw';
|
||
el.style.height = '100vh';
|
||
el.style.maxWidth = '100vw';
|
||
el.style.maxHeight = '100vh';
|
||
el.style.borderRadius = '0';
|
||
el.style.resize = 'none';
|
||
if (btn) btn.textContent = '🗗';
|
||
try { localStorage.setItem('model_linker_modal_fullscreen', '1'); } catch (e) {}
|
||
} else {
|
||
// Restore centered sizing
|
||
el.style.maxWidth = '95vw';
|
||
el.style.maxHeight = '95vh';
|
||
el.style.borderRadius = '8px';
|
||
el.style.resize = 'both';
|
||
// Restore saved pre-FS size if available
|
||
let wh = null;
|
||
try { wh = JSON.parse(localStorage.getItem('model_linker_modal_size_before_fs') || 'null'); } catch (e) {}
|
||
if (wh && wh.w && wh.h) {
|
||
el.style.width = `${wh.w}px`;
|
||
el.style.height = `${wh.h}px`;
|
||
} else {
|
||
el.style.width = '1100px';
|
||
el.style.height = '700px';
|
||
}
|
||
// Restore last known position if available, else center
|
||
try {
|
||
const pos = JSON.parse(localStorage.getItem('model_linker_modal_pos') || 'null');
|
||
if (pos && Number.isFinite(pos.top) && Number.isFinite(pos.left)) {
|
||
el.style.top = `${pos.top}px`;
|
||
el.style.left = `${pos.left}px`;
|
||
el.style.transform = 'none';
|
||
} else {
|
||
el.style.top = '50%';
|
||
el.style.left = '50%';
|
||
el.style.transform = 'translate(-50%, -50%)';
|
||
}
|
||
} catch (e) {
|
||
el.style.top = '50%';
|
||
el.style.left = '50%';
|
||
el.style.transform = 'translate(-50%, -50%)';
|
||
}
|
||
if (btn) btn.textContent = '⛶';
|
||
try { localStorage.setItem('model_linker_modal_fullscreen', '0'); } catch (e) {}
|
||
}
|
||
}
|
||
|
||
// Begin window drag
|
||
startDrag(e) {
|
||
try {
|
||
const el = this.element;
|
||
if (!el) return;
|
||
const rect = el.getBoundingClientRect();
|
||
// Switch to absolute top/left (no transform) before dragging
|
||
el.style.top = `${rect.top}px`;
|
||
el.style.left = `${rect.left}px`;
|
||
el.style.transform = 'none';
|
||
this._dragging = true;
|
||
this._dragStart = {
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
top: rect.top,
|
||
left: rect.left
|
||
};
|
||
// Prevent text selection while dragging
|
||
this._prevUserSelect = document.body.style.userSelect;
|
||
document.body.style.userSelect = 'none';
|
||
// Attach listeners
|
||
this._onMouseMove = (ev) => this.onDrag(ev);
|
||
this._onMouseUp = () => this.endDrag();
|
||
document.addEventListener('mousemove', this._onMouseMove);
|
||
document.addEventListener('mouseup', this._onMouseUp, { once: true });
|
||
} catch (err) { /* ignore */ }
|
||
}
|
||
|
||
onDrag(e) {
|
||
if (!this._dragging || !this._dragStart) return;
|
||
const el = this.element;
|
||
if (!el) return;
|
||
const dx = e.clientX - this._dragStart.x;
|
||
const dy = e.clientY - this._dragStart.y;
|
||
let top = this._dragStart.top + dy;
|
||
let left = this._dragStart.left + dx;
|
||
// Clamp to viewport
|
||
const vw = window.innerWidth;
|
||
const vh = window.innerHeight;
|
||
const w = el.offsetWidth;
|
||
const h = el.offsetHeight;
|
||
const pad = 4; // small padding
|
||
left = Math.max(-w + pad, Math.min(vw - pad, left));
|
||
top = Math.max(-h + pad, Math.min(vh - pad, top));
|
||
el.style.top = `${Math.round(top)}px`;
|
||
el.style.left = `${Math.round(left)}px`;
|
||
}
|
||
|
||
endDrag() {
|
||
if (!this._dragging) return;
|
||
this._dragging = false;
|
||
document.removeEventListener('mousemove', this._onMouseMove);
|
||
// Persist position
|
||
try {
|
||
const el = this.element;
|
||
const rect = el.getBoundingClientRect();
|
||
localStorage.setItem('model_linker_modal_pos', JSON.stringify({ top: Math.round(rect.top), left: Math.round(rect.left) }));
|
||
} catch (e) { /* ignore */ }
|
||
// Restore selection
|
||
try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) {}
|
||
}
|
||
|
||
/**
|
||
* Simple debounce helper
|
||
*/
|
||
debounce(callback, wait = 250) {
|
||
let t = null;
|
||
return (...args) => {
|
||
if (t) clearTimeout(t);
|
||
t = setTimeout(() => {
|
||
callback.apply(this, args);
|
||
}, wait);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Ensure all models are loaded for the dropdown.
|
||
*/
|
||
async ensureAllModelsLoaded() {
|
||
if (this.allModels && this.allModels.length) return;
|
||
try {
|
||
const resp = await api.fetchApi('/model_linker/models');
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const models = await resp.json();
|
||
const list = Array.isArray(models) ? models : [];
|
||
// Build labels and sort alphabetically
|
||
this.allModels = list.map((m) => ({
|
||
...m,
|
||
__label: `${m.category ? m.category + ': ' : ''}${m.relative_path || m.filename || ''}`
|
||
})).sort((a, b) => (a.__label || '').localeCompare(b.__label || ''));
|
||
} catch (e) {
|
||
console.warn('Model Linker: could not load all models', e);
|
||
this.allModels = [];
|
||
}
|
||
}
|
||
|
||
switchTab(tab) {
|
||
this.activeTab = tab;
|
||
|
||
if (tab === 'missing') {
|
||
this.missingTab.classList.add('ml-tab-active');
|
||
this.loadedTab.classList.remove('ml-tab-active');
|
||
this.downloadAllButton.style.display = 'inline-flex';
|
||
this.autoResolveButton.style.display = 'inline-flex';
|
||
this.applyPendingBtn.style.display = 'inline-flex';
|
||
// Show queue panel
|
||
if (this.queueElement && !this.queueCollapsed) {
|
||
this.queueElement.style.display = '';
|
||
}
|
||
if (this.splitterElement) {
|
||
this.splitterElement.style.display = '';
|
||
}
|
||
this.loadWorkflowData();
|
||
} else {
|
||
this.missingTab.classList.remove('ml-tab-active');
|
||
this.loadedTab.classList.add('ml-tab-active');
|
||
this.downloadAllButton.style.display = 'none';
|
||
this.autoResolveButton.style.display = 'none';
|
||
this.applyPendingBtn.style.display = 'none';
|
||
// Hide queue panel in loaded models tab
|
||
if (this.queueElement) {
|
||
this.queueElement.style.display = 'none';
|
||
}
|
||
if (this.splitterElement) {
|
||
this.splitterElement.style.display = 'none';
|
||
}
|
||
this.loadLoadedModels();
|
||
}
|
||
}
|
||
|
||
async loadLoadedModels() {
|
||
if (!this.contentElement) return;
|
||
|
||
this.contentElement.innerHTML = '<p>Loading loaded models...</p>';
|
||
|
||
try {
|
||
const workflow = this.getCurrentWorkflow();
|
||
if (!workflow) {
|
||
this.contentElement.innerHTML = '<p>No workflow loaded. Please load a workflow first.</p>';
|
||
return;
|
||
}
|
||
|
||
const response = await api.fetchApi('/model_linker/loaded', {
|
||
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.displayLoadedModels(this.contentElement, data);
|
||
|
||
} catch (error) {
|
||
console.error('Model Linker: Error loading loaded models:', error);
|
||
if (this.contentElement) {
|
||
this.contentElement.innerHTML = `<p style="color: red;">Error: ${error.message}</p>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
displayLoadedModels(container, data) {
|
||
const loadedModels = data.loaded_models || [];
|
||
const total = data.total || 0;
|
||
|
||
if (total === 0) {
|
||
container.innerHTML = this.renderStatusMessage('No models found in workflow.', 'info');
|
||
return;
|
||
}
|
||
|
||
const categoryDisplayNames = {
|
||
'checkpoints': 'checkpoint',
|
||
'loras': 'lora',
|
||
'vae': 'vae',
|
||
'controlnet': 'controlnet',
|
||
'embeddings': 'embedding',
|
||
'upscale_models': 'upscale_model',
|
||
'diffusion_models': 'unet',
|
||
'clip': 'clip',
|
||
'clip_vision': 'clip_vision',
|
||
'hypernetworks': 'hypernetwork'
|
||
};
|
||
|
||
const byCategory = {};
|
||
|
||
for (const model of loadedModels) {
|
||
const cat = model.category || 'unknown';
|
||
if (!byCategory[cat]) {
|
||
byCategory[cat] = { active: [], inactive: [] };
|
||
}
|
||
|
||
// Determine if model is active or inactive
|
||
// For LoraLoaderV2/LoraManager: check model.active field
|
||
// For other nodes: check model.connected field (false means not connected or bypassed)
|
||
let isActive = true;
|
||
if (model.is_lora_v2) {
|
||
// For text-based lora loaders, check both active flag AND connected status
|
||
isActive = model.active !== false && model.connected !== false;
|
||
} else {
|
||
// For regular nodes, check connected status
|
||
isActive = model.connected !== false;
|
||
}
|
||
|
||
if (isActive) {
|
||
byCategory[cat].active.push(model);
|
||
} else {
|
||
byCategory[cat].inactive.push(model);
|
||
}
|
||
}
|
||
|
||
const activeCount = Object.values(byCategory).reduce((sum, cat) => sum + cat.active.length, 0);
|
||
const inactiveCount = Object.values(byCategory).reduce((sum, cat) => sum + cat.inactive.length, 0);
|
||
|
||
const buildCategoryStrings = (filter) => {
|
||
const result = {};
|
||
for (const [category, modelsObj] of Object.entries(byCategory)) {
|
||
const displayCat = categoryDisplayNames[category] || category;
|
||
const models = filter === 'active' ? modelsObj.active : filter === 'inactive' ? modelsObj.inactive : [...modelsObj.active, ...modelsObj.inactive];
|
||
const parts = models.map(model => {
|
||
const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown';
|
||
let name = fullName;
|
||
if (fullName.match(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i)) {
|
||
name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
|
||
}
|
||
const strength = model.strength !== null && model.strength !== undefined
|
||
? model.strength.toFixed(2)
|
||
: '1.00';
|
||
return `<${displayCat}:${name}:${strength}>`;
|
||
});
|
||
result[category] = parts.join(' ');
|
||
}
|
||
return Object.values(result).join(' ');
|
||
};
|
||
|
||
const activeString = buildCategoryStrings('active');
|
||
const inactiveString = buildCategoryStrings('inactive');
|
||
const allString = buildCategoryStrings('all');
|
||
|
||
const self = this;
|
||
|
||
let html = `
|
||
<div class="ml-loaded-models-header">
|
||
<h3 class="ml-loaded-models-title">${total} Model${total > 1 ? 's' : ''} in Workflow</h3>
|
||
<p class="ml-loaded-models-subtitle">LoraManager / LoraLoaderV2 nodes distinguish active/inactive</p>
|
||
</div>
|
||
<div style="padding: 16px 20px;">
|
||
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||
<button class="ml-btn-filter active" id="filter-all" onclick="window.MLFilterSwitch('all')">All (${activeCount + inactiveCount})</button>
|
||
<button class="ml-btn-filter" id="filter-active" onclick="window.MLFilterSwitch('active')">Active (${activeCount})</button>
|
||
<button class="ml-btn-filter" id="filter-inactive" onclick="window.MLFilterSwitch('inactive')">Inactive (${inactiveCount})</button>
|
||
</div>
|
||
<style>
|
||
.ml-btn-filter { padding: 6px 12px; font-size: 12px; background: #333; border: 1px solid #444; color: #aaa; cursor: pointer; border-radius: 4px; }
|
||
.ml-btn-filter:hover { background: #444; color: #fff; }
|
||
.ml-btn-filter.active { background: #4CAF50; border-color: #4CAF50; color: white; }
|
||
</style>
|
||
</div>
|
||
<div class="ml-models-list" style="padding: 0 20px;">
|
||
`;
|
||
|
||
for (const [category, modelsObj] of Object.entries(byCategory)) {
|
||
const displayName = categoryDisplayNames[category] || category;
|
||
const hasActive = modelsObj.active.length > 0;
|
||
const hasInactive = modelsObj.inactive.length > 0;
|
||
|
||
html += `<div class="ml-model-section" data-ml-filter="all" data-ml-active="${hasActive}" data-ml-inactive="${hasInactive}" style="margin-bottom: 16px; padding: 12px; background: var(--ml-card-bg-alt, #252525); border-radius: 8px;">`;
|
||
|
||
// Add category header
|
||
html += `<div style="margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid #333;">
|
||
<span style="color: var(--ml-text-primary); font-size: 14px; font-weight: 600;">${displayName.toUpperCase()}</span>
|
||
</div>`;
|
||
|
||
if (hasActive) {
|
||
const activeStr = modelsObj.active.map(m => {
|
||
const fullName = m.name || m.original_path?.split(/[\/\\]/).pop() || 'Unknown';
|
||
let name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
|
||
const strength = m.strength !== null && m.strength !== undefined ? m.strength.toFixed(2) : '1.00';
|
||
return `<${displayName}:${name}:${strength}>`;
|
||
}).join(' ');
|
||
|
||
html += `<div style="margin-bottom: 10px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||
<span style="color: #4CAF50; font-size: 11px; font-weight: 600;">● ACTIVE</span>
|
||
<button class="ml-btn ml-btn-sm" style="padding: 3px 8px; font-size: 10px;" onclick="window.MLCopy('${activeStr.replace(/'/g, "\\'")}', this)">Copy</button>
|
||
</div>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;
|
||
|
||
for (const model of modelsObj.active) {
|
||
const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown';
|
||
const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
|
||
const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null;
|
||
const modelData = encodeURIComponent(JSON.stringify(model));
|
||
html += `<span class="ml-model-chip" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">${name}${strength !== null ? `<span class="ml-model-chip-strength">${strength}</span>` : ''}</span>`;
|
||
}
|
||
html += `</div></div>`;
|
||
}
|
||
|
||
if (hasInactive) {
|
||
const inactiveStr = modelsObj.inactive.map(m => {
|
||
const fullName = m.name || m.original_path?.split(/[\/\\]/).pop() || 'Unknown';
|
||
let name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
|
||
const strength = m.strength !== null && m.strength !== undefined ? m.strength.toFixed(2) : '1.00';
|
||
return `<${displayName}:${name}:${strength}>`;
|
||
}).join(' ');
|
||
|
||
html += `<div>
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||
<span style="color: #888; font-size: 11px; font-weight: 600;">○ INACTIVE</span>
|
||
<button class="ml-btn ml-btn-sm" style="padding: 3px 8px; font-size: 10px; opacity: 0.6;" onclick="window.MLCopy('${inactiveStr.replace(/'/g, "\\'")}', this)">Copy</button>
|
||
</div>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 8px; opacity: 0.5;">`;
|
||
|
||
for (const model of modelsObj.inactive) {
|
||
const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown';
|
||
const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
|
||
const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null;
|
||
const modelData = encodeURIComponent(JSON.stringify(model));
|
||
html += `<span class="ml-model-chip" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">${name}${strength !== null ? `<span class="ml-model-chip-strength">${strength}</span>` : ''}</span>`;
|
||
}
|
||
html += `</div></div>`;
|
||
}
|
||
|
||
html += `</div>`;
|
||
}
|
||
|
||
const copySectionId = 'ml-copy-' + Date.now();
|
||
html += `
|
||
</div>
|
||
<div id="${copySectionId}" style="padding: 16px 20px; border-top: 1px solid var(--ml-border);" data-ml-active="${activeString.replace(/'/g, "\\'")}" data-ml-inactive="${inactiveString.replace(/'/g, "\\'")}" data-ml-all="${allString.replace(/'/g, "\\'")}">
|
||
<div style="font-size: 12px; color: var(--ml-text-muted); margin-bottom: 8px;" id="${copySectionId}-label">Copy all:</div>
|
||
<div style="display: flex; gap: 8px; align-items: center;">
|
||
<code style="flex: 1; padding: 8px 12px; background: #1a1a1a; border-radius: 4px; font-family: 'SF Mono', 'Consolas', monospace; font-size: 11px; color: #90caf9; overflow-x: auto; white-space: nowrap;" id="${copySectionId}-code">${allString}</code>
|
||
<button class="ml-btn ml-btn-secondary" onclick="window.MLCopyCode('${copySectionId}', this)">Copy</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
container.innerHTML = html;
|
||
|
||
// Store data on container for filter function
|
||
container.dataset.mlActiveString = activeString;
|
||
container.dataset.mlInactiveString = inactiveString;
|
||
container.dataset.mlAllString = allString;
|
||
}
|
||
|
||
createContent() {
|
||
// Wrap the body in a two-column layout: left = items, right = queued panel
|
||
const body = $el("div", {
|
||
id: "model-linker-body",
|
||
style: {
|
||
display: "flex",
|
||
gap: "12px",
|
||
padding: "16px",
|
||
flex: "1",
|
||
minHeight: "0",
|
||
alignItems: "stretch",
|
||
position: "relative"
|
||
}
|
||
});
|
||
|
||
this.contentElement = $el("div.ml-scrollable", {
|
||
id: "model-linker-content",
|
||
style: {
|
||
overflowY: "auto",
|
||
flex: "1",
|
||
minHeight: "0",
|
||
backgroundColor: "var(--ml-bg, #222)"
|
||
}
|
||
});
|
||
|
||
this.queueElement = $el("div", {
|
||
id: "model-linker-queue",
|
||
style: {
|
||
width: "320px",
|
||
minWidth: "240px",
|
||
maxWidth: "70%",
|
||
borderLeft: "1px solid var(--border-color)",
|
||
paddingLeft: "12px",
|
||
display: "flex",
|
||
flexDirection: "column"
|
||
}
|
||
}, [
|
||
this.createQueuePanel()
|
||
]);
|
||
|
||
// Splitter between content and queue
|
||
this.splitterElement = $el("div", {
|
||
id: "model-linker-splitter",
|
||
title: "Drag to resize panels",
|
||
style: {
|
||
cursor: "col-resize",
|
||
width: "6px",
|
||
minWidth: "6px",
|
||
background: "var(--border-color)",
|
||
opacity: "0.4",
|
||
borderRadius: "3px"
|
||
},
|
||
ondragstart: (e) => e.preventDefault()
|
||
});
|
||
|
||
body.appendChild(this.contentElement);
|
||
body.appendChild(this.splitterElement);
|
||
body.appendChild(this.queueElement);
|
||
|
||
// Restore saved queue width and wire splitter
|
||
try {
|
||
const savedSplit = localStorage.getItem('model_linker_split_w');
|
||
if (savedSplit) {
|
||
const w = parseInt(savedSplit, 10);
|
||
if (!isNaN(w) && w > 0) {
|
||
this.queueElement.style.width = `${w}px`;
|
||
}
|
||
}
|
||
} catch (e) { }
|
||
|
||
try {
|
||
const onSplitMouseDown = (e) => this.startSplitDrag(e);
|
||
this.splitterElement.addEventListener('mousedown', onSplitMouseDown);
|
||
this._splitterMouseDown = onSplitMouseDown;
|
||
} catch (e) { }
|
||
|
||
// Toggle icon always visible
|
||
try {
|
||
this.queueToggleIcon = $el("button", {
|
||
id: "queue-toggle-icon",
|
||
title: "Collapse queue",
|
||
onclick: () => this.toggleQueueCollapsed(),
|
||
style: {
|
||
position: "absolute",
|
||
top: "50%",
|
||
right: "6px",
|
||
transform: "translateY(-50%)",
|
||
zIndex: "1000",
|
||
padding: "2px 6px",
|
||
border: "1px solid var(--border-color)",
|
||
borderRadius: "4px",
|
||
background: "var(--comfy-input-bg, #2f2f2f)",
|
||
cursor: "pointer",
|
||
opacity: "0.9"
|
||
}
|
||
}, [document.createTextNode('⮜')]);
|
||
body.appendChild(this.queueToggleIcon);
|
||
this.updateQueueToggleIcon();
|
||
} catch (e) { }
|
||
|
||
// Restore queue collapsed state
|
||
try {
|
||
const col = localStorage.getItem('model_linker_queue_collapsed');
|
||
if (col === '1') this.setQueueCollapsed(true);
|
||
} catch (e) { }
|
||
|
||
return body;
|
||
}
|
||
|
||
createQueuePanel() {
|
||
// Header row with title and clear button
|
||
this.queueHeader = $el("div", {
|
||
style: {
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
marginBottom: "8px"
|
||
}
|
||
}, [
|
||
$el("div", { id: "queue-title", textContent: "Queued Selections (0)", style: { fontWeight: "600" } }),
|
||
$el("div", { style: { display: "flex", gap: "6px" } }, [
|
||
$el("button", {
|
||
id: "queue-toggle",
|
||
className: "ml-btn ml-btn-secondary ml-btn-sm",
|
||
textContent: "Collapse",
|
||
onclick: () => this.toggleQueueCollapsed(),
|
||
style: { padding: "4px 8px" }
|
||
}),
|
||
$el("button", {
|
||
id: "queue-clear",
|
||
className: "ml-btn ml-btn-secondary ml-btn-sm",
|
||
textContent: "Clear All",
|
||
onclick: () => this.clearAllQueued(),
|
||
style: { padding: "4px 8px" }
|
||
})
|
||
])
|
||
]);
|
||
|
||
// Scrollable list
|
||
this.queueList = $el("div", {
|
||
id: "queue-list",
|
||
style: {
|
||
overflowY: "auto",
|
||
flex: "1",
|
||
minHeight: "0",
|
||
border: "1px solid var(--border-color)",
|
||
borderRadius: "4px",
|
||
padding: "8px",
|
||
background: "var(--comfy-input-bg, #2f2f2f)"
|
||
}
|
||
});
|
||
|
||
const panel = $el("div", { style: { display: "flex", flexDirection: "column", minHeight: "0", flex: "1 1 auto" } }, [this.queueHeader, this.queueList]);
|
||
return panel;
|
||
}
|
||
|
||
updateQueuePanel() {
|
||
if (!this.queueList || !this.queueHeader) return;
|
||
const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : [];
|
||
// Update title count
|
||
const title = this.queueHeader.querySelector('#queue-title');
|
||
if (title) title.textContent = `Queued Selections (${list.length})`;
|
||
const toggleBtn = this.queueHeader.querySelector('#queue-toggle');
|
||
if (toggleBtn) toggleBtn.textContent = this.queueCollapsed ? 'Expand' : 'Collapse';
|
||
|
||
if (!list.length) {
|
||
this.queueList.innerHTML = '<div style="opacity:0.7;">No selections queued.</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '<div style="display:flex; flex-direction:column; gap:6px;">';
|
||
for (let i = 0; i < list.length; i++) {
|
||
const r = list[i];
|
||
const label = (r.resolved_model?.relative_path || r.resolved_model?.filename || r.resolved_path || '').toString();
|
||
const nodeLabel = r.node_label || r.node_type || (r.subgraph_id ? 'Subgraph' : 'Node');
|
||
const orig = (r.original_path || '').toString();
|
||
const rmId = `queue-remove-${i}`;
|
||
html += `<div style="border:1px solid var(--border-color); border-radius:4px; padding:6px; background: rgba(255,255,255,0.02);">`;
|
||
html += `<div style="font-weight:600;">${nodeLabel} #${r.node_id}</div>`;
|
||
html += `<div style="font-size:12px; opacity:0.9;">Original: <code>${orig}</code></div>`;
|
||
html += `<div style="font-size:12px;">Selected: <code>${label}</code></div>`;
|
||
html += `<div style="margin-top:6px;"><button id="${rmId}" class="ml-btn ml-btn-secondary ml-btn-sm" style="padding:2px 8px;">Remove</button></div>`;
|
||
}
|
||
html += '</div>';
|
||
this.queueList.innerHTML = html;
|
||
|
||
// Wire remove buttons
|
||
for (let i = 0; i < list.length; i++) {
|
||
const rmId = `queue-remove-${i}`;
|
||
const btn = this.queueList.querySelector(`#${rmId}`);
|
||
if (btn) {
|
||
btn.addEventListener('click', () => this.removeQueuedByIndex(i));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Remove queued by index
|
||
removeQueuedByIndex(i) {
|
||
const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : [];
|
||
if (i < 0 || i >= list.length) return;
|
||
const r = list[i];
|
||
// Remove
|
||
this.pendingResolutions.splice(i, 1);
|
||
this.rebuildPendingIndex();
|
||
// Update per-item selected bar
|
||
const m = { node_id: r.node_id, widget_index: r.widget_index, subgraph_id: r.subgraph_id, is_top_level: r.is_top_level };
|
||
this.updateSelectedBarForMissing?.(m);
|
||
this.updateApplyPendingButton?.();
|
||
this.updateQueuePanel();
|
||
}
|
||
|
||
// Clear all queued selections
|
||
clearAllQueued() {
|
||
this.pendingResolutions = [];
|
||
this.pendingIndex = new Map();
|
||
this.updateApplyPendingButton?.();
|
||
this.updateQueuePanel();
|
||
try {
|
||
document.querySelectorAll('.model-linker-selected').forEach(el => { el.style.display = 'none'; el.innerHTML = ''; });
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
// Update selected bar for a specific missing model slot
|
||
updateSelectedBarForMissing(missing) {
|
||
if (!missing) return;
|
||
const nodeId = missing.node_id;
|
||
const widgetIndex = missing.widget_index;
|
||
const subgraphId = missing.subgraph_id || '';
|
||
const isTopLevel = missing.is_top_level !== false;
|
||
const key = `${nodeId}:${widgetIndex}:${subgraphId}:${isTopLevel ? 'T' : 'F'}`;
|
||
|
||
const selectedBar = document.getElementById(`selected-bar-${nodeId}-${widgetIndex}`);
|
||
if (!selectedBar) return;
|
||
|
||
// Find selection for this slot
|
||
let selection = null;
|
||
let selectionIdx = -1;
|
||
if (this.pendingIndex.has(key)) {
|
||
const idx = this.pendingIndex.get(key);
|
||
if (idx >= 0 && idx < this.pendingResolutions.length) {
|
||
selection = this.pendingResolutions[idx];
|
||
selectionIdx = idx;
|
||
}
|
||
}
|
||
|
||
if (!selection) {
|
||
selectedBar.style.display = 'none';
|
||
selectedBar.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
// Build selected bar content
|
||
const label = selection.resolved_model?.relative_path || selection.resolved_model?.filename || selection.resolved_path || '';
|
||
const resolveBtnId = `selected-remove-${nodeId}-${widgetIndex}`;
|
||
|
||
selectedBar.innerHTML = `<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">`;
|
||
selectedBar.innerHTML += `<span style="color: #4CAF50; font-weight: 600;">✓ Selected:</span>`;
|
||
selectedBar.innerHTML += `<code style="flex: 1; overflow: hidden; text-overflow: ellipsis;">${label}</code>`;
|
||
selectedBar.innerHTML += `<button id="${resolveBtnId}" class="ml-btn ml-btn-secondary ml-btn-sm" style="padding: 2px 8px;">Remove</button>`;
|
||
selectedBar.innerHTML += `</div>`;
|
||
selectedBar.style.display = 'block';
|
||
|
||
// Wire remove button - use key-based removal
|
||
const removeBtn = selectedBar.querySelector(`#${resolveBtnId}`);
|
||
if (removeBtn) {
|
||
removeBtn.addEventListener('click', () => {
|
||
this.removeQueuedByKey(key);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Remove queued by key (more reliable than index)
|
||
removeQueuedByKey(key) {
|
||
if (!key || !this.pendingIndex.has(key)) return;
|
||
|
||
// Find and remove the item with this key
|
||
const idx = this.pendingIndex.get(key);
|
||
if (idx >= 0 && idx < this.pendingResolutions.length) {
|
||
const r = this.pendingResolutions[idx];
|
||
// Update the selected bar before removing
|
||
const m = { node_id: r.node_id, widget_index: r.widget_index, subgraph_id: r.subgraph_id, is_top_level: r.is_top_level };
|
||
|
||
// Remove from array
|
||
this.pendingResolutions.splice(idx, 1);
|
||
this.rebuildPendingIndex();
|
||
this.updateSelectedBarForMissing(m);
|
||
this.updateApplyPendingButton?.();
|
||
this.updateQueuePanel();
|
||
}
|
||
}
|
||
|
||
// Rebuild pending index after modification
|
||
rebuildPendingIndex() {
|
||
this.pendingIndex = new Map();
|
||
for (let i = 0; i < this.pendingResolutions.length; i++) {
|
||
const r = this.pendingResolutions[i];
|
||
const key = `${r.node_id}:${r.widget_index}:${r.subgraph_id || ''}:${r.is_top_level ? 'T' : 'F'}`;
|
||
this.pendingIndex.set(key, i);
|
||
}
|
||
}
|
||
|
||
// Collapse/expand queue panel
|
||
toggleQueueCollapsed() {
|
||
this.setQueueCollapsed(!this.queueCollapsed);
|
||
}
|
||
|
||
setQueueCollapsed(collapsed) {
|
||
this.queueCollapsed = !!collapsed;
|
||
if (!this.queueElement || !this.splitterElement) return;
|
||
if (this.queueCollapsed) {
|
||
this.queueElement.style.display = 'none';
|
||
this.splitterElement.style.display = 'none';
|
||
try { localStorage.setItem('model_linker_queue_collapsed', '1'); } catch (e) { }
|
||
} else {
|
||
this.queueElement.style.display = '';
|
||
this.splitterElement.style.display = '';
|
||
try { localStorage.setItem('model_linker_queue_collapsed', '0'); } catch (e) { }
|
||
}
|
||
this.updateQueuePanel();
|
||
this.updateQueueToggleIcon();
|
||
}
|
||
|
||
updateQueueToggleIcon() {
|
||
if (!this.queueToggleIcon) return;
|
||
if (this.queueCollapsed) {
|
||
this.queueToggleIcon.textContent = '⮞';
|
||
this.queueToggleIcon.title = 'Expand queue';
|
||
} else {
|
||
this.queueToggleIcon.textContent = '⮜';
|
||
this.queueToggleIcon.title = 'Collapse queue';
|
||
}
|
||
}
|
||
|
||
// Begin split drag for resizable panels
|
||
startSplitDrag(e) {
|
||
try {
|
||
if (!this.queueElement) return;
|
||
const rect = this.queueElement.getBoundingClientRect();
|
||
const body = document.getElementById('model-linker-body');
|
||
const bodyRect = body ? body.getBoundingClientRect() : { width: window.innerWidth };
|
||
this._splitDragging = true;
|
||
this._splitStart = {
|
||
x: e.clientX,
|
||
startWidth: rect.width,
|
||
containerWidth: bodyRect.width
|
||
};
|
||
this._prevUserSelect = document.body.style.userSelect;
|
||
document.body.style.userSelect = 'none';
|
||
this._onSplitMove = (ev) => this.onSplitDrag(ev);
|
||
this._onSplitUp = () => this.endSplitDrag();
|
||
document.addEventListener('mousemove', this._onSplitMove);
|
||
document.addEventListener('mouseup', this._onSplitUp, { once: true });
|
||
} catch (err) { /* ignore */ }
|
||
}
|
||
|
||
onSplitDrag(e) {
|
||
if (!this._splitDragging || !this._splitStart || !this.queueElement) return;
|
||
const dx = e.clientX - this._splitStart.x;
|
||
let newW = this._splitStart.startWidth - dx;
|
||
const minW = 240;
|
||
const maxW = Math.max(minW, Math.floor(this._splitStart.containerWidth - 360));
|
||
if (newW < minW) newW = minW;
|
||
if (newW > maxW) newW = maxW;
|
||
this.queueElement.style.width = `${Math.round(newW)}px`;
|
||
}
|
||
|
||
endSplitDrag() {
|
||
if (!this._splitDragging) return;
|
||
this._splitDragging = false;
|
||
document.removeEventListener('mousemove', this._onSplitMove);
|
||
try {
|
||
const rect = this.queueElement.getBoundingClientRect();
|
||
localStorage.setItem('model_linker_split_w', String(Math.round(rect.width)));
|
||
} catch (e) { }
|
||
try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { }
|
||
}
|
||
|
||
/**
|
||
* Queue a resolution for later batch apply
|
||
*/
|
||
queueResolution(missing, resolvedModel) {
|
||
if (!resolvedModel) {
|
||
this.showNotification('No model selected', 'error');
|
||
return;
|
||
}
|
||
|
||
const resolution = {
|
||
node_id: missing.node_id,
|
||
widget_index: missing.widget_index,
|
||
resolved_path: resolvedModel.path,
|
||
category: missing.category,
|
||
resolved_model: resolvedModel,
|
||
original_path: missing.original_path,
|
||
subgraph_id: missing.subgraph_id,
|
||
is_top_level: missing.is_top_level,
|
||
node_type: missing.node_type,
|
||
node_label: missing.subgraph_name || missing.node_type
|
||
};
|
||
|
||
const key = `${resolution.node_id}:${resolution.widget_index}:${resolution.subgraph_id || ''}:${resolution.is_top_level ? 'T' : 'F'}`;
|
||
if (this.pendingIndex.has(key)) {
|
||
// replace existing selection for this slot
|
||
const idx = this.pendingIndex.get(key);
|
||
this.pendingResolutions[idx] = resolution;
|
||
} else {
|
||
this.pendingIndex.set(key, this.pendingResolutions.length);
|
||
this.pendingResolutions.push(resolution);
|
||
}
|
||
|
||
// Update selected bar UI
|
||
this.updateSelectedBarForMissing?.(missing);
|
||
this.updateQueuePanel();
|
||
this.updateApplyPendingButton();
|
||
}
|
||
|
||
/**
|
||
* Apply all pending resolutions in batch
|
||
*/
|
||
async applyPendingResolutions() {
|
||
const list = this.pendingResolutions || [];
|
||
if (!list.length) {
|
||
this.showNotification('No selections queued', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const workflow = this.getCurrentWorkflow();
|
||
if (!workflow) {
|
||
this.showNotification('No workflow loaded', 'error');
|
||
return;
|
||
}
|
||
|
||
const response = await api.fetchApi('/model_linker/resolve', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ workflow, resolutions: list })
|
||
});
|
||
|
||
if (!response.ok) throw new Error(`API error: ${response.status}`);
|
||
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
await this.updateWorkflowInComfyUI(data.workflow);
|
||
this.showNotification(`✓ Linked ${list.length} selection${list.length>1?'s':''}`, 'success');
|
||
// Clear queue and refresh analysis
|
||
this.pendingResolutions = [];
|
||
this.pendingIndex = new Map();
|
||
this.updateApplyPendingButton();
|
||
this.updateQueuePanel();
|
||
await this.loadWorkflowData(data.workflow);
|
||
} else {
|
||
this.showNotification('Failed to apply selections: ' + (data.error || 'Unknown error'), 'error');
|
||
}
|
||
} catch (e) {
|
||
console.error('Model Linker: applyPendingResolutions error', e);
|
||
this.showNotification('Error applying selections: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
updateApplyPendingButton() {
|
||
if (!this.applyPendingBtn) return;
|
||
const count = this.pendingResolutions?.length || 0;
|
||
this.applyPendingBtn.textContent = `Apply Selected (${count})`;
|
||
this.applyPendingBtn.disabled = count === 0;
|
||
}
|
||
|
||
createFooter() {
|
||
// Store reference to download all button so we can update its text
|
||
this.downloadAllButton = $el("button.ml-btn.ml-btn-download", {
|
||
onclick: () => this.handleDownloadAllClick(),
|
||
style: {
|
||
padding: "10px 20px",
|
||
fontSize: "13px"
|
||
}
|
||
}, [
|
||
$el("span.ml-btn-icon", { textContent: "☁" }),
|
||
$el("span", { textContent: " Download All Missing" })
|
||
]);
|
||
|
||
// Auto-resolve button (secondary style)
|
||
this.autoResolveButton = $el("button.ml-btn.ml-btn-secondary", {
|
||
onclick: () => this.autoResolve100Percent(),
|
||
style: {
|
||
padding: "10px 20px",
|
||
fontSize: "13px"
|
||
}
|
||
}, [
|
||
$el("span.ml-btn-icon", { textContent: "🔗" }),
|
||
$el("span", { textContent: " Auto-Link 100%" })
|
||
]);
|
||
|
||
// Apply pending resolutions button
|
||
this.applyPendingBtn = $el("button.ml-btn.ml-btn-primary", {
|
||
id: "apply-pending-resolutions",
|
||
textContent: "Apply Selected (0)",
|
||
onclick: () => this.applyPendingResolutions(),
|
||
style: {
|
||
padding: "10px 20px",
|
||
fontSize: "13px"
|
||
}
|
||
});
|
||
|
||
return $el("div.ml-footer", {
|
||
style: {
|
||
position: "sticky",
|
||
bottom: "0",
|
||
backgroundColor: "var(--ml-bg, #222)",
|
||
backdropFilter: "blur(8px)",
|
||
WebkitBackdropFilter: "blur(8px)"
|
||
}
|
||
}, [
|
||
this.autoResolveButton,
|
||
this.applyPendingBtn,
|
||
this.downloadAllButton
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Handle click on Download All / Cancel All button
|
||
*/
|
||
handleDownloadAllClick() {
|
||
if (Object.keys(this.activeDownloads).length > 0) {
|
||
// Cancel all active downloads
|
||
this.cancelAllDownloads();
|
||
} else {
|
||
// Start downloading all missing
|
||
this.downloadAllMissing();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cancel all active downloads
|
||
*/
|
||
async cancelAllDownloads() {
|
||
const downloadIds = Object.keys(this.activeDownloads);
|
||
if (downloadIds.length === 0) return;
|
||
|
||
this.showNotification(`Cancelling ${downloadIds.length} download${downloadIds.length > 1 ? 's' : ''}...`, 'info');
|
||
|
||
for (const downloadId of downloadIds) {
|
||
try {
|
||
await api.fetchApi(`/model_linker/cancel/${downloadId}`, {
|
||
method: 'POST'
|
||
});
|
||
} catch (error) {
|
||
console.error('Model Linker: Error cancelling download:', error);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update the Download All button state based on active downloads
|
||
*/
|
||
updateDownloadAllButtonState() {
|
||
if (!this.downloadAllButton) return;
|
||
|
||
const activeCount = Object.keys(this.activeDownloads).length;
|
||
if (activeCount > 0) {
|
||
this.downloadAllButton.innerHTML = `<span class="ml-btn-icon">✕</span> Cancel All (${activeCount})`;
|
||
this.downloadAllButton.classList.remove('ml-btn-download');
|
||
this.downloadAllButton.classList.add('ml-btn-danger');
|
||
} else {
|
||
this.downloadAllButton.innerHTML = `<span class="ml-btn-icon">☁</span> Download All Missing`;
|
||
this.downloadAllButton.classList.remove('ml-btn-danger');
|
||
this.downloadAllButton.classList.add('ml-btn-download');
|
||
}
|
||
}
|
||
|
||
async show(workflow = null) {
|
||
this.backdrop.style.display = "block";
|
||
this.element.style.display = "flex";
|
||
|
||
// Update button state in case there are active downloads
|
||
this.updateDownloadAllButtonState();
|
||
|
||
// Ensure all models are loaded for dropdown
|
||
await this.ensureAllModelsLoaded();
|
||
|
||
// Always default to Missing Models tab when opening dialog
|
||
if (this.activeTab !== 'missing') {
|
||
// Manually switch tab without loading
|
||
this.activeTab = 'missing';
|
||
this.missingTab.classList.add('ml-tab-active');
|
||
this.loadedTab.classList.remove('ml-tab-active');
|
||
this.downloadAllButton.style.display = 'inline-flex';
|
||
this.autoResolveButton.style.display = 'inline-flex';
|
||
}
|
||
|
||
// Restore fullscreen state if enabled
|
||
try {
|
||
const fs = localStorage.getItem('model_linker_modal_fullscreen');
|
||
if (fs === '1') this.setFullScreen(true);
|
||
} catch (e) { }
|
||
|
||
// Attach drag handle event listener (only once)
|
||
this.attachDragHandleIfNeeded();
|
||
|
||
// Use provided workflow or fetch from current graph
|
||
await this.loadWorkflowData(workflow);
|
||
}
|
||
|
||
// Attach drag handle event listeners
|
||
attachDragHandleIfNeeded() {
|
||
if (this._dragHandleAttached) return;
|
||
|
||
const handle = document.getElementById('model-linker-drag-handle');
|
||
if (!handle) return;
|
||
|
||
const onMouseDown = (e) => {
|
||
if (this.fullscreen) return; // no drag in fullscreen
|
||
handle.style.cursor = 'grabbing';
|
||
this.startDrag(e);
|
||
};
|
||
const onMouseUp = () => { handle.style.cursor = 'grab'; };
|
||
|
||
handle.addEventListener('mousedown', onMouseDown);
|
||
document.addEventListener('mouseup', onMouseUp, { once: true });
|
||
|
||
this._dragHandleAttached = true;
|
||
}
|
||
|
||
close() {
|
||
this._hidePreview?.();
|
||
this.backdrop.style.display = "none";
|
||
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 = '<p>Analyzing workflow...</p>';
|
||
|
||
try {
|
||
// Use provided workflow, or get current workflow from ComfyUI
|
||
if (!workflow) {
|
||
workflow = this.getCurrentWorkflow();
|
||
}
|
||
|
||
if (!workflow) {
|
||
this.contentElement.innerHTML = '<p>No workflow loaded. Please load a workflow first.</p>';
|
||
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.searchResultCache.clear();
|
||
this.displayMissingModels(this.contentElement, data);
|
||
|
||
// Reconnect any active downloads to their new progress divs
|
||
this.reconnectActiveDownloads();
|
||
|
||
} catch (error) {
|
||
console.error('Model Linker: Error loading workflow data:', error);
|
||
if (this.contentElement) {
|
||
this.contentElement.innerHTML = `<p style="color: red;">Error: ${error.message}</p>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* Locate and focus a node in the ComfyUI canvas
|
||
*/
|
||
locateNodeInGraph(nodeId) {
|
||
try {
|
||
if (!app?.graph) {
|
||
this.showNotification('Cannot locate node - graph not available', 'error');
|
||
return;
|
||
}
|
||
|
||
// Find the node in the graph
|
||
const node = app.graph.getNodeById(nodeId);
|
||
if (!node) {
|
||
this.showNotification(`Node #${nodeId} not found in graph`, 'error');
|
||
return;
|
||
}
|
||
|
||
// Focus on the node in the canvas
|
||
if (app.canvas && typeof app.canvas.centerOnNode === 'function') {
|
||
// Modern ComfyUI versions
|
||
app.canvas.centerOnNode(node);
|
||
} else if (app.graph._nodes && app.graph._nodes.get(nodeId)) {
|
||
// Alternative method for older versions
|
||
const canvasNode = app.graph._nodes.get(nodeId);
|
||
if (canvasNode && canvasNode.setSelected && canvasNode.graph) {
|
||
canvasNode.setSelected(true);
|
||
// Scroll to node
|
||
app.canvas.scrollToNode(canvasNode);
|
||
}
|
||
} else if (app.ui && app.ui.nodeGraph && typeof app.ui.nodeGraph.scrollToNode === 'function') {
|
||
// Alternative for other versions
|
||
app.ui.nodeGraph.scrollToNode(node);
|
||
}
|
||
|
||
// Also try to flash/select the node
|
||
// Deselect all nodes first
|
||
if (app.graph && app.graph.nodes) {
|
||
app.graph.nodes.forEach(n => {
|
||
if (n.selected) n.selected = false;
|
||
});
|
||
}
|
||
|
||
// Select and highlight our node
|
||
node.selected = true;
|
||
|
||
this.showNotification(`Focused on Node #${nodeId} (${node.type})`, 'info');
|
||
} catch (e) {
|
||
console.error('Model Linker: Error locating node:', e);
|
||
this.showNotification('Error locating node: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reconnect active downloads to their new progress div elements after UI refresh
|
||
*/
|
||
reconnectActiveDownloads() {
|
||
if (!this.contentElement) return;
|
||
|
||
for (const [downloadId, info] of Object.entries(this.activeDownloads)) {
|
||
const { missing } = info;
|
||
if (!missing) continue;
|
||
|
||
// Find the new progress div by ID
|
||
const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`;
|
||
const newProgressDiv = this.contentElement.querySelector(`#${progressId}`);
|
||
const newDownloadBtn = this.contentElement.querySelector(`#download-${missing.node_id}-${missing.widget_index}`);
|
||
|
||
if (newProgressDiv) {
|
||
// Update the reference
|
||
info.progressDiv = newProgressDiv;
|
||
info.downloadBtn = newDownloadBtn;
|
||
|
||
// Show that download is in progress
|
||
newProgressDiv.style.display = 'block';
|
||
newProgressDiv.innerHTML = `
|
||
<div class="ml-progress-container">
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<div class="ml-progress-bar" style="flex: 1;">
|
||
<div class="ml-progress-fill" style="width: 0%;"></div>
|
||
</div>
|
||
<button class="cancel-download-btn ml-btn ml-btn-danger ml-btn-sm" data-download-id="${downloadId}">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
<div class="ml-progress-text">
|
||
<span style="color: #2196F3;">Downloading...</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Attach cancel handler
|
||
const cancelBtn = newProgressDiv.querySelector('.cancel-download-btn');
|
||
if (cancelBtn) {
|
||
cancelBtn.addEventListener('click', () => this.cancelDownload(downloadId));
|
||
}
|
||
|
||
// Update download button if exists
|
||
if (newDownloadBtn) {
|
||
newDownloadBtn.disabled = true;
|
||
newDownloadBtn.textContent = 'Downloading...';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Display missing models in the dialog
|
||
*/
|
||
displayMissingModels(container, data) {
|
||
const missingModels = data.missing_models || [];
|
||
const totalMissing = data.total_missing || 0;
|
||
|
||
// Check if there are active downloads
|
||
const activeCount = Object.keys(this.activeDownloads).length;
|
||
|
||
// Check if any model has a 100% confidence match
|
||
const hasAny100Match = missingModels.some(m =>
|
||
(m.matches || []).some(match => match.confidence === 100)
|
||
);
|
||
|
||
// Show/hide Auto-Link button based on whether 100% matches exist
|
||
if (this.autoResolveButton) {
|
||
this.autoResolveButton.style.display = hasAny100Match ? 'inline-flex' : 'none';
|
||
}
|
||
|
||
// Hide download all button if no missing models
|
||
if (this.downloadAllButton) {
|
||
this.downloadAllButton.style.display = totalMissing > 0 ? 'inline-flex' : 'none';
|
||
}
|
||
|
||
if (totalMissing === 0 && activeCount === 0) {
|
||
container.innerHTML = this.renderStatusMessage('All models are available! No missing models found.', 'success');
|
||
return;
|
||
}
|
||
|
||
// If no missing models but downloads are active, show a waiting message
|
||
if (totalMissing === 0 && activeCount > 0) {
|
||
container.innerHTML = this.renderStatusMessage(
|
||
`${activeCount} download${activeCount > 1 ? 's' : ''} in progress. Models will be auto-linked when complete.`,
|
||
'info'
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Summary header with count
|
||
let html = `
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--ml-border);">
|
||
<div>
|
||
<span style="font-size: 15px; font-weight: 600; color: var(--ml-text);">
|
||
${totalMissing} Missing Model${totalMissing > 1 ? 's' : ''}
|
||
</span>
|
||
${activeCount > 0 ? `<span style="margin-left: 12px; color: var(--ml-text-muted); font-size: 12px;">${activeCount} downloading</span>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
html += '<div style="display: flex; flex-direction: column; gap: 8px;">';
|
||
|
||
// Skip rendering if active tab is not "missing"
|
||
if (this.activeTab !== 'missing') {
|
||
container.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
// 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 (let mi = 0; mi < sortedMissingModels.length; mi++) {
|
||
html += this.renderMissingModel(sortedMissingModels[mi], mi);
|
||
}
|
||
|
||
html += '</div>';
|
||
container.innerHTML = html;
|
||
|
||
// Attach event listeners for resolve buttons (use sorted order)
|
||
// Note: We need to match the exact same logic as renderMissingModel to find which buttons were rendered
|
||
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 otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70);
|
||
|
||
// Match the same logic as renderMissingModel
|
||
const matchesToShow = perfectMatches.length > 0
|
||
? perfectMatches
|
||
: otherMatches.sort((a, b) => b.confidence - a.confidence).slice(0, 5);
|
||
|
||
// Sort: 100% matches first, then by confidence descending (same as renderMissingModel)
|
||
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;
|
||
});
|
||
|
||
sortedMatches.forEach((match, matchIndex) => {
|
||
// Attach listener to all matches (all now have resolve buttons)
|
||
// Use unique ID that includes missingIndex to avoid conflicts between different missing models with same node_id
|
||
const buttonId = `resolve-${missingIndex}-${missing.node_id}-${missing.widget_index}-${matchIndex}`;
|
||
const resolveButton = container.querySelector(`#${buttonId}`);
|
||
console.log('Attaching listener for', buttonId, 'missingIndex:', missingIndex);
|
||
if (resolveButton) {
|
||
// Remove any existing listeners to prevent duplicates
|
||
resolveButton.onclick = null;
|
||
resolveButton.addEventListener('click', () => {
|
||
console.log('>>> Queue button clicked:', buttonId, missing.original_path, '->', match.model?.filename);
|
||
// Queue the selection instead of directly applying
|
||
this.queueResolution(missing, match.model);
|
||
});
|
||
} else {
|
||
console.warn('Button not found:', buttonId);
|
||
}
|
||
});
|
||
|
||
// Attach alternative (other matches below 100%) button listeners
|
||
if (otherMatches && otherMatches.length > 0) {
|
||
for (let mIdx = 0; mIdx < otherMatches.length; mIdx++) {
|
||
const match = otherMatches[mIdx];
|
||
const altBtnId = `resolve-alt-${missingIndex}-${missing.node_id}-${missing.widget_index}-${mIdx}`;
|
||
const altBtn = container.querySelector(`#${altBtnId}`);
|
||
if (altBtn) {
|
||
altBtn.addEventListener('click', () => {
|
||
this.queueResolution(missing, match.model);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Attach download button listener
|
||
const downloadBtnId = `download-${missing.node_id}-${missing.widget_index}`;
|
||
const downloadBtn = container.querySelector(`#${downloadBtnId}`);
|
||
if (downloadBtn && missing.download_source) {
|
||
downloadBtn.addEventListener('click', () => {
|
||
this.downloadModel(missing);
|
||
});
|
||
}
|
||
|
||
// Attach search button listener
|
||
const searchBtnId = `search-${missing.node_id}-${missing.widget_index}`;
|
||
const searchBtn = container.querySelector(`#${searchBtnId}`);
|
||
if (searchBtn) {
|
||
searchBtn.addEventListener('click', () => {
|
||
this.searchOnline(missing);
|
||
});
|
||
}
|
||
|
||
const sourceButtons = container.querySelectorAll('.ml-search-source-btn');
|
||
sourceButtons.forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
this.setSearchSource(missing, btn.dataset.searchSource, container);
|
||
});
|
||
});
|
||
if (sourceButtons.length > 0) {
|
||
this.syncSearchSourceUi(missing, container);
|
||
}
|
||
|
||
// Wire Locate button (only for top-level nodes)
|
||
const locateId = `locate-${missing.node_id}-${missing.widget_index}`;
|
||
const locateBtn = container.querySelector(`#${locateId}`);
|
||
if (locateBtn && missing.is_top_level !== false) {
|
||
locateBtn.addEventListener('click', () => {
|
||
this.locateNodeInGraph(missing.node_id);
|
||
});
|
||
}
|
||
|
||
// Wire up all-models search + dropdown (combo-style)
|
||
const comboId = `combo-${missing.node_id}-${missing.widget_index}`;
|
||
const comboInput = container.querySelector(`#combo-input-${comboId}`);
|
||
const comboList = container.querySelector(`#combo-list-${comboId}`);
|
||
const comboRefresh = container.querySelector(`#combo-refresh-${comboId}`);
|
||
|
||
const allModels = Array.isArray(this.allModels) ? this.allModels : [];
|
||
const buildLabel = (m) => `${m.category ? m.category + ': ' : ''}${m.relative_path || m.filename || ''}`;
|
||
const getFolder = (m) => m.path || m.base_directory || '';
|
||
|
||
// Populate dropdown with filtered models
|
||
const populateComboOptions = (filterText, highlightIdx = -1) => {
|
||
if (!comboList) return;
|
||
const f = (filterText || '').toLowerCase();
|
||
const filtered = f
|
||
? allModels.filter(m => buildLabel(m).toLowerCase().includes(f))
|
||
: allModels.slice(); // Copy to avoid mutation
|
||
|
||
let html = '';
|
||
for (let i = 0; i < filtered.length; i++) {
|
||
const m = filtered[i];
|
||
const label = buildLabel(m);
|
||
const folder = getFolder(m);
|
||
const isHighlighted = i === highlightIdx;
|
||
const folderDisplay = folder ? folder.replace(/\\/g, '/').replace(/:/, '') : '';
|
||
html += `<div data-idx="${allModels.indexOf(m)}" style="padding: 6px; cursor:pointer; ${isHighlighted ? 'background: rgba(0,122,204,0.25);' : ''}">`;
|
||
html += `<div style="display:flex; align-items:center; gap:6px;">`;
|
||
html += `<code style="flex: 1 1 0%; white-space: nowrap;">${label}</code>`;
|
||
html += `</div>`;
|
||
if (folderDisplay) {
|
||
html += `<div style="font-size: 10px; color: #888; opacity: 0.8; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${folderDisplay}">📁 ${folderDisplay}</div>`;
|
||
}
|
||
html += `</div>`;
|
||
}
|
||
comboList.innerHTML = html;
|
||
|
||
// Add click listeners to options
|
||
comboList.querySelectorAll('div[data-idx]').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
const idx = parseInt(el.dataset.idx, 10);
|
||
if (!isNaN(idx) && idx >= 0 && idx < allModels.length) {
|
||
const chosenModel = allModels[idx];
|
||
if (chosenModel) {
|
||
this.queueResolution(missing, chosenModel);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
};
|
||
|
||
// Initial populate
|
||
if (comboList) {
|
||
populateComboOptions('');
|
||
}
|
||
|
||
// Filter input with debounce
|
||
if (comboInput) {
|
||
const debouncedFilter = this.debounce(() => {
|
||
populateComboOptions(comboInput.value);
|
||
}, 200);
|
||
comboInput.addEventListener('input', debouncedFilter);
|
||
|
||
// Show dropdown on focus
|
||
comboInput.addEventListener('focus', () => {
|
||
if (comboList) comboList.style.display = 'block';
|
||
populateComboOptions(comboInput.value);
|
||
});
|
||
|
||
// Close on blur (with delay to allow click)
|
||
comboInput.addEventListener('blur', () => {
|
||
setTimeout(() => {
|
||
if (comboList) comboList.style.display = 'none';
|
||
}, 200);
|
||
});
|
||
}
|
||
|
||
// Refresh button - reload all models
|
||
if (comboRefresh) {
|
||
comboRefresh.addEventListener('click', async () => {
|
||
this.allModels = null; // Force reload
|
||
await this.ensureAllModelsLoaded();
|
||
populateComboOptions(comboInput?.value || '');
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Render a single missing model entry
|
||
*/
|
||
renderMissingModel(missing, missingIndex = 0) {
|
||
const allMatches = missing.matches || [];
|
||
|
||
// Filter out matches below 70% confidence threshold
|
||
const filteredMatches = allMatches.filter(m => m.confidence >= 70);
|
||
const hasMatches = filteredMatches.length > 0;
|
||
|
||
// Calculate 100% matches upfront (needed for download section)
|
||
const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
|
||
const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70);
|
||
|
||
// Format the missing filename for display
|
||
const missingFilename = this.formatFilename(missing.original_path, 60);
|
||
|
||
// Determine node info for the chip
|
||
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);
|
||
let nodeLabel;
|
||
if (missing.subgraph_name) {
|
||
nodeLabel = missing.subgraph_name;
|
||
} else if (isSubgraphNode) {
|
||
nodeLabel = 'Subgraph';
|
||
} else {
|
||
nodeLabel = missing.node_type || 'Node';
|
||
}
|
||
|
||
// Start card
|
||
let html = `<div class="ml-card">`;
|
||
|
||
// Card Header: Filename as headline + node chip
|
||
html += `<div class="ml-card-header">`;
|
||
|
||
// Always show the URN/ filename first
|
||
let titleHtml = `<span title="${missingFilename.full}">${missingFilename.display}</span>`;
|
||
|
||
const modelId = missing.urn_model_id || missing.urn?.model_id;
|
||
const versionId = missing.urn_version_id || missing.urn?.version_id;
|
||
const modelUrl = missing.is_urn && modelId ? `https://civitai.com/models/${modelId}${versionId ? '?modelVersionId=' + versionId : ''}` : '';
|
||
const urnLoadingId = `urn-loading-${missing.node_id}-${missing.widget_index}`;
|
||
|
||
if (missing.is_urn && !missing.civitai_info) {
|
||
// URN without info - show Loading and fetch async in background
|
||
titleHtml += ` <span id="${urnLoadingId}" style="color: var(--ml-text-muted); font-size: 12px;">⏳ Loading...</span>`;
|
||
setTimeout(() => this.resolveUrnAsync(modelId, versionId, urnLoadingId, modelUrl), 10);
|
||
} else if (missing.is_urn && missing.civitai_info) {
|
||
// URN with resolved info - show model name/version
|
||
const civitaiInfo = missing.civitai_info;
|
||
let civitaiLabel = '';
|
||
if (civitaiInfo.model_name) {
|
||
civitaiLabel += civitaiInfo.model_name;
|
||
}
|
||
if (civitaiInfo.version_name && civitaiInfo.version_name !== civitaiInfo.model_name) {
|
||
civitaiLabel += ` v${civitaiInfo.version_name}`;
|
||
}
|
||
if (civitaiLabel) {
|
||
const linkHtml = modelUrl ? `<a href="${modelUrl}" target="_blank" style="color: #FF9800; font-size: 13px; font-weight: 600;">${civitaiLabel}</a>` : `<span style="color: #FF9800; font-size: 13px; font-weight: 600;">${civitaiLabel}</span>`;
|
||
titleHtml += ` ${linkHtml}`;
|
||
}
|
||
if (civitaiInfo.expected_filename) {
|
||
titleHtml += ` <span style="color: var(--ml-text-muted); font-size: 11px;">"${civitaiInfo.expected_filename}"</span>`;
|
||
}
|
||
}
|
||
|
||
html += `<h3 class="ml-card-title">${titleHtml}</h3>`;
|
||
html += `<div style="display: flex; align-items: center; gap: 6px; flex-wrap: wrap;">`;
|
||
if (missing.category) {
|
||
html += `<span class="ml-category-chip">${missing.category}</span>`;
|
||
}
|
||
html += `<span class="ml-node-chip">${nodeLabel} #${missing.node_id}</span>`;
|
||
// Add Locate button for top-level nodes only
|
||
if (missing.is_top_level !== false) {
|
||
const locateId = `locate-${missing.node_id}-${missing.widget_index}`;
|
||
html += `<button id="${locateId}" class="ml-btn ml-btn-secondary ml-btn-sm" style="padding: 2px 8px; font-size: 11px;">📍 Locate</button>`;
|
||
}
|
||
html += `</div>`;
|
||
// Removed - filename is already shown in title
|
||
html += `</div>`;
|
||
|
||
// Selected bar - shows if this slot has a queued selection (BELOW card header)
|
||
const selectedBarId = `selected-bar-${missing.node_id}-${missing.widget_index}`;
|
||
html += `<div id="${selectedBarId}" class="model-linker-selected" style="display: none; margin: 8px 0; padding: 8px; background: rgba(0, 122, 204, 0.15); border: 1px solid rgba(0, 122, 204, 0.4); border-radius: 4px;"></div>`;
|
||
|
||
// Two-column layout
|
||
html += `<div class="ml-columns">`;
|
||
|
||
// LEFT COLUMN: Local Matches
|
||
html += `<div class="ml-column">`;
|
||
html += `<div class="ml-column-header">Local Matches</div>`;
|
||
|
||
if (hasMatches) {
|
||
// 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);
|
||
|
||
// 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-${missingIndex}-${missing.node_id}-${missing.widget_index}-${matchIndex}`;
|
||
const matchPath = match.model?.relative_path || match.filename || '';
|
||
const formattedPath = this.formatPath(matchPath, 45);
|
||
const isBestMatch = matchIndex === 0 && match.confidence >= 95;
|
||
|
||
html += `<div class="ml-match-row ${isBestMatch ? 'ml-best-match' : ''}">`;
|
||
html += this.getConfidenceBadge(match.confidence);
|
||
html += `<span class="ml-match-filename" title="${formattedPath.full}">${formattedPath.display}</span>`;
|
||
html += `<button id="${buttonId}" class="ml-btn ${isBestMatch ? 'ml-btn-primary' : 'ml-btn-secondary'} ml-btn-sm">`;
|
||
html += `<span class="ml-btn-icon">🔗</span> Link`;
|
||
html += `</button>`;
|
||
html += `</div>`;
|
||
}
|
||
|
||
// Add note if only showing 100% matches - make it expandable
|
||
if (perfectMatches.length > 0 && otherMatches.length > 0) {
|
||
const matchId = `more-matches-${missing.node_id}-${missing.widget_index}`;
|
||
html += `<div class="ml-no-matches" style="cursor: pointer; color: #2196F3;" onclick="document.getElementById('${matchId}').style.display = document.getElementById('${matchId}').style.display === 'none' ? 'block' : 'none'; this.textContent = this.textContent === '${otherMatches.length} other matches below 100%' ? 'Hide alternatives' : '${otherMatches.length} other matches below 100%'">${otherMatches.length} other match${otherMatches.length > 1 ? 'es' : ''} below 100%</div>`;
|
||
// Hidden container with other matches
|
||
html += `<div id="${matchId}" style="display: none; flex-direction: column; gap: 4px; margin-top: 8px;">`;
|
||
for (let mIdx = 0; mIdx < otherMatches.length; mIdx++) {
|
||
const match = otherMatches[mIdx];
|
||
const confClass = match.confidence >= 70 ? 'ml-badge-medium' : 'ml-badge-low';
|
||
const altBtnId = `resolve-alt-${missingIndex}-${missing.node_id}-${missing.widget_index}-${mIdx}`;
|
||
html += `<div class="ml-match-row" style="padding: 4px; background: var(--ml-bg-secondary); border-radius: 4px;">`;
|
||
html += `<span class="ml-badge ${confClass}">${match.confidence}%</span>`;
|
||
html += `<span class="ml-match-filename" title="${match.path || match.filename}" style="flex: 1; overflow: hidden; text-overflow: ellipsis;">${match.filename || match.path?.split(/[/\\]/).pop()}</span>`;
|
||
html += `<button id="${altBtnId}" class="ml-btn ml-btn-secondary ml-btn-sm">🔗 Link</button>`;
|
||
html += `</div>`;
|
||
}
|
||
html += `</div>`;
|
||
}
|
||
} else if (allMatches.length > 0 && filteredMatches.length === 0) {
|
||
html += `<div class="ml-no-matches">No matches above 70% confidence</div>`;
|
||
} else {
|
||
html += `<div class="ml-no-matches">No local matches found</div>`;
|
||
}
|
||
|
||
// Add all-models search picker - combo-style dropdown
|
||
const comboId = `combo-${missing.node_id}-${missing.widget_index}`;
|
||
html += `<div style="position: relative; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--ml-border);">`;
|
||
html += `<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 8px;">`;
|
||
html += `<label style="opacity: 0.9; font-size: 12px; color: var(--ml-text-muted);">Model:</label>`;
|
||
html += `<input id="combo-input-${comboId}" type="text" placeholder="type to filter..." style="flex: 1; padding: 6px; background: var(--comfy-input-bg); color: var(--input-text); border: 1px solid var(--border-color); border-radius: 4px; font-size: 13px;">`;
|
||
html += `<button id="combo-refresh-${comboId}" title="Refresh model list" class="ml-btn ml-btn-secondary ml-btn-sm" style="padding: 4px 8px;">⟳</button>`;
|
||
html += `</div>`;
|
||
html += `<div id="combo-list-${comboId}" style="position: absolute; top: 100%; left: 55px; background: var(--comfy-input-bg, #2f2f2f); border: 1px solid var(--border-color); border-radius: 4px; max-height: 280px; overflow: auto; display: none; z-index: 100000; right: auto; width: calc(100% - 60px);"></div>`;
|
||
html += `</div>`;
|
||
|
||
html += `</div>`; // End left column
|
||
|
||
// RIGHT COLUMN: Download Option
|
||
html += `<div class="ml-column">`;
|
||
html += `<div class="ml-column-header">Download</div>`;
|
||
|
||
const filename = missing.original_path?.split('/').pop()?.split('\\').pop() || '';
|
||
const downloadSource = missing.download_source;
|
||
|
||
if (perfectMatches.length > 0) {
|
||
// Has perfect local match - download not needed
|
||
html += `<div class="ml-no-matches">Not needed - exact local match available</div>`;
|
||
} else if (downloadSource && downloadSource.url) {
|
||
// We have a known download URL
|
||
const isExact = downloadSource.match_type === 'exact' || downloadSource.source === 'popular' || downloadSource.source === 'huggingface' || downloadSource.source === 'civitai';
|
||
const isFromWorkflow = downloadSource.url_source === 'workflow';
|
||
const sourceLabels = {
|
||
'popular': 'Popular Models',
|
||
'model_list': 'Model Database',
|
||
'huggingface': 'HuggingFace',
|
||
'civitai': 'CivitAI',
|
||
'workflow': 'Workflow'
|
||
};
|
||
const sourceLabel = isFromWorkflow ? 'Workflow' : (sourceLabels[downloadSource.source] || 'Online');
|
||
const downloadFilename = downloadSource.filename || filename;
|
||
const formattedDownloadName = this.formatFilename(downloadFilename, 45);
|
||
|
||
// Format file size
|
||
let sizeDisplay = '';
|
||
if (downloadSource.size) {
|
||
if (typeof downloadSource.size === 'number') {
|
||
sizeDisplay = this.formatBytes(downloadSource.size);
|
||
} else {
|
||
sizeDisplay = downloadSource.size;
|
||
}
|
||
}
|
||
|
||
html += `<div class="ml-download-section">`;
|
||
html += `<button id="download-${missing.node_id}-${missing.widget_index}" class="ml-btn ml-btn-download">`;
|
||
html += `<span class="ml-btn-icon">☁</span> Download${sizeDisplay ? ` (${sizeDisplay})` : ''}`;
|
||
html += `</button>`;
|
||
html += `<div class="ml-download-info">`;
|
||
html += `<span class="ml-download-source">${isFromWorkflow ? 'URL from workflow' : sourceLabel}</span>`;
|
||
const modelCardUrl = this.getModelCardUrl(downloadSource.url);
|
||
if (modelCardUrl) {
|
||
html += `<br><a href="${modelCardUrl}" target="_blank" rel="noopener noreferrer" class="ml-link" title="Open model card">${formattedDownloadName.display}</a>`;
|
||
} else {
|
||
html += `<br><span title="${formattedDownloadName.full}">${formattedDownloadName.display}</span>`;
|
||
}
|
||
html += `</div>`;
|
||
html += `</div>`;
|
||
} else {
|
||
// No known download - offer search
|
||
html += `<div class="ml-download-section">`;
|
||
const searchSourcesId = `search-sources-${missing.node_id}-${missing.widget_index}`;
|
||
const searchInfoId = `search-source-info-${missing.node_id}-${missing.widget_index}`;
|
||
html += `<div id="${searchSourcesId}" style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px;">`;
|
||
html += `<button type="button" class="ml-btn ml-btn-secondary ml-btn-sm ml-search-source-btn" data-search-source="local">Local DB</button>`;
|
||
html += `<button type="button" class="ml-btn ml-btn-secondary ml-btn-sm ml-search-source-btn" data-search-source="huggingface">HuggingFace</button>`;
|
||
html += `<button type="button" class="ml-btn ml-btn-secondary ml-btn-sm ml-search-source-btn" data-search-source="civitai">CivitAI</button>`;
|
||
html += `<button type="button" class="ml-btn ml-btn-secondary ml-btn-sm ml-search-source-btn" data-search-source="all">All</button>`;
|
||
html += `</div>`;
|
||
html += `<button id="search-${missing.node_id}-${missing.widget_index}" class="ml-btn ml-btn-link">`;
|
||
html += `<span class="ml-btn-icon">🔍</span> Search Online`;
|
||
html += `</button>`;
|
||
html += `<div id="${searchInfoId}" class="ml-download-info">Selected source: Everything</div>`;
|
||
html += `</div>`;
|
||
html += `<div id="search-results-${missing.node_id}-${missing.widget_index}" style="margin-top: 8px; display: none;"></div>`;
|
||
}
|
||
|
||
// Progress container (for downloads)
|
||
html += `<div id="download-progress-${missing.node_id}-${missing.widget_index}" style="margin-top: 8px; display: none;"></div>`;
|
||
|
||
html += `</div>`; // End right column
|
||
html += `</div>`; // End columns
|
||
|
||
html += `</div>`; // End card
|
||
return html;
|
||
}
|
||
|
||
/**
|
||
* Show a notification banner (similar to ComfyUI's "Reconnecting" banner)
|
||
*/
|
||
showNotification(message, type = 'success') {
|
||
// Build children array, filtering out nulls
|
||
const children = [];
|
||
|
||
if (type === 'success') {
|
||
children.push($el("span", {
|
||
textContent: "✓",
|
||
style: {
|
||
fontSize: "18px",
|
||
fontWeight: "bold"
|
||
}
|
||
}));
|
||
} else if (type === 'error') {
|
||
children.push($el("span", {
|
||
textContent: "×",
|
||
style: {
|
||
fontSize: "18px",
|
||
fontWeight: "bold"
|
||
}
|
||
}));
|
||
} else if (type === 'info') {
|
||
children.push($el("span", {
|
||
textContent: "ℹ",
|
||
style: {
|
||
fontSize: "18px",
|
||
fontWeight: "bold"
|
||
}
|
||
}));
|
||
}
|
||
|
||
// 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"
|
||
}
|
||
}, [
|
||
...children,
|
||
$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 model - resolves ALL nodes that reference this model
|
||
*/
|
||
async resolveModel(missing, resolvedModel) {
|
||
console.log('resolveModel called:', missing?.original_path, '->', resolvedModel?.filename);
|
||
|
||
if (!resolvedModel) {
|
||
this.showNotification('No resolved model selected', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const workflow = this.getCurrentWorkflow();
|
||
if (!workflow) {
|
||
this.showNotification('No workflow loaded', 'error');
|
||
return;
|
||
}
|
||
|
||
// Resolve ALL nodes that need this model (all_node_refs contains deduplicated refs)
|
||
const nodeRefs = missing.all_node_refs || [missing];
|
||
console.log('nodeRefs count:', nodeRefs?.length, 'is_lora_v2:', nodeRefs?.[0]?.is_lora_v2);
|
||
|
||
const resolutions = nodeRefs.map(ref => ({
|
||
node_id: ref.node_id,
|
||
widget_index: ref.widget_index,
|
||
resolved_path: resolvedModel.path,
|
||
category: ref.category,
|
||
resolved_model: resolvedModel,
|
||
subgraph_id: ref.subgraph_id,
|
||
is_top_level: ref.is_top_level,
|
||
is_lora_v2: ref.is_lora_v2,
|
||
original_lora_name: ref.name || ref.original_path
|
||
}));
|
||
|
||
const response = await api.fetchApi('/model_linker/resolve', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
workflow,
|
||
resolutions: resolutions
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`API error: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
console.log('Resolve response: success=', data.success, ' missing count:', data.workflow?.nodes?.length);
|
||
|
||
if (data.success) {
|
||
// Update workflow in ComfyUI
|
||
await this.updateWorkflowInComfyUI(data.workflow);
|
||
|
||
// Show success notification
|
||
const modelName = resolvedModel.relative_path || resolvedModel.filename || 'model';
|
||
const count = resolutions.length;
|
||
const refText = count > 1 ? ` (${count} references)` : '';
|
||
this.showNotification(`✓ Model linked successfully: ${modelName}${refText}`, '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
|
||
* @returns {object|null} The updated workflow if successful, null otherwise
|
||
*/
|
||
async autoResolve100Percent() {
|
||
try {
|
||
const workflow = this.getCurrentWorkflow();
|
||
if (!workflow) {
|
||
this.showNotification('No workflow loaded', 'error');
|
||
return null;
|
||
}
|
||
|
||
// 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
|
||
is_lora_v2: missing.is_lora_v2,
|
||
original_lora_name: missing.name || missing.original_path
|
||
});
|
||
}
|
||
}
|
||
|
||
if (resolutions.length === 0) {
|
||
this.showNotification('No 100% confidence matches found to auto-resolve.', 'error');
|
||
return null;
|
||
}
|
||
|
||
// 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 (if dialog is visible)
|
||
if (this.contentElement) {
|
||
await this.loadWorkflowData(resolveData.workflow);
|
||
}
|
||
|
||
// Return the updated workflow for callers who need it
|
||
return resolveData.workflow;
|
||
} else {
|
||
this.showNotification('Failed to resolve models: ' + (resolveData.error || 'Unknown error'), 'error');
|
||
return null;
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Model Linker: Error auto-resolving:', error);
|
||
this.showNotification('Error auto-resolving: ' + error.message, 'error');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Download all missing models that have download sources but no 100% local match
|
||
*/
|
||
async downloadAllMissing() {
|
||
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 models that need downloading:
|
||
// - Have a download_source with valid URL
|
||
// - Do NOT have any 100% confidence local matches
|
||
const toDownload = [];
|
||
for (const missing of missingModels) {
|
||
const perfectMatches = (missing.matches || []).filter(m => m.confidence === 100);
|
||
|
||
// Skip if has 100% local match or no download source
|
||
if (perfectMatches.length > 0 || !missing.download_source?.url) {
|
||
continue;
|
||
}
|
||
|
||
toDownload.push(missing);
|
||
}
|
||
|
||
if (toDownload.length === 0) {
|
||
this.showNotification('No models available for download (all have local matches or no download URLs).', 'info');
|
||
return;
|
||
}
|
||
|
||
// Start all downloads
|
||
this.showNotification(`Starting ${toDownload.length} download${toDownload.length > 1 ? 's' : ''}...`, 'info');
|
||
|
||
for (const missing of toDownload) {
|
||
// Use downloadModel which handles progress tracking
|
||
this.downloadModel(missing);
|
||
}
|
||
|
||
// Update button state to show Cancel All
|
||
this.updateDownloadAllButtonState();
|
||
|
||
} catch (error) {
|
||
console.error('Model Linker: Error in downloadAllMissing:', error);
|
||
this.showNotification('Error starting downloads: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Auto-resolve a model after download completes
|
||
* Reloads the workflow analysis and resolves if the downloaded model is found
|
||
*/
|
||
async autoResolveAfterDownload(missing, downloadedFilename) {
|
||
try {
|
||
const workflow = this.getCurrentWorkflow();
|
||
if (!workflow) {
|
||
// Just reload the UI to show updated state
|
||
await this.loadWorkflowData();
|
||
return;
|
||
}
|
||
|
||
// Re-analyze workflow to find the newly downloaded model
|
||
const analyzeResponse = await api.fetchApi('/model_linker/analyze', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ workflow })
|
||
});
|
||
|
||
if (!analyzeResponse.ok) {
|
||
// Just reload UI
|
||
await this.loadWorkflowData();
|
||
return;
|
||
}
|
||
|
||
const analyzeData = await analyzeResponse.json();
|
||
const missingModels = analyzeData.missing_models || [];
|
||
|
||
// Find the missing model entry that matches our download by filename
|
||
const targetMissing = missingModels.find(m => {
|
||
const missingFilename = m.original_path?.split('/').pop()?.split('\\').pop() || '';
|
||
return missingFilename.toLowerCase() === downloadedFilename.toLowerCase();
|
||
});
|
||
|
||
if (!targetMissing) {
|
||
// Model no longer missing - already resolved or workflow changed
|
||
await this.loadWorkflowData();
|
||
return;
|
||
}
|
||
|
||
// Look for a 100% match with the downloaded filename
|
||
const matches = targetMissing.matches || [];
|
||
const perfectMatch = matches.find(m => {
|
||
const matchFilename = m.filename || m.model?.filename || '';
|
||
// Check for exact match or 100% confidence
|
||
return m.confidence === 100 ||
|
||
matchFilename.toLowerCase() === downloadedFilename.toLowerCase();
|
||
});
|
||
|
||
if (perfectMatch && perfectMatch.model) {
|
||
// Auto-resolve ALL nodes that need this model
|
||
// all_node_refs contains all nodes referencing this model (deduplicated)
|
||
const nodeRefs = targetMissing.all_node_refs || [targetMissing];
|
||
const resolutions = nodeRefs.map(ref => ({
|
||
node_id: ref.node_id,
|
||
widget_index: ref.widget_index,
|
||
resolved_path: perfectMatch.model.path,
|
||
category: ref.category,
|
||
resolved_model: perfectMatch.model,
|
||
subgraph_id: ref.subgraph_id,
|
||
is_top_level: ref.is_top_level,
|
||
is_lora_v2: ref.is_lora_v2,
|
||
original_lora_name: ref.name || ref.original_path
|
||
}));
|
||
|
||
const resolveResponse = await api.fetchApi('/model_linker/resolve', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
workflow,
|
||
resolutions: resolutions
|
||
})
|
||
});
|
||
|
||
if (resolveResponse.ok) {
|
||
const resolveData = await resolveResponse.json();
|
||
if (resolveData.success) {
|
||
await this.updateWorkflowInComfyUI(resolveData.workflow);
|
||
const count = resolutions.length;
|
||
this.showNotification(`✓ Auto-resolved: ${downloadedFilename} (${count} reference${count > 1 ? 's' : ''})`, 'success');
|
||
await this.loadWorkflowData(resolveData.workflow);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// If we couldn't auto-resolve, just reload the UI
|
||
await this.loadWorkflowData();
|
||
|
||
} catch (error) {
|
||
console.error('Model Linker: Error auto-resolving after download:', error);
|
||
// Still reload UI even on error
|
||
await this.loadWorkflowData();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Download a model from a known source
|
||
*/
|
||
async downloadModel(missing) {
|
||
const source = missing.download_source;
|
||
if (!source || !source.url) {
|
||
this.showNotification('No download URL available', 'error');
|
||
return;
|
||
}
|
||
|
||
// Use filename from download source if available (may be different from original)
|
||
const originalFilename = missing.original_path?.split('/').pop()?.split('\\').pop() || 'model.safetensors';
|
||
const filename = source.filename || originalFilename;
|
||
const category = source.directory || missing.category || 'checkpoints';
|
||
const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`;
|
||
const progressDiv = this.contentElement?.querySelector(`#${progressId}`);
|
||
const downloadBtn = this.contentElement?.querySelector(`#download-${missing.node_id}-${missing.widget_index}`);
|
||
|
||
try {
|
||
// Disable button and show progress with cancel button immediately
|
||
if (downloadBtn) {
|
||
downloadBtn.disabled = true;
|
||
downloadBtn.textContent = 'Starting...';
|
||
}
|
||
if (progressDiv) {
|
||
progressDiv.style.display = 'block';
|
||
// Show progress bar with cancel button immediately
|
||
progressDiv.innerHTML = `
|
||
<div class="ml-progress-container">
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<div class="ml-progress-bar" style="flex: 1;">
|
||
<div class="ml-progress-fill" style="width: 0%;"></div>
|
||
</div>
|
||
<button class="cancel-download-btn-pending ml-btn ml-btn-danger ml-btn-sm">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
<div class="ml-progress-text">
|
||
<span style="color: #2196F3;">Connecting...</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Start download
|
||
const response = await api.fetchApi('/model_linker/download', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
url: source.url,
|
||
filename: filename,
|
||
category: category
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Download failed: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
if (!data.success) {
|
||
throw new Error(data.error || 'Download failed');
|
||
}
|
||
|
||
// Track download and poll for progress
|
||
const downloadId = data.download_id;
|
||
this.activeDownloads[downloadId] = { missing, progressDiv, downloadBtn };
|
||
|
||
// Update the Download All button state
|
||
this.updateDownloadAllButtonState();
|
||
|
||
// Attach cancel handler to pending button (before polling replaces it)
|
||
const pendingCancelBtn = progressDiv?.querySelector('.cancel-download-btn-pending');
|
||
if (pendingCancelBtn) {
|
||
pendingCancelBtn.addEventListener('click', () => this.cancelDownload(downloadId));
|
||
}
|
||
|
||
this.pollDownloadProgress(downloadId);
|
||
|
||
} catch (error) {
|
||
console.error('Model Linker: Download error:', error);
|
||
if (progressDiv) {
|
||
progressDiv.innerHTML = this.renderStatusMessage(error.message, 'error');
|
||
}
|
||
if (downloadBtn) {
|
||
downloadBtn.disabled = false;
|
||
downloadBtn.innerHTML = '<span class="ml-btn-icon">☁</span> Retry';
|
||
}
|
||
this.showNotification('Download failed: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Poll download progress
|
||
*/
|
||
async pollDownloadProgress(downloadId) {
|
||
const info = this.activeDownloads[downloadId];
|
||
if (!info) return;
|
||
|
||
try {
|
||
const response = await api.fetchApi(`/model_linker/progress/${downloadId}`);
|
||
if (!response.ok) {
|
||
throw new Error('Failed to get progress');
|
||
}
|
||
|
||
const progress = await response.json();
|
||
const { progressDiv, downloadBtn, missing } = info;
|
||
|
||
if (progress.status === 'downloading' || progress.status === 'starting') {
|
||
const percent = progress.progress || 0;
|
||
const downloaded = this.formatBytes(progress.downloaded || 0);
|
||
const total = this.formatBytes(progress.total_size || 0);
|
||
const speed = progress.speed ? this.formatBytes(progress.speed) + '/s' : '';
|
||
|
||
if (progressDiv) {
|
||
progressDiv.innerHTML = `
|
||
<div class="ml-progress-container">
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<div class="ml-progress-bar" style="flex: 1;">
|
||
<div class="ml-progress-fill" style="width: ${percent}%;"></div>
|
||
</div>
|
||
<button class="cancel-download-btn ml-btn ml-btn-danger ml-btn-sm" data-download-id="${downloadId}">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
<div class="ml-progress-text">
|
||
<span>${downloaded} / ${total} (${percent}%)</span>
|
||
<span>${speed}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
// Attach cancel handler
|
||
const cancelBtn = progressDiv.querySelector('.cancel-download-btn');
|
||
if (cancelBtn && !cancelBtn._hasListener) {
|
||
cancelBtn._hasListener = true;
|
||
cancelBtn.addEventListener('click', () => this.cancelDownload(downloadId));
|
||
}
|
||
}
|
||
if (downloadBtn) {
|
||
downloadBtn.textContent = `${percent}%`;
|
||
}
|
||
|
||
// Continue polling
|
||
setTimeout(() => this.pollDownloadProgress(downloadId), 1000);
|
||
|
||
} else if (progress.status === 'completed') {
|
||
if (progressDiv) {
|
||
progressDiv.innerHTML = this.renderStatusMessage('Download complete! Auto-linking...', 'success');
|
||
}
|
||
if (downloadBtn) {
|
||
downloadBtn.textContent = '✓ Done';
|
||
downloadBtn.classList.add('ml-btn-primary');
|
||
}
|
||
delete this.activeDownloads[downloadId];
|
||
this.updateDownloadAllButtonState();
|
||
this.showNotification(`Downloaded: ${progress.filename}`, 'success');
|
||
|
||
// Auto-resolve: Reload workflow data and try to resolve the downloaded model
|
||
// Small delay to ensure file system is updated
|
||
setTimeout(async () => {
|
||
await this.autoResolveAfterDownload(missing, progress.filename);
|
||
}, 500);
|
||
|
||
} else if (progress.status === 'error') {
|
||
if (progressDiv) {
|
||
progressDiv.innerHTML = this.renderStatusMessage(progress.error || 'Download failed', 'error');
|
||
}
|
||
if (downloadBtn) {
|
||
downloadBtn.disabled = false;
|
||
downloadBtn.textContent = 'Retry';
|
||
}
|
||
delete this.activeDownloads[downloadId];
|
||
this.updateDownloadAllButtonState();
|
||
|
||
} else if (progress.status === 'cancelled') {
|
||
if (progressDiv) {
|
||
progressDiv.innerHTML = this.renderStatusMessage('Download cancelled - incomplete file removed', 'warning');
|
||
}
|
||
if (downloadBtn) {
|
||
downloadBtn.disabled = false;
|
||
downloadBtn.innerHTML = '<span class="ml-btn-icon">☁</span> Download';
|
||
}
|
||
delete this.activeDownloads[downloadId];
|
||
this.updateDownloadAllButtonState();
|
||
this.showNotification('Download cancelled', 'info');
|
||
|
||
} else {
|
||
// Unknown status, keep polling
|
||
setTimeout(() => this.pollDownloadProgress(downloadId), 500);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Model Linker: Progress poll error:', error);
|
||
const info = this.activeDownloads[downloadId];
|
||
// Update UI to show error state instead of just disappearing
|
||
if (info) {
|
||
const { progressDiv, downloadBtn } = info;
|
||
if (progressDiv) {
|
||
progressDiv.innerHTML = this.renderStatusMessage('Connection lost - download may have failed', 'error');
|
||
}
|
||
if (downloadBtn) {
|
||
downloadBtn.disabled = false;
|
||
downloadBtn.textContent = 'Retry';
|
||
downloadBtn.style.background = '#4CAF50';
|
||
}
|
||
}
|
||
delete this.activeDownloads[downloadId];
|
||
this.updateDownloadAllButtonState();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cancel an active download
|
||
*/
|
||
async cancelDownload(downloadId) {
|
||
try {
|
||
const response = await api.fetchApi(`/model_linker/cancel/${downloadId}`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to cancel download');
|
||
}
|
||
|
||
const info = this.activeDownloads[downloadId];
|
||
if (info?.progressDiv) {
|
||
info.progressDiv.innerHTML = this.renderStatusMessage('Cancelling download...', 'info');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Model Linker: Cancel error:', error);
|
||
this.showNotification('Failed to cancel download', 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Search online for a model
|
||
*/
|
||
async searchOnline(missing) {
|
||
let filename = missing.original_path?.split('/').pop()?.split('\\').pop() || '';
|
||
let category = missing.category || '';
|
||
const state = this.getSearchState(missing);
|
||
const selectedSource = state.selectedSource || 'all';
|
||
const selectedSourceLabel = this.getSearchSourceLabel(selectedSource);
|
||
|
||
// For URNs, use the CivitAI model name for searching instead of the URN itself
|
||
// and pass the URN type as category (CivitAI expects specific type names)
|
||
if (missing.is_urn) {
|
||
if (missing.civitai_info?.model_name) {
|
||
filename = missing.civitai_info.model_name;
|
||
}
|
||
// Pass URN type directly - CivitAPI expects types like 'Upscaler', 'Checkpoint'
|
||
const urnType = missing.urn_type || '';
|
||
if (urnType) {
|
||
// Map URN types to CivitAI type names
|
||
const typeMap = {
|
||
'checkpoint': 'Checkpoint',
|
||
'lora': 'LORA',
|
||
'vae': 'VAE',
|
||
'upscaler': 'Upscaler',
|
||
'upscale_model': 'Upscaler',
|
||
'embedding': 'TextualInversion',
|
||
'controlnet': 'Controlnet'
|
||
};
|
||
const civitaiType = typeMap[urnType.toLowerCase()];
|
||
if (civitaiType) {
|
||
category = civitaiType;
|
||
}
|
||
}
|
||
}
|
||
|
||
const isUrn = missing.is_urn || false;
|
||
const resultsId = `search-results-${missing.node_id}-${missing.widget_index}`;
|
||
const resultsDiv = this.contentElement?.querySelector(`#${resultsId}`);
|
||
const searchBtn = this.contentElement?.querySelector(`#search-${missing.node_id}-${missing.widget_index}`);
|
||
|
||
try {
|
||
if (searchBtn) {
|
||
searchBtn.disabled = true;
|
||
searchBtn.textContent = `🔍 Searching ${selectedSourceLabel}...`;
|
||
}
|
||
if (resultsDiv) {
|
||
resultsDiv.style.display = 'block';
|
||
resultsDiv.innerHTML = `<span style="color: #2196F3;">Searching ${selectedSourceLabel}...</span>`;
|
||
}
|
||
|
||
// For URNs, include model_id and version_id for direct download
|
||
const searchData = { filename, category, is_urn: isUrn, sources: [selectedSource] };
|
||
if (isUrn && missing.urn) {
|
||
searchData.model_id = missing.urn.model_id;
|
||
searchData.version_id = missing.urn.version_id;
|
||
}
|
||
|
||
console.log('Model Linker: Search request:', JSON.stringify(searchData));
|
||
|
||
const response = await api.fetchApi('/model_linker/search', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(searchData)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Search failed: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
console.log('Model Linker: Search response:', JSON.stringify(data));
|
||
state.results = this.mergeSearchResults(state.results, data);
|
||
state.lastAttemptSources = Array.isArray(data.searched_sources) ? data.searched_sources : [selectedSource];
|
||
state.lastAttemptFound = this.hasSearchResults(data);
|
||
this.displaySearchResults(missing, state, resultsDiv);
|
||
|
||
} catch (error) {
|
||
console.error('Model Linker: Search error:', error);
|
||
if (resultsDiv) {
|
||
resultsDiv.innerHTML = this.renderStatusMessage(`Search failed: ${error.message}`, 'error');
|
||
}
|
||
} finally {
|
||
if (searchBtn) {
|
||
searchBtn.disabled = false;
|
||
searchBtn.innerHTML = '<span class="ml-btn-icon">🔍</span> Search Again';
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolve URN asynchronously - fetch CivitAI info and update UI
|
||
*/
|
||
async resolveUrnAsync(modelId, versionId, loadingElementId, modelUrl) {
|
||
console.log('resolveUrnAsync called:', modelId, versionId);
|
||
if (!modelId || !versionId) {
|
||
console.log('resolveUrnAsync: missing modelId or versionId');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const payload = {
|
||
filename: modelId + '_' + versionId,
|
||
category: '',
|
||
is_urn: true,
|
||
sources: ['civitai'],
|
||
model_id: modelId,
|
||
version_id: versionId
|
||
};
|
||
console.log('resolveUrnAsync payload:', JSON.stringify(payload));
|
||
|
||
const response = await api.fetchApi('/model_linker/search', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
console.log('resolveUrnAsync response status:', response.status);
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
const loadingEl = document.getElementById(loadingElementId);
|
||
if (loadingEl && data.civitai) {
|
||
const civitai = data.civitai;
|
||
let label = civitai.name || 'Model';
|
||
if (civitai.filename && civitai.filename !== civitai.name) {
|
||
label += ` (${civitai.filename})`;
|
||
}
|
||
const url = modelUrl || `https://civitai.com/models/${modelId}?modelVersionId=${versionId}`;
|
||
loadingEl.innerHTML = `<a href="${url}" target="_blank" style="color: #FF9800; font-size: 12px; font-weight: 600; text-decoration: none;">${label}</a>`;
|
||
} else if (loadingEl) {
|
||
loadingEl.textContent = 'Not found';
|
||
loadingEl.style.color = 'var(--ml-text-muted)';
|
||
}
|
||
} else {
|
||
const loadingEl = document.getElementById(loadingElementId);
|
||
if (loadingEl) {
|
||
loadingEl.textContent = 'Error';
|
||
loadingEl.style.color = '#f44336';
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Model Linker: URN resolve error:', error);
|
||
const loadingEl = document.getElementById(loadingElementId);
|
||
if (loadingEl) {
|
||
loadingEl.textContent = 'Error';
|
||
loadingEl.style.color = '#f44336';
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Display search results
|
||
*/
|
||
displaySearchResults(missing, state, container) {
|
||
if (!container) return;
|
||
|
||
const results = state?.results || {};
|
||
const popular = results.popular;
|
||
const modelListResult = results.model_list;
|
||
const hfResult = results.huggingface ? (Array.isArray(results.huggingface) ? results.huggingface[0] : results.huggingface) : null;
|
||
const civitaiResult = results.civitai ? (Array.isArray(results.civitai) ? results.civitai[0] : results.civitai) : null;
|
||
const hasResults = popular || modelListResult || hfResult || civitaiResult;
|
||
|
||
if (!hasResults) {
|
||
const searchedLabel = (state?.lastAttemptSources || []).map(source => this.getSearchSourceLabel(source)).join(', ');
|
||
container.innerHTML = this.renderStatusMessage(
|
||
searchedLabel ? `No matches found in ${searchedLabel}.` : 'No matches found online for this model.',
|
||
'warning'
|
||
);
|
||
return;
|
||
}
|
||
|
||
let html = '<div style="margin-top: 8px; display: flex; flex-direction: column; gap: 8px;">';
|
||
|
||
if (state?.lastAttemptFound === false) {
|
||
const searchedLabel = (state.lastAttemptSources || []).map(source => this.getSearchSourceLabel(source)).join(', ');
|
||
html += this.renderStatusMessage(`No new matches found in ${searchedLabel}. Existing results are kept below.`, 'warning');
|
||
}
|
||
|
||
// Popular models result (highest priority)
|
||
if (popular) {
|
||
html += `<div class="ml-status ml-status-success" style="flex-direction: column; align-items: flex-start;">`;
|
||
html += `<strong>Found in Popular Models</strong>`;
|
||
html += `<div style="margin-top: 8px;">`;
|
||
html += `<button class="search-download-btn ml-btn ml-btn-primary ml-btn-sm" data-url="${popular.url}" data-filename="${popular.filename || missing.original_path?.split('/').pop()?.split('\\').pop()}" data-category="${popular.directory || missing.category}">`;
|
||
html += `<span class="ml-btn-icon">☁</span> Download`;
|
||
html += `</button>`;
|
||
html += `</div></div>`;
|
||
}
|
||
|
||
// Model list result (ComfyUI Manager database with fuzzy matching)
|
||
if (modelListResult && modelListResult.url) {
|
||
const confidence = modelListResult.confidence ? ` (${modelListResult.confidence}%)` : '';
|
||
const matchType = modelListResult.match_type === 'exact' ? 'Exact match' : 'Similar model';
|
||
const statusClass = modelListResult.match_type === 'exact' ? 'ml-status-success' : 'ml-status-info';
|
||
|
||
html += `<div class="ml-status ${statusClass}" style="flex-direction: column; align-items: flex-start;">`;
|
||
html += `<strong>${matchType} in Model Database${confidence}</strong>`;
|
||
html += `<div style="margin-top: 4px; font-size: 12px;">`;
|
||
html += `<span class="ml-chip">${modelListResult.filename}</span>`;
|
||
if (modelListResult.name) {
|
||
html += ` <span style="color: var(--ml-text-muted);">${modelListResult.name}</span>`;
|
||
}
|
||
if (modelListResult.size) {
|
||
html += ` <span class="ml-download-size">[${modelListResult.size}]</span>`;
|
||
}
|
||
html += `</div>`;
|
||
html += `<div style="margin-top: 8px;">`;
|
||
html += `<button class="search-download-btn ml-btn ml-btn-primary ml-btn-sm" data-url="${modelListResult.url}" data-filename="${modelListResult.filename}" data-category="${modelListResult.directory || missing.category}">`;
|
||
html += `<span class="ml-btn-icon">☁</span> Download`;
|
||
html += `</button>`;
|
||
html += `</div></div>`;
|
||
}
|
||
|
||
// HuggingFace result
|
||
if (hfResult && hfResult.url) {
|
||
const hfRepo = hfResult.repo_id || hfResult.repo || '';
|
||
html += `<div class="ml-status ml-status-info" style="flex-direction: column; align-items: flex-start;">`;
|
||
html += `<strong>Found on HuggingFace</strong>`;
|
||
html += `<div style="margin-top: 4px; font-size: 12px;">`;
|
||
html += `<span class="ml-chip">${hfResult.filename}</span> `;
|
||
html += `<span style="color: var(--ml-text-muted);">${hfRepo}</span>`;
|
||
html += `</div>`;
|
||
html += `<div style="margin-top: 8px;">`;
|
||
html += `<button class="search-download-btn ml-btn ml-btn-link ml-btn-sm" data-url="${hfResult.url}" data-filename="${hfResult.filename}" data-category="${missing.category}">`;
|
||
html += `<span class="ml-btn-icon">☁</span> Download`;
|
||
html += `</button>`;
|
||
html += `</div></div>`;
|
||
}
|
||
|
||
// CivitAI result
|
||
if (civitaiResult && civitaiResult.download_url) {
|
||
const modelUrl = civitaiResult.url || `https://civitai.com/models/${civitaiResult.model_id || ''}`;
|
||
// Use expected_filename from URN resolution, or fallback to civitaiResult filename
|
||
const downloadFilename = missing.civitai_info?.expected_filename || civitaiResult.filename || civitaiResult.name;
|
||
// Build display name with version if available
|
||
const modelName = missing.civitai_info?.version_name ? `${missing.civitai_info.model_name} v${missing.civitai_info.version_name}` : (civitaiResult.name || 'Model');
|
||
html += `<div class="ml-status ml-status-warning" style="flex-direction: column; align-items: flex-start;">`;
|
||
html += `<strong>Found on CivitAI</strong>`;
|
||
html += `<div style="margin-top: 4px; font-size: 12px;">`;
|
||
// Model name as clickable button/link
|
||
html += `<button class="ml-btn ml-btn-sm" style="background: transparent; color: #FF9800; border: 1px solid #FF9800; font-weight: bold; cursor: pointer;" onclick="window.open('${modelUrl}', '_blank')">${modelName}</button> `;
|
||
html += `<span style="color: var(--ml-text-muted);">${civitaiResult.type || ''}</span>`;
|
||
html += `</div>`;
|
||
html += `<div style="margin-top: 8px; display: flex; gap: 8px;">`;
|
||
// Download button - use expected_filename from URN resolution
|
||
html += `<button class="search-download-btn ml-btn ml-btn-sm" style="background: #FF9800;" data-url="${civitaiResult.download_url}" data-filename="${downloadFilename}" data-category="${missing.category}">`;
|
||
html += `<span class="ml-btn-icon">☁</span> Download`;
|
||
html += `</button>`;
|
||
html += `</div></div>`;
|
||
}
|
||
|
||
html += '</div>';
|
||
container.innerHTML = html;
|
||
|
||
// Attach download listeners
|
||
const downloadBtns = container.querySelectorAll('.search-download-btn');
|
||
downloadBtns.forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const url = btn.dataset.url;
|
||
const filename = btn.dataset.filename;
|
||
const category = btn.dataset.category;
|
||
this.downloadFromSearch(missing, url, filename, category, btn);
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Download from search results
|
||
*/
|
||
async downloadFromSearch(missing, url, filename, category, btn) {
|
||
const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`;
|
||
const progressDiv = this.contentElement?.querySelector(`#${progressId}`);
|
||
|
||
try {
|
||
btn.disabled = true;
|
||
btn.textContent = 'Starting...';
|
||
|
||
if (progressDiv) {
|
||
progressDiv.style.display = 'block';
|
||
// Show progress bar with cancel button immediately
|
||
progressDiv.innerHTML = `
|
||
<div class="ml-progress-container">
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<div class="ml-progress-bar" style="flex: 1;">
|
||
<div class="ml-progress-fill" style="width: 0%;"></div>
|
||
</div>
|
||
<button class="cancel-download-btn-pending ml-btn ml-btn-danger ml-btn-sm">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
<div class="ml-progress-text">
|
||
<span style="color: #2196F3;">Connecting...</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const response = await api.fetchApi('/model_linker/download', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url, filename, category })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Download failed: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
if (!data.success) {
|
||
throw new Error(data.error || 'Download failed');
|
||
}
|
||
|
||
// Track and poll
|
||
const downloadId = data.download_id;
|
||
this.activeDownloads[downloadId] = { missing, progressDiv, downloadBtn: btn };
|
||
|
||
// Update the Download All button state
|
||
this.updateDownloadAllButtonState();
|
||
|
||
// Attach cancel handler to pending button (before polling replaces it)
|
||
const pendingCancelBtn = progressDiv?.querySelector('.cancel-download-btn-pending');
|
||
if (pendingCancelBtn) {
|
||
pendingCancelBtn.addEventListener('click', () => this.cancelDownload(downloadId));
|
||
}
|
||
|
||
this.pollDownloadProgress(downloadId);
|
||
|
||
} catch (error) {
|
||
console.error('Model Linker: Download error:', error);
|
||
if (progressDiv) {
|
||
progressDiv.innerHTML = this.renderStatusMessage(error.message, 'error');
|
||
}
|
||
btn.disabled = false;
|
||
btn.textContent = 'Retry';
|
||
this.showNotification('Download failed: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Format bytes to human readable string
|
||
*/
|
||
formatBytes(bytes) {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||
}
|
||
|
||
/**
|
||
* Extract model card URL from a download URL
|
||
* HuggingFace: https://huggingface.co/Owner/Repo/resolve/main/file.safetensors -> https://huggingface.co/Owner/Repo
|
||
* CivitAI: https://civitai.com/api/download/models/123?type=Model -> https://civitai.com/models/123
|
||
*/
|
||
getModelCardUrl(downloadUrl) {
|
||
if (!downloadUrl) return null;
|
||
|
||
try {
|
||
// HuggingFace URLs
|
||
if (downloadUrl.includes('huggingface.co')) {
|
||
// Extract owner/repo from URL
|
||
const match = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)/);
|
||
if (match) {
|
||
return `https://huggingface.co/${match[1]}`;
|
||
}
|
||
}
|
||
|
||
// CivitAI URLs
|
||
if (downloadUrl.includes('civitai.com')) {
|
||
// Format: /api/download/models/123456 or /models/123456/...
|
||
const modelIdMatch = downloadUrl.match(/models\/(\d+)/);
|
||
if (modelIdMatch) {
|
||
return `https://civitai.com/models/${modelIdMatch[1]}`;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Error parsing model card URL:', e);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 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.buttonGroup = null;
|
||
this.buttonId = "model-linker-button";
|
||
this.dialog = null;
|
||
this.isCheckingMissing = false; // Prevent multiple simultaneous checks
|
||
this.lastCheckedWorkflow = null; // Track to avoid duplicate checks
|
||
}
|
||
|
||
setup = async () => {
|
||
// Remove any existing button
|
||
this.removeExistingButton();
|
||
|
||
// Create dialog instance
|
||
if (!this.dialog) {
|
||
this.dialog = new LinkerManagerDialog();
|
||
window.modelLinkerDialog = this.dialog;
|
||
}
|
||
|
||
// Register keyboard shortcut (Ctrl+Shift+L)
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'l') {
|
||
e.preventDefault();
|
||
this.openLinkerManager();
|
||
}
|
||
});
|
||
|
||
// Listen for workflow load events to auto-check for missing models
|
||
this.setupAutoOpenOnMissingModels();
|
||
|
||
// 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 Linker button
|
||
this.buttonGroup = new ComfyButtonGroup(
|
||
new ComfyButton({
|
||
icon: "link-variant",
|
||
action: () => this.openLinkerManager(),
|
||
tooltip: "Model Linker - Resolve missing models (Ctrl+Shift+L)",
|
||
content: "Model Linker",
|
||
classList: "comfyui-button comfyui-menu-mobile-collapse"
|
||
}).element
|
||
);
|
||
|
||
// 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
|
||
console.log('Model Linker: 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();
|
||
|
||
console.log('Model Linker: Missing models popup button injection enabled');
|
||
}
|
||
|
||
/**
|
||
* 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-linker-btn-container')) return;
|
||
|
||
// Find a suitable place to inject the button
|
||
const injectButtons = () => {
|
||
// Common button style
|
||
const btnStyle = `
|
||
padding: 6px 12px;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
transition: all 0.2s ease;
|
||
white-space: nowrap;
|
||
`;
|
||
|
||
// Auto-resolve button (green)
|
||
const autoResolveBtn = document.createElement('button');
|
||
autoResolveBtn.id = 'model-linker-btn-container'; // Use this ID to prevent duplicate injection
|
||
autoResolveBtn.textContent = '🔗 Auto-resolve 100%';
|
||
autoResolveBtn.title = 'Automatically link models with 100% confidence matches';
|
||
autoResolveBtn.style.cssText = btnStyle + `background: #4CAF50;`;
|
||
|
||
autoResolveBtn.addEventListener('mouseenter', () => {
|
||
autoResolveBtn.style.background = '#45a049';
|
||
});
|
||
autoResolveBtn.addEventListener('mouseleave', () => {
|
||
autoResolveBtn.style.background = '#4CAF50';
|
||
});
|
||
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.style.cssText = `
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 0 16px;
|
||
margin-bottom: 8px;
|
||
`;
|
||
// 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.style.cssText = 'display: flex; justify-content: flex-end; padding: 0 16px; margin-bottom: 8px;';
|
||
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(btnContainer, el.nextSibling);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log('Model Linker: 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 Linker 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 LinkerManagerDialog();
|
||
window.modelLinkerDialog = this.dialog;
|
||
}
|
||
|
||
// Run auto-resolve for 100% matches - returns the updated workflow
|
||
const updatedWorkflow = await this.dialog.autoResolve100Percent();
|
||
|
||
// Always open Model Linker to show remaining unresolved models
|
||
// Pass the updated workflow if available to avoid race condition
|
||
this.dialog.show(updatedWorkflow || null);
|
||
}
|
||
|
||
/**
|
||
* Mark resolved model items in the popup as linked (green) and hide download buttons
|
||
*/
|
||
removeResolvedFromPopup(dialog, resolvedFilenames) {
|
||
console.log('Model Linker: Looking for resolved filenames:', resolvedFilenames);
|
||
|
||
// Strategy: For each filename, find text nodes containing it,
|
||
// then find the nearest Download button and mark that row
|
||
for (const filename of resolvedFilenames) {
|
||
// Get all text in the dialog and find elements containing our filename
|
||
const walker = document.createTreeWalker(
|
||
dialog,
|
||
NodeFilter.SHOW_TEXT,
|
||
null,
|
||
false
|
||
);
|
||
|
||
let node;
|
||
while (node = walker.nextNode()) {
|
||
if (node.textContent?.toLowerCase().includes(filename)) {
|
||
// Found text containing filename - now find parent with Download button
|
||
let parent = node.parentElement;
|
||
let attempts = 0;
|
||
|
||
while (parent && parent !== dialog && attempts < 10) {
|
||
// Look for Download button at this level
|
||
const downloadBtn = Array.from(parent.querySelectorAll('button'))
|
||
.find(btn => btn.textContent?.includes('Download') &&
|
||
!btn.id?.includes('model-linker'));
|
||
|
||
if (downloadBtn) {
|
||
console.log('Model Linker: Found entry for', filename);
|
||
this.markEntryAsResolved(parent, downloadBtn);
|
||
break;
|
||
}
|
||
|
||
parent = parent.parentElement;
|
||
attempts++;
|
||
}
|
||
|
||
// Only process first match for this filename
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Mark a model entry as resolved with visual feedback
|
||
*/
|
||
markEntryAsResolved(container, downloadBtn) {
|
||
// Already marked?
|
||
if (container.dataset.resolved === 'true') return;
|
||
container.dataset.resolved = 'true';
|
||
|
||
console.log('Model Linker: Marking entry as resolved', container);
|
||
|
||
// Add green background/styling to the container
|
||
container.style.transition = 'all 0.3s ease';
|
||
container.style.background = 'rgba(76, 175, 80, 0.2)';
|
||
container.style.borderRadius = '6px';
|
||
container.style.border = '1px solid #4CAF50';
|
||
|
||
// Hide the Download button and replace with badge
|
||
if (downloadBtn) {
|
||
// Create badge
|
||
const badge = document.createElement('span');
|
||
badge.textContent = '✓ Linked';
|
||
badge.style.cssText = `
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 4px 12px;
|
||
background: #4CAF50;
|
||
color: white;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
`;
|
||
|
||
// Replace download button with badge
|
||
downloadBtn.style.display = 'none';
|
||
downloadBtn.parentElement?.insertBefore(badge, downloadBtn);
|
||
}
|
||
|
||
// Find and hide Copy URL button
|
||
const allBtns = container.querySelectorAll('button');
|
||
for (const btn of allBtns) {
|
||
if (btn.textContent?.includes('Copy URL')) {
|
||
btn.style.display = 'none';
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Count remaining model items in the popup
|
||
*/
|
||
countRemainingItems(dialog) {
|
||
// Count elements that look like model entries (have Download buttons)
|
||
const downloadButtons = dialog.querySelectorAll('button');
|
||
let count = 0;
|
||
for (const btn of downloadButtons) {
|
||
if (btn.textContent?.includes('Download') && !btn.id?.includes('model-linker')) {
|
||
count++;
|
||
}
|
||
}
|
||
return count;
|
||
}
|
||
|
||
/**
|
||
* Update nodes directly in the graph without triggering a full workflow reload
|
||
* This prevents the Missing Models popup from closing
|
||
*/
|
||
updateNodesDirectly(resolutions) {
|
||
if (!app?.graph) {
|
||
console.warn('Model Linker: Cannot update nodes - graph not available');
|
||
return;
|
||
}
|
||
|
||
for (const resolution of resolutions) {
|
||
const nodeId = resolution.node_id;
|
||
const widgetIndex = resolution.widget_index;
|
||
const resolvedPath = resolution.resolved_path;
|
||
|
||
// Find the node in the graph
|
||
const node = app.graph.getNodeById(nodeId);
|
||
if (!node) {
|
||
console.warn(`Model Linker: Node ${nodeId} not found in graph`);
|
||
continue;
|
||
}
|
||
|
||
// Update the widget value
|
||
if (node.widgets && node.widgets[widgetIndex]) {
|
||
const widget = node.widgets[widgetIndex];
|
||
widget.value = resolvedPath;
|
||
|
||
// Trigger widget callback if it exists
|
||
if (widget.callback) {
|
||
widget.callback(resolvedPath, app.graph, node, null, null);
|
||
}
|
||
|
||
console.log(`Model Linker: Updated node ${nodeId} widget ${widgetIndex} to ${resolvedPath}`);
|
||
} else if (node.widgets_values) {
|
||
// Fallback: update widgets_values array directly
|
||
node.widgets_values[widgetIndex] = resolvedPath;
|
||
console.log(`Model Linker: Updated node ${nodeId} widgets_values[${widgetIndex}] to ${resolvedPath}`);
|
||
}
|
||
|
||
// Mark node as dirty to trigger redraw
|
||
if (node.setDirtyCanvas) {
|
||
node.setDirtyCanvas(true, true);
|
||
}
|
||
}
|
||
|
||
// Trigger canvas redraw
|
||
if (app.graph.setDirtyCanvas) {
|
||
app.graph.setDirtyCanvas(true, true);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if auto-open is enabled in user settings
|
||
*/
|
||
isAutoOpenEnabled() {
|
||
return localStorage.getItem('modelLinker.autoOpenOnMissing') !== 'false';
|
||
}
|
||
|
||
/**
|
||
* Set auto-open preference
|
||
*/
|
||
setAutoOpenEnabled(enabled) {
|
||
localStorage.setItem('modelLinker.autoOpenOnMissing', enabled ? 'true' : 'false');
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
|
||
// Call analyze endpoint to check for missing models
|
||
const response = await api.fetchApi('/model_linker/analyze', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ workflow })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
console.warn('Model Linker: Failed to analyze workflow for missing models');
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
// Auto-open dialog if there are missing models
|
||
if (data.total_missing > 0) {
|
||
console.log(`Model Linker: Found ${data.total_missing} missing model(s), opening dialog...`);
|
||
this.openLinkerManager();
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Model Linker: 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();
|
||
}
|
||
|
||
// 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.linkerButton && this.linkerButton.parentNode) {
|
||
this.linkerButton.remove();
|
||
this.linkerButton = null;
|
||
}
|
||
}
|
||
|
||
createFloatingButton() {
|
||
// Create a floating button as fallback for legacy ComfyUI versions
|
||
this.linkerButton = $el("button", {
|
||
id: this.buttonId,
|
||
textContent: "🔗 Model Linker",
|
||
title: "Open Model Linker to resolve missing models (Ctrl+Shift+L)",
|
||
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",
|
||
whiteSpace: "nowrap"
|
||
}
|
||
});
|
||
|
||
// 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();
|
||
window.modelLinkerDialog = this.dialog;
|
||
}
|
||
this.dialog.show();
|
||
} catch (error) {
|
||
console.error("🔗 Model Linker: Error creating/showing dialog:", error);
|
||
alert("Error opening Model Linker: " + error.message);
|
||
}
|
||
}
|
||
|
||
static switchFilter(filter) {
|
||
document.querySelectorAll('.ml-btn-filter').forEach(b => b.classList.remove('active'));
|
||
document.getElementById('filter-' + filter).classList.add('active');
|
||
|
||
document.querySelectorAll('.ml-model-section').forEach(s => {
|
||
const hasActive = s.dataset.mlActive === 'true';
|
||
const hasInactive = s.dataset.mlInactive === 'true';
|
||
|
||
if (filter === 'all') {
|
||
s.style.display = 'block';
|
||
} else if (filter === 'active') {
|
||
s.style.display = hasActive ? 'block' : 'none';
|
||
} else if (filter === 'inactive') {
|
||
s.style.display = hasInactive ? 'block' : 'none';
|
||
}
|
||
});
|
||
|
||
const copySection = document.querySelector('[id^="ml-copy-"]');
|
||
if (copySection) {
|
||
const codeEl = copySection.querySelector('code');
|
||
const labelEl = copySection.querySelector('div');
|
||
|
||
if (filter === 'all') {
|
||
codeEl.textContent = copySection.dataset.mlAll;
|
||
labelEl.textContent = 'Copy all:';
|
||
} else if (filter === 'active') {
|
||
codeEl.textContent = copySection.dataset.mlActive;
|
||
labelEl.textContent = 'Copy active:';
|
||
} else if (filter === 'inactive') {
|
||
codeEl.textContent = copySection.dataset.mlInactive;
|
||
labelEl.textContent = 'Copy inactive:';
|
||
}
|
||
}
|
||
}
|
||
|
||
static copyToClipboard(text, btn) {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
const orig = btn.textContent;
|
||
btn.textContent = '✓';
|
||
setTimeout(() => btn.textContent = orig, 1500);
|
||
});
|
||
}
|
||
|
||
static copyFromCode(sectionId, btn) {
|
||
const section = document.getElementById(sectionId);
|
||
const codeEl = section.querySelector('code');
|
||
navigator.clipboard.writeText(codeEl.textContent).then(() => {
|
||
const orig = btn.textContent;
|
||
btn.textContent = '✓ Copied!';
|
||
setTimeout(() => btn.textContent = orig, 1500);
|
||
});
|
||
}
|
||
}
|
||
|
||
const modelLinker = new ModelLinker();
|
||
|
||
// Register the extension
|
||
app.registerExtension({
|
||
name: "Model Linker",
|
||
setup: modelLinker.setup
|
||
});
|
||
|
||
// Global helper functions for inline onclick handlers
|
||
window.MLFilterSwitch = function(filter) {
|
||
const filterBtn = document.getElementById('filter-' + filter);
|
||
if (!filterBtn) return;
|
||
|
||
// Update button states
|
||
document.querySelectorAll('.ml-btn-filter').forEach(b => b.classList.remove('active'));
|
||
filterBtn.classList.add('active');
|
||
|
||
// Filter model sections
|
||
document.querySelectorAll('.ml-model-section').forEach(s => {
|
||
const hasActive = s.getAttribute('data-ml-active') === 'true';
|
||
const hasInactive = s.getAttribute('data-ml-inactive') === 'true';
|
||
|
||
// Get child divs: category header, active section, inactive section
|
||
const childDivs = Array.from(s.children).filter(c => c.tagName === 'DIV');
|
||
const activeSection = childDivs[1];
|
||
const inactiveSection = childDivs[2];
|
||
|
||
if (filter === 'all') {
|
||
s.style.display = 'block';
|
||
if (activeSection) activeSection.style.display = 'block';
|
||
if (inactiveSection) inactiveSection.style.display = 'block';
|
||
} else if (filter === 'active') {
|
||
s.style.display = hasActive ? 'block' : 'none';
|
||
if (activeSection) activeSection.style.display = hasActive ? 'block' : 'none';
|
||
if (inactiveSection) inactiveSection.style.display = 'none';
|
||
} else if (filter === 'inactive') {
|
||
s.style.display = hasInactive ? 'block' : 'none';
|
||
if (activeSection) activeSection.style.display = 'none';
|
||
if (inactiveSection) inactiveSection.style.display = hasInactive ? 'block' : 'none';
|
||
}
|
||
});
|
||
|
||
const copySection = document.querySelector('[id^="ml-copy-"]');
|
||
if (copySection) {
|
||
const codeEl = copySection.querySelector('code');
|
||
const labelEl = copySection.querySelector('div');
|
||
|
||
if (filter === 'all') {
|
||
codeEl.textContent = copySection.dataset.mlAll;
|
||
labelEl.textContent = 'Copy all:';
|
||
} else if (filter === 'active') {
|
||
codeEl.textContent = copySection.dataset.mlActive;
|
||
labelEl.textContent = 'Copy active:';
|
||
} else if (filter === 'inactive') {
|
||
codeEl.textContent = copySection.dataset.mlInactive;
|
||
labelEl.textContent = 'Copy inactive:';
|
||
}
|
||
}
|
||
};
|
||
|
||
window.MLCopy = function(text, btn) {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
const orig = btn.textContent;
|
||
btn.textContent = '✓';
|
||
setTimeout(() => btn.textContent = orig, 1500);
|
||
});
|
||
};
|
||
|
||
window.MLCopyCode = function(sectionId, btn) {
|
||
const section = document.getElementById(sectionId);
|
||
const codeEl = section.querySelector('code');
|
||
navigator.clipboard.writeText(codeEl.textContent).then(() => {
|
||
const orig = btn.textContent;
|
||
btn.textContent = '✓ Copied!';
|
||
setTimeout(() => btn.textContent = orig, 1500);
|
||
});
|
||
};
|
||
|
||
window.MLOpenContextMenu = function(event, element) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
try {
|
||
const modelData = element.getAttribute('data-model');
|
||
if (!modelData) return;
|
||
|
||
const model = JSON.parse(decodeURIComponent(modelData));
|
||
|
||
// Get dialog instance
|
||
const dialog = window.modelLinkerDialog;
|
||
if (dialog && dialog.showContextMenu) {
|
||
dialog.showContextMenu(event.clientX, event.clientY, model);
|
||
}
|
||
} catch (e) {
|
||
console.error('Model Linker: Error opening context menu:', e);
|
||
}
|
||
};
|
||
|