Move direct URN->CivitAI download handling into the frontend and enrich backend metadata. Removed server-side immediate URN download exposure and adjusted comment numbering. Backend now selects a primary file from resolved CivitAI model info, includes model_id, version_id, size, and a modelVersionId query on model URLs. Frontend: add renderDownloadSourceSection to render download buttons and CivitAI model-card links, display file sizes, and format names. Store missingModels for later updates; render a placeholder URN download section while resolving; when URN resolution returns, inject civitai info into the missing entry, build a download_source object, replace the placeholder with the rendered download UI, and attach the download button handler. Improved error handling and model URL construction to include modelVersionId when available.
5459 lines
221 KiB
JavaScript
5459 lines
221 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);
|
||
}
|
||
|
||
renderDownloadSourceSection(missing, downloadSource) {
|
||
const filename = missing.original_path?.split('/').pop()?.split('\\').pop() || '';
|
||
const isFromWorkflow = downloadSource.url_source === 'workflow';
|
||
const isCivitaiSource = downloadSource.source === 'civitai';
|
||
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);
|
||
|
||
let sizeDisplay = '';
|
||
if (downloadSource.size) {
|
||
sizeDisplay = typeof downloadSource.size === 'number'
|
||
? this.formatBytes(downloadSource.size)
|
||
: downloadSource.size;
|
||
}
|
||
|
||
const modelCardUrl = downloadSource.model_url || this.getModelCardUrl(downloadSource.url);
|
||
|
||
let 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>`;
|
||
if (isCivitaiSource && modelCardUrl) {
|
||
html += ` <a href="${modelCardUrl}" target="_blank" rel="noopener noreferrer" class="ml-btn ml-btn-secondary">Open on CivitAI</a>`;
|
||
}
|
||
html += `<div class="ml-download-info">`;
|
||
html += `<span class="ml-download-source">${isFromWorkflow ? 'URL from workflow' : sourceLabel}</span>`;
|
||
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>`;
|
||
}
|
||
if (isCivitaiSource) {
|
||
html += `<br><span class="ml-muted-note" style="display:inline-block; margin-top: 8px;">Direct CivitAI download can require an API key for some models.</span>`;
|
||
}
|
||
html += `</div>`;
|
||
html += `</div>`;
|
||
return html;
|
||
}
|
||
|
||
/**
|
||
* 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: #1b1d20;
|
||
--ml-bg-soft: #20242a;
|
||
--ml-card-bg: linear-gradient(180deg, #242930 0%, #1f2329 100%);
|
||
--ml-card-bg-alt: linear-gradient(180deg, #22272e 0%, #1d2127 100%);
|
||
--ml-panel-bg: rgba(255, 255, 255, 0.03);
|
||
--ml-panel-bg-strong: rgba(255, 255, 255, 0.05);
|
||
--ml-border: rgba(255, 255, 255, 0.09);
|
||
--ml-border-strong: rgba(255, 255, 255, 0.14);
|
||
--ml-text: #eef2f7;
|
||
--ml-text-muted: #a5afbd;
|
||
--ml-text-dim: #748091;
|
||
--ml-accent: #3fb950;
|
||
--ml-accent-hover: #35a246;
|
||
--ml-accent-blue: #4ea1ff;
|
||
--ml-warning: #ffb648;
|
||
--ml-confidence-high: #4CAF50;
|
||
--ml-confidence-medium: #FFC107;
|
||
--ml-confidence-low: #f44336;
|
||
--ml-link-color: #8fc2ff;
|
||
--ml-shadow: 0 14px 34px rgba(0,0,0,0.24);
|
||
--ml-radius-sm: 8px;
|
||
--ml-radius-md: 12px;
|
||
--ml-radius-lg: 16px;
|
||
}
|
||
|
||
.ml-missing-summary {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 16px;
|
||
margin-bottom: 16px;
|
||
padding: 14px 16px;
|
||
border-radius: var(--ml-radius-lg);
|
||
background: linear-gradient(135deg, rgba(78,161,255,0.12) 0%, rgba(63,185,80,0.09) 100%);
|
||
border: 1px solid var(--ml-border-strong);
|
||
box-shadow: var(--ml-shadow);
|
||
}
|
||
.ml-missing-summary-title {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.ml-missing-summary-count {
|
||
font-size: 17px;
|
||
font-weight: 700;
|
||
color: var(--ml-text);
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.ml-missing-summary-meta {
|
||
font-size: 12px;
|
||
color: var(--ml-text-muted);
|
||
}
|
||
|
||
/* Card Styles */
|
||
.ml-card {
|
||
background: var(--ml-card-bg);
|
||
border: 1px solid var(--ml-border);
|
||
border-radius: var(--ml-radius-lg);
|
||
padding: 14px;
|
||
margin-bottom: 12px;
|
||
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
|
||
box-shadow: 0 6px 20px rgba(0,0,0,0.16);
|
||
}
|
||
.ml-card:nth-child(even) {
|
||
background: var(--ml-card-bg-alt);
|
||
}
|
||
.ml-card:hover {
|
||
transform: translateY(-1px);
|
||
border-color: var(--ml-border-strong);
|
||
box-shadow: 0 10px 28px rgba(0,0,0,0.2);
|
||
}
|
||
|
||
/* Card Header */
|
||
.ml-card-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 14px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.ml-card-title-wrap {
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
.ml-card-title {
|
||
font-size: 15px;
|
||
line-height: 1.35;
|
||
font-weight: 700;
|
||
color: var(--ml-text);
|
||
margin: 0;
|
||
word-break: break-word;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.ml-card-subtitle {
|
||
margin-top: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.ml-node-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 9px;
|
||
background: rgba(255,255,255,0.06);
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
color: var(--ml-text-muted);
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
.ml-category-chip {
|
||
display: inline-flex;
|
||
padding: 4px 8px;
|
||
background: rgba(78,161,255,0.12);
|
||
border: 1px solid rgba(78,161,255,0.18);
|
||
border-radius: 999px;
|
||
font-size: 10px;
|
||
color: #b8d7ff;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
font-weight: 700;
|
||
}
|
||
.ml-card-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
flex-shrink: 0;
|
||
}
|
||
.ml-inline-civitai-link {
|
||
color: #ffb648;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
text-decoration: none;
|
||
}
|
||
.ml-inline-civitai-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
/* Two-Column Layout */
|
||
.ml-columns {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 14px;
|
||
}
|
||
@media (max-width: 700px) {
|
||
.ml-missing-summary {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
.ml-columns {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.ml-card-header {
|
||
flex-direction: column;
|
||
}
|
||
.ml-card-actions {
|
||
justify-content: flex-start;
|
||
}
|
||
}
|
||
.ml-column {
|
||
min-width: 0;
|
||
background: var(--ml-panel-bg);
|
||
border: 1px solid var(--ml-border);
|
||
border-radius: var(--ml-radius-md);
|
||
padding: 12px;
|
||
}
|
||
.ml-column-header {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
color: var(--ml-text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
margin-bottom: 10px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid var(--ml-border);
|
||
}
|
||
.ml-muted-note,
|
||
.ml-no-matches {
|
||
padding: 10px 12px;
|
||
border-radius: var(--ml-radius-sm);
|
||
background: rgba(255,255,255,0.03);
|
||
border: 1px solid rgba(255,255,255,0.06);
|
||
color: var(--ml-text-muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* Filename Chips */
|
||
.ml-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 5px 10px;
|
||
background: rgba(255,255,255,0.07);
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
border-radius: 999px;
|
||
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: 10px;
|
||
padding: 8px 10px;
|
||
border-radius: var(--ml-radius-sm);
|
||
margin-bottom: 6px;
|
||
background: rgba(255,255,255,0.03);
|
||
border: 1px solid rgba(255,255,255,0.05);
|
||
transition: background 0.15s ease, border-color 0.15s ease;
|
||
}
|
||
.ml-match-row:hover {
|
||
background: rgba(255,255,255,0.05);
|
||
border-color: rgba(255,255,255,0.09);
|
||
}
|
||
.ml-match-row.ml-best-match {
|
||
background: rgba(63,185,80,0.1);
|
||
border-color: rgba(63,185,80,0.26);
|
||
}
|
||
.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: 7px 12px;
|
||
border: none;
|
||
border-radius: 10px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
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: rgba(255,255,255,0.03);
|
||
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: var(--ml-border-strong);
|
||
}
|
||
.ml-btn-link {
|
||
background: var(--ml-accent-blue);
|
||
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: var(--ml-panel-bg-strong);
|
||
border-radius: var(--ml-radius-md);
|
||
border: 1px solid var(--ml-border);
|
||
}
|
||
.ml-download-info {
|
||
font-size: 12px;
|
||
color: var(--ml-text-muted);
|
||
margin-top: 6px;
|
||
line-height: 1.45;
|
||
}
|
||
.ml-download-source {
|
||
color: var(--ml-accent);
|
||
font-weight: 700;
|
||
}
|
||
.ml-download-size {
|
||
color: var(--ml-text-dim);
|
||
margin-left: 8px;
|
||
}
|
||
.ml-search-source-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.ml-search-results {
|
||
margin-top: 10px;
|
||
display: none;
|
||
}
|
||
.ml-combo-section {
|
||
position: relative;
|
||
margin-top: 12px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid var(--ml-border);
|
||
}
|
||
.ml-combo-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.ml-combo-label {
|
||
opacity: 0.9;
|
||
font-size: 12px;
|
||
color: var(--ml-text-muted);
|
||
font-weight: 600;
|
||
min-width: 44px;
|
||
}
|
||
.ml-combo-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: 8px 10px;
|
||
background: rgba(255,255,255,0.04);
|
||
color: var(--ml-text);
|
||
border: 1px solid var(--ml-border);
|
||
border-radius: 10px;
|
||
font-size: 13px;
|
||
outline: none;
|
||
}
|
||
.ml-combo-input:focus {
|
||
border-color: rgba(78,161,255,0.45);
|
||
box-shadow: 0 0 0 3px rgba(78,161,255,0.12);
|
||
}
|
||
.ml-combo-list {
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 52px;
|
||
right: 0;
|
||
width: auto;
|
||
max-height: 280px;
|
||
overflow: auto;
|
||
display: none;
|
||
z-index: 100000;
|
||
background: #22272e;
|
||
border: 1px solid var(--ml-border-strong);
|
||
border-radius: 12px;
|
||
box-shadow: var(--ml-shadow);
|
||
}
|
||
.ml-combo-option {
|
||
padding: 8px 10px;
|
||
cursor: pointer;
|
||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||
}
|
||
.ml-combo-option:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.ml-combo-option:hover,
|
||
.ml-combo-option.is-highlighted {
|
||
background: rgba(78,161,255,0.16);
|
||
}
|
||
.ml-combo-option-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.ml-combo-option code {
|
||
flex: 1 1 0%;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
color: var(--ml-text);
|
||
}
|
||
.ml-combo-folder {
|
||
margin-top: 4px;
|
||
font-size: 10px;
|
||
color: var(--ml-text-dim);
|
||
opacity: 0.9;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* Status Messages */
|
||
.ml-status {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
padding: 10px 14px;
|
||
border-radius: 10px;
|
||
font-size: 13px;
|
||
margin-top: 8px;
|
||
border: 1px solid rgba(255,255,255,0.06);
|
||
}
|
||
.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;
|
||
}
|
||
|
||
.model-linker-selected {
|
||
margin: 8px 0;
|
||
padding: 10px 12px;
|
||
background: rgba(78, 161, 255, 0.12);
|
||
border: 1px solid rgba(78, 161, 255, 0.28);
|
||
border-radius: 10px;
|
||
color: var(--ml-text);
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* 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;
|
||
this.missingModels = missingModels;
|
||
|
||
// 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 class="ml-missing-summary">
|
||
<div class="ml-missing-summary-title">
|
||
<span class="ml-missing-summary-count">${totalMissing} Missing Model${totalMissing > 1 ? 's' : ''}</span>
|
||
<span class="ml-missing-summary-meta">Compact relinking and download view</span>
|
||
</div>
|
||
<div class="ml-missing-summary-meta">
|
||
${activeCount > 0 ? `${activeCount} downloading` : (hasAny100Match ? 'Auto-Link ready for exact matches' : 'Review matches or search online')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
html += '<div style="display: flex; flex-direction: column; gap: 10px;">';
|
||
|
||
// 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(`#search-sources-${missing.node_id}-${missing.widget_index} .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)}" class="ml-combo-option ${isHighlighted ? 'is-highlighted' : ''}">`;
|
||
html += `<div class="ml-combo-option-row">`;
|
||
html += `<code>${label}</code>`;
|
||
html += `</div>`;
|
||
if (folderDisplay) {
|
||
html += `<div class="ml-combo-folder" 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">`;
|
||
html += `<div class="ml-card-title-wrap">`;
|
||
|
||
// 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" class="ml-inline-civitai-link">${civitaiLabel}</a>` : `<span class="ml-inline-civitai-link">${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 class="ml-card-subtitle">`;
|
||
if (missing.category) {
|
||
html += `<span class="ml-category-chip">${missing.category}</span>`;
|
||
}
|
||
html += `<span class="ml-node-chip">${nodeLabel} #${missing.node_id}</span>`;
|
||
html += `</div>`;
|
||
html += `</div>`;
|
||
html += `<div class="ml-card-actions">`;
|
||
// 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">📍 Locate</button>`;
|
||
}
|
||
html += `</div>`;
|
||
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;"></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: var(--ml-accent-blue);" 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">`;
|
||
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 class="ml-combo-section">`;
|
||
html += `<div class="ml-combo-row">`;
|
||
html += `<label class="ml-combo-label">Model</label>`;
|
||
html += `<input id="combo-input-${comboId}" class="ml-combo-input" type="text" placeholder="Type to filter local models...">`;
|
||
html += `<button id="combo-refresh-${comboId}" title="Refresh model list" class="ml-btn ml-btn-secondary ml-btn-sm">⟳</button>`;
|
||
html += `</div>`;
|
||
html += `<div id="combo-list-${comboId}" class="ml-combo-list"></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;
|
||
const urnDownloadId = `urn-download-${missing.node_id}-${missing.widget_index}`;
|
||
|
||
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) {
|
||
html += this.renderDownloadSourceSection(missing, downloadSource);
|
||
} else if (missing.is_urn) {
|
||
html += `<div id="${urnDownloadId}" class="ml-download-section">`;
|
||
html += `<div class="ml-download-info">Resolving CivitAI download for this URN...</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}" class="ml-search-source-bar">`;
|
||
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}" class="ml-search-results"></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)';
|
||
}
|
||
|
||
const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-');
|
||
const downloadEl = document.getElementById(downloadContainerId);
|
||
if (downloadEl && data.civitai) {
|
||
const missing = this.missingModels.find(m =>
|
||
`urn-download-${m.node_id}-${m.widget_index}` === downloadContainerId
|
||
);
|
||
if (missing) {
|
||
missing.civitai_info = {
|
||
model_name: data.civitai.name,
|
||
version_name: data.civitai.version_name,
|
||
expected_filename: data.civitai.filename
|
||
};
|
||
missing.download_source = {
|
||
source: 'civitai',
|
||
url: data.civitai.download_url,
|
||
filename: data.civitai.filename,
|
||
name: data.civitai.name,
|
||
type: data.civitai.type,
|
||
directory: missing.category || 'checkpoints',
|
||
match_type: 'exact',
|
||
size: data.civitai.size,
|
||
model_id: data.civitai.model_id || modelId,
|
||
version_id: data.civitai.version_id || versionId,
|
||
model_url: data.civitai.url || `https://civitai.com/models/${modelId}?modelVersionId=${versionId}`
|
||
};
|
||
downloadEl.outerHTML = this.renderDownloadSourceSection(missing, missing.download_source);
|
||
|
||
const refreshedBtn = this.contentElement?.querySelector(`#download-${missing.node_id}-${missing.widget_index}`);
|
||
if (refreshedBtn) {
|
||
refreshedBtn.addEventListener('click', () => {
|
||
this.downloadModel(missing);
|
||
});
|
||
}
|
||
}
|
||
} else if (downloadEl) {
|
||
downloadEl.innerHTML = `<div class="ml-download-info">Unable to resolve direct download for this URN.</div>`;
|
||
}
|
||
} else {
|
||
const loadingEl = document.getElementById(loadingElementId);
|
||
if (loadingEl) {
|
||
loadingEl.textContent = 'Error';
|
||
loadingEl.style.color = '#f44336';
|
||
}
|
||
const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-');
|
||
const downloadEl = document.getElementById(downloadContainerId);
|
||
if (downloadEl) {
|
||
downloadEl.innerHTML = `<div class="ml-download-info">Failed to resolve URN download.</div>`;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Model Linker: URN resolve error:', error);
|
||
const loadingEl = document.getElementById(loadingElementId);
|
||
if (loadingEl) {
|
||
loadingEl.textContent = 'Error';
|
||
loadingEl.style.color = '#f44336';
|
||
}
|
||
const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-');
|
||
const downloadEl = document.getElementById(downloadContainerId);
|
||
if (downloadEl) {
|
||
downloadEl.innerHTML = `<div class="ml-download-info">Failed to resolve URN download.</div>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 || (civitaiResult.model_id ? `https://civitai.com/models/${civitaiResult.model_id}${civitaiResult.version_id ? `?modelVersionId=${civitaiResult.version_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);
|
||
}
|
||
};
|
||
|