Files
Azornes-Comfyui-Model-Resolver/web/linker.js
T
Dariusz L 5dec88fd15 Improve tab styling and accessibility in linker UI
- Redesign tabs with new visual styling (active/hover states, separators)
- Add ARIA attributes for tab accessibility (aria-selected, aria-disabled, aria-current)
- Update header styling in linker-shell.css
- Refactor tab switching logic to use centralized state management
2026-05-28 06:05:21 +02:00

6789 lines
291 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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";
import { loadStylesWhenNeeded } from "./utils/css_loader.js";
import { getSvgIcon } from "./utils/icon_utils.js";
class LinkerManagerDialog extends ComfyDialog {
constructor() {
super();
this.currentWorkflow = null;
this.missingModels = [];
this.allModels = null; // list of all available models for dropdown
this.downloadDirectories = null;
this.capabilities = null;
this.downloadSubfolders = new Map();
this.pendingResolutions = [];
this.pendingIndex = new Map(); // key -> index in pendingResolutions
this.activeDownloads = {}; // Track active downloads
this.searchResultCache = new Map();
this.searchProgressTimers = new Map();
this.cachedAnalysisData = null;
this.cachedWorkflowSignature = null;
this.selectedMissingModelKey = null;
this.boundHandleOutsideClick = this.handleOutsideClick.bind(this);
this.activeTabStorageKey = 'model_linker_active_tab';
this.activeTab = this.restoreActiveTab(); // Default tab
this.fullscreen = false;
this._dragging = false;
this._dragStart = null;
this._analysisProgressToken = null;
this._locateAnimationFrame = null;
this._viewportClampFrame = null;
this._boundHandleViewportResize = () => this.scheduleModalViewportClamp(true);
// Create backdrop overlay for click-outside-to-close
this.backdrop = $el("div.model-linker-backdrop", {
parent: document.body
});
// Create context menu for model chips
this.contextMenu = $el("div.ml-context-menu", {
parent: document.body
}, [
$el("div.ml-context-menu-item", {
onclick: () => this.handleContextMenuAction('showInfo')
}, [
$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')
}, [
$el("span.ml-context-menu-item-icon", { textContent: "🌐" }),
$el("span", { textContent: "Open in CivitAI" })
]),
$el("div.ml-context-menu-divider"),
$el("div.ml-context-menu-item", {
onclick: () => this.handleContextMenuAction('openFolder')
}, [
$el("span.ml-context-menu-item-icon", { textContent: "📁" }),
$el("span", { textContent: "Open Containing Folder" })
])
]);
this.tooltipElement = $el("div.ml-global-tooltip", { parent: document.body });
// Selected model for context menu
this._contextMenuModel = null;
// Create dialog element using $el
this.element = $el("div.comfy-modal.model-linker-modal", {
id: "model-linker-modal",
parent: document.body
}, [
this.createHeader(),
this.createContent(),
this.createFooter()
]);
this.tooltipObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
this.bindTooltips(node);
}
}
}
});
this.tooltipObserver.observe(this.element, {
childList: true,
subtree: true
});
this.bindTooltips(this.element);
// Add click listener to hide context menu when clicking outside
this.boundHandleContextMenuClick = (e) => this.handleContextMenuOutsideClick(e);
document.addEventListener('click', this.boundHandleContextMenuClick);
window.addEventListener('resize', this._boundHandleViewportResize);
}
/**
* 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,
lora_manager_archive: null
},
lastAttemptSources: [],
lastAttemptFound: null,
lastAttemptError: null,
sourceProgress: {},
activeSearchRunId: 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,
lora_manager_archive: newResults.lora_manager_archive || existingResults.lora_manager_archive || null
};
}
getSearchIconHtml() {
return `<span class="ml-btn-icon" aria-hidden="true">${getSvgIcon('search')}</span>`;
}
getLocateIconHtml() {
return `<span class="ml-node-chip-icon" aria-hidden="true">${getSvgIcon('locate')}</span>`;
}
showTooltip(target) {
if (!target || !this.tooltipElement) return;
this.normalizeTooltipTarget(target);
const text = target.getAttribute('data-tooltip');
if (!text) return;
this.tooltipElement.textContent = text;
this.tooltipElement.style.display = 'block';
const rect = target.getBoundingClientRect();
const tooltipRect = this.tooltipElement.getBoundingClientRect();
const margin = 12;
const maxLeft = Math.max(margin, window.innerWidth - tooltipRect.width - margin);
let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
left = Math.min(Math.max(margin, left), maxLeft);
let top = rect.top - tooltipRect.height - 10;
if (top < margin) {
top = Math.min(window.innerHeight - tooltipRect.height - margin, rect.bottom + 10);
}
top = Math.max(margin, top);
this.tooltipElement.style.left = `${Math.round(left)}px`;
this.tooltipElement.style.top = `${Math.round(top)}px`;
this.tooltipElement.setAttribute('data-visible', 'true');
}
hideTooltip() {
if (!this.tooltipElement) return;
this.tooltipElement.style.display = 'none';
this.tooltipElement.removeAttribute('data-visible');
}
normalizeTooltipTarget(target) {
if (!target || !target.getAttribute) return;
const title = target.getAttribute('title');
if (title && !target.getAttribute('data-tooltip')) {
target.setAttribute('data-tooltip', title);
}
if (target.hasAttribute('title')) {
target.removeAttribute('title');
}
if (target.classList?.contains('ml-tooltip-badge') && !target.hasAttribute('tabindex')) {
target.setAttribute('tabindex', '0');
}
}
setTooltip(target, text) {
if (!target || !text) return;
target.setAttribute('data-tooltip', text);
if (target.hasAttribute('title')) {
target.removeAttribute('title');
}
this.bindTooltips(target);
}
bindTooltips(container) {
if (!container) return;
const selector = '[data-tooltip], [title]';
const targets = [];
if (container.matches?.(selector)) {
targets.push(container);
}
if (container.querySelectorAll) {
targets.push(...container.querySelectorAll(selector));
}
targets.forEach((target) => {
this.normalizeTooltipTarget(target);
if (!target.dataset) return;
if (target.dataset.mlTooltipBound === '1') return;
target.dataset.mlTooltipBound = '1';
target.addEventListener('mouseenter', () => this.showTooltip(target));
target.addEventListener('focus', () => this.showTooltip(target));
target.addEventListener('mouseleave', () => this.hideTooltip());
target.addEventListener('blur', () => this.hideTooltip());
});
}
getValidTab(tab) {
return ['missing', 'loaded', 'options'].includes(tab) ? tab : 'missing';
}
restoreActiveTab() {
try {
return this.getValidTab(localStorage.getItem(this.activeTabStorageKey));
} catch (error) {
console.warn('Model Linker: Failed to restore active tab:', error);
return 'missing';
}
}
persistActiveTab(tab) {
try {
localStorage.setItem(this.activeTabStorageKey, this.getValidTab(tab));
} catch (error) {
console.warn('Model Linker: Failed to persist active tab:', error);
}
}
/**
* Return true when at least one downloadable source was found
*/
hasSearchResults(data = {}) {
return !!(data.popular || data.model_list || data.huggingface || data.civitai || data.lora_manager_archive);
}
/**
* Convert source ids to readable labels
*/
getSearchSourceLabel(source) {
const labels = {
all: 'Everything',
local: 'Local Database',
huggingface: 'HuggingFace',
civitai: 'CivitAI',
lora_manager_archive: 'LoRA Manager Archive'
};
return labels[source] || source;
}
getSearchSourcesForSelection(selectedSource, missing = {}) {
if (selectedSource !== 'all') {
return this.isSourceAvailable(selectedSource) ? [selectedSource] : [];
}
const sources = ['local', 'huggingface', 'civitai'];
if (this.isSourceAvailable('lora_manager_archive')) {
sources.push('lora_manager_archive');
}
return sources;
}
setSourceProgress(state, source, patch = {}, missing = null) {
state.sourceProgress = {
...(state.sourceProgress || {}),
[source]: {
...(state.sourceProgress?.[source] || {}),
...patch
}
};
if (missing) {
this.refreshMissingSourcesSummary(missing);
}
}
refreshMissingSourcesSummary(missing = {}) {
if (!this.contentElement || !missing) return;
const key = this.getMissingModelKey(missing);
const rows = this.contentElement.querySelectorAll('.ml-missing-list-row');
for (const row of rows) {
if (row.getAttribute('data-missing-key') !== key) continue;
const sourcesEl = row.querySelector('.ml-missing-row-sources');
if (sourcesEl) {
sourcesEl.innerHTML = this.renderMissingSourcesSummary(missing);
}
}
}
getSearchSourceEstimateMs(source, isUrn = false) {
if (isUrn && source === 'civitai') return 5000;
const estimates = {
local: 1400,
lora_manager_archive: 3200,
huggingface: 18000,
civitai: 22000
};
return estimates[source] || 8000;
}
getEstimatedSearchProgressPercent(elapsedMs, estimateMs) {
const safeEstimate = Math.max(1000, Number(estimateMs) || 8000);
const elapsed = Math.max(0, Number(elapsedMs) || 0);
if (elapsed <= safeEstimate) {
const normalized = Math.min(1, elapsed / safeEstimate);
const eased = 1 - Math.pow(1 - normalized, 2.1);
return 6 + eased * 78;
}
const overtime = elapsed - safeEstimate;
const slowTail = 1 - Math.exp(-overtime / (safeEstimate * 1.6));
return Math.min(94, 84 + slowTail * 10);
}
getSearchProgressTimerKey(runId, source) {
return `${runId || 'search'}:${source}`;
}
clearSearchProgressTimer(runId, source) {
const key = this.getSearchProgressTimerKey(runId, source);
const timer = this.searchProgressTimers.get(key);
if (timer) {
clearInterval(timer);
this.searchProgressTimers.delete(key);
}
}
clearSearchProgressTimers(runId) {
if (!runId) return;
const prefix = `${runId}:`;
for (const [key, timer] of this.searchProgressTimers.entries()) {
if (key.startsWith(prefix)) {
clearInterval(timer);
this.searchProgressTimers.delete(key);
}
}
}
startEstimatedSearchProgress(state, missing, container, source, runId) {
this.clearSearchProgressTimer(runId, source);
const tick = () => {
if (state.activeSearchRunId !== runId) {
this.clearSearchProgressTimer(runId, source);
return;
}
const progress = state.sourceProgress?.[source];
if (!progress || progress.status !== 'running') {
this.clearSearchProgressTimer(runId, source);
return;
}
const elapsedMs = Date.now() - (progress.startedAt || Date.now());
const percent = this.getEstimatedSearchProgressPercent(
elapsedMs,
progress.estimateMs
);
if (percent > (Number(progress.percent) || 0) + 0.2) {
this.setSourceProgress(state, source, { percent });
this.displaySearchResults(missing, state, container);
}
};
tick();
const timer = setInterval(tick, 450);
this.searchProgressTimers.set(
this.getSearchProgressTimerKey(runId, source),
timer
);
}
hasActiveSearchProgress(state = {}) {
return Object.values(state.sourceProgress || {}).some(progress => (
progress?.status === 'pending' || progress?.status === 'running'
));
}
renderSearchProgress(state = {}) {
const progressEntries = Object.entries(state.sourceProgress || {});
if (!progressEntries.length) return '';
const isCompact = !this.hasActiveSearchProgress(state);
const statusLabels = {
pending: 'Queued',
running: 'Searching...',
found: 'Found',
none: 'No match',
error: 'Error'
};
if (isCompact) {
let html = '<div class="ml-search-progress-list ml-search-progress-list-compact">';
for (const [source, progress] of progressEntries) {
const status = progress?.status || 'pending';
const statusClass = String(status).replace(/[^a-z0-9_-]/gi, '');
const label = this.getSearchSourceLabel(source);
const statusLabel = progress?.message || statusLabels[status] || status;
const title = `${label}: ${statusLabel}`;
html += `
<div class="ml-search-progress-item ml-search-progress-${statusClass}" data-tooltip="${this.escapeHtml(title)}">
<span class="ml-search-progress-source">${this.escapeHtml(label)}</span>
<span class="ml-search-progress-status">${this.escapeHtml(statusLabel)}</span>
</div>
`;
}
html += '</div>';
return html;
}
let html = '<div class="ml-search-progress-list">';
for (const [source, progress] of progressEntries) {
const status = progress?.status || 'pending';
const statusClass = String(status).replace(/[^a-z0-9_-]/gi, '');
const label = this.getSearchSourceLabel(source);
const percent = status === 'pending'
? 0
: (status === 'running' ? progress?.percent : 100);
const safePercent = Math.max(0, Math.min(100, Number(percent) || 0));
const statusLabel = status === 'running'
? `Searching... ${Math.round(safePercent)}%`
: (progress?.message || statusLabels[status] || status);
html += `
<div class="ml-search-progress-item ml-search-progress-${statusClass}">
<div class="ml-search-progress-head">
<span>${this.escapeHtml(label)}</span>
<span>${this.escapeHtml(statusLabel)}</span>
</div>
<div class="ml-search-progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${safePercent}">
<div class="ml-search-progress-fill" style="width: ${safePercent}%;"></div>
</div>
</div>
`;
}
html += '</div>';
return html;
}
/**
* Update source selector buttons and helper text for one card
*/
syncSearchSourceUi(missing, container) {
if (!container) return;
const state = this.getSearchState(missing);
const selectEl = container.querySelector(`#search-source-select-${missing.node_id}-${missing.widget_index}`);
if (selectEl) {
this.setDropdownValue(selectEl, state.selectedSource, 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);
}
getDropdownValue(el) {
return el?.dataset?.value || el?.value || '';
}
setDropdownValue(el, value, label = value) {
if (!el) return;
el.dataset.value = value || '';
el.value = label || value || '';
}
normalizeVersionName(versionName) {
return String(versionName || '').trim().replace(/^v{2,}(?=\d)/i, 'v');
}
getModelVersionParts(modelName, versionName) {
const name = String(modelName || '').trim();
const version = this.normalizeVersionName(versionName);
if (!version || version === name) {
return { name: name || version, version: '' };
}
if (name && name.toLowerCase().includes(version.toLowerCase())) {
return { name, version: '' };
}
return { name, version };
}
getVersionedModelName(modelName, versionName) {
const parts = this.getModelVersionParts(modelName, versionName);
if (!parts.version) return parts.name;
return parts.name ? `${parts.name} ${parts.version}` : parts.version;
}
renderVersionedModelNameHtml(modelName, versionName) {
const parts = this.getModelVersionParts(modelName, versionName);
const nameHtml = parts.name ? this.escapeHtml(parts.name) : '';
const versionHtml = parts.version
? `<em class="ml-model-version">${this.escapeHtml(parts.version)}</em>`
: '';
return [nameHtml, versionHtml].filter(Boolean).join(' ');
}
getSearchSourceIconName(sourceKey) {
const icons = {
popular: 'star',
'model-list': 'database',
huggingface: 'huggingface',
civitai: 'civitai',
'lora-archive': 'loraManager',
lora_manager_archive: 'loraManager',
'lora-manager-archive': 'loraManager',
local: 'database',
'workflow-url': 'link',
workflow: 'link',
online: 'globe'
};
return icons[sourceKey] || 'globe';
}
renderSearchSourcePill(sourceKey, sourceLabel) {
const iconName = this.getSearchSourceIconName(sourceKey);
const iconHtml = getSvgIcon(iconName, 'currentColor', 'ml-search-source-icon');
return `
<span class="ml-search-source-pill ml-search-source-${sourceKey}" data-tooltip="${this.escapeHtml(sourceLabel)}">
${iconHtml}
<span>${this.escapeHtml(sourceLabel)}</span>
</span>
`;
}
getSearchResultsTableLayout(rows = []) {
const textWidth = (value, charPx = 6, minPx = 40, maxPx = 180) => {
const length = String(value || '').length;
return Math.max(minPx, Math.min(maxPx, Math.ceil(length * charPx)));
};
const maxSourceLabel = rows.reduce(
(max, row) => Math.max(max, String(row.sourceLabel || row.sourceKey || '').length),
'Source'.length
);
const maxMatchLabel = rows.reduce(
(max, row) => Math.max(max, String(row.match?.label || '').length),
'Match'.length
);
const maxSizeLabel = rows.reduce(
(max, row) => Math.max(max, String(row.size || '-').length),
'Size'.length
);
const maxActions = rows.reduce((max, row) => {
const count = (row.downloadUrl ? 1 : 0) + (row.openUrl ? 1 : 0);
return Math.max(max, count);
}, 1);
const sourcePx = textWidth('x'.repeat(maxSourceLabel), 6, 34, 100) + 52;
const matchPx = textWidth('x'.repeat(maxMatchLabel), 7, 34, 76) + 22;
const sizePx = textWidth('x'.repeat(maxSizeLabel), 6.5, 28, 72) + 22;
const actionsPx = Math.max(60, (maxActions * 24) + (Math.max(0, maxActions - 1) * 6) + 22);
const modelMinPx = 210;
const tableMinPx = Math.ceil(sourcePx + matchPx + sizePx + actionsPx + modelMinPx);
return { sourcePx, matchPx, sizePx, actionsPx, tableMinPx };
}
formatSearchResultSize(result = {}) {
if (result.size === 0) return '0 B';
if (!result.size) return '';
return typeof result.size === 'number' ? this.formatBytes(result.size) : String(result.size);
}
getSearchResultMatchDisplay(result = {}, fallbackLabel = 'Match', fallbackClass = 'neutral') {
const matchType = String(result.match_type || '').toLowerCase();
if (matchType === 'exact') {
return { label: 'Exact', className: 'strong' };
}
const confidence = Number(result.confidence);
if (Number.isFinite(confidence) && confidence > 0) {
const className = confidence >= 95 ? 'strong' : (confidence >= 70 ? 'medium' : 'weak');
return { label: `${Math.round(confidence)}%`, className };
}
if (matchType === 'partial') {
return { label: 'Partial', className: 'medium' };
}
if (matchType === 'fuzzy' || matchType === 'similar') {
return { label: matchType === 'fuzzy' ? 'Fuzzy' : 'Similar', className: 'medium' };
}
return { label: fallbackLabel, className: fallbackClass };
}
getDownloadSourceTableRow(missing, downloadSource = {}) {
if (!downloadSource?.url) return null;
const originalFilename = missing.original_path?.split('/').pop()?.split('\\').pop() || '';
const downloadFilename = downloadSource.filename || originalFilename || 'model';
const isFromWorkflow = downloadSource.url_source === 'workflow';
const source = downloadSource.source || (isFromWorkflow ? 'workflow' : 'online');
const sourceLabels = {
popular: 'Popular',
model_list: 'Local Database',
huggingface: 'HuggingFace',
civitai: 'CivitAI',
lora_manager_archive: 'LoRA Archive',
workflow: 'Workflow',
online: 'Online'
};
const sourceLabel = isFromWorkflow ? 'Workflow URL' : (sourceLabels[source] || source);
const sourceKey = isFromWorkflow
? 'workflow-url'
: String(source).replace(/_/g, '-');
const sourceSecondary = isFromWorkflow && sourceLabels[source] && source !== 'workflow'
? sourceLabels[source]
: '';
const modelUrl = downloadSource.model_url
|| downloadSource.workflow_model_url
|| this.getModelCardUrl(downloadSource.url);
const versionName = downloadSource.version_name || missing.civitai_info?.version_name || '';
const modelParts = this.getModelVersionParts(
downloadSource.name || missing.civitai_info?.model_name || '',
versionName
);
const modelName = modelParts.name || downloadFilename;
const fullModelName = this.getVersionedModelName(modelName, modelParts.version);
return {
sourceKey,
sourceLabel,
model: modelName,
version: modelParts.version,
filename: downloadFilename,
secondary: sourceSecondary || (fullModelName && fullModelName !== downloadFilename ? downloadFilename : ''),
match: isFromWorkflow
? { label: 'Provided', className: 'strong' }
: this.getSearchResultMatchDisplay(downloadSource, 'Known', 'strong'),
size: this.formatSearchResultSize(downloadSource),
downloadUrl: downloadSource.url,
downloadFilename,
category: downloadSource.directory || downloadSource.category || missing.category || 'checkpoints',
openUrl: modelUrl
};
}
renderKnownDownloadPanel(missing, downloadSource) {
let html = `<div class="ml-download-section">`;
html += this.renderSearchControls(missing, { buttonText: 'Search Again' });
html += this.renderDownloadTargetControls(
missing,
downloadSource.directory || downloadSource.category || missing.category || 'checkpoints'
);
html += `</div>`;
const downloadSourceRow = this.getDownloadSourceTableRow(missing, downloadSource);
html += `<div id="search-results-${missing.node_id}-${missing.widget_index}" class="ml-search-results ml-is-visible">`;
html += this.renderSearchResultsTable(downloadSourceRow ? [downloadSourceRow] : []);
html += `</div>`;
return html;
}
renderSearchResultsTable(rows = []) {
if (!rows.length) return '';
const layout = this.getSearchResultsTableLayout(rows);
const tableStyle = [
`--ml-source-col:${layout.sourcePx}px`,
`--ml-match-col:${layout.matchPx}px`,
`--ml-size-col:${layout.sizePx}px`,
`--ml-actions-col:${layout.actionsPx}px`,
`--ml-table-min:${layout.tableMinPx}px`
].join(';');
let html = `
<div class="ml-search-results-table-wrap">
<table class="ml-search-results-table" style="${tableStyle}">
<colgroup>
<col class="ml-search-col-source">
<col class="ml-search-col-model">
<col class="ml-search-col-match">
<col class="ml-search-col-size">
<col class="ml-search-col-actions">
</colgroup>
<thead>
<tr>
<th>Source</th>
<th>Model</th>
<th>Match</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
`;
for (const row of rows) {
const sourceKey = String(row.sourceKey || '').replace(/[^a-z0-9_-]/gi, '');
const sourceLabel = row.sourceLabel || row.sourceKey || 'Source';
const sourcePill = this.renderSearchSourcePill(sourceKey, sourceLabel);
const rawModel = row.model || row.filename || 'Model';
const rawVersion = row.version || '';
const model = this.escapeHtml(rawModel);
const modelTitle = this.escapeHtml(this.getVersionedModelName(rawModel, rawVersion) || rawModel);
const modelHtml = this.renderVersionedModelNameHtml(rawModel, rawVersion) || model;
const secondary = row.secondary ? this.escapeHtml(row.secondary) : '';
const filename = row.filename && row.filename !== row.model ? this.escapeHtml(row.filename) : '';
const match = row.match || { label: 'Match', className: 'neutral' };
const matchClass = String(match.className || 'neutral').replace(/[^a-z0-9_-]/gi, '');
const size = this.escapeHtml(row.size || '-');
const downloadUrl = row.downloadUrl || '';
const openUrl = row.openUrl || '';
const downloadFilename = row.downloadFilename || row.filename || row.model || 'model';
const category = row.category || '';
let actions = '';
if (downloadUrl) {
actions += `
<button type="button" class="search-download-btn ml-search-result-action-btn"
data-tooltip="Download"
aria-label="Download ${this.escapeHtml(downloadFilename)}"
data-url="${this.escapeHtml(downloadUrl)}"
data-filename="${this.escapeHtml(downloadFilename)}"
data-category="${this.escapeHtml(category)}">${getSvgIcon('download')}</button>
`;
}
if (openUrl) {
actions += `
<button type="button"
class="search-open-page-btn ml-search-result-action-btn"
data-tooltip="Open model page"
aria-label="Open model page"
data-url="${this.escapeHtml(openUrl)}">${getSvgIcon('externalLink')}</button>
`;
}
if (!actions) {
actions = '<span class="ml-search-result-empty">-</span>';
}
html += `
<tr>
<td>${sourcePill}</td>
<td>
<div class="ml-search-result-model" data-tooltip="${modelTitle}">
<span>${modelHtml}</span>
${secondary || filename ? `<small>${secondary || filename}</small>` : ''}
</div>
</td>
<td><span class="ml-search-match ml-search-match-${matchClass}">${this.escapeHtml(match.label)}</span></td>
<td class="ml-search-size">${size}</td>
<td><div class="ml-search-result-actions">${actions}</div></td>
</tr>
`;
}
html += `
</tbody>
</table>
</div>
`;
return html;
}
renderSearchControls(missing, options = {}) {
const searchSourcesId = `search-sources-${missing.node_id}-${missing.widget_index}`;
const searchSourceSelectId = `search-source-select-${missing.node_id}-${missing.widget_index}`;
const searchSourceListId = `search-source-list-${missing.node_id}-${missing.widget_index}`;
const buttonText = options.buttonText || 'Search';
const state = this.getSearchState(missing);
const selectedSource = state.selectedSource || 'all';
let html = `<div id="${searchSourcesId}" class="ml-search-source-bar">`;
html += `<button id="search-${missing.node_id}-${missing.widget_index}" class="ml-btn ml-btn-link">`;
html += `${this.getSearchIconHtml()} ${buttonText}`;
html += `</button>`;
html += `<div class="ml-search-source-picker">`;
html += `<label class="ml-search-source-picker-label" for="${searchSourceSelectId}">Source</label>`;
html += `<div class="ml-download-target-wrap">`;
html += `<input id="${searchSourceSelectId}" class="ml-download-target-input ml-search-source-select" type="text" readonly autocomplete="off" data-value="${this.escapeHtml(selectedSource)}" value="${this.escapeHtml(this.getSearchSourceLabel(selectedSource))}">`;
html += `<div id="${searchSourceListId}" class="ml-download-target-list ml-search-source-list"></div>`;
html += `</div>`;
html += `</div>`;
html += `</div>`;
return html;
}
renderProgressWithAction({
percent = 0,
leftText = '',
rightText = '',
actionClass = '',
actionText = '',
actionDataAttr = ''
} = {}) {
const safePercent = Math.max(0, Math.min(100, Number(percent) || 0));
const actionAttr = actionDataAttr ? ` ${actionDataAttr}` : '';
return `
<div class="ml-progress-container">
<div class="ml-progress-row">
<div class="ml-progress-bar ml-progress-bar-grow">
<div class="ml-progress-fill" style="width: ${safePercent}%;"></div>
</div>
<button class="${actionClass}"${actionAttr}>${actionText}</button>
</div>
<div class="ml-progress-text">
<span>${leftText}</span>
<span>${rightText}</span>
</div>
</div>
`;
}
buildContextMenuModelData(model = {}, fallbackName = '') {
const resolvedPath = model.path || model.resolved_path || '';
const filename = model.filename || fallbackName || resolvedPath.split(/[\/\\]/).pop() || '';
return {
...model,
name: model.name || filename,
original_path: model.original_path || filename,
resolved_path: resolvedPath,
category: model.category || ''
};
}
renderLocalMatchesContent(missing, missingIndex = 0) {
const allMatches = missing.matches || [];
const filteredMatches = allMatches.filter(m => m.confidence >= 70);
const hasMatches = filteredMatches.length > 0;
const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70);
let html = '';
if (hasMatches) {
const matchesToShow = perfectMatches.length > 0
? perfectMatches
: otherMatches.sort((a, b) => b.confidence - a.confidence).slice(0, 5);
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;
const contextModel = this.buildContextMenuModelData(match.model || {}, match.filename || '');
const modelData = encodeURIComponent(JSON.stringify(contextModel));
html += `<div class="ml-match-row ${isBestMatch ? 'ml-best-match' : ''}" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">`;
html += this.getConfidenceBadge(match.confidence);
html += `<span class="ml-match-filename" data-tooltip="${this.escapeHtml(formattedPath.full)}">${formattedPath.display}</span>`;
html += `<span class="ml-match-status ${match.confidence === 100 ? 'ml-match-status-exact' : 'ml-match-status-partial'}">${match.confidence === 100 ? 'Exact' : 'Partial'}</span>`;
html += `<button id="${buttonId}" class="ml-btn ml-btn-secondary ml-btn-sm ml-btn-icon-only ml-local-link-btn" data-tooltip="Link this local match" aria-label="Link this local match">`;
html += getSvgIcon('link');
html += `</button>`;
html += `</div>`;
}
if (perfectMatches.length > 0 && otherMatches.length > 0) {
const matchId = `more-matches-${missing.node_id}-${missing.widget_index}`;
const altLabel = `Alternatives (${otherMatches.length})`;
html += `<button type="button" class="ml-local-alternatives-toggle" aria-expanded="true" onclick="window.MLToggleHidden('${matchId}', this, '${altLabel}', '${altLabel}')">`;
html += `<span class="ml-local-alternatives-label">${altLabel}</span>`;
html += `<span class="ml-local-alternatives-state">Hide</span>`;
html += `<span class="ml-local-alternatives-chevron" aria-hidden="true"></span>`;
html += `</button>`;
html += `<div id="${matchId}" class="ml-stack-sm">`;
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 contextModel = this.buildContextMenuModelData(match.model || {}, match.filename || '');
const modelData = encodeURIComponent(JSON.stringify(contextModel));
html += `<div class="ml-match-row" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">`;
html += this.getConfidenceBadge(match.confidence);
html += `<span class="ml-match-filename" data-tooltip="${this.escapeHtml(match.path || match.filename)}">${match.filename || match.path?.split(/[/\\]/).pop()}</span>`;
html += `<span class="ml-match-status ml-match-status-partial">Partial</span>`;
html += `<button id="${altBtnId}" class="ml-btn ml-btn-secondary ml-btn-sm ml-btn-icon-only ml-local-link-btn" data-tooltip="Link this local match" aria-label="Link this local match">${getSvgIcon('link')}</button>`;
html += `</div>`;
}
html += `</div>`;
}
} else if (missing.is_urn && !missing.civitai_info) {
html += `<div class="ml-no-matches">Waiting for CivitAI filename to search local models...</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>`;
}
return html;
}
wireLocalMatchButtons(container, missing, missingIndex = 0) {
const allMatches = missing.matches || [];
const filteredMatches = allMatches.filter(m => m.confidence >= 70);
const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70);
const matchesToShow = perfectMatches.length > 0
? perfectMatches
: otherMatches.sort((a, b) => b.confidence - a.confidence).slice(0, 5);
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) => {
const buttonId = `resolve-${missingIndex}-${missing.node_id}-${missing.widget_index}-${matchIndex}`;
const resolveButton = container.querySelector(`#${buttonId}`);
if (resolveButton) {
resolveButton.onclick = null;
resolveButton.addEventListener('click', () => {
this.queueResolution(missing, match.model);
});
}
});
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);
});
}
}
}
}
async refreshUrnLocalMatches(missing) {
if (!missing?.civitai_info?.expected_filename || !this.contentElement) return;
const bodyId = `local-matches-body-${missing.node_id}-${missing.widget_index}`;
const container = this.contentElement.querySelector(`#${bodyId}`);
if (!container) return;
container.innerHTML = `<div class="ml-no-matches">Searching local matches for "${missing.civitai_info.expected_filename}"...</div>`;
try {
const response = await api.fetchApi('/model_linker/local-matches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: missing.civitai_info.expected_filename,
category: missing.category || ''
})
});
if (!response.ok) {
throw new Error(`Local match search failed: ${response.status}`);
}
const data = await response.json();
missing.matches = Array.isArray(data.matches) ? data.matches : [];
container.innerHTML = this.renderLocalMatchesContent(missing, missing.__displayIndex || 0);
this.wireLocalMatchButtons(this.contentElement, missing, missing.__displayIndex || 0);
} catch (error) {
console.error('Model Linker: URN local match refresh error:', error);
container.innerHTML = `<div class="ml-no-matches">Failed to refresh local matches.</div>`;
}
}
/**
* 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 === 'openFolder') {
this.openContainingFolder(model);
} else if (action === 'showInfo') {
this.showModelInfo(model);
}
}
async openContainingFolder(model) {
const path = model?.path || model?.resolved_path || '';
if (!path) {
this.showNotification('No local file path available', 'error');
return;
}
try {
const response = await api.fetchApi('/model_linker/open-containing-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
});
if (!response.ok) {
throw new Error(`Open folder failed: ${response.status}`);
}
} catch (error) {
console.error('Model Linker: Open folder error:', error);
this.showNotification('Failed to open containing folder', 'error');
}
}
/**
* 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);
this.restoreInfoDialogSize(dialog);
// Show the dialog
document.body.appendChild(dialog);
this.bindTooltips(dialog);
this.bindInfoDialogResizePersistence(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._selectedTrainedWords = new Set();
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 ml-hidden-initial">
<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-tooltip-badge" data-tooltip="The model file name found locally or returned by CivitAI.">?</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-tooltip-badge" data-tooltip="Unique fingerprint of the local file. Model Linker uses it to confirm the exact CivitAI version.">?</span></span></td>
<td><span class="ml-info-hash"></span></td>
</tr>
<tr class="ml-info-civitai-row">
<td><span>CivitAI <span class="ml-tooltip-badge" data-tooltip="Opens the matching CivitAI model or version page when one was found.">?</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-tooltip-badge" data-tooltip="Model name from CivitAI or local metadata.">?</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-tooltip-badge" data-tooltip="Base model this resource was made for, for example SD1.5, SDXL or Flux.">?</span></span></td>
<td><span class="ml-info-base-model"></span></td>
</tr>
<tr class="ml-info-trainedwords-row ml-hidden-initial">
<td>
<div class="ml-info-trained-words-label">
Trained Words <span class="ml-tooltip-badge" data-tooltip="Trigger words recommended by the model author. Click the words you want, then copy them into your prompt.">?</span>
<small class="ml-info-trained-words-meta">
<span class="ml-info-trained-words-count">0 selected</span>
<button type="button" class="ml-info-copy-trained-words" disabled>Copy</button>
</small>
</div>
</td>
<td>
<div class="ml-info-trained-words-hint">Click words to select them.</div>
<div class="ml-info-trained-words"></div>
</td>
</tr>
<tr class="ml-info-clipskip-row ml-hidden-initial">
<td><span>Clip Skip <span class="ml-tooltip-badge" data-tooltip="Recommended Clip Skip value from the model author, if one is provided.">?</span></span></td>
<td><span class="ml-info-clip-skip"></span></td>
</tr>
<tr class="ml-info-description-row ml-hidden-initial">
<td><span>Description <span class="ml-tooltip-badge" data-tooltip="Model description from CivitAI or local metadata. Long descriptions are shortened until you click Show more.">?</span></span></td>
<td>
<div class="ml-info-description-wrap">
<div class="ml-info-description"></div>
<div class="ml-info-description-actions ml-hidden-initial">
<button type="button" class="ml-info-description-toggle">Show more</button>
</div>
</div>
</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;
}
escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
truncateText(value, maxLength = 160) {
const text = String(value ?? '').trim();
if (!text) return '';
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
}
sanitizeDescriptionHtml(html) {
const raw = String(html ?? '').trim();
if (!raw) return '';
const parser = new DOMParser();
const doc = parser.parseFromString(`<div>${raw}</div>`, 'text/html');
const root = doc.body.firstElementChild;
if (!root) return this.escapeHtml(raw);
const allowedTags = new Set([
'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'pre',
'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'a', 'span'
]);
const allowedStyles = new Set(['color']);
const sanitizeNode = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
node.remove();
return;
}
const tag = node.tagName.toLowerCase();
if (!allowedTags.has(tag)) {
const parent = node.parentNode;
if (!parent) {
node.remove();
return;
}
while (node.firstChild) {
parent.insertBefore(node.firstChild, node);
}
parent.removeChild(node);
return;
}
const attrs = Array.from(node.attributes);
for (const attr of attrs) {
const name = attr.name.toLowerCase();
if (name.startsWith('on') || name === 'id' || name === 'class' || name.startsWith('data-')) {
node.removeAttribute(attr.name);
continue;
}
if (name === 'href' && tag === 'a') {
const href = node.getAttribute('href') || '';
if (!/^https?:\/\//i.test(href)) {
node.removeAttribute('href');
} else {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
continue;
}
if (name === 'style') {
const safeStyles = [];
const styleValue = node.getAttribute('style') || '';
for (const part of styleValue.split(';')) {
const [prop, value] = part.split(':').map(v => v?.trim());
if (!prop || !value) continue;
if (allowedStyles.has(prop.toLowerCase())) {
safeStyles.push(`${prop}: ${value}`);
}
}
if (safeStyles.length) {
node.setAttribute('style', safeStyles.join('; '));
} else {
node.removeAttribute('style');
}
continue;
}
if (!(tag === 'a' && (name === 'target' || name === 'rel')) ) {
node.removeAttribute(attr.name);
}
}
Array.from(node.childNodes).forEach(child => sanitizeNode(child));
};
Array.from(root.childNodes).forEach(child => sanitizeNode(child));
return root.innerHTML;
}
normalizeTrainedWords(words) {
if (Array.isArray(words)) {
return [...new Set(words.map(word => String(word || '').trim()).filter(Boolean))];
}
if (typeof words === 'string') {
return [...new Set(
words
.split(/[\n,|;]/)
.map(word => word.trim())
.filter(Boolean)
)];
}
return [];
}
updateSelectedTrainedWordsSummary(dialog) {
if (!dialog) return;
const countEl = dialog.querySelector('.ml-info-trained-words-count');
const copyBtn = dialog.querySelector('.ml-info-copy-trained-words');
const selected = dialog._selectedTrainedWords instanceof Set
? Array.from(dialog._selectedTrainedWords)
: [];
if (countEl) {
countEl.textContent = `${selected.length} selected`;
}
if (copyBtn) {
copyBtn.disabled = selected.length === 0;
copyBtn.textContent = 'Copy';
}
}
bindInfoDialogInteractions(dialog) {
if (!dialog || dialog.dataset.mlInfoBound === 'true') return;
dialog.dataset.mlInfoBound = 'true';
dialog.addEventListener('click', async (event) => {
const wordBtn = event.target.closest('.ml-info-trained-word');
if (wordBtn && dialog.contains(wordBtn)) {
const word = wordBtn.dataset.word || '';
if (word) {
if (!(dialog._selectedTrainedWords instanceof Set)) {
dialog._selectedTrainedWords = new Set();
}
if (dialog._selectedTrainedWords.has(word)) {
dialog._selectedTrainedWords.delete(word);
wordBtn.classList.remove('is-selected');
wordBtn.setAttribute('aria-pressed', 'false');
} else {
dialog._selectedTrainedWords.add(word);
wordBtn.classList.add('is-selected');
wordBtn.setAttribute('aria-pressed', 'true');
}
this.updateSelectedTrainedWordsSummary(dialog);
}
return;
}
const copyBtn = event.target.closest('.ml-info-copy-trained-words');
if (copyBtn && dialog.contains(copyBtn)) {
const words = dialog._selectedTrainedWords instanceof Set
? Array.from(dialog._selectedTrainedWords)
: [];
if (!words.length) return;
try {
await navigator.clipboard.writeText(words.join(', '));
copyBtn.textContent = 'Copied';
} catch (error) {
console.error('Model Linker: Failed to copy trained words:', error);
copyBtn.textContent = 'Failed';
}
setTimeout(() => {
if (copyBtn.isConnected) {
copyBtn.textContent = 'Copy';
}
}, 1200);
return;
}
const descToggleBtn = event.target.closest('.ml-info-description-toggle');
if (descToggleBtn && dialog.contains(descToggleBtn)) {
const descEl = dialog.querySelector('.ml-info-description');
if (!descEl) return;
const isExpanded = descEl.classList.toggle('is-expanded');
descToggleBtn.textContent = isExpanded ? 'Show less' : 'Show more';
}
});
}
getInfoDialogElement(dialog) {
return dialog?.querySelector?.('.ml-info-dialog') || null;
}
restoreInfoDialogSize(dialog) {
const panel = this.getInfoDialogElement(dialog);
if (!panel) return;
try {
const saved = JSON.parse(localStorage.getItem('model_linker_info_dialog_size') || 'null');
if (!saved || typeof saved !== 'object') return;
const width = Number(saved.w);
const height = Number(saved.h);
if (!Number.isFinite(width) || !Number.isFinite(height)) return;
const maxWidth = Math.floor(window.innerWidth * 0.9);
const maxHeight = Math.floor(window.innerHeight * 0.8);
const clampedWidth = Math.max(420, Math.min(width, maxWidth));
const clampedHeight = Math.max(320, Math.min(height, maxHeight));
panel.style.width = `${clampedWidth}px`;
panel.style.height = `${clampedHeight}px`;
} catch (error) {
console.warn('Model Linker: Failed to restore info dialog size:', error);
}
}
saveInfoDialogSize(dialog) {
const panel = this.getInfoDialogElement(dialog);
if (!panel) return;
const rect = panel.getBoundingClientRect();
const width = Math.round(rect.width);
const height = Math.round(rect.height);
if (!width || !height) return;
try {
localStorage.setItem('model_linker_info_dialog_size', JSON.stringify({ w: width, h: height }));
} catch (error) {
console.warn('Model Linker: Failed to save info dialog size:', error);
}
}
bindInfoDialogResizePersistence(dialog) {
const panel = this.getInfoDialogElement(dialog);
if (!panel || typeof ResizeObserver === 'undefined') return;
if (dialog._infoDialogResizeObserver) return;
let resizeSaveTimer = null;
const observer = new ResizeObserver(() => {
clearTimeout(resizeSaveTimer);
resizeSaveTimer = setTimeout(() => this.saveInfoDialogSize(dialog), 180);
dialog._infoDialogResizeSaveTimer = resizeSaveTimer;
});
observer.observe(panel);
dialog._infoDialogResizeObserver = observer;
dialog._infoDialogResizeSaveTimer = resizeSaveTimer;
}
/**
* 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');
this.bindInfoDialogInteractions(dialog);
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.innerHTML = this.renderVersionedModelNameHtml(modelName, versionName) || this.escapeHtml(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">
${getSvgIcon('civitai', 'currentColor', 'ml-info-civitai-logo')}
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">
${this.getSearchIconHtml()} 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 = this.normalizeTrainedWords(data.trained_words || data.trainedWords || []);
if (words.length > 0) {
dialog._selectedTrainedWords = new Set();
trainedWordsEl.innerHTML = `<div class="ml-info-trained-words-list">${words.map(word => `
<button
type="button"
class="ml-info-trained-word"
data-word="${this.escapeHtml(word)}"
data-tooltip="${this.escapeHtml(word)}"
aria-pressed="false"
>
${this.escapeHtml(word)}
</button>
`).join('')}</div>`;
const row = trainedWordsEl.closest('tr');
if (row) row.style.display = '';
this.bindTooltips(trainedWordsEl);
this.updateSelectedTrainedWordsSummary(dialog);
} 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) {
const actionsEl = dialog.querySelector('.ml-info-description-actions');
const toggleBtn = dialog.querySelector('.ml-info-description-toggle');
let sanitizedHtml = '';
try {
sanitizedHtml = this.sanitizeDescriptionHtml(desc);
} catch (error) {
console.error('Model Linker: Failed to sanitize description HTML:', error);
}
const fallbackText = this.escapeHtml(String(desc).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim());
const finalHtml = sanitizedHtml && sanitizedHtml.trim() ? sanitizedHtml : `<p>${fallbackText}</p>`;
const textOnly = finalHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
descEl.innerHTML = finalHtml;
descEl.classList.remove('is-expanded');
const shouldCollapse = textOnly.length > 520 || finalHtml.length > 900;
if (actionsEl) {
actionsEl.style.display = shouldCollapse ? '' : 'none';
}
if (toggleBtn) {
toggleBtn.textContent = 'Show more';
}
if (!shouldCollapse) {
descEl.classList.add('is-expanded');
}
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) return;
if (!images.length) {
imagesContainer.innerHTML = '';
return;
}
const visibleImages = images.slice(0, 8).filter(img => img?.url);
const renderImageCard = (img) => {
const captionParts = [];
if (img.civitaiUrl) {
captionParts.push(`<a href="${this.escapeHtml(img.civitaiUrl)}" target="_blank" rel="noopener noreferrer" class="ml-info-image-link">civitai</a>`);
}
if (img.seed) captionParts.push(`<span><label>seed</label> ${this.escapeHtml(img.seed)}</span>`);
if (img.steps) captionParts.push(`<span><label>steps</label> ${this.escapeHtml(img.steps)}</span>`);
if (img.cfg) captionParts.push(`<span><label>cfg</label> ${this.escapeHtml(img.cfg)}</span>`);
if (img.sampler) captionParts.push(`<span><label>sampler</label> ${this.escapeHtml(img.sampler)}</span>`);
if (img.model) captionParts.push(`<span><label>model</label> ${this.escapeHtml(this.truncateText(img.model, 72))}</span>`);
if (img.positive) captionParts.push(`<span><label>positive</label> ${this.escapeHtml(this.truncateText(img.positive, 180))}</span>`);
if (img.negative) captionParts.push(`<span><label>negative</label> ${this.escapeHtml(this.truncateText(img.negative, 180))}</span>`);
return `
<div class="ml-info-image-item">
<figure>
<img src="${this.escapeHtml(img.url)}" alt="Example" loading="lazy" />
<figcaption>${captionParts.join('')}</figcaption>
</figure>
</div>
`;
};
let imagesHtml = '<div class="ml-info-images-header">Example Images</div><div class="ml-info-images-layout">';
imagesHtml += visibleImages.map(renderImageCard).join('');
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?._infoDialogResizeObserver) {
dialog._infoDialogResizeObserver.disconnect();
dialog._infoDialogResizeObserver = null;
}
if (dialog?._infoDialogResizeSaveTimer) {
clearTimeout(dialog._infoDialogResizeSaveTimer);
dialog._infoDialogResizeSaveTimer = null;
}
this.saveInfoDialogSize(dialog);
if (dialog && dialog.parentNode) {
dialog.parentNode.removeChild(dialog);
}
}
/**
* 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>`;
}
getStatusBadge(label, variant = 'neutral') {
return `<span class="ml-badge ml-badge-${variant}">${label}</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>
`;
}
renderAnalysisProgress(progress = {}) {
const current = Number(progress.current) || 0;
const total = Number(progress.total) || 0;
const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((current / total) * 100))) : 0;
const message = progress.message || 'Analyzing workflow...';
const modelName = progress.model_name ? this.escapeHtml(String(progress.model_name)) : '';
const detail = total > 0 ? `${current} / ${total}` : 'Preparing...';
return `
<div class="ml-download-section">
<div class="ml-status-inline">
${this.getStatusBadge('Analyzing', 'info')}
<span class="ml-download-info">${message}</span>
</div>
${this.renderProgressBar(percent, detail, `${percent}%`)}
${modelName ? `<div class="ml-download-info">${modelName}</div>` : ''}
</div>
`;
}
async pollAnalysisProgress(analysisId, token) {
while (this._analysisProgressToken === token) {
try {
const response = await api.fetchApi(`/model_linker/analyze-progress/${analysisId}`);
if (response.ok && this.contentElement && this._analysisProgressToken === token) {
const progress = await response.json();
this.contentElement.innerHTML = this.renderAnalysisProgress(progress);
if (progress.status === 'completed' || progress.status === 'error') {
return;
}
}
} catch (error) {
console.warn('Model Linker: analysis progress polling failed', error);
}
await new Promise(resolve => setTimeout(resolve, 250));
}
}
getWorkflowSignature(workflow) {
if (!workflow) return null;
try {
return JSON.stringify(workflow);
} catch (error) {
console.warn('Model Linker: workflow signature generation failed', error);
return null;
}
}
/**
* 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')
});
this.optionsTab = $el("button.ml-tab", {
textContent: "Options",
onclick: () => this.switchTab('options')
});
if (this.activeTab === 'missing') {
this.updateTabButtonStates();
}
const dragHandle = $el("div", {
id: "model-linker-drag-handle",
ondragstart: (e) => e.preventDefault()
}, [
$el("span", { textContent: "⠿" })
]);
this.setTooltip(dragHandle, "Drag window");
const fullscreenButton = $el("button", {
id: "model-linker-fullscreen-toggle",
className: "ml-window-btn ml-window-btn--fullscreen",
innerHTML: getSvgIcon('windowMaximize', 'currentColor', 'ml-window-btn-icon'),
ariaLabel: "Toggle full screen",
onclick: () => this.toggleFullScreen()
});
this.setTooltip(fullscreenButton, "Toggle full screen");
return $el("div.ml-dialog-shell", {}, [
$el("div.ml-dialog-topbar", {}, [
$el("div.ml-dialog-brand", {}, [
dragHandle,
$el("div.ml-tabs", {}, [
this.missingTab,
this.loadedTab,
this.optionsTab
])
]),
$el("div.ml-dialog-controls", {}, [
fullscreenButton,
$el("button", {
className: "ml-window-btn ml-window-btn--close",
innerHTML: getSvgIcon('x', 'currentColor', 'ml-window-btn-icon'),
ariaLabel: "Close Model Linker",
onclick: () => this.close()
})
])
])
]);
}
// 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.innerHTML = getSvgIcon('windowRestore', 'currentColor', 'ml-window-btn-icon');
btn.setAttribute('aria-label', 'Exit full screen');
this.setTooltip(btn, 'Exit full screen');
}
try { localStorage.setItem('model_linker_modal_fullscreen', '1'); } catch (e) {}
} else {
// Restore centered sizing
el.style.maxWidth = '100vw';
el.style.maxHeight = '100vh';
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.innerHTML = getSvgIcon('windowMaximize', 'currentColor', 'ml-window-btn-icon');
btn.setAttribute('aria-label', 'Enter full screen');
this.setTooltip(btn, 'Enter full screen');
}
this.ensureModalHandleInViewport({ persist: true });
try { localStorage.setItem('model_linker_modal_fullscreen', '0'); } catch (e) {}
}
}
getViewportClampedModalPosition(top, left) {
const el = this.element;
if (!el) return { top, left };
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
const vh = window.innerHeight || document.documentElement.clientHeight || 0;
const pad = 4;
const handle = document.getElementById('model-linker-drag-handle');
if (handle) {
const elRect = el.getBoundingClientRect();
const handleRect = handle.getBoundingClientRect();
const handleOffsetLeft = handleRect.left - elRect.left;
const handleOffsetTop = handleRect.top - elRect.top;
const handleWidth = handleRect.width || handle.offsetWidth;
const handleHeight = handleRect.height || handle.offsetHeight;
const minLeft = pad - handleOffsetLeft;
const maxLeft = vw - pad - handleOffsetLeft - handleWidth;
const minTop = pad - handleOffsetTop;
const maxTop = vh - pad - handleOffsetTop - handleHeight;
left = Math.max(minLeft, Math.min(maxLeft, left));
top = Math.max(minTop, Math.min(maxTop, top));
} else {
const w = el.offsetWidth;
const h = el.offsetHeight;
left = Math.max(-w + pad, Math.min(vw - pad, left));
top = Math.max(-h + pad, Math.min(vh - pad, top));
}
return { top, left };
}
saveModalPosition() {
try {
const el = this.element;
if (!el) return;
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 */ }
}
ensureModalHandleInViewport({ persist = false } = {}) {
if (this.fullscreen) return;
const el = this.element;
if (!el || getComputedStyle(el).display === 'none') return;
const rect = el.getBoundingClientRect();
const { top, left } = this.getViewportClampedModalPosition(rect.top, rect.left);
const nextTop = Math.round(top);
const nextLeft = Math.round(left);
if (Math.round(rect.top) === nextTop && Math.round(rect.left) === nextLeft) return;
el.style.top = `${nextTop}px`;
el.style.left = `${nextLeft}px`;
el.style.transform = 'none';
if (persist) this.saveModalPosition();
}
scheduleModalViewportClamp(persist = false) {
if (this._viewportClampFrame) {
cancelAnimationFrame(this._viewportClampFrame);
}
this._viewportClampFrame = requestAnimationFrame(() => {
this._viewportClampFrame = null;
this.ensureModalHandleInViewport({ persist });
});
}
// 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;
({ top, left } = this.getViewportClampedModalPosition(top, left));
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
this.saveModalPosition();
// 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 = [];
}
}
async ensureDownloadDirectoriesLoaded() {
if (this.downloadDirectories) return;
try {
const resp = await api.fetchApi('/model_linker/directories');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const directories = await resp.json();
this.downloadDirectories = directories && typeof directories === 'object' ? directories : {};
} catch (e) {
console.warn('Model Linker: could not load download directories', e);
this.downloadDirectories = {};
}
}
async ensureCapabilitiesLoaded() {
if (this.capabilities) return;
try {
const resp = await api.fetchApi('/model_linker/capabilities');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
this.capabilities = data && typeof data === 'object' ? data : { sources: {} };
} catch (e) {
console.warn('Model Linker: could not load capabilities', e);
this.capabilities = { sources: {} };
}
}
isSourceAvailable(source) {
if (!source || ['all', 'local', 'huggingface', 'civitai'].includes(source)) {
return true;
}
return Boolean(this.capabilities?.sources?.[source]);
}
getCategoryDisplayName(category = '') {
const displayNames = {
'checkpoints': 'checkpoint',
'loras': 'lora',
'vae': 'vae',
'controlnet': 'controlnet',
'embeddings': 'embedding',
'upscale_models': 'upscale_model',
'latent_upscale_models': 'latent_upscale_model',
'diffusion_models': 'unet',
'text_encoders': 'text encoders',
'clip': 'clip',
'clip_vision': 'clip_vision',
'hypernetworks': 'hypernetwork'
};
return displayNames[category] || category || 'unknown';
}
getCategoryTokenName(category = '') {
const tokenNames = {
'checkpoints': 'checkpoint',
'loras': 'lora',
'vae': 'vae',
'controlnet': 'controlnet',
'embeddings': 'embedding',
'upscale_models': 'upscale_model',
'latent_upscale_models': 'latent_upscale_model',
'diffusion_models': 'unet',
'text_encoders': 'text_encoders',
'clip': 'clip',
'clip_vision': 'clip_vision',
'hypernetworks': 'hypernetwork'
};
return tokenNames[category] || category || 'unknown';
}
getDownloadCategoryOptions(defaultCategory = 'checkpoints') {
const directories = this.downloadDirectories || {};
const keys = Object.keys(directories);
const preferred = defaultCategory || 'checkpoints';
const ordered = [
preferred,
...keys.filter(key => key !== preferred)
].filter((value, index, arr) => value && arr.indexOf(value) === index);
return ordered.length > 0 ? ordered : [preferred];
}
getSearchSourceOptions() {
const sources = ['all', 'local', 'huggingface', 'civitai'];
if (this.isSourceAvailable('lora_manager_archive')) {
sources.push('lora_manager_archive');
}
return sources.map(source => ({
value: source,
label: this.getSearchSourceLabel(source)
}));
}
getAvailableSubfolders(category = '') {
return this.downloadSubfolders.get((category || '').toLowerCase()) || [];
}
normalizeFolderToken(value = '') {
return String(value || '')
.toLowerCase()
.replace(/[\/\\]+/g, ' ')
.replace(/[^a-z0-9]+/g, '');
}
getSuggestedCivitaiSubfolder(missing, category, folders = []) {
if ((category || '').toLowerCase() !== 'loras' || !folders.length) {
return '';
}
const civitaiData = {
...(missing?.civitai_info || {}),
...(missing?.civitai_search_result || {}),
...(missing?.download_source || {})
};
const baseModel = civitaiData.base_model || '';
const tags = Array.isArray(civitaiData.tags) ? civitaiData.tags.filter(Boolean) : [];
if (!baseModel) return '';
const priorityTags = [
'concept',
'style',
'character',
'clothing',
'pose',
'object',
'vehicle',
'artist',
'celebrity'
];
const normalizedBase = this.normalizeFolderToken(baseModel);
if (!normalizedBase) return '';
const folderEntries = folders.map(folder => {
const segments = String(folder || '').split(/[\/\\]/).filter(Boolean);
return {
value: folder,
segments,
normalizedSegments: segments.map(segment => this.normalizeFolderToken(segment))
};
});
const baseMatches = folderEntries.filter(entry => entry.normalizedSegments[0] === normalizedBase);
if (!baseMatches.length) return '';
const exactBase = baseMatches.find(entry => entry.segments.length === 1);
const orderedTags = [
...priorityTags.filter(tag => tags.some(value => this.normalizeFolderToken(value) === this.normalizeFolderToken(tag))),
...tags
].filter((value, index, arr) => value && arr.findIndex(other => this.normalizeFolderToken(other) === this.normalizeFolderToken(value)) === index);
for (const tag of orderedTags) {
const normalizedTag = this.normalizeFolderToken(tag);
if (!normalizedTag) continue;
const match = baseMatches.find(entry => entry.normalizedSegments[1] === normalizedTag);
if (match) {
return match.value;
}
}
return exactBase?.value || '';
}
async applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl) {
if (!categoryEl || !subfolderEl || subfolderEl.value.trim()) return;
const category = this.getDropdownValue(categoryEl);
await this.ensureDownloadSubfoldersLoaded(category);
const folders = this.getAvailableSubfolders(category);
const suggestion = this.getSuggestedCivitaiSubfolder(missing, category, folders);
if (suggestion) {
subfolderEl.value = suggestion;
}
}
applySearchResultSuggestion(missing) {
const categoryEl = this.contentElement?.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`);
const subfolderEl = this.contentElement?.querySelector(`#download-subfolder-${missing.node_id}-${missing.widget_index}`);
if (!categoryEl || !subfolderEl) return;
this.applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl);
}
async ensureDownloadSubfoldersLoaded(category = '') {
const key = (category || '').trim().toLowerCase();
if (!key) return [];
if (key === 'unknown') {
this.downloadSubfolders.set(key, []);
return [];
}
if (this.downloadSubfolders.has(key)) {
return this.downloadSubfolders.get(key) || [];
}
try {
const resp = await api.fetchApi(`/model_linker/subfolders/${encodeURIComponent(key)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const subfolders = await resp.json();
const list = Array.isArray(subfolders) ? subfolders : [];
this.downloadSubfolders.set(key, list);
return list;
} catch (e) {
console.warn(`Model Linker: could not load subfolders for ${key}`, e);
this.downloadSubfolders.set(key, []);
return [];
}
}
renderDownloadTargetControls(missing, defaultCategory = 'checkpoints') {
const selectId = `download-category-${missing.node_id}-${missing.widget_index}`;
const subfolderId = `download-subfolder-${missing.node_id}-${missing.widget_index}`;
const categoryListId = `download-category-list-${missing.node_id}-${missing.widget_index}`;
const subfolderListId = `download-subfolder-list-${missing.node_id}-${missing.widget_index}`;
const selectedCategory = defaultCategory || 'checkpoints';
let html = `<div class="ml-download-target">`;
html += `<div class="ml-download-target-grid">`;
html += `<label class="ml-download-target-label" for="${selectId}">Folder</label>`;
html += `<label class="ml-download-target-label" for="${subfolderId}">Subfolder (optional)</label>`;
html += `<div class="ml-download-target-wrap">`;
html += `<input id="${selectId}" class="ml-download-target-input ml-download-target-select" type="text" readonly autocomplete="off" data-value="${this.escapeHtml(selectedCategory)}" value="${this.escapeHtml(this.getCategoryDisplayName(selectedCategory))}">`;
html += `<div id="${categoryListId}" class="ml-download-target-list"></div>`;
html += `</div>`;
html += `<div class="ml-download-target-wrap">`;
html += `<input id="${subfolderId}" class="ml-download-target-input" type="text" placeholder="e.g. ponyxl\\styles" autocomplete="off">`;
html += `<div id="${subfolderListId}" class="ml-download-target-list"></div>`;
html += `</div>`;
html += `</div>`;
html += `</div>`;
return html;
}
getDownloadTargetSelection(missing, fallbackCategory = 'checkpoints') {
const categoryEl = this.contentElement?.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`);
const subfolderEl = this.contentElement?.querySelector(`#download-subfolder-${missing.node_id}-${missing.widget_index}`);
return {
category: this.getDropdownValue(categoryEl) || fallbackCategory || 'checkpoints',
subfolder: (subfolderEl?.value || '').trim()
};
}
enableWheelScrollChaining(scrollEl) {
if (!scrollEl || scrollEl.dataset.mlWheelChainBound === 'true') return;
scrollEl.dataset.mlWheelChainBound = 'true';
scrollEl.addEventListener('wheel', (event) => {
const deltaY = event.deltaY;
if (!deltaY) {
return;
}
event.preventDefault();
event.stopPropagation();
const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
if (maxScrollTop <= 0) {
return;
}
const nextScrollTop = Math.min(
maxScrollTop,
Math.max(0, scrollEl.scrollTop + deltaY)
);
scrollEl.scrollTop = nextScrollTop;
}, { passive: false });
}
wireDownloadTargetAutocomplete(container, missing) {
const categoryEl = container.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`);
const subfolderEl = container.querySelector(`#download-subfolder-${missing.node_id}-${missing.widget_index}`);
const categoryListEl = container.querySelector(`#download-category-list-${missing.node_id}-${missing.widget_index}`);
const listEl = container.querySelector(`#download-subfolder-list-${missing.node_id}-${missing.widget_index}`);
if (!categoryEl || !subfolderEl || !listEl) return;
this.enableWheelScrollChaining(listEl);
if (categoryListEl) {
this.enableWheelScrollChaining(categoryListEl);
}
const renderOptions = (targetEl, values, onSelect) => {
const options = values.map(value => (
typeof value === 'object'
? value
: { value, label: value }
));
if (!options.length) {
targetEl.innerHTML = '';
targetEl.style.display = 'none';
return;
}
targetEl.innerHTML = options
.slice(0, 50)
.map(option => {
const value = String(option.value || '');
const label = String(option.label || value);
return `<div class="ml-download-target-option" data-value="${encodeURIComponent(value)}" data-label="${encodeURIComponent(label)}">${this.escapeHtml(label)}</div>`;
})
.join('');
targetEl.style.display = 'block';
targetEl.querySelectorAll('.ml-download-target-option').forEach(option => {
option.addEventListener('mousedown', (event) => {
event.preventDefault();
const value = decodeURIComponent(option.dataset.value || '');
const label = decodeURIComponent(option.dataset.label || option.dataset.value || '');
onSelect(value, label);
targetEl.style.display = 'none';
});
});
};
const populateCategoryOptions = () => {
if (!categoryListEl) return;
const options = this.getDownloadCategoryOptions(this.getDropdownValue(categoryEl) || 'checkpoints')
.map(category => ({
value: category,
label: this.getCategoryDisplayName(category)
}));
renderOptions(categoryListEl, options, (value, label) => {
this.setDropdownValue(categoryEl, value, label);
subfolderEl.value = '';
listEl.innerHTML = '';
listEl.style.display = 'none';
this.applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl);
});
};
const populateSubfolderOptions = async (filterText = '') => {
const filter = (filterText || '').toLowerCase();
const category = this.getDropdownValue(categoryEl);
await this.ensureDownloadSubfoldersLoaded(category);
const folders = this.getAvailableSubfolders(category);
const filtered = filter
? folders.filter(folder => folder.toLowerCase().includes(filter))
: folders;
renderOptions(listEl, filtered, (value) => {
subfolderEl.value = value;
});
};
const hideList = (targetEl) => {
setTimeout(() => {
targetEl.style.display = 'none';
}, 150);
};
if (categoryListEl && categoryEl.dataset.mlCategoryBound !== 'true') {
categoryEl.dataset.mlCategoryBound = 'true';
categoryEl.addEventListener('focus', () => populateCategoryOptions());
categoryEl.addEventListener('click', () => populateCategoryOptions());
categoryEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
event.preventDefault();
populateCategoryOptions();
}
});
categoryEl.addEventListener('blur', () => hideList(categoryListEl));
}
subfolderEl.addEventListener('focus', () => {
populateSubfolderOptions(subfolderEl.value);
});
subfolderEl.addEventListener('input', () => {
populateSubfolderOptions(subfolderEl.value);
});
subfolderEl.addEventListener('blur', () => hideList(listEl));
this.applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl);
}
getStoredTokens() {
const civitaiCandidateLimitRaw = parseInt(localStorage.getItem('modelLinker.civitaiCandidateLimit') || '5', 10);
const civitai_candidate_limit = Number.isFinite(civitaiCandidateLimitRaw)
? Math.min(20, Math.max(1, civitaiCandidateLimitRaw))
: 5;
return {
civitai_key: localStorage.getItem('modelLinker.civitaiApiKey') || '',
civitai_session_token: localStorage.getItem('modelLinker.civitaiSessionToken') || '',
hf_token: localStorage.getItem('modelLinker.huggingFaceToken') || '',
brave_search_api_key: localStorage.getItem('modelLinker.braveSearchApiKey') || '',
civitai_use_trpc_search: localStorage.getItem('modelLinker.civitaiUseTrpcSearch') !== 'false',
civitai_use_html_fallback: localStorage.getItem('modelLinker.civitaiUseHtmlFallback') !== 'false',
hf_use_api_search: localStorage.getItem('modelLinker.hfUseApiSearch') !== 'false',
hf_use_comfy_org_fallback: localStorage.getItem('modelLinker.hfUseComfyOrgFallback') !== 'false',
hf_use_brave_fallback: localStorage.getItem('modelLinker.hfUseBraveFallback') !== 'false',
civitai_candidate_limit
};
}
async clearSearchCaches() {
this.searchResultCache.clear();
try {
const response = await api.fetchApi('/model_linker/clear-search-cache', {
method: 'POST'
});
if (!response.ok) {
throw new Error('Failed to clear backend search cache');
}
} catch (error) {
console.error('Model Linker: Clear search cache error:', error);
}
}
displayOptions() {
if (!this.contentElement) return;
this.contentElement.style.overflowY = 'hidden';
const tokens = this.getStoredTokens();
this.contentElement.innerHTML = `
<div class="ml-options-wrap">
<div class="ml-options-shell">
<aside class="ml-options-sidebar">
<div class="ml-options-sidebar-group">
<h3 class="ml-options-sidebar-title">Settings</h3>
</div>
<div class="ml-options-sidebar-group">
<div class="ml-options-sidebar-label">Providers</div>
<div class="ml-options-nav">
<button type="button" class="ml-options-nav-btn is-active" data-target="ml-options-section-civitai">
<span>CivitAI</span>
<span class="ml-options-nav-meta">01</span>
</button>
<button type="button" class="ml-options-nav-btn" data-target="ml-options-section-hf">
<span>HuggingFace</span>
<span class="ml-options-nav-meta">02</span>
</button>
</div>
</div>
<div class="ml-options-actions">
<div id="ml-options-status" class="ml-options-status">Saved only on this machine.</div>
<button id="ml-options-save" class="ml-btn ml-btn-primary ml-footer-btn">Save</button>
</div>
</aside>
<div class="ml-options-main">
<section id="ml-options-section-civitai" class="ml-options-card ml-options-section">
<div class="ml-options-section-head">
<h4 class="ml-options-section-title">CivitAI</h4>
</div>
<div class="ml-options-grid">
<div class="ml-options-panel">
<div class="ml-options-stack">
<div class="ml-options-field">
<div class="ml-options-input-row">
<label for="ml-options-civitai" class="ml-options-label">CivitAI API Key <a href="https://civitai.com/user/account" target="_blank" rel="noopener noreferrer" class="ml-options-inline-link">Get key</a> <span class="ml-tooltip-badge" data-tooltip="Optional. Used when downloading from CivitAI requires your account. Add this if CivitAI downloads fail or need access to models available to your account.">?</span></label>
<input id="ml-options-civitai" class="ml-options-input" type="password" placeholder="Paste CivitAI API key" value="${tokens.civitai_key}">
<button id="ml-options-civitai-toggle" type="button" class="ml-options-visibility-btn" aria-label="Toggle visibility for saved CivitAI API key" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
</div>
<div class="ml-options-field">
<div class="ml-options-input-row">
<label for="ml-options-civitai-session" class="ml-options-label">CivitAI Session Token <span class="ml-tooltip-badge" data-tooltip="Optional. Makes CivitAI search use your logged-in session, so results can match what you see in the browser. Useful for NSFW or account-visible results.">?</span></label>
<input id="ml-options-civitai-session" class="ml-options-input" type="password" placeholder="Paste __Secure-civitai-token" value="${tokens.civitai_session_token}">
<button id="ml-options-civitai-session-toggle" type="button" class="ml-options-visibility-btn" aria-label="Toggle visibility for saved CivitAI session token" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
</div>
</div>
</div>
<div class="ml-options-panel">
<div class="ml-options-toggle-list">
<label class="ml-options-toggle-row">
<div class="ml-options-toggle-copy">
<span class="ml-options-toggle-title">Use CivitAI tRPC search <span class="ml-tooltip-badge" data-tooltip="Main CivitAI search method. Keep this enabled unless CivitAI search stops working.">?</span></span>
</div>
<span class="ml-options-toggle-control">
<input id="ml-options-civitai-use-trpc-search" class="ml-options-switch-input" type="checkbox" ${tokens.civitai_use_trpc_search ? 'checked' : ''}>
<span class="ml-options-switch"></span>
</span>
</label>
<label class="ml-options-toggle-row">
<div class="ml-options-toggle-copy">
<span class="ml-options-toggle-title">Use CivitAI HTML fallback <span class="ml-tooltip-badge" data-tooltip="Backup CivitAI search. Leave this enabled to try the regular CivitAI page when the main search does not find enough results.">?</span></span>
</div>
<span class="ml-options-toggle-control">
<input id="ml-options-civitai-use-html-fallback" class="ml-options-switch-input" type="checkbox" ${tokens.civitai_use_html_fallback ? 'checked' : ''}>
<span class="ml-options-switch"></span>
</span>
</label>
<div class="ml-options-number-row">
<div class="ml-options-number-copy">
<span class="ml-options-label">CivitAI Models To Inspect <span class="ml-tooltip-badge" data-tooltip="How many CivitAI results to check for the exact file. Higher values may find more matches, but searches can take longer. Range: 1-20.">?</span></span>
</div>
<input id="ml-options-civitai-limit" class="ml-options-input" type="number" min="1" max="20" step="1" value="${tokens.civitai_candidate_limit}">
</div>
</div>
</div>
</div>
</section>
<section id="ml-options-section-hf" class="ml-options-card ml-options-section">
<div class="ml-options-section-head">
<h4 class="ml-options-section-title">HuggingFace</h4>
</div>
<div class="ml-options-grid">
<div class="ml-options-panel">
<div class="ml-options-stack">
<div class="ml-options-field">
<div class="ml-options-input-row">
<label for="ml-options-hf" class="ml-options-label">HuggingFace Token <a href="https://huggingface.co/settings/tokens" target="_blank" rel="noopener noreferrer" class="ml-options-inline-link">Get key</a> <span class="ml-tooltip-badge" data-tooltip="Optional. Used to search and download files from Hugging Face repos your account can access, including gated repos. A read-only token is enough.">?</span></label>
<input id="ml-options-hf" class="ml-options-input" type="password" placeholder="Paste HuggingFace token" value="${tokens.hf_token}">
<button id="ml-options-hf-toggle" type="button" class="ml-options-visibility-btn" aria-label="Toggle visibility for saved HuggingFace token" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
</div>
<div class="ml-options-field">
<div class="ml-options-input-row">
<label for="ml-options-brave" class="ml-options-label">Brave Search API Key <a href="https://api-dashboard.search.brave.com/app/keys" target="_blank" rel="noopener noreferrer" class="ml-options-inline-link">Get key</a> <span class="ml-tooltip-badge" data-tooltip="Optional. Used only by the Brave fallback. It helps find Hugging Face files when Hugging Face search does not show the right repo.">?</span></label>
<input id="ml-options-brave" class="ml-options-input" type="password" placeholder="Paste Brave Search API key" value="${tokens.brave_search_api_key}">
<button id="ml-options-brave-toggle" type="button" class="ml-options-visibility-btn" aria-label="Toggle visibility for saved Brave Search API key" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
</div>
</div>
</div>
<div class="ml-options-panel">
<div class="ml-options-toggle-list">
<label class="ml-options-toggle-row">
<div class="ml-options-toggle-copy">
<span class="ml-options-toggle-title">Use HuggingFace API repo search <span class="ml-tooltip-badge" data-tooltip="Main Hugging Face search method. It searches by filename, then checks matching repos for the actual file.">?</span></span>
</div>
<span class="ml-options-toggle-control">
<input id="ml-options-hf-use-api-search" class="ml-options-switch-input" type="checkbox" ${tokens.hf_use_api_search ? 'checked' : ''}>
<span class="ml-options-switch"></span>
</span>
</label>
<label class="ml-options-toggle-row">
<div class="ml-options-toggle-copy">
<span class="ml-options-toggle-title">Use Comfy-Org fallback <span class="ml-tooltip-badge" data-tooltip="Checks Comfy-Org repositories directly. Useful for ComfyUI model packs that normal Hugging Face search may miss.">?</span></span>
</div>
<span class="ml-options-toggle-control">
<input id="ml-options-hf-use-comfy-org-fallback" class="ml-options-switch-input" type="checkbox" ${tokens.hf_use_comfy_org_fallback ? 'checked' : ''}>
<span class="ml-options-switch"></span>
</span>
</label>
<label class="ml-options-toggle-row">
<div class="ml-options-toggle-copy">
<span class="ml-options-toggle-title">Use Brave fallback <span class="ml-tooltip-badge" data-tooltip="Last-resort web search for the exact filename on huggingface.co. Results are still checked before Model Linker offers them.">?</span></span>
</div>
<span class="ml-options-toggle-control">
<input id="ml-options-hf-use-brave-fallback" class="ml-options-switch-input" type="checkbox" ${tokens.hf_use_brave_fallback ? 'checked' : ''}>
<span class="ml-options-switch"></span>
</span>
</label>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
`;
const civitaiInput = this.contentElement.querySelector('#ml-options-civitai');
const civitaiSessionInput = this.contentElement.querySelector('#ml-options-civitai-session');
const hfInput = this.contentElement.querySelector('#ml-options-hf');
const braveInput = this.contentElement.querySelector('#ml-options-brave');
const civitaiToggle = this.contentElement.querySelector('#ml-options-civitai-toggle');
const civitaiSessionToggle = this.contentElement.querySelector('#ml-options-civitai-session-toggle');
const hfToggle = this.contentElement.querySelector('#ml-options-hf-toggle');
const braveToggle = this.contentElement.querySelector('#ml-options-brave-toggle');
const civitaiLimitInput = this.contentElement.querySelector('#ml-options-civitai-limit');
const civitaiUseTrpcSearchInput = this.contentElement.querySelector('#ml-options-civitai-use-trpc-search');
const civitaiUseHtmlFallbackInput = this.contentElement.querySelector('#ml-options-civitai-use-html-fallback');
const hfUseApiSearchInput = this.contentElement.querySelector('#ml-options-hf-use-api-search');
const hfUseComfyOrgFallbackInput = this.contentElement.querySelector('#ml-options-hf-use-comfy-org-fallback');
const hfUseBraveFallbackInput = this.contentElement.querySelector('#ml-options-hf-use-brave-fallback');
const status = this.contentElement.querySelector('#ml-options-status');
const saveBtn = this.contentElement.querySelector('#ml-options-save');
const navButtons = Array.from(this.contentElement.querySelectorAll('.ml-options-nav-btn'));
const optionSections = Array.from(this.contentElement.querySelectorAll('.ml-options-section'));
const trackedInputs = [
civitaiInput,
civitaiSessionInput,
hfInput,
braveInput,
civitaiLimitInput,
civitaiUseTrpcSearchInput,
civitaiUseHtmlFallbackInput,
hfUseApiSearchInput,
hfUseComfyOrgFallbackInput,
hfUseBraveFallbackInput,
].filter(Boolean);
const setStatus = (text, mode = '') => {
if (!status) return;
status.textContent = text;
status.classList.remove('is-dirty', 'is-saved');
if (mode) status.classList.add(mode);
};
const setActiveNav = (targetId) => {
navButtons.forEach((btn) => {
btn.classList.toggle('is-active', btn.dataset.target === targetId);
});
};
const setVisibleSection = (targetId) => {
optionSections.forEach((section) => {
section.classList.toggle('is-hidden', section.id !== targetId);
});
setActiveNav(targetId);
};
const getVisibilityIcon = (visible) => visible
? getSvgIcon('eye')
: getSvgIcon('eyeOff');
const syncVisibilityToggle = (input, button) => {
if (!input || !button) return;
const visible = input.type === 'text';
button.innerHTML = getVisibilityIcon(visible);
button.classList.toggle('ml-is-active-text', visible);
button.classList.toggle('ml-is-muted', !visible);
button.setAttribute('aria-pressed', visible ? 'true' : 'false');
this.setTooltip(button, visible ? 'Hide saved value' : 'Show saved value');
};
const bindVisibilityToggle = (input, button) => {
if (!input || !button) return;
syncVisibilityToggle(input, button);
button.addEventListener('click', () => {
input.type = input.type === 'password' ? 'text' : 'password';
syncVisibilityToggle(input, button);
});
};
bindVisibilityToggle(civitaiInput, civitaiToggle);
bindVisibilityToggle(civitaiSessionInput, civitaiSessionToggle);
bindVisibilityToggle(hfInput, hfToggle);
bindVisibilityToggle(braveInput, braveToggle);
setStatus('Saved only on this machine.');
setVisibleSection('ml-options-section-civitai');
trackedInputs.forEach((input) => {
const eventName = input.type === 'checkbox' ? 'change' : 'input';
input.addEventListener(eventName, () => {
setStatus('You have unsaved changes.', 'is-dirty');
});
});
this.bindTooltips(this.contentElement);
navButtons.forEach((btn) => {
btn.addEventListener('click', () => {
const targetId = btn.dataset.target;
if (!targetId) return;
setVisibleSection(targetId);
});
});
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const civitaiCandidateLimitRaw = parseInt(civitaiLimitInput?.value || `${tokens.civitai_candidate_limit}`, 10);
const civitaiCandidateLimit = Number.isFinite(civitaiCandidateLimitRaw)
? Math.min(20, Math.max(1, civitaiCandidateLimitRaw))
: 5;
localStorage.setItem('modelLinker.civitaiApiKey', civitaiInput?.value || '');
localStorage.setItem('modelLinker.civitaiSessionToken', civitaiSessionInput?.value || '');
localStorage.setItem('modelLinker.civitaiUseTrpcSearch', civitaiUseTrpcSearchInput?.checked ? 'true' : 'false');
localStorage.setItem('modelLinker.civitaiUseHtmlFallback', civitaiUseHtmlFallbackInput?.checked ? 'true' : 'false');
localStorage.setItem('modelLinker.huggingFaceToken', hfInput?.value || '');
localStorage.setItem('modelLinker.braveSearchApiKey', braveInput?.value || '');
localStorage.setItem('modelLinker.hfUseApiSearch', hfUseApiSearchInput?.checked ? 'true' : 'false');
localStorage.setItem('modelLinker.hfUseComfyOrgFallback', hfUseComfyOrgFallbackInput?.checked ? 'true' : 'false');
localStorage.setItem('modelLinker.hfUseBraveFallback', hfUseBraveFallbackInput?.checked ? 'true' : 'false');
localStorage.setItem('modelLinker.civitaiCandidateLimit', `${civitaiCandidateLimit}`);
if (civitaiLimitInput) {
civitaiLimitInput.value = `${civitaiCandidateLimit}`;
}
await this.clearSearchCaches();
setStatus('Options saved locally.', 'is-saved');
this.showNotification('Options saved and search cache cleared', 'success');
});
}
}
getTabButton(tab) {
return {
missing: this.missingTab,
loaded: this.loadedTab,
options: this.optionsTab
}[tab] || null;
}
updateTabButtonStates() {
['missing', 'loaded', 'options'].forEach((tab) => {
const button = this.getTabButton(tab);
if (!button) return;
const isActive = tab === this.activeTab;
button.classList.toggle('ml-tab-active', isActive);
button.setAttribute('aria-selected', isActive ? 'true' : 'false');
button.setAttribute('aria-disabled', isActive ? 'true' : 'false');
if (isActive) {
button.setAttribute('aria-current', 'page');
} else {
button.removeAttribute('aria-current');
}
});
}
switchTab(tab) {
const nextTab = this.getValidTab(tab);
const nextTabButton = this.getTabButton(nextTab);
if (nextTab === this.activeTab && nextTabButton?.classList.contains('ml-tab-active')) {
this.hideTooltip();
return;
}
this.activeTab = nextTab;
this.persistActiveTab(this.activeTab);
this.hideTooltip();
this.animateTabContentTransition();
this.updateTabButtonStates();
if (this.activeTab === 'missing') {
if (this.contentElement) {
this.contentElement.style.overflowY = 'auto';
}
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 if (this.activeTab === 'loaded') {
if (this.contentElement) {
this.contentElement.style.overflowY = 'auto';
}
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();
} else {
if (this.contentElement) {
this.contentElement.style.overflowY = 'hidden';
}
this.downloadAllButton.style.display = 'none';
this.autoResolveButton.style.display = 'none';
this.applyPendingBtn.style.display = 'none';
if (this.queueElement) {
this.queueElement.style.display = 'none';
}
if (this.splitterElement) {
this.splitterElement.style.display = 'none';
}
this.displayOptions();
}
}
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 class="ml-error-text">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 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 = this.getCategoryTokenName(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 class="ml-loaded-filters">
<div class="ml-loaded-filter-row">
<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>
</div>
<div class="ml-models-list ml-models-list-pad">
`;
for (const [category, modelsObj] of Object.entries(byCategory)) {
const displayName = this.getCategoryDisplayName(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}">`;
// Add category header
html += `<div class="ml-model-section-header">
<span class="ml-model-section-title">${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 `<${this.getCategoryTokenName(category)}:${name}:${strength}>`;
}).join(' ');
html += `<div class="ml-model-group">
<div class="ml-model-group-head">
<span class="ml-model-group-label ml-model-group-label-active">● ACTIVE</span>
<button class="ml-btn ml-btn-sm ml-btn-copy-compact" onclick="window.MLCopy('${activeStr.replace(/'/g, "\\'")}', this)">Copy</button>
</div>
<div class="ml-model-chip-list">`;
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 `<${this.getCategoryTokenName(category)}:${name}:${strength}>`;
}).join(' ');
html += `<div>
<div class="ml-model-group-head">
<span class="ml-model-group-label ml-model-group-label-inactive">○ INACTIVE</span>
<button class="ml-btn ml-btn-sm ml-btn-copy-compact is-muted" onclick="window.MLCopy('${inactiveStr.replace(/'/g, "\\'")}', this)">Copy</button>
</div>
<div class="ml-model-chip-list ml-model-chip-list-inactive">`;
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}" class="ml-copy-section" data-ml-active="${activeString.replace(/'/g, "\\'")}" data-ml-inactive="${inactiveString.replace(/'/g, "\\'")}" data-ml-all="${allString.replace(/'/g, "\\'")}">
<div class="ml-copy-label" id="${copySectionId}-label">Copy all:</div>
<div class="ml-copy-row">
<code class="ml-copy-code" 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"
});
this.contentElement = $el("div.ml-scrollable", {
id: "model-linker-content"
});
this.queueElement = $el("div", {
id: "model-linker-queue"
}, [
this.createQueuePanel()
]);
// Splitter between content and queue
this.splitterElement = $el("div", {
id: "model-linker-splitter",
ondragstart: (e) => e.preventDefault()
});
this.setTooltip(this.splitterElement, "Drag to resize panels");
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",
onclick: () => this.toggleQueueCollapsed()
}, [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.ml-queue-header", {}, [
$el("div#queue-title.ml-queue-title", { textContent: "Queued Selections (0)" }),
$el("div.ml-queue-actions", {}, [
$el("button", {
id: "queue-toggle",
className: "ml-btn ml-btn-secondary ml-btn-sm",
textContent: "Collapse",
onclick: () => this.toggleQueueCollapsed()
}),
$el("button", {
id: "queue-clear",
className: "ml-btn ml-btn-secondary ml-btn-sm",
textContent: "Clear All",
onclick: () => this.clearAllQueued()
})
])
]);
// Scrollable list
this.queueList = $el("div#queue-list.ml-queue-list");
const panel = $el("div.ml-queue-panel", {}, [
$el("div.ml-queue-stack", {}, [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 class="ml-queue-empty">No selections queued.</div>';
return;
}
let html = '<div class="ml-queue-items">';
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 class="ml-queue-item">`;
html += `<div class="ml-queue-item-title">${nodeLabel} #${r.node_id}</div>`;
html += `<div class="ml-queue-item-meta">Original: <code>${orig}</code></div>`;
html += `<div class="ml-queue-item-selection">Selected: <code>${label}</code></div>`;
html += `<div class="ml-queue-item-actions"><button id="${rmId}" class="ml-btn ml-btn-secondary ml-btn-sm">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 class="ml-selected-bar-inner">`;
selectedBar.innerHTML += `<span class="ml-selected-label">✓ Selected:</span>`;
selectedBar.innerHTML += `<code class="ml-selected-code">${label}</code>`;
selectedBar.innerHTML += `<button id="${resolveBtnId}" class="ml-btn ml-btn-secondary ml-btn-sm">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.setTooltip(this.queueToggleIcon, 'Show queued selections');
} else {
this.queueToggleIcon.textContent = '⮜';
this.setTooltip(this.queueToggleIcon, 'Hide queued selections');
}
}
// 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.ml-footer-btn", {
onclick: () => this.handleDownloadAllClick()
}, [
$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.ml-footer-btn", {
onclick: () => this.autoResolve100Percent()
}, [
$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.ml-footer-btn", {
id: "apply-pending-resolutions",
textContent: "Apply Selected (0)",
onclick: () => this.applyPendingResolutions()
});
return $el("div.ml-footer", {}, [
this.autoResolveButton,
this.applyPendingBtn,
this.downloadAllButton
]);
}
animateTabContentTransition() {
if (!this.contentElement?.animate) return;
try {
this.contentElement.animate(
[
{ opacity: 0.72, transform: 'translateY(7px)' },
{ opacity: 1, transform: 'translateY(0)' }
],
{
duration: 180,
easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)'
}
);
} catch (error) {
console.warn('Model Linker: tab content animation failed', error);
}
}
/**
* 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 = "none";
this.element.style.display = "flex";
this.scheduleModalViewportClamp();
// Update button state in case there are active downloads
this.updateDownloadAllButtonState();
// Ensure all models are loaded for dropdown
await this.ensureCapabilitiesLoaded();
await this.ensureAllModelsLoaded();
await this.ensureDownloadDirectoriesLoaded();
// 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();
this.activeTab = this.restoreActiveTab();
if (this.activeTab === 'missing') {
await this.loadWorkflowData(workflow);
} else if (this.activeTab === 'loaded') {
this.switchTab('loaded');
} else {
this.switchTab('options');
}
}
// 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.hideTooltip();
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
try {
// Use provided workflow, or get current workflow from ComfyUI
if (!workflow) {
workflow = this.getCurrentWorkflow();
}
if (!workflow) {
this._analysisProgressToken = null;
this.contentElement.innerHTML = '<p>No workflow loaded. Please load a workflow first.</p>';
return;
}
const workflowSignature = this.getWorkflowSignature(workflow);
if (
workflowSignature &&
this.cachedWorkflowSignature === workflowSignature &&
this.cachedAnalysisData
) {
this.displayMissingModels(this.contentElement, this.cachedAnalysisData);
this.reconnectActiveDownloads();
return;
}
const analysisId = `an-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
this._analysisProgressToken = analysisId;
this.contentElement.innerHTML = this.renderAnalysisProgress({
status: 'starting',
message: 'Starting analysis...',
current: 0,
total: 0
});
// Call analyze endpoint
const progressPromise = this.pollAnalysisProgress(analysisId, analysisId);
const response = await api.fetchApi('/model_linker/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow, analysis_id: analysisId })
});
this._analysisProgressToken = null;
await progressPromise;
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
this.cachedWorkflowSignature = workflowSignature;
this.cachedAnalysisData = data;
this.searchResultCache.clear();
this.displayMissingModels(this.contentElement, data);
// Reconnect any active downloads to their new progress divs
this.reconnectActiveDownloads();
} catch (error) {
this._analysisProgressToken = null;
console.error('Model Linker: Error loading workflow data:', error);
if (this.contentElement) {
this.contentElement.innerHTML = `<p class="ml-error-text">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;
}
let locatedWithAnimation = false;
// Focus on the node in the canvas with animated panning when possible
if (app.canvas?.ds && app.canvas?.canvas) {
locatedWithAnimation = this.animateCanvasToNode(node);
}
if (!locatedWithAnimation && 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 (!locatedWithAnimation && app.ui && app.ui.nodeGraph && typeof app.ui.nodeGraph.scrollToNode === 'function') {
// Alternative for other versions
app.ui.nodeGraph.scrollToNode(node);
} else if (!locatedWithAnimation && app.canvas && typeof app.canvas.centerOnNode === 'function') {
// Final fallback for versions where animation path is unavailable
app.canvas.centerOnNode(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');
}
}
animateCanvasToNode(node) {
const canvas = app?.canvas;
const ds = canvas?.ds;
const htmlCanvas = canvas?.canvas;
if (!canvas || !ds || !htmlCanvas) return false;
const rect = node.boundingRect || [node.pos?.[0] || 0, node.pos?.[1] || 0, node.size?.[0] || 0, node.size?.[1] || 0];
const centerX = rect[0] + (rect[2] || 0) / 2;
const centerY = rect[1] + (rect[3] || 0) / 2;
const startScale = Number.isFinite(ds.scale) && ds.scale > 0 ? ds.scale : 1;
const dpr = window.devicePixelRatio || 1;
const canvasWidth = htmlCanvas.width || htmlCanvas.clientWidth || 0;
const canvasHeight = htmlCanvas.height || htmlCanvas.clientHeight || 0;
if (!canvasWidth || !canvasHeight) return false;
const viewportWidth = canvasWidth / dpr;
const viewportHeight = canvasHeight / dpr;
const nodeWidth = Math.max(rect[2] || 0, 1);
const nodeHeight = Math.max(rect[3] || 0, 1);
const paddingX = 140;
const paddingY = 96;
const fitScaleX = viewportWidth / (nodeWidth + (paddingX * 2));
const fitScaleY = viewportHeight / (nodeHeight + (paddingY * 2));
const targetScale = Math.max(0.15, Math.min(1, fitScaleX, fitScaleY));
const getCenteredOffset = (scale) => {
const viewWidth = canvasWidth / (scale * dpr);
const viewHeight = canvasHeight / (scale * dpr);
return {
x: -centerX + (viewWidth / 2),
y: -centerY + (viewHeight / 2)
};
};
const startOffset = {
x: Number.isFinite(ds.offset?.[0]) ? ds.offset[0] : getCenteredOffset(startScale).x,
y: Number.isFinite(ds.offset?.[1]) ? ds.offset[1] : getCenteredOffset(startScale).y
};
const targetOffset = getCenteredOffset(targetScale);
if (this._locateAnimationFrame) {
cancelAnimationFrame(this._locateAnimationFrame);
this._locateAnimationFrame = null;
}
const durationMs = 380;
const startTime = performance.now();
const easeInOutCubic = (t) => t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
const tick = (now) => {
const progress = Math.min(1, (now - startTime) / durationMs);
const eased = easeInOutCubic(progress);
ds.scale = startScale + ((targetScale - startScale) * eased);
ds.offset[0] = startOffset.x + ((targetOffset.x - startOffset.x) * eased);
ds.offset[1] = startOffset.y + ((targetOffset.y - startOffset.y) * eased);
canvas.setDirty?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
if (progress < 1) {
this._locateAnimationFrame = requestAnimationFrame(tick);
} else {
this._locateAnimationFrame = null;
}
};
this._locateAnimationFrame = requestAnimationFrame(tick);
return true;
}
/**
* 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.classList.remove('ml-is-hidden');
newProgressDiv.classList.add('ml-is-visible');
newProgressDiv.innerHTML = this.renderProgressWithAction({
percent: 0,
leftText: '<span class="ml-info-accent-text">Downloading...</span>',
rightText: '',
actionClass: 'cancel-download-btn ml-btn ml-btn-danger ml-btn-sm',
actionText: 'Cancel',
actionDataAttr: `data-download-id="${downloadId}"`
});
// 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...';
}
}
}
}
getMissingModelKey(missing = {}) {
const nodeId = missing.node_id ?? '';
const widgetIndex = missing.widget_index ?? '';
const subgraphId = missing.subgraph_id || '';
const scope = missing.is_top_level !== false ? 'T' : 'F';
return `${nodeId}:${widgetIndex}:${subgraphId}:${scope}`;
}
getMissingFilename(missing = {}) {
return missing.original_path?.split('/').pop()?.split('\\').pop() || missing.name || 'Missing model';
}
getMissingNodeDisplay(missing = {}) {
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';
}
const nodeId = missing.node_id ?? '';
const customNodeTitle = String(missing.node_title || '').trim();
const hasCustomNodeTitle = customNodeTitle && customNodeTitle !== nodeLabel;
const text = hasCustomNodeTitle
? `${nodeLabel} #${nodeId} · ${customNodeTitle}`
: `${nodeLabel} #${nodeId}`;
return {
label: nodeLabel,
text,
canLocate: missing.is_top_level !== false && nodeId !== ''
};
}
getBestLocalMatch(missing = {}, minConfidence = 0) {
const matches = Array.isArray(missing.matches) ? missing.matches : [];
return matches
.filter(match => Number(match.confidence) >= minConfidence)
.sort((a, b) => Number(b.confidence || 0) - Number(a.confidence || 0))[0] || null;
}
getMissingModelSummaryStats(missingModels = []) {
return missingModels.reduce((stats, missing) => {
const best = this.getBestLocalMatch(missing, 70);
if (best?.confidence === 100) {
stats.exact += 1;
} else if (best) {
stats.partial += 1;
} else {
stats.none += 1;
}
return stats;
}, { exact: 0, partial: 0, none: 0 });
}
getMissingSourceStatus(missing = {}, source = '') {
const state = this.searchResultCache.get(this.getMissingSearchKey(missing));
const progress = state?.sourceProgress?.[source];
const resultStatus = this.getMissingSourceResultStatus(missing, source, state);
if (progress?.status === 'found') return resultStatus || 'found';
if (progress?.status) return progress.status;
if (resultStatus) return resultStatus;
return 'idle';
}
getMissingSourceResultStatus(missing = {}, source = '', state = null) {
const results = state?.results || {};
const candidates = [];
if (source === 'local') {
candidates.push(results.model_list, results.popular);
} else if (source === 'huggingface') {
candidates.push(results.huggingface);
} else if (source === 'civitai') {
candidates.push(results.civitai);
} else if (source === 'lora_manager_archive') {
candidates.push(results.lora_manager_archive);
}
const downloadSource = missing.download_source || {};
const mappedDownloadSource = {
model_list: 'local',
popular: 'local',
workflow: 'civitai'
}[downloadSource.source] || downloadSource.source;
if (mappedDownloadSource === source && downloadSource.url) {
candidates.push(downloadSource);
}
return this.getSearchResultStatusLevel(candidates);
}
getSearchResultStatusLevel(resultOrResults) {
const results = Array.isArray(resultOrResults)
? resultOrResults.flatMap(result => Array.isArray(result) ? result : [result])
: [resultOrResults];
let hasPartial = false;
let hasUnknownFound = false;
for (const result of results) {
if (!result) continue;
const confidence = Number(result.confidence);
const hasConfidence = Number.isFinite(confidence);
const matchType = String(result.match_type || '').toLowerCase();
if (hasConfidence) {
if (confidence >= 100) return 'exact';
if (confidence > 0) {
hasPartial = true;
continue;
}
}
if (matchType === 'exact' || result.source === 'popular') {
return 'exact';
}
if (matchType === 'partial' || matchType === 'fuzzy' || matchType === 'similar') {
hasPartial = true;
continue;
}
if (result.url || result.download_url) {
hasUnknownFound = true;
}
}
if (hasPartial) return 'partial';
if (hasUnknownFound) return 'found';
return '';
}
renderMissingSourcesSummary(missing = {}) {
const sourceItems = [
{ source: 'local' },
{ source: 'huggingface' },
{ source: 'civitai' }
];
if (this.isSourceAvailable('lora_manager_archive')) {
sourceItems.push({ source: 'lora_manager_archive' });
}
return sourceItems.map(item => {
const status = this.getMissingSourceStatus(missing, item.source);
const statusClass = String(status || 'idle').replace(/[^a-z0-9_-]/gi, '');
const label = this.getSearchSourceLabel(item.source);
const statusLabels = {
pending: 'Queued',
running: 'Searching',
exact: 'Exact match',
partial: 'Partial match',
found: 'Found',
none: 'No match',
error: 'Error',
idle: 'Not searched'
};
const title = `${label}: ${statusLabels[status] || status}`;
const iconName = this.getSearchSourceIconName(item.source);
const iconHtml = getSvgIcon(iconName, 'currentColor', 'ml-missing-source-icon');
return `<span class="ml-missing-source-dot ml-missing-source-${statusClass}" data-tooltip="${this.escapeHtml(title)}" aria-label="${this.escapeHtml(title)}">${iconHtml}</span>`;
}).join('');
}
hasRenderableSearchState(state = {}) {
return Boolean(
(state.lastAttemptSources || []).length
|| Object.keys(state.sourceProgress || {}).length
|| this.hasSearchResults(state.results || {})
|| state.lastAttemptError
);
}
getMissingModelsListLayout(missingModels = []) {
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const textWidth = (value, charPx) => Math.ceil(String(value || '').length * charPx);
let modelPx = 180;
let typePx = 66;
for (const missing of missingModels) {
const filename = this.getMissingFilename(missing);
const nodeLabel = this.getMissingNodeDisplay(missing).text;
const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown';
modelPx = Math.max(
modelPx,
textWidth(filename, 7.2) + 12,
textWidth(nodeLabel, 5.5) + 34
);
typePx = Math.max(typePx, textWidth(typeLabel, 5.8) + 18);
}
return {
modelPx: clamp(modelPx, 180, 430),
typePx: clamp(typePx, 66, 120)
};
}
renderMissingModelsBrowser(missingModels, selectedKey, totalMissing, activeCount, hasAny100Match) {
const stats = this.getMissingModelSummaryStats(missingModels);
const detailIndex = missingModels.findIndex(missing => this.getMissingModelKey(missing) === selectedKey);
const detailMissing = detailIndex >= 0 ? missingModels[detailIndex] : null;
const activeHint = activeCount > 0
? `${activeCount} downloading`
: (hasAny100Match ? 'Auto-link ready for exact matches' : 'Review matches or search online');
const listLayout = this.getMissingModelsListLayout(missingModels);
const listStyle = `--ml-missing-model-col:${listLayout.modelPx}px;--ml-missing-type-col:${listLayout.typePx}px;`;
let html = `
<div class="ml-missing-browser" style="${listStyle}">
<section class="ml-missing-list-pane" aria-label="Missing model list">
<div class="ml-missing-list-toolbar">
<div>
<div class="ml-missing-list-title">${totalMissing} missing model${totalMissing === 1 ? '' : 's'}</div>
<div class="ml-missing-list-meta">${this.escapeHtml(activeHint)}</div>
</div>
<div class="ml-missing-list-stats">
<span class="ml-missing-stat ml-missing-stat-exact">${stats.exact} exact</span>
<span class="ml-missing-stat ml-missing-stat-partial">${stats.partial} partial</span>
<span class="ml-missing-stat ml-missing-stat-none">${stats.none} no match</span>
</div>
</div>
<div class="ml-missing-list-head">
<span>#</span>
<span>Missing Model</span>
<span>Type</span>
<span>Best Local Match</span>
<span>Match</span>
<span>Sources</span>
</div>
<div class="ml-missing-list">
`;
missingModels.forEach((missing, index) => {
const key = this.getMissingModelKey(missing);
const isSelected = key === selectedKey;
const filename = this.getMissingFilename(missing);
const formattedFilename = this.formatFilename(filename, 46);
const bestMatch = this.getBestLocalMatch(missing, 70);
const confidence = bestMatch ? Number(bestMatch.confidence || 0) : 0;
const matchName = bestMatch?.model?.relative_path || bestMatch?.filename || bestMatch?.path || '';
const matchDisplay = matchName || 'No local match';
const matchClass = confidence === 100 ? 'exact' : (bestMatch ? 'partial' : 'none');
const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown';
const nodeDisplay = this.getMissingNodeDisplay(missing);
const nodeId = missing.node_id ?? '';
const rowNodeHtml = nodeDisplay.canLocate
? `<button type="button" class="ml-node-chip is-locatable ml-missing-row-node ml-missing-row-locate" data-node-id="${this.escapeHtml(String(nodeId))}" data-tooltip="Center this node in the ComfyUI graph." aria-label="Center ${this.escapeHtml(nodeDisplay.text)} in the ComfyUI graph">${this.getLocateIconHtml()}<span class="ml-missing-row-node-label">${this.escapeHtml(nodeDisplay.text)}</span></button>`
: `<span class="ml-missing-row-node">${this.escapeHtml(nodeDisplay.text)}</span>`;
html += `
<div role="button" tabindex="0"
class="ml-missing-list-row ${isSelected ? 'is-selected' : ''}"
data-missing-key="${this.escapeHtml(key)}">
<span class="ml-missing-row-index">${index + 1}</span>
<span class="ml-missing-row-model">
<span class="ml-missing-row-name" data-tooltip="${this.escapeHtml(filename)}">${this.escapeHtml(formattedFilename.display)}</span>
${rowNodeHtml}
</span>
<span class="ml-missing-row-type">${this.escapeHtml(typeLabel)}</span>
<span class="ml-missing-row-best" data-tooltip="${this.escapeHtml(matchDisplay)}">
${bestMatch ? this.escapeHtml(matchDisplay) : '<span class="ml-missing-row-none">-- No local match</span>'}
</span>
<span class="ml-missing-row-match ml-missing-row-match-${matchClass}">
<strong>${bestMatch ? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%` : '--'}</strong>
</span>
<span class="ml-missing-row-sources">${this.renderMissingSourcesSummary(missing)}</span>
</div>
`;
});
html += `
</div>
</section>
<section class="ml-missing-detail-pane" aria-label="Missing model details">
${detailMissing ? this.renderMissingModel(detailMissing, detailIndex) : this.renderStatusMessage('Select a missing model to inspect details.', 'info')}
</section>
</div>
`;
return html;
}
wireMissingModelsBrowser(container, data, sortedMissingModels) {
const selectRow = (row) => {
const key = row.dataset.missingKey;
if (!key || key === this.selectedMissingModelKey) return;
this.selectedMissingModelKey = key;
this.displayMissingModels(container, data);
};
container.querySelectorAll('.ml-missing-list-row').forEach(row => {
row.addEventListener('click', (event) => {
const clickedLocate = event.target instanceof Element && event.target.closest('.ml-missing-row-locate');
if (clickedLocate) return;
selectRow(row);
});
row.addEventListener('keydown', (event) => {
if (event.target !== row || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();
selectRow(row);
});
});
container.querySelectorAll('.ml-missing-row-locate').forEach(button => {
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const rawNodeId = button.dataset.nodeId;
const numericNodeId = Number(rawNodeId);
this.locateNodeInGraph(Number.isNaN(numericNodeId) ? rawNodeId : numericNodeId);
});
});
const selectedMissing = sortedMissingModels.find(missing => this.getMissingModelKey(missing) === this.selectedMissingModelKey);
if (!selectedMissing) return;
const selectedIndex = sortedMissingModels.indexOf(selectedMissing);
this.wireMissingModelDetail(container, selectedMissing, selectedIndex);
}
wireMissingModelDetail(container, missing, missingIndex) {
this.wireLocalMatchButtons(container, missing, missingIndex);
this.wireDownloadSearchPanel(container, missing);
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);
});
}
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}`);
if (comboList) {
this.enableWheelScrollChaining(comboList);
}
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 || '';
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();
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>${this.escapeHtml(label)}</code>`;
html += `</div>`;
if (folderDisplay) {
html += `<div class="ml-combo-folder" data-tooltip="${this.escapeHtml(folderDisplay)}">Folder ${this.escapeHtml(folderDisplay)}</div>`;
}
html += `</div>`;
}
comboList.innerHTML = html;
comboList.querySelectorAll('div[data-idx]').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.idx, 10);
if (!Number.isNaN(idx) && idx >= 0 && idx < allModels.length) {
const chosenModel = allModels[idx];
if (chosenModel) {
this.queueResolution(missing, chosenModel);
}
}
});
});
};
if (comboList) {
populateComboOptions('');
}
if (comboInput) {
const debouncedFilter = this.debounce(() => {
populateComboOptions(comboInput.value);
}, 200);
comboInput.addEventListener('input', debouncedFilter);
comboInput.addEventListener('focus', () => {
if (comboList) {
comboList.classList.remove('ml-is-hidden');
comboList.classList.add('ml-is-visible');
}
populateComboOptions(comboInput.value);
});
comboInput.addEventListener('blur', () => {
setTimeout(() => {
if (comboList) {
comboList.classList.remove('ml-is-visible');
comboList.classList.add('ml-is-hidden');
}
}, 200);
});
}
if (comboRefresh) {
comboRefresh.addEventListener('click', async () => {
this.allModels = null;
await this.ensureAllModelsLoaded();
populateComboOptions(comboInput?.value || '');
});
}
const state = this.searchResultCache.get(this.getMissingSearchKey(missing));
const searchResultsDiv = container.querySelector(`#search-results-${missing.node_id}-${missing.widget_index}`);
if (state && searchResultsDiv && this.hasRenderableSearchState(state)) {
searchResultsDiv.classList.remove('ml-is-hidden');
searchResultsDiv.classList.add('ml-is-visible');
this.displaySearchResults(missing, state, searchResultsDiv);
}
}
/**
* 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;
}
// 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++) {
sortedMissingModels[mi].__displayIndex = mi;
}
const selectedStillExists = sortedMissingModels.some(missing => (
this.getMissingModelKey(missing) === this.selectedMissingModelKey
));
if (!selectedStillExists) {
this.selectedMissingModelKey = sortedMissingModels.length
? this.getMissingModelKey(sortedMissingModels[0])
: null;
}
this.missingModels = sortedMissingModels;
container.innerHTML = this.renderMissingModelsBrowser(
sortedMissingModels,
this.selectedMissingModelKey,
totalMissing,
activeCount,
hasAny100Match
);
this.wireMissingModelsBrowser(container, data, sortedMissingModels);
}
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 nodeDisplay = this.getMissingNodeDisplay(missing);
const nodeChipText = this.escapeHtml(nodeDisplay.text);
// 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">`;
const titleMetaParts = [];
let titlePrimaryHtml = `<span class="ml-card-title-primary" data-tooltip="${this.escapeHtml(missingFilename.full)}">${missingFilename.display}</span>`;
let titleSecondaryHtml = '';
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) {
titleMetaParts.push(`<span class="ml-card-title-eyebrow" data-tooltip="${this.escapeHtml(missingFilename.full)}">${missingFilename.display}</span>`);
}
if (missing.is_urn && !missing.civitai_info) {
// URN without info - show Loading and fetch async in background
titlePrimaryHtml = `<span class="ml-card-title-primary" id="${urnLoadingId}">Resolving CivitAI model...</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;
const civitaiLabelHtml = this.renderVersionedModelNameHtml(civitaiInfo.model_name, civitaiInfo.version_name);
if (civitaiLabelHtml) {
const linkHtml = modelUrl ? `<a href="${modelUrl}" target="_blank" class="ml-inline-civitai-link">${civitaiLabelHtml}</a>` : `<span class="ml-inline-civitai-link">${civitaiLabelHtml}</span>`;
titlePrimaryHtml = `<span class="ml-card-title-primary">${linkHtml}</span>`;
}
if (civitaiInfo.expected_filename) {
titleSecondaryHtml = `<span class="ml-card-title-secondary">Expected file: ${civitaiInfo.expected_filename}</span>`;
}
}
html += `<div class="ml-card-title-meta">`;
html += titleMetaParts.join('');
html += `<h3 class="ml-card-title">${titlePrimaryHtml}</h3>`;
if (titleSecondaryHtml) {
html += titleSecondaryHtml;
}
html += `</div>`;
const locateId = `locate-${missing.node_id}-${missing.widget_index}`;
const nodeChipClasses = missing.is_top_level !== false ? 'ml-node-chip is-locatable' : 'ml-node-chip';
const nodeChipTitle = missing.is_top_level !== false ? 'Center this node in the ComfyUI graph.' : '';
html += `<div class="ml-card-subtitle">`;
if (missing.category) {
html += `<span class="ml-category-chip" data-tooltip="${this.escapeHtml(missing.category)}">${this.getCategoryDisplayName(missing.category)}</span>`;
}
html += `<span id="${locateId}" class="${nodeChipClasses}"${nodeChipTitle ? ` data-tooltip="${this.escapeHtml(nodeChipTitle)}"` : ''}>`;
if (missing.is_top_level !== false) {
html += this.getLocateIconHtml();
}
html += `${nodeChipText}</span>`;
html += `</div>`;
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"></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>`;
html += `<div id="local-matches-body-${missing.node_id}-${missing.widget_index}">`;
html += this.renderLocalMatchesContent(missing, missingIndex);
html += `</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}" type="button" aria-label="Reload local model list" data-tooltip="Reload local model list" class="ml-btn ml-btn-secondary ml-btn-sm ml-btn-icon-only ml-combo-refresh-btn">${getSvgIcon('refreshCw', 'currentColor', 'ml-combo-refresh-icon')}</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, but allow online re-check.
html += `<div class="ml-download-section">`;
html += this.renderSearchControls(missing, { buttonText: 'Search Online' });
html += this.renderDownloadTargetControls(missing, missing.category || 'checkpoints');
html += `</div>`;
html += `<div id="search-results-${missing.node_id}-${missing.widget_index}" class="ml-search-results"></div>`;
} else if (downloadSource && downloadSource.url) {
html += this.renderKnownDownloadPanel(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">`;
html += this.renderSearchControls(missing);
html += this.renderDownloadTargetControls(missing, missing.category || 'checkpoints');
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}" class="ml-download-progress-slot"></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: "✓",
className: "ml-notification-icon"
}));
} else if (type === 'error') {
children.push($el("span", {
textContent: "×",
className: "ml-notification-icon"
}));
} else if (type === 'info') {
children.push($el("span", {
textContent: "ℹ",
className: "ml-notification-icon"
}));
}
// Create notification banner
const notification = $el("div", {
className: `ml-notification ml-notification--${type}`
}, [
...children,
$el("span", {
textContent: message
}),
$el("button", {
className: "ml-notification-close",
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);
}
}
})
]);
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 targetSelection = this.getDownloadTargetSelection(missing, source.directory || missing.category || 'checkpoints');
const category = targetSelection.category;
const subfolder = targetSelection.subfolder;
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}`);
const tokens = this.getStoredTokens();
try {
// Disable button and show progress with cancel button immediately
if (downloadBtn) {
downloadBtn.disabled = true;
downloadBtn.classList.remove('ml-is-success-action', 'ml-btn-primary');
downloadBtn.textContent = 'Starting...';
}
if (progressDiv) {
progressDiv.classList.remove('ml-is-hidden');
progressDiv.classList.add('ml-is-visible');
// Show progress bar with cancel button immediately
progressDiv.innerHTML = this.renderProgressWithAction({
percent: 0,
leftText: '<span class="ml-info-accent-text">Connecting...</span>',
rightText: '',
actionClass: 'cancel-download-btn-pending ml-btn ml-btn-danger ml-btn-sm',
actionText: 'Cancel'
});
}
// 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,
subfolder: subfolder,
hf_token: tokens.hf_token,
civitai_key: tokens.civitai_key
})
});
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.classList.remove('ml-is-success-action', 'ml-btn-primary');
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 = this.renderProgressWithAction({
percent,
leftText: `${downloaded} / ${total} (${percent}%)`,
rightText: speed,
actionClass: 'cancel-download-btn ml-btn ml-btn-danger ml-btn-sm',
actionText: 'Cancel',
actionDataAttr: `data-download-id="${downloadId}"`
});
// 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.remove('ml-is-success-action');
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.classList.remove('ml-is-success-action', 'ml-btn-primary');
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.classList.remove('ml-is-success-action', 'ml-btn-primary');
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.classList.remove('ml-btn-primary');
downloadBtn.classList.add('ml-is-success-action');
}
}
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);
const sourceIds = this.getSearchSourcesForSelection(selectedSource, missing);
// For URNs, use the resolved file/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) {
const urnSearchName = missing.civitai_info?.expected_filename
|| missing.download_source?.filename
|| missing.civitai_info?.model_name;
if (urnSearchName) {
filename = urnSearchName;
}
// 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}`);
let searchRunId = null;
try {
if (!sourceIds.length) {
throw new Error(`${selectedSourceLabel} is not available in this install`);
}
this.clearSearchProgressTimers(state.activeSearchRunId);
searchRunId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
state.activeSearchRunId = searchRunId;
state.lastAttemptSources = sourceIds;
state.lastAttemptFound = null;
state.lastAttemptError = null;
state.sourceProgress = {};
for (const source of sourceIds) {
this.setSourceProgress(state, source, {
status: 'running',
percent: 6,
startedAt: Date.now(),
estimateMs: this.getSearchSourceEstimateMs(source, isUrn)
}, missing);
}
if (searchBtn) {
searchBtn.disabled = true;
searchBtn.innerHTML = `${this.getSearchIconHtml()} Searching ${selectedSourceLabel}...`;
}
if (resultsDiv) {
resultsDiv.classList.remove('ml-is-hidden');
resultsDiv.classList.add('ml-is-visible');
this.displaySearchResults(missing, state, resultsDiv);
}
for (const source of sourceIds) {
this.startEstimatedSearchProgress(
state,
missing,
resultsDiv,
source,
searchRunId
);
}
// For URNs, include model_id and version_id for direct download
const tokens = this.getStoredTokens();
const baseSearchData = {
filename,
category,
is_urn: isUrn,
civitai_session_token: tokens.civitai_session_token,
civitai_candidate_limit: tokens.civitai_candidate_limit,
civitai_use_trpc_search: tokens.civitai_use_trpc_search,
civitai_use_html_fallback: tokens.civitai_use_html_fallback,
hf_token: tokens.hf_token,
brave_search_api_key: tokens.brave_search_api_key,
hf_use_api_search: tokens.hf_use_api_search,
hf_use_comfy_org_fallback: tokens.hf_use_comfy_org_fallback,
hf_use_brave_fallback: tokens.hf_use_brave_fallback
};
if (isUrn && missing.urn) {
baseSearchData.model_id = missing.urn.model_id;
baseSearchData.version_id = missing.urn.version_id;
}
const attemptedSources = new Set();
let anyFound = false;
let hadError = false;
const searchPromises = sourceIds.map(async (source) => {
const sourceIsUrn = isUrn && source !== 'lora_manager_archive';
const searchData = {
...baseSearchData,
is_urn: sourceIsUrn,
sources: [source]
};
try {
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));
if (state.activeSearchRunId !== searchRunId) {
return { source, stale: true };
}
const responseSources = Array.isArray(data.searched_sources) && data.searched_sources.length
? data.searched_sources
: [source];
responseSources.forEach(responseSource => attemptedSources.add(responseSource));
const found = this.hasSearchResults(data);
anyFound = anyFound || found;
state.results = this.mergeSearchResults(state.results, data);
state.lastAttemptSources = Array.from(attemptedSources);
state.lastAttemptFound = anyFound;
this.clearSearchProgressTimer(searchRunId, source);
this.setSourceProgress(state, source, {
status: found ? 'found' : 'none',
percent: 100,
message: found ? 'Found' : 'No match'
}, missing);
if (data.civitai) {
missing.civitai_search_result = {
base_model: data.civitai.base_model,
tags: data.civitai.tags || [],
trained_words: data.civitai.trained_words || [],
filename: data.civitai.filename,
name: data.civitai.name,
type: data.civitai.type
};
}
this.displaySearchResults(missing, state, resultsDiv);
this.applySearchResultSuggestion(missing);
return { source, data };
} catch (error) {
console.error(`Model Linker: Search error for ${source}:`, error);
if (state.activeSearchRunId !== searchRunId) {
return { source, stale: true, error };
}
hadError = true;
attemptedSources.add(source);
state.lastAttemptSources = Array.from(attemptedSources);
state.lastAttemptFound = anyFound;
this.clearSearchProgressTimer(searchRunId, source);
this.setSourceProgress(state, source, {
status: 'error',
percent: 100,
message: error.message || 'Error'
}, missing);
this.displaySearchResults(missing, state, resultsDiv);
return { source, error };
}
});
await Promise.all(searchPromises);
this.clearSearchProgressTimers(searchRunId);
if (state.activeSearchRunId === searchRunId) {
state.activeSearchRunId = null;
state.lastAttemptSources = attemptedSources.size ? Array.from(attemptedSources) : sourceIds;
state.lastAttemptFound = anyFound;
state.lastAttemptError = hadError && !anyFound
? 'Search finished with errors. Check source statuses above.'
: null;
this.displaySearchResults(missing, state, resultsDiv);
}
} catch (error) {
console.error('Model Linker: Search error:', error);
this.clearSearchProgressTimers(searchRunId);
state.lastAttemptError = error.message;
if (resultsDiv) {
resultsDiv.innerHTML = this.renderStatusMessage(`Search failed: ${error.message}`, 'error');
}
} finally {
if (!searchRunId || state.activeSearchRunId === searchRunId) {
this.clearSearchProgressTimers(searchRunId);
state.activeSearchRunId = null;
}
if (searchBtn) {
searchBtn.disabled = false;
searchBtn.innerHTML = `${this.getSearchIconHtml()} 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 tokens = this.getStoredTokens();
const payload = {
filename: modelId + '_' + versionId,
category: '',
is_urn: true,
sources: ['civitai'],
model_id: modelId,
version_id: versionId,
civitai_candidate_limit: tokens.civitai_candidate_limit
};
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) {
loadingEl.classList.remove('ml-is-muted', 'ml-is-error');
const civitai = data.civitai;
const labelHtml = this.renderVersionedModelNameHtml(civitai.name, civitai.version_name)
|| this.escapeHtml(civitai.filename || 'Model');
const url = modelUrl || `https://civitai.com/models/${modelId}?modelVersionId=${versionId}`;
loadingEl.innerHTML = `<a href="${url}" target="_blank" class="ml-inline-civitai-link">${labelHtml}</a>`;
} else if (loadingEl) {
loadingEl.textContent = 'Not found';
loadingEl.classList.remove('ml-is-error');
loadingEl.classList.add('ml-is-muted');
}
const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-');
const downloadEl = document.getElementById(downloadContainerId);
if (downloadEl && data.civitai?.download_url) {
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,
base_model: data.civitai.base_model,
tags: data.civitai.tags || []
};
missing.download_source = {
source: 'civitai',
url: data.civitai.download_url,
filename: data.civitai.filename,
name: data.civitai.name,
version_name: data.civitai.version_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}`,
base_model: data.civitai.base_model,
tags: data.civitai.tags || []
};
const downloadParent = downloadEl.parentElement;
downloadEl.outerHTML = this.renderKnownDownloadPanel(missing, missing.download_source);
this.wireDownloadSearchPanel(downloadParent || this.contentElement, missing);
this.refreshUrnLocalMatches(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.classList.remove('ml-is-muted');
loadingEl.classList.add('ml-is-error');
}
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.classList.remove('ml-is-muted');
loadingEl.classList.add('ml-is-error');
}
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>`;
}
}
}
wireSearchDownloadButtons(container, missing) {
if (!container) return;
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);
});
});
const openPageBtns = container.querySelectorAll('.search-open-page-btn');
openPageBtns.forEach(btn => {
btn.addEventListener('click', () => {
const url = btn.dataset.url;
if (!url) return;
const opened = window.open(url, '_blank', 'noopener,noreferrer');
if (opened) {
opened.opener = null;
}
});
});
}
wireDownloadSearchPanel(container, missing) {
if (!container) return;
const searchResultsId = `search-results-${missing.node_id}-${missing.widget_index}`;
const searchResultsDiv = container.querySelector(`#${searchResultsId}`);
if (searchResultsDiv) {
this.wireSearchDownloadButtons(searchResultsDiv, missing);
}
const searchBtn = container.querySelector(`#search-${missing.node_id}-${missing.widget_index}`);
if (searchBtn && searchBtn.dataset.mlSearchBound !== 'true') {
searchBtn.dataset.mlSearchBound = 'true';
searchBtn.addEventListener('click', () => {
this.searchOnline(missing);
});
}
const sourceSelect = container.querySelector(`#search-source-select-${missing.node_id}-${missing.widget_index}`);
const sourceList = container.querySelector(`#search-source-list-${missing.node_id}-${missing.widget_index}`);
if (sourceSelect && sourceList && sourceSelect.dataset.mlSearchSourceBound !== 'true') {
sourceSelect.dataset.mlSearchSourceBound = 'true';
const renderSourceOptions = () => {
const options = this.getSearchSourceOptions();
sourceList.innerHTML = options
.map(option => `<div class="ml-download-target-option" data-value="${encodeURIComponent(option.value)}" data-label="${encodeURIComponent(option.label)}">${this.escapeHtml(option.label)}</div>`)
.join('');
sourceList.style.display = 'block';
sourceList.querySelectorAll('.ml-download-target-option').forEach(optionEl => {
optionEl.addEventListener('mousedown', (event) => {
event.preventDefault();
const value = decodeURIComponent(optionEl.dataset.value || '');
const label = decodeURIComponent(optionEl.dataset.label || optionEl.dataset.value || '');
this.setDropdownValue(sourceSelect, value, label);
this.setSearchSource(missing, value, container);
sourceList.style.display = 'none';
});
});
};
const hideSourceList = () => {
setTimeout(() => {
sourceList.style.display = 'none';
}, 150);
};
this.enableWheelScrollChaining(sourceList);
sourceSelect.addEventListener('focus', () => renderSourceOptions());
sourceSelect.addEventListener('click', () => renderSourceOptions());
sourceSelect.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
event.preventDefault();
renderSourceOptions();
}
});
sourceSelect.addEventListener('blur', hideSourceList);
this.syncSearchSourceUi(missing, container);
}
this.wireDownloadTargetAutocomplete(container, missing);
}
/**
* 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 loraManagerArchiveResult = results.lora_manager_archive ? (Array.isArray(results.lora_manager_archive) ? results.lora_manager_archive[0] : results.lora_manager_archive) : null;
const knownDownloadRow = this.getDownloadSourceTableRow(missing, missing.download_source);
const hasResults = knownDownloadRow || popular || modelListResult || hfResult || civitaiResult || loraManagerArchiveResult;
const progressHtml = this.renderSearchProgress(state);
const hasActiveProgress = this.hasActiveSearchProgress(state);
if (!hasResults) {
if (hasActiveProgress) {
container.innerHTML = progressHtml;
return;
}
if (state?.lastAttemptError) {
container.innerHTML = `${progressHtml}${this.renderStatusMessage(state.lastAttemptError, 'error')}`;
return;
}
container.innerHTML = progressHtml || this.renderStatusMessage(
'No matches found online for this model.',
'warning'
);
return;
}
const rows = [];
const rowKeys = new Set();
const addRow = (row) => {
if (!row) return;
const rowKey = row.downloadUrl || row.openUrl || `${row.sourceKey}:${row.model}:${row.filename}`;
if (rowKeys.has(rowKey)) return;
rowKeys.add(rowKey);
rows.push(row);
};
let statusHtml = '';
if (!hasActiveProgress && state?.lastAttemptError) {
statusHtml += this.renderStatusMessage(state.lastAttemptError, 'error');
}
addRow(knownDownloadRow);
if (popular) {
const popularFilename = popular.filename || missing.original_path?.split('/').pop()?.split('\\').pop() || '';
addRow({
sourceKey: 'popular',
sourceLabel: 'Popular',
model: popular.name || popularFilename,
filename: popularFilename,
secondary: popular.name && popular.name !== popularFilename ? popularFilename : '',
match: this.getSearchResultMatchDisplay(popular, 'Known', 'strong'),
size: this.formatSearchResultSize(popular),
downloadUrl: popular.url,
downloadFilename: popularFilename,
category: popular.directory || missing.category,
openUrl: this.getModelCardUrl(popular.url)
});
}
if (modelListResult && modelListResult.url) {
addRow({
sourceKey: 'model-list',
sourceLabel: 'Local Database',
model: modelListResult.name || modelListResult.filename,
filename: modelListResult.filename,
secondary: modelListResult.name && modelListResult.name !== modelListResult.filename ? modelListResult.filename : '',
match: this.getSearchResultMatchDisplay(modelListResult),
size: this.formatSearchResultSize(modelListResult),
downloadUrl: modelListResult.url,
downloadFilename: modelListResult.filename,
category: modelListResult.directory || missing.category,
openUrl: this.getModelCardUrl(modelListResult.url)
});
}
if (hfResult && hfResult.url) {
const hfRepo = hfResult.repo_id || hfResult.repo || '';
const hfModelUrl = hfRepo ? `https://huggingface.co/${hfRepo}` : this.getModelCardUrl(hfResult.url);
addRow({
sourceKey: 'huggingface',
sourceLabel: 'HuggingFace',
model: hfRepo || hfResult.filename,
filename: hfResult.filename,
secondary: hfResult.path && hfResult.path !== hfResult.filename ? hfResult.path : '',
match: this.getSearchResultMatchDisplay(hfResult),
size: this.formatSearchResultSize(hfResult),
downloadUrl: hfResult.url,
downloadFilename: hfResult.filename,
category: missing.category,
openUrl: hfModelUrl
});
}
if (loraManagerArchiveResult && loraManagerArchiveResult.url) {
const archiveFilename = loraManagerArchiveResult.filename || missing.original_path?.split('/').pop()?.split('\\').pop() || '';
const archiveName = loraManagerArchiveResult.name || archiveFilename;
addRow({
sourceKey: 'lora-archive',
sourceLabel: 'LoRA Archive',
model: archiveName,
version: loraManagerArchiveResult.version_name || '',
filename: archiveFilename,
secondary: archiveName && archiveName !== archiveFilename ? archiveFilename : '',
match: this.getSearchResultMatchDisplay(loraManagerArchiveResult),
size: this.formatSearchResultSize(loraManagerArchiveResult),
downloadUrl: loraManagerArchiveResult.download_url || '',
downloadFilename: archiveFilename,
category: missing.category,
openUrl: loraManagerArchiveResult.url
});
}
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}` : ''}` : '');
const downloadFilename = missing.civitai_info?.expected_filename || civitaiResult.filename || civitaiResult.name;
const modelName = missing.civitai_info?.model_name || civitaiResult.name || downloadFilename || 'Model';
addRow({
sourceKey: 'civitai',
sourceLabel: 'CivitAI',
model: modelName,
version: missing.civitai_info?.version_name || civitaiResult.version_name || '',
filename: downloadFilename,
secondary: civitaiResult.type || civitaiResult.base_model || '',
match: this.getSearchResultMatchDisplay(civitaiResult),
size: this.formatSearchResultSize(civitaiResult),
downloadUrl: civitaiResult.download_url,
downloadFilename,
category: missing.category,
openUrl: modelUrl
});
}
const html = `${progressHtml}${statusHtml}${this.renderSearchResultsTable(rows)}`;
container.innerHTML = html;
this.wireSearchDownloadButtons(container, missing);
}
/**
* 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}`);
const tokens = this.getStoredTokens();
const targetSelection = this.getDownloadTargetSelection(missing, category || missing.category || 'checkpoints');
try {
btn.disabled = true;
btn.classList.remove('ml-is-success-action', 'ml-btn-primary');
btn.textContent = 'Starting...';
if (progressDiv) {
progressDiv.classList.remove('ml-is-hidden');
progressDiv.classList.add('ml-is-visible');
// Show progress bar with cancel button immediately
progressDiv.innerHTML = this.renderProgressWithAction({
percent: 0,
leftText: '<span class="ml-info-accent-text">Connecting...</span>',
rightText: '',
actionClass: 'cancel-download-btn-pending ml-btn ml-btn-danger ml-btn-sm',
actionText: 'Cancel'
});
}
const response = await api.fetchApi('/model_linker/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url,
filename,
category: targetSelection.category,
subfolder: targetSelection.subfolder,
hf_token: tokens.hf_token,
civitai_key: tokens.civitai_key
})
});
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.classList.remove('ml-is-success-action', 'ml-btn-primary');
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 () => {
loadStylesWhenNeeded();
// 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
const modelLinkerTooltip = "Open Model Linker to find or download missing workflow models. Shortcut: Ctrl+Shift+L.";
const modelLinkerButton = new ComfyButton({
icon: "link-variant",
action: () => this.openLinkerManager(),
content: "Model Linker",
classList: "comfyui-button comfyui-menu-mobile-collapse"
}).element;
this.dialog.setTooltip(modelLinkerButton, modelLinkerTooltip);
this.buttonGroup = new ComfyButtonGroup(
modelLinkerButton
);
// 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 = () => {
// Auto-resolve button (green)
const autoResolveBtn = document.createElement('button');
autoResolveBtn.id = 'model-linker-btn-container'; // Use this ID to prevent duplicate injection
autoResolveBtn.className = 'ml-popup-auto-resolve-btn';
autoResolveBtn.textContent = '🔗 Auto-resolve 100%';
this.dialog?.setTooltip(autoResolveBtn, 'Link every missing model that already has an exact local match, then open Model Linker for the rest.');
autoResolveBtn.addEventListener('click', async () => {
await this.handleAutoResolveInPopup(dialog, autoResolveBtn);
});
// Find the "Don't show this again" checkbox row and add button next to it
const checkbox = dialog.querySelector('input[type="checkbox"]');
if (checkbox) {
const checkboxRow = checkbox.closest('label') || checkbox.parentElement;
if (checkboxRow && checkboxRow.parentElement) {
// Make the parent a flex container to align checkbox and button
checkboxRow.parentElement.classList.add('ml-popup-inline-actions');
// Insert button at the beginning (left side)
checkboxRow.parentElement.insertBefore(autoResolveBtn, checkboxRow);
return;
}
}
// Fallback: Find the list of models and insert before it
const modelList = dialog.querySelector('[style*="overflow"]') ||
dialog.querySelector('[class*="list"]') ||
dialog.querySelector('[class*="content"]');
if (modelList) {
// Create a wrapper and insert before the model list
const wrapper = document.createElement('div');
wrapper.className = 'ml-popup-actions-wrap';
wrapper.appendChild(autoResolveBtn);
modelList.parentElement?.insertBefore(wrapper, modelList);
} else {
// Find after the description text
const allElements = dialog.querySelectorAll('*');
for (const el of allElements) {
if (el.textContent?.includes('following models were not found') &&
el.children.length === 0) {
el.parentElement?.insertBefore(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.classList.add('ml-resolved-entry');
// Hide the Download button and replace with badge
if (downloadBtn) {
// Create badge
const badge = document.createElement('span');
badge.className = 'ml-resolved-badge';
badge.textContent = '✓ Linked';
// 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",
onclick: () => {
this.openLinkerManager();
},
className: "model-linker-floating-button"
});
document.body.appendChild(this.linkerButton);
this.dialog?.setTooltip(this.linkerButton, "Open Model Linker to find or download missing workflow models. Shortcut: Ctrl+Shift+L.");
}
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.MLToggleHidden = function(id, trigger, collapsedText, expandedText) {
const element = document.getElementById(id);
if (!element) return;
const isHidden = element.classList.toggle('ml-hidden');
if (trigger) {
trigger.setAttribute('aria-expanded', String(!isHidden));
const label = trigger.querySelector?.('.ml-local-alternatives-label');
const state = trigger.querySelector?.('.ml-local-alternatives-state');
if (label || state) {
if (label) label.textContent = isHidden ? collapsedText : expandedText;
if (state) state.textContent = isHidden ? 'Show' : 'Hide';
} else {
trigger.textContent = isHidden ? collapsedText : expandedText;
}
}
};
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);
}
};