/**
* 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 = `
`;
html += ``;
if (isCivitaiSource && modelCardUrl) {
html += ` Open on CivitAI`;
}
html += `
`;
html += `${isFromWorkflow ? 'URL from workflow' : sourceLabel}`;
if (modelCardUrl) {
html += ` ${formattedDownloadName.display}`;
} else {
html += ` ${formattedDownloadName.display}`;
}
if (isCivitaiSource) {
html += ` Direct CivitAI download can require an API key for some models.`;
}
html += `
`;
html += `
`;
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 = `
${loraDisplayName}
Loading...
File
Hash (sha256)
CivitAI
Name
Base Model
Trained Words
Clip Skip
Description
`;
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 = `
View on Civitai
`;
} else {
const searchName = data.model_name || data.modelName || 'Unknown';
civitaiLinkEl.innerHTML = `
Model not found
🔍 Search on CivitAI
`;
}
}
// 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 = '
Example Images
';
for (const img of images.slice(0, 6)) {
if (!img.url) continue;
let caption = '';
if (img.civitaiUrl) {
caption += `civitai`;
}
if (img.seed) caption += ` ${img.seed}`;
if (img.steps) caption += ` ${img.steps}`;
if (img.cfg) caption += ` ${img.cfg}`;
if (img.sampler) caption += ` ${img.sampler}`;
if (img.positive) caption += ` ${img.positive.substring(0, 100)}${img.positive.length > 100 ? '...' : ''}`;
imagesHtml += `
`;
}
/**
* 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 `
`;
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 = `✕ Cancel All (${activeCount})`;
this.downloadAllButton.classList.remove('ml-btn-download');
this.downloadAllButton.classList.add('ml-btn-danger');
} else {
this.downloadAllButton.innerHTML = `☁ 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 = '
Analyzing workflow...
';
try {
// Use provided workflow, or get current workflow from ComfyUI
if (!workflow) {
workflow = this.getCurrentWorkflow();
}
if (!workflow) {
this.contentElement.innerHTML = '
No workflow loaded. Please load a workflow first.
';
return;
}
// Call analyze endpoint
const response = await api.fetchApi('/model_linker/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow })
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
this.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 = `
Error: ${error.message}
`;
}
}
}
/**
* Get current workflow from ComfyUI
*/
getCurrentWorkflow() {
// Try to get workflow from app
if (app?.graph) {
try {
// Use ComfyUI's workflow serialization
const workflow = app.graph.serialize();
return workflow;
} catch (e) {
console.warn('Model Linker: Could not serialize workflow from graph:', e);
}
}
return null;
}
/**
* 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 = `
Downloading...
`;
// 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 = `
${activeCount > 0 ? `${activeCount} downloading` : (hasAny100Match ? 'Auto-Link ready for exact matches' : 'Review matches or search online')}
`;
html += '
';
// 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 += '
';
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 += `
`;
html += `
`;
html += `${label}`;
html += `
`;
if (folderDisplay) {
html += `
📁 ${folderDisplay}
`;
}
html += `
`;
}
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 = `
`;
// Card Header: Filename as headline + node chip
html += `
`;
html += `
`;
// Always show the URN/ filename first
let titleHtml = `${missingFilename.display}`;
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 += ` ⏳ Loading...`;
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 ? `${civitaiLabel}` : `${civitaiLabel}`;
titleHtml += ` ${linkHtml}`;
}
if (civitaiInfo.expected_filename) {
titleHtml += ` "${civitaiInfo.expected_filename}"`;
}
}
html += `
${titleHtml}
`;
html += `
`;
if (missing.category) {
html += `${missing.category}`;
}
html += `${nodeLabel} #${missing.node_id}`;
html += `
`;
html += `
`;
html += `
`;
// Add Locate button for top-level nodes only
if (missing.is_top_level !== false) {
const locateId = `locate-${missing.node_id}-${missing.widget_index}`;
html += ``;
}
html += `
`;
html += `
`;
// Selected bar - shows if this slot has a queued selection (BELOW card header)
const selectedBarId = `selected-bar-${missing.node_id}-${missing.widget_index}`;
html += ``;
// Two-column layout
html += `
`;
// LEFT COLUMN: Local Matches
html += `
`;
html += `
Local Matches
`;
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 += `
`;
html += this.getConfidenceBadge(match.confidence);
html += `${formattedPath.display}`;
html += ``;
html += `
`;
}
// 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 += `
`; // End left column
// RIGHT COLUMN: Download Option
html += `
`;
html += `
Download
`;
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 += `
Not needed - exact local match available
`;
} else if (downloadSource && downloadSource.url) {
html += this.renderDownloadSourceSection(missing, downloadSource);
} else if (missing.is_urn) {
html += `
`;
html += `
Resolving CivitAI download for this URN...
`;
html += `
`;
} else {
// No known download - offer search
html += `
`;
}
}
}
/**
* 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 = '
';
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 += `
`;
html += `Found in Popular Models`;
html += `
`;
html += ``;
html += `
`;
}
// 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 += `
`;
html += `${matchType} in Model Database${confidence}`;
html += `
`;
html += `${modelListResult.filename}`;
if (modelListResult.name) {
html += ` ${modelListResult.name}`;
}
if (modelListResult.size) {
html += ` [${modelListResult.size}]`;
}
html += `
`;
html += `
`;
html += ``;
html += `
`;
}
// HuggingFace result
if (hfResult && hfResult.url) {
const hfRepo = hfResult.repo_id || hfResult.repo || '';
html += `
`;
html += `Found on HuggingFace`;
html += `
`;
html += `${hfResult.filename} `;
html += `${hfRepo}`;
html += `
`;
html += `
`;
html += ``;
html += `
`;
}
// 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 += `
`;
html += `Found on CivitAI`;
html += `
`;
// Model name as clickable button/link
html += ` `;
html += `${civitaiResult.type || ''}`;
html += `
`;
html += `
`;
// Download button - use expected_filename from URN resolution
html += ``;
html += `