/**
* 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.workflowPendingSelections = new Map(); // workflow key -> queued selections
this.workflowSearchResultCaches = new Map(); // workflow key -> search results by missing model
this.activeDownloads = {}; // Track active downloads
this.searchResultCache = new Map();
this.searchProgressTimers = new Map();
this.urnResolvePromises = new Map();
this.urnLocalMatchPromises = 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.returnToDockedAfterFullscreen = false;
this.docked = false;
this.dockContainer = null;
this.lastDockContainer = null;
this.pendingDockToSidebar = false;
this.sidebarTabId = "comfyui-model-linker";
this.sidebarOpenModeStorageKey = "model_linker_sidebar_open_mode";
this.missingBrowserSplitStorageKey = "model_linker_missing_browser_detail_w";
this.dockButton = null;
this.undockButton = null;
this._floatingRectBeforeDock = null;
this._dragging = false;
this._dragStart = null;
this._analysisProgressToken = null;
this._locateAnimationFrame = null;
this._viewportClampFrame = null;
this.activeWorkflowRouteKey = this.getActiveWorkflowRouteKey();
this.activeWorkflowSignature = null;
this._workflowRefreshTimer = null;
this._workflowRefreshExpectedRoute = null;
this._workflowRefreshPreviousSignature = 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) {
const nodeId = missing?.node_id ?? '';
const widgetIndex = missing?.widget_index ?? '';
const subgraphId = missing?.subgraph_id ?? '';
const category = missing?.category ?? '';
const modelPath = missing?.original_path || missing?.name || missing?.filename || missing?.urn_string || '';
return [nodeId, widgetIndex, subgraphId, category, modelPath].map(value => String(value)).join(':');
}
/**
* 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,
civarchive: 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,
civarchive: newResults.civarchive || existingResults.civarchive || null,
lora_manager_archive: newResults.lora_manager_archive || existingResults.lora_manager_archive || null
};
}
getSearchIconHtml() {
return `${getSvgIcon('search')} `;
}
getLocateIconHtml() {
return `${getSvgIcon('locate')} `;
}
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.civarchive || data.lora_manager_archive);
}
/**
* Convert source ids to readable labels
*/
getSearchSourceLabel(source) {
const labels = {
all: 'Everything',
local: 'Local Database',
huggingface: 'HuggingFace',
civitai: 'CivitAI',
civarchive: 'CivArchive',
lora_manager_archive: 'LoRA Manager Archive'
};
return labels[source] || source;
}
getSearchSourceDefinitions() {
return [
{
source: 'local',
storageKey: 'modelLinker.searchSource.localEnabled',
tooltip: 'Searches bundled known-model data before online providers.'
},
{
source: 'huggingface',
storageKey: 'modelLinker.searchSource.huggingFaceEnabled',
tooltip: 'Searches Hugging Face when Everything is selected.'
},
{
source: 'civitai',
storageKey: 'modelLinker.searchSource.civitaiEnabled',
tooltip: 'Searches CivitAI when Everything is selected.'
},
{
source: 'civarchive',
storageKey: 'modelLinker.searchSource.civArchiveEnabled',
tooltip: 'Searches CivArchive when Everything is selected.'
},
{
source: 'lora_manager_archive',
storageKey: 'modelLinker.searchSource.loraManagerArchiveEnabled',
tooltip: 'Searches the local LoRA Manager archive when Everything is selected.'
}
];
}
getSearchSourceDefinition(source) {
return this.getSearchSourceDefinitions().find(def => def.source === source) || null;
}
isSearchSourceEnabled(source) {
if (!source || source === 'all') return true;
const definition = this.getSearchSourceDefinition(source);
if (!definition) return true;
return localStorage.getItem(definition.storageKey) !== 'false';
}
isSearchSourceUsable(source) {
return this.isSourceAvailable(source) && this.isSearchSourceEnabled(source);
}
getEnabledSearchSources() {
const sources = this.getSearchSourceDefinitions()
.filter(def => this.isSearchSourceUsable(def.source))
.map(def => def.source);
return sources.length ? sources : ['local'];
}
getSearchSourceEnabledMap() {
return this.getSearchSourceDefinitions().reduce((enabled, def) => {
enabled[def.source] = this.isSearchSourceEnabled(def.source);
return enabled;
}, {});
}
getSearchSourcesForSelection(selectedSource, missing = {}) {
if (selectedSource !== 'all') {
return this.isSearchSourceUsable(selectedSource) ? [selectedSource] : [];
}
return this.getEnabledSearchSources();
}
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;
if (isUrn && source === 'civarchive') return 6000;
const estimates = {
local: 1400,
lora_manager_archive: 3200,
civarchive: 10000,
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 = '
';
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 += `
${this.escapeHtml(label)}
${this.escapeHtml(statusLabel)}
`;
}
html += '
';
return html;
}
let html = '';
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 += `
${this.escapeHtml(label)}
${this.escapeHtml(statusLabel)}
`;
}
html += '
';
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) {
const options = this.getSearchSourceOptions();
if (!options.some(option => option.value === state.selectedSource)) {
state.selectedSource = 'all';
}
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.persistSearchStateForActiveWorkflow();
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
? `${this.escapeHtml(parts.version)} `
: '';
return [nameHtml, versionHtml].filter(Boolean).join(' ');
}
getSearchSourceIconName(sourceKey) {
const icons = {
popular: 'star',
'model-list': 'comfyui',
huggingface: 'huggingface',
civitai: 'civitai',
civarchive: 'civarchive',
'lora-archive': 'loraManager',
lora_manager_archive: 'loraManager',
'lora-manager-archive': 'loraManager',
local: 'comfyui',
'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 `
${iconHtml}
${this.escapeHtml(sourceLabel)}
`;
}
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',
civarchive: 'CivArchive',
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 = ``;
html += this.renderSearchControls(missing, { buttonText: 'Search Again' });
html += this.renderDownloadTargetControls(
missing,
downloadSource.directory || downloadSource.category || missing.category || 'checkpoints'
);
html += `
`;
const downloadSourceRow = this.getDownloadSourceTableRow(missing, downloadSource);
html += ``;
html += this.renderSearchResultsTable(downloadSourceRow ? [downloadSourceRow] : []);
html += `
`;
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 = `
Source
Model
Match
Size
Actions
`;
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 += `
${getSvgIcon('download')}
`;
}
if (openUrl) {
actions += `
${getSvgIcon('externalLink')}
`;
}
if (!actions) {
actions = '- ';
}
html += `
${sourcePill}
${modelHtml}
${secondary || filename ? `${secondary || filename} ` : ''}
${this.escapeHtml(match.label)}
${size}
${actions}
`;
}
html += `
`;
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 = ``;
html += `
`;
html += `${this.getSearchIconHtml()} ${buttonText}`;
html += ` `;
html += `
`;
html += `
Source `;
html += `
`;
html += `
`;
html += `
`;
html += `
`;
html += `
`;
html += `
`;
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 `
`;
}
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.model?.path || match.path || match.filename || '';
const isBestMatch = matchIndex === 0 && match.confidence >= 95;
const contextModel = this.buildContextMenuModelData(match.model || {}, match.filename || '');
const modelData = encodeURIComponent(JSON.stringify(contextModel));
html += ``;
html += this.getConfidenceBadge(match.confidence);
html += `${this.escapeHtml(matchPath)} `;
html += `${match.confidence === 100 ? 'Exact' : 'Partial'} `;
html += ``;
html += getSvgIcon('link');
html += ` `;
html += `
`;
}
if (perfectMatches.length > 0 && otherMatches.length > 0) {
const matchId = `more-matches-${missing.node_id}-${missing.widget_index}`;
const altLabel = `Alternatives (${otherMatches.length})`;
html += ``;
html += `${altLabel} `;
html += `Hide `;
html += ` `;
html += ` `;
html += ``;
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));
const matchPath = match.model?.relative_path || match.model?.path || match.path || match.filename || '';
html += `
`;
html += this.getConfidenceBadge(match.confidence);
html += `${this.escapeHtml(matchPath)} `;
html += `Partial `;
html += `${getSvgIcon('link')} `;
html += `
`;
}
html += `
`;
}
} else if (missing.is_urn && !missing.civitai_info) {
html += `Waiting for CivitAI filename to search local models...
`;
} else if (allMatches.length > 0 && filteredMatches.length === 0) {
html += `No matches above 70% confidence
`;
} else {
html += `No local matches found
`;
}
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);
});
}
}
}
}
getUrnIds(missing = {}) {
return {
modelId: missing.urn_model_id || missing.urn?.model_id,
versionId: missing.urn_version_id || missing.urn?.version_id
};
}
getUrnResolveKey(missing = {}) {
const ids = this.getUrnIds(missing);
return `${ids.modelId || ''}:${ids.versionId || ''}:${this.getMissingModelKey(missing)}`;
}
getCivitaiResultFromMissing(missing = {}) {
if (!missing.civitai_info?.expected_filename && !missing.download_source?.url) return null;
const ids = this.getUrnIds(missing);
return {
name: missing.civitai_info?.model_name || missing.download_source?.name,
version_name: missing.civitai_info?.version_name || missing.download_source?.version_name,
filename: missing.civitai_info?.expected_filename || missing.download_source?.filename,
download_url: missing.download_source?.url,
url: missing.download_source?.model_url,
type: missing.download_source?.type || missing.category,
size: missing.download_source?.size,
model_id: missing.download_source?.model_id || ids.modelId,
version_id: missing.download_source?.version_id || ids.versionId,
base_model: missing.civitai_info?.base_model || missing.download_source?.base_model,
tags: missing.civitai_info?.tags || missing.download_source?.tags || []
};
}
applyCivitaiUrnResult(missing, civitai = {}) {
if (!missing || !civitai) return;
const ids = this.getUrnIds(missing);
missing.civitai_info = {
model_name: civitai.name,
version_name: civitai.version_name,
expected_filename: civitai.filename,
base_model: civitai.base_model,
tags: civitai.tags || []
};
missing.download_source = {
source: 'civitai',
url: civitai.download_url,
filename: civitai.filename,
name: civitai.name,
version_name: civitai.version_name,
type: civitai.type,
directory: missing.category || 'checkpoints',
match_type: 'exact',
size: civitai.size,
model_id: civitai.model_id || ids.modelId,
version_id: civitai.version_id || ids.versionId,
model_url: civitai.url || `https://civitai.com/models/${ids.modelId}?modelVersionId=${ids.versionId}`,
base_model: civitai.base_model,
tags: civitai.tags || []
};
}
async resolveUrnDataForMissing(missing) {
if (!missing?.is_urn) return null;
const existing = this.getCivitaiResultFromMissing(missing);
if (existing?.filename) {
return { civitai: existing };
}
const ids = this.getUrnIds(missing);
if (!ids.modelId || !ids.versionId) return null;
const key = this.getUrnResolveKey(missing);
if (this.urnResolvePromises.has(key)) {
return this.urnResolvePromises.get(key);
}
const promise = (async () => {
const tokens = this.getStoredTokens();
const payload = {
filename: `${ids.modelId}_${ids.versionId}`,
category: '',
is_urn: true,
sources: ['civitai'],
model_id: ids.modelId,
version_id: ids.versionId,
civitai_candidate_limit: tokens.civitai_candidate_limit
};
const response = await api.fetchApi('/model_linker/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`URN resolve failed: ${response.status}`);
}
const data = await response.json();
if (data.civitai) {
this.applyCivitaiUrnResult(missing, data.civitai);
}
return data;
})().finally(() => {
this.urnResolvePromises.delete(key);
});
this.urnResolvePromises.set(key, promise);
return promise;
}
refreshMissingListStats() {
if (!this.contentElement) return;
const statsEl = this.contentElement.querySelector('.ml-missing-list-stats');
if (!statsEl) return;
const stats = this.getMissingModelSummaryStats(this.missingModels || []);
statsEl.innerHTML = `
${stats.exact} exact
${stats.partial} partial
${stats.none} no match
`;
}
refreshMissingListRowLocalMatch(missing = {}) {
if (!this.contentElement) 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 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 bestEl = row.querySelector('.ml-missing-row-best');
if (bestEl) {
bestEl.setAttribute('data-tooltip', matchDisplay);
bestEl.innerHTML = bestMatch
? this.escapeHtml(matchDisplay)
: '-- No local match ';
}
const matchEl = row.querySelector('.ml-missing-row-match');
if (matchEl) {
matchEl.classList.remove(
'ml-missing-row-match-exact',
'ml-missing-row-match-partial',
'ml-missing-row-match-none'
);
matchEl.classList.add(`ml-missing-row-match-${matchClass}`);
const valueEl = matchEl.querySelector('strong');
if (valueEl) {
valueEl.textContent = bestMatch
? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%`
: '--';
}
}
}
this.refreshMissingListStats();
}
async fetchUrnLocalMatches(missing) {
if (!missing?.civitai_info?.expected_filename) return [];
const filename = missing.civitai_info.expected_filename;
if (missing.__urnLocalMatchesFilename === filename && Array.isArray(missing.matches)) {
return missing.matches;
}
const key = this.getUrnResolveKey(missing);
if (this.urnLocalMatchPromises.has(key)) {
return this.urnLocalMatchPromises.get(key);
}
const promise = (async () => {
const response = await api.fetchApi('/model_linker/local-matches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
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 : [];
missing.__urnLocalMatchesFilename = filename;
return missing.matches;
})().finally(() => {
this.urnLocalMatchPromises.delete(key);
});
this.urnLocalMatchPromises.set(key, promise);
return promise;
}
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) {
container.innerHTML = `Searching local matches for "${missing.civitai_info.expected_filename}"...
`;
}
try {
await this.fetchUrnLocalMatches(missing);
if (container) {
container.innerHTML = this.renderLocalMatchesContent(missing, missing.__displayIndex || 0);
this.wireLocalMatchButtons(this.contentElement, missing, missing.__displayIndex || 0);
}
this.refreshMissingListRowLocalMatch(missing);
} catch (error) {
console.error('Model Linker: URN local match refresh error:', error);
if (container) {
container.innerHTML = `Failed to refresh local matches.
`;
}
}
}
scheduleInitialUrnLocalMatchRefresh(missingModels = [], container = null, data = null) {
const refreshTasks = [];
missingModels.forEach((missing) => {
if (!missing?.is_urn || missing.__urnLocalRefreshQueued) return;
if (this.getBestLocalMatch(missing, 70)) return;
missing.__urnLocalRefreshQueued = true;
const task = (async () => {
try {
await this.resolveUrnDataForMissing(missing);
await this.refreshUrnLocalMatches(missing);
} catch (error) {
missing.__urnLocalRefreshFailed = true;
console.error('Model Linker: initial URN local match refresh error:', error);
}
})();
refreshTasks.push(task);
});
if (!refreshTasks.length || !container || !data) return;
Promise.allSettled(refreshTasks).then(() => {
if (!container.isConnected || this.activeTab !== 'missing') return;
this.displayMissingModels(container, data);
});
}
/**
* 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 = `
Loading...
File ?
Hash (sha256) ?
CivitAI ?
Name ?
Base Model ?
Trained Words ?
0 selected
Copy
Click words to select them.
Clip Skip ?
Description ?
`;
return dialog;
}
escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
escapeJsString(value) {
return this.escapeHtml(JSON.stringify(String(value ?? '')));
}
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(`${raw}
`, '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 ${this.getModelTypeColorClass(modelType)}`;
}
// 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 = `
${getSvgIcon('civitai', 'currentColor', 'ml-info-civitai-logo')}
View on Civitai
`;
} else {
const searchName = data.model_name || data.modelName || 'Unknown';
civitaiLinkEl.innerHTML = `
Model not found
${this.getSearchIconHtml()} Search on CivitAI
`;
}
}
// Update name
const nameEl = dialog.querySelector('.ml-info-name');
if (nameEl) {
nameEl.textContent = data.model_name || data.modelName || '';
}
// Update base model row
const baseModelRowEl = dialog.querySelector('.ml-info-base-model');
if (baseModelRowEl) {
const baseModel = data.base_model || data.baseModel || '';
baseModelRowEl.textContent = baseModel;
const row = baseModelRowEl.closest('tr');
if (row && baseModel) {
row.style.display = '';
} else if (row) {
row.style.display = 'none';
}
}
// Update trained words
const trainedWordsEl = dialog.querySelector('.ml-info-trained-words');
if (trainedWordsEl) {
const words = this.normalizeTrainedWords(data.trained_words || data.trainedWords || []);
if (words.length > 0) {
dialog._selectedTrainedWords = new Set();
trainedWordsEl.innerHTML = `${words.map(word => `
${this.escapeHtml(word)}
`).join('')}
`;
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 : `${fallbackText}
`;
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(`civitai `);
}
if (img.seed) captionParts.push(`seed ${this.escapeHtml(img.seed)} `);
if (img.steps) captionParts.push(`steps ${this.escapeHtml(img.steps)} `);
if (img.cfg) captionParts.push(`cfg ${this.escapeHtml(img.cfg)} `);
if (img.sampler) captionParts.push(`sampler ${this.escapeHtml(img.sampler)} `);
if (img.model) captionParts.push(`model ${this.escapeHtml(this.truncateText(img.model, 72))} `);
if (img.positive) captionParts.push(`positive ${this.escapeHtml(this.truncateText(img.positive, 180))} `);
if (img.negative) captionParts.push(`negative ${this.escapeHtml(this.truncateText(img.negative, 180))} `);
return `
${captionParts.join('')}
`;
};
let imagesHtml = '';
imagesHtml += visibleImages.map(renderImageCard).join('');
imagesHtml += '
';
imagesContainer.innerHTML = imagesHtml;
}
/**
* Update the info dialog with error
*/
updateInfoDialogError(dialog, message) {
const civitaiLink = dialog.querySelector('.ml-info-civitai-link');
if (civitaiLink) {
civitaiLink.innerHTML = `${message} `;
}
}
/**
* 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 `${confidence}% `;
}
getStatusBadge(label, variant = 'neutral') {
return `${label} `;
}
/**
* 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 `
${icon}
${message}
`;
}
/**
* 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 `
`;
}
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 `
${this.getStatusBadge('Analyzing', 'info')}
${message}
${this.renderProgressBar(percent, detail, `${percent}%`)}
${modelName ? `
${modelName}
` : ''}
`;
}
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;
}
}
getActiveWorkflowRouteKey() {
try {
return window.location?.hash || '';
} catch (error) {
return '';
}
}
rememberActiveWorkflow(workflow = null) {
const currentWorkflow = workflow || this.getCurrentWorkflow();
this.activeWorkflowRouteKey = this.getActiveWorkflowRouteKey();
this.activeWorkflowSignature = this.getWorkflowSignature(currentWorkflow);
}
getWorkflowScopedQueueKey(route = this.activeWorkflowRouteKey, signature = this.activeWorkflowSignature) {
return route || signature || null;
}
clonePendingResolutions(list = this.pendingResolutions) {
try {
return JSON.parse(JSON.stringify(Array.isArray(list) ? list : []));
} catch (error) {
console.warn('Model Linker: failed to clone queued selections', error);
return Array.isArray(list) ? list.map(item => ({ ...item })) : [];
}
}
savePendingQueueForActiveWorkflow() {
const key = this.getWorkflowScopedQueueKey();
if (!key) return;
const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : [];
if (list.length) {
this.workflowPendingSelections.set(key, this.clonePendingResolutions(list));
} else {
this.workflowPendingSelections.delete(key);
}
}
restorePendingQueueForActiveWorkflow() {
const key = this.getWorkflowScopedQueueKey();
const saved = key ? this.workflowPendingSelections.get(key) : null;
this.pendingResolutions = saved ? this.clonePendingResolutions(saved) : [];
this.rebuildPendingIndex();
this.updateApplyPendingButton?.();
this.updateQueuePanel?.();
this.updateQueueVisibility?.();
}
cloneSearchState(state = {}) {
let clone = {};
try {
clone = JSON.parse(JSON.stringify(state || {}));
} catch (error) {
console.warn('Model Linker: failed to clone search state', error);
clone = { ...state };
}
clone.selectedSource = clone.selectedSource || 'all';
clone.results = this.mergeSearchResults({}, clone.results || {});
clone.lastAttemptSources = Array.isArray(clone.lastAttemptSources)
? clone.lastAttemptSources
: [];
clone.lastAttemptFound = clone.lastAttemptFound ?? null;
clone.lastAttemptError = clone.lastAttemptError || null;
clone.sourceProgress = clone.sourceProgress || {};
clone.activeSearchRunId = null;
for (const progress of Object.values(clone.sourceProgress)) {
if (!progress || (progress.status !== 'pending' && progress.status !== 'running')) continue;
progress.status = 'error';
progress.percent = 100;
progress.message = 'Search interrupted';
}
return clone;
}
cloneSearchResultCache(cache = this.searchResultCache) {
const cloned = new Map();
for (const [key, state] of cache.entries()) {
if (!this.isPersistableSearchState(state)) {
continue;
}
cloned.set(key, this.cloneSearchState(state));
}
return cloned;
}
isPersistableSearchState(state = {}) {
if ((state?.selectedSource || 'all') !== 'all') return true;
if (state?.lastAttemptError) return true;
if (this.hasSearchResults(state?.results || {})) return true;
const progressEntries = Object.values(state?.sourceProgress || {});
return progressEntries.some(progress => (
progress?.status && progress.status !== 'pending' && progress.status !== 'running'
));
}
saveSearchCacheForActiveWorkflow() {
const key = this.getWorkflowScopedQueueKey();
if (!key) return;
const cloned = this.cloneSearchResultCache();
if (cloned.size) {
this.workflowSearchResultCaches.set(key, cloned);
} else {
this.workflowSearchResultCaches.delete(key);
}
}
restoreSearchCacheForActiveWorkflow() {
const key = this.getWorkflowScopedQueueKey();
const saved = key ? this.workflowSearchResultCaches.get(key) : null;
this.searchResultCache = saved ? this.cloneSearchResultCache(saved) : new Map();
}
persistSearchStateForActiveWorkflow() {
this.saveSearchCacheForActiveWorkflow();
}
clearAllSearchProgressTimers() {
for (const timer of this.searchProgressTimers.values()) {
clearInterval(timer);
}
this.searchProgressTimers.clear();
}
syncWorkflowScopedQueue(workflow = null) {
const currentWorkflow = workflow || this.getCurrentWorkflow();
const nextRoute = this.getActiveWorkflowRouteKey();
const nextSignature = this.getWorkflowSignature(currentWorkflow);
const previousKey = this.getWorkflowScopedQueueKey();
const nextKey = this.getWorkflowScopedQueueKey(nextRoute, nextSignature);
if (!nextKey || nextKey === previousKey) {
this.activeWorkflowRouteKey = nextRoute;
this.activeWorkflowSignature = nextSignature;
return;
}
this.savePendingQueueForActiveWorkflow();
this.saveSearchCacheForActiveWorkflow();
this.clearWorkflowScopedState();
this.activeWorkflowRouteKey = nextRoute;
this.activeWorkflowSignature = nextSignature;
this.restorePendingQueueForActiveWorkflow();
this.restoreSearchCacheForActiveWorkflow();
}
clearWorkflowScopedState() {
this.cachedWorkflowSignature = null;
this.cachedAnalysisData = null;
for (const state of this.searchResultCache.values()) {
state.activeSearchRunId = null;
}
this.clearAllSearchProgressTimers();
this.searchResultCache.clear();
this.urnResolvePromises.clear();
this.urnLocalMatchPromises.clear();
this.pendingResolutions = [];
this.rebuildPendingIndex();
this.updateApplyPendingButton?.();
this.updateQueuePanel?.();
this.updateQueueVisibility();
}
scheduleActiveWorkflowRefresh(reason = 'workflow-change') {
if (!this.isVisible()) return;
this._workflowRefreshExpectedRoute = this.getActiveWorkflowRouteKey();
this._workflowRefreshPreviousSignature = this.activeWorkflowSignature;
if (this._workflowRefreshTimer) {
clearTimeout(this._workflowRefreshTimer);
}
this._workflowRefreshTimer = setTimeout(() => {
this._workflowRefreshTimer = null;
this.refreshForActiveWorkflowChange({
reason,
expectedRoute: this._workflowRefreshExpectedRoute,
previousSignature: this._workflowRefreshPreviousSignature,
attempt: 0
});
}, 180);
}
async refreshForActiveWorkflowChange({ reason = 'workflow-change', expectedRoute = '', previousSignature = null, attempt = 0 } = {}) {
if (!this.isVisible()) return;
const currentRoute = this.getActiveWorkflowRouteKey();
if (expectedRoute && currentRoute !== expectedRoute) {
this.scheduleActiveWorkflowRefresh(reason);
return;
}
const workflow = this.getCurrentWorkflow();
const signature = this.getWorkflowSignature(workflow);
const routeChanged = currentRoute !== this.activeWorkflowRouteKey;
const signatureChanged = signature && signature !== this.activeWorkflowSignature;
const graphStillLooksOld = routeChanged && previousSignature && signature === previousSignature;
if ((!signature || graphStillLooksOld) && attempt < 8) {
setTimeout(() => {
this.refreshForActiveWorkflowChange({
reason,
expectedRoute,
previousSignature,
attempt: attempt + 1
});
}, 180 + (attempt * 120));
return;
}
if (!routeChanged && !signatureChanged) return;
console.log('Model Linker: active workflow changed, refreshing current tab', {
reason,
route: currentRoute,
tab: this.activeTab
});
this.savePendingQueueForActiveWorkflow();
this.saveSearchCacheForActiveWorkflow();
this.clearWorkflowScopedState();
this.activeWorkflowRouteKey = currentRoute;
this.activeWorkflowSignature = signature;
this.restorePendingQueueForActiveWorkflow();
this.restoreSearchCacheForActiveWorkflow();
if (this.activeTab === 'missing') {
if (this.contentElement) this.contentElement.style.overflowY = 'auto';
await this.loadWorkflowData(workflow);
} else if (this.activeTab === 'loaded') {
if (this.contentElement) this.contentElement.style.overflowY = 'auto';
await this.loadLoadedModels(workflow);
}
}
/**
* 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", {
onclick: () => this.switchTab('missing')
}, [$el("span.ml-tab-label", { textContent: "Missing Models" })]);
this.loadedTab = $el("button.ml-tab", {
onclick: () => this.switchTab('loaded')
}, [$el("span.ml-tab-label", { textContent: "Loaded Models" })]);
this.optionsTab = $el("button.ml-tab", {
onclick: () => this.switchTab('options')
}, [$el("span.ml-tab-label", { textContent: "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");
this.dockButton = $el("button", {
id: "model-linker-dock-toggle",
className: "ml-window-btn ml-window-btn--dock",
innerHTML: getSvgIcon('internalLink', 'currentColor', 'ml-window-btn-icon'),
ariaLabel: "Dock Model Linker to sidebar",
onclick: () => this.dockToSidebar()
});
this.setTooltip(this.dockButton, "Dock to sidebar");
this.undockButton = $el("button", {
id: "model-linker-undock-toggle",
className: "ml-window-btn ml-window-btn--undock",
innerHTML: getSvgIcon('externalLink', 'currentColor', 'ml-window-btn-icon'),
ariaLabel: "Undock Model Linker",
onclick: () => this.undockToFloating()
});
this.setTooltip(this.undockButton, "Undock to floating window");
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", {}, [
this.dockButton,
this.undockButton,
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()
})
])
])
]);
}
captureFloatingRect() {
if (!this.element || this.docked || getComputedStyle(this.element).display === 'none') return;
const rect = this.element.getBoundingClientRect();
if (!rect.width || !rect.height) return;
this._floatingRectBeforeDock = {
top: Math.round(rect.top),
left: Math.round(rect.left),
width: Math.round(rect.width),
height: Math.round(rect.height)
};
}
clearModalPlacementStyles() {
if (!this.element) return;
[
'position',
'top',
'left',
'right',
'bottom',
'inset',
'transform',
'width',
'height',
'maxWidth',
'maxHeight',
'minWidth',
'minHeight',
'borderRadius',
'resize'
].forEach((property) => {
this.element.style[property] = '';
});
}
restoreFloatingGeometry() {
const el = this.element;
if (!el) return;
el.style.width = '1100px';
el.style.height = '700px';
el.style.maxWidth = '100vw';
el.style.maxHeight = '100vh';
el.style.minWidth = '640px';
el.style.minHeight = '420px';
el.style.resize = 'both';
el.style.borderRadius = '7px';
const rect = this._floatingRectBeforeDock;
if (rect?.width && rect?.height) {
el.style.width = `${rect.width}px`;
el.style.height = `${rect.height}px`;
el.style.top = `${rect.top}px`;
el.style.left = `${rect.left}px`;
el.style.transform = 'none';
return;
}
try {
const wh = JSON.parse(localStorage.getItem('model_linker_modal_size_before_fs') || 'null');
if (wh?.w && wh?.h) {
el.style.width = `${wh.w}px`;
el.style.height = `${wh.h}px`;
}
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';
return;
}
} catch (e) {
// Fall back to centered floating geometry below.
}
el.style.top = '50%';
el.style.left = '50%';
el.style.transform = 'translate(-50%, -50%)';
}
rememberSidebarOpenMode(mode) {
try {
localStorage.setItem(this.sidebarOpenModeStorageKey, mode === 'floating' ? 'floating' : 'docked');
} catch (e) {}
}
shouldOpenFromSidebarFloating() {
try {
return localStorage.getItem(this.sidebarOpenModeStorageKey) === 'floating';
} catch (e) {
return false;
}
}
isVisible() {
if (!this.element?.isConnected) return false;
return getComputedStyle(this.element).display !== 'none';
}
dockTo(container) {
if (!container || !this.element) return;
if (!this.docked) {
this.captureFloatingRect();
}
if (this.fullscreen) {
this.setFullScreen(false);
}
this.docked = true;
this.dockContainer = container;
this.lastDockContainer = container;
this.pendingDockToSidebar = false;
this.rememberSidebarOpenMode('docked');
this.backdrop.style.display = 'none';
container.classList.add('ml-sidebar-dock-panel');
this.element.classList.add('ml-is-docked');
this.clearModalPlacementStyles();
this.element.style.display = 'flex';
if (this.element.parentNode !== container) {
container.appendChild(this.element);
}
}
dockToSidebar() {
if (this.docked) return;
this.rememberSidebarOpenMode('docked');
this.pendingDockToSidebar = true;
if (this.isUsableDockContainer(this.lastDockContainer)) {
this.dockTo(this.lastDockContainer);
return;
}
this.tryOpenComfySidebarState();
requestAnimationFrame(() => {
if (!this.pendingDockToSidebar || this.docked) return;
const button = document.querySelector(`.${this.sidebarTabId}-tab-button`);
if (button instanceof HTMLElement) {
const isActive = button.matches([
'[aria-pressed="true"]',
'[aria-selected="true"]',
'[data-active="true"]',
'[data-selected="true"]',
'.active',
'.is-active',
'.selected',
'.p-highlight'
].join(','));
if (!isActive) {
button.click();
}
}
});
}
isUsableDockContainer(container) {
if (!(container instanceof HTMLElement) || !container.isConnected) return false;
const style = getComputedStyle(container);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = container.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
trySetSidebarStateProperty(target, property, value) {
try {
if (!(property in target)) return false;
return Reflect.set(target, property, value);
} catch (error) {
return false;
}
}
tryOpenComfySidebarState() {
const extensionManager = app.extensionManager;
let opened = false;
const candidates = [
extensionManager?.sidebarTab,
extensionManager?.sidebarTabs,
extensionManager?.sidebar,
extensionManager
].filter(Boolean);
for (const candidate of candidates) {
try {
if (typeof candidate.openSidebar === 'function') {
candidate.openSidebar(this.sidebarTabId);
opened = true;
}
if (typeof candidate.openSidebarTab === 'function') {
candidate.openSidebarTab(this.sidebarTabId);
opened = true;
}
if (typeof candidate.setActiveSidebarTab === 'function') {
candidate.setActiveSidebarTab(this.sidebarTabId);
opened = true;
}
if (typeof candidate.setActiveSidebarTabId === 'function') {
candidate.setActiveSidebarTabId(this.sidebarTabId);
opened = true;
}
if (this.trySetSidebarStateProperty(candidate, 'activeSidebarTabId', this.sidebarTabId)) {
opened = true;
}
} catch (error) {
// Sidebar internals differ between ComfyUI versions; unsupported state APIs are ignored.
}
}
return opened;
}
undockToFloating({ persist = true, closeSidebar = true } = {}) {
if (!this.element) return;
const wasDocked = this.docked;
const dockContainer = this.dockContainer;
this.docked = false;
this.dockContainer = null;
this.element.classList.remove('ml-is-docked');
this.clearModalPlacementStyles();
if (this.element.parentNode !== document.body) {
document.body.appendChild(this.element);
}
this.element.style.display = 'flex';
this.restoreFloatingGeometry();
this.ensureModalHandleInViewport({ persist });
if (wasDocked && closeSidebar) {
this.closeComfySidebar(dockContainer);
}
}
closeComfySidebar(dockContainer = null) {
const closedByState = this.tryCloseComfySidebarState();
requestAnimationFrame(() => {
if (dockContainer && !dockContainer.isConnected) return;
const button = document.querySelector(`.${this.sidebarTabId}-tab-button`);
if (!(button instanceof HTMLElement)) return;
const isActive = button.matches([
'[aria-pressed="true"]',
'[aria-selected="true"]',
'[data-active="true"]',
'[data-selected="true"]',
'.active',
'.is-active',
'.selected',
'.p-highlight'
].join(','));
if (!closedByState || isActive) {
button.click();
}
});
}
tryCloseComfySidebarState() {
const extensionManager = app.extensionManager;
let closed = false;
const candidates = [
extensionManager?.sidebarTab,
extensionManager?.sidebarTabs,
extensionManager?.sidebar,
extensionManager
].filter(Boolean);
for (const candidate of candidates) {
try {
if (typeof candidate.closeSidebar === 'function') {
candidate.closeSidebar();
closed = true;
}
if (typeof candidate.closeSidebarTab === 'function') {
candidate.closeSidebarTab(this.sidebarTabId);
closed = true;
}
if (typeof candidate.setActiveSidebarTab === 'function') {
candidate.setActiveSidebarTab(null);
closed = true;
}
if (typeof candidate.setActiveSidebarTabId === 'function') {
candidate.setActiveSidebarTabId(null);
closed = true;
}
if (
this.trySetSidebarStateProperty(candidate, 'activeSidebarTabId', null) ||
this.trySetSidebarStateProperty(candidate, 'activeSidebarTab', null)
) {
closed = true;
}
} catch (error) {
// Sidebar internals differ between ComfyUI versions; unsupported state APIs are ignored.
}
}
return closed;
}
// Toggle full screen mode for the dialog
toggleFullScreen() {
this.setFullScreen(!this.fullscreen);
}
setFullScreen(enable) {
const shouldReturnToDocked = !enable && this.returnToDockedAfterFullscreen;
if (enable && this.docked) {
this.returnToDockedAfterFullscreen = true;
this.undockToFloating({ persist: false });
} else if (enable) {
this.returnToDockedAfterFullscreen = false;
}
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 {
this.returnToDockedAfterFullscreen = false;
// 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) {}
if (shouldReturnToDocked) {
this.dockToSidebar();
}
}
}
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() {
if (this.docked) return;
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.docked) return;
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) {
if (this.docked) return;
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', 'civarchive'].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';
}
getModelTypeColorClass(value = '') {
const token = String(value || '')
.trim()
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.toLowerCase()
.replace(/[\s./\\-]+/g, '_')
.replace(/[^a-z0-9_]/g, '')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
const colorNames = {
checkpoints: 'model',
checkpoint: 'model',
ckpt: 'model',
model: 'model',
diffusion_models: 'model',
diffusion_model: 'model',
unet: 'model',
loras: 'lora',
lora: 'lora',
locon: 'lora',
lycoris: 'lora',
dora: 'lora',
hypernetworks: 'lora',
hypernetwork: 'lora',
style_models: 'style-model',
style_model: 'style-model',
vae: 'vae',
vaes: 'vae',
vae_approx: 'taesd',
taesd: 'taesd',
controlnet: 'controlnet',
control_net: 'controlnet',
controlnets: 'controlnet',
control_nets: 'controlnet',
t2i_adapter: 'controlnet',
t2i_adapters: 'controlnet',
embeddings: 'conditioning',
embedding: 'conditioning',
textualinversion: 'conditioning',
textual_inversion: 'conditioning',
aesthetic_gradient: 'conditioning',
text_encoders: 'clip',
text_encoder: 'clip',
clip: 'clip',
clips: 'clip',
clip_vision: 'clip-vision',
clipvision: 'clip-vision',
clip_vision_output: 'clip-vision-output',
upscale_models: 'image',
upscale_model: 'image',
upscaler: 'image',
upscalers: 'image',
latent_upscale_models: 'latent',
latent_upscale_model: 'latent',
latent: 'latent',
image: 'image',
images: 'image',
mask: 'mask',
masks: 'mask',
noise: 'noise',
sampler: 'sampler',
samplers: 'sampler',
sigmas: 'sigmas',
guider: 'guider',
guiders: 'guider'
};
return `ml-type-chip ml-type-chip--${colorNames[token] || 'generic'}`;
}
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', ...this.getEnabledSearchSources()];
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 = ``;
html += `
`;
html += `
Folder `;
html += `
Subfolder (optional) `;
html += `
`;
html += `
`;
html += `
`;
html += `
`;
html += `
`;
html += `
`;
html += `
`;
html += `
`;
html += `
`;
html += `
`;
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 `${this.escapeHtml(label)}
`;
})
.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;
const search_source_enabled = this.getSearchSourceEnabledMap();
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,
search_source_enabled
};
}
async clearSearchCaches() {
for (const state of this.searchResultCache.values()) {
state.activeSearchRunId = null;
}
this.clearAllSearchProgressTimers();
this.searchResultCache.clear();
const key = this.getWorkflowScopedQueueKey();
if (key) {
this.workflowSearchResultCaches.delete(key);
}
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();
const sourceDefaultsRows = this.getSearchSourceDefinitions()
.filter(def => this.isSourceAvailable(def.source))
.map(def => {
const iconName = this.getSearchSourceIconName(def.source);
const checked = tokens.search_source_enabled?.[def.source] !== false;
const inputId = `ml-options-source-${def.source.replace(/_/g, '-')}`;
return `
${getSvgIcon(iconName)}
${this.escapeHtml(this.getSearchSourceLabel(def.source))}
?
`;
})
.join('');
this.contentElement.innerHTML = `
Local Database
Models
Loading...
Last update
Loading...
Status
Checking local file...
Local Database uses ComfyUI-Manager model-list.json.
${getSvgIcon('refreshCw')} Check latest
${getSvgIcon('download')} Update Local Database
Source
`;
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 sourceEnabledInputs = Array.from(this.contentElement.querySelectorAll('.ml-options-source-enabled'));
const status = this.contentElement.querySelector('#ml-options-status');
const saveBtn = this.contentElement.querySelector('#ml-options-save');
const modelListCountEl = this.contentElement.querySelector('#ml-options-model-list-count');
const modelListUpdatedEl = this.contentElement.querySelector('#ml-options-model-list-updated');
const modelListStateEl = this.contentElement.querySelector('#ml-options-model-list-state');
const modelListMessageEl = this.contentElement.querySelector('#ml-options-model-list-message');
const modelListCheckBtn = this.contentElement.querySelector('#ml-options-model-list-check');
const modelListUpdateBtn = this.contentElement.querySelector('#ml-options-model-list-update');
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,
...sourceEnabledInputs,
].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 formatOptionDate = (value) => {
if (!value) return 'Bundled';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
};
const shortSha = (value) => value ? String(value).slice(0, 8) : '';
const setModelListBusy = (busy) => {
if (modelListCheckBtn) modelListCheckBtn.disabled = busy;
if (modelListUpdateBtn) modelListUpdateBtn.disabled = busy;
};
const renderModelListStatus = (data = {}, checkedRemote = false) => {
if (modelListCountEl) {
modelListCountEl.textContent = Number.isFinite(Number(data.local_count))
? Number(data.local_count).toLocaleString()
: '-';
}
if (modelListUpdatedEl) {
modelListUpdatedEl.textContent = formatOptionDate(data.local_updated_at);
}
if (modelListStateEl) {
if (checkedRemote && data.can_compare && data.update_available) {
modelListStateEl.textContent = 'Update available';
} else if (checkedRemote && data.can_compare) {
modelListStateEl.textContent = 'Up to date';
} else if (checkedRemote && !data.can_compare) {
modelListStateEl.textContent = 'Remote checked';
} else {
modelListStateEl.textContent = data.local_sha ? 'Tracked' : 'Bundled';
}
}
if (modelListMessageEl) {
const localSha = shortSha(data.local_sha);
const remoteSha = shortSha(data.remote_sha);
if (checkedRemote && data.update_available) {
modelListMessageEl.textContent = `A newer ComfyUI-Manager model-list.json is available (${localSha || 'local'} -> ${remoteSha}).`;
} else if (checkedRemote && data.can_compare) {
modelListMessageEl.textContent = `Local Database is current (${localSha}).`;
} else if (checkedRemote) {
modelListMessageEl.textContent = `Remote file was checked. Local SHA is not recorded yet; run update once to track future changes.`;
} else {
modelListMessageEl.textContent = `Local Database uses ComfyUI-Manager model-list.json.`;
}
}
};
const loadModelListStatus = async (checkRemote = false) => {
try {
setModelListBusy(true);
if (modelListStateEl) {
modelListStateEl.textContent = checkRemote ? 'Checking GitHub...' : 'Loading...';
}
const response = await api.fetchApi(`/model_linker/model-list/status${checkRemote ? '?check_remote=1' : ''}`);
if (!response.ok) {
throw new Error(`Status failed: ${response.status}`);
}
const data = await response.json();
renderModelListStatus(data, checkRemote);
return data;
} catch (error) {
console.error('Model Linker: model-list status error:', error);
if (modelListStateEl) modelListStateEl.textContent = 'Error';
if (modelListMessageEl) modelListMessageEl.textContent = error.message || 'Failed to read Local Database status.';
return null;
} finally {
setModelListBusy(false);
}
};
const updateModelList = async () => {
try {
setModelListBusy(true);
if (modelListStateEl) modelListStateEl.textContent = 'Updating...';
if (modelListMessageEl) modelListMessageEl.textContent = 'Downloading latest ComfyUI-Manager model-list.json...';
const response = await api.fetchApi('/model_linker/model-list/update', {
method: 'POST'
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `Update failed: ${response.status}`);
}
await this.clearSearchCaches();
renderModelListStatus(data, false);
if (modelListStateEl) modelListStateEl.textContent = 'Updated';
if (modelListMessageEl) modelListMessageEl.textContent = `Local Database updated. ${Number(data.local_count || 0).toLocaleString()} models loaded.`;
this.showNotification('Local Database updated', 'success');
} catch (error) {
console.error('Model Linker: model-list update error:', error);
if (modelListStateEl) modelListStateEl.textContent = 'Error';
if (modelListMessageEl) modelListMessageEl.textContent = error.message || 'Failed to update Local Database.';
this.showNotification('Local Database update failed', 'error');
} finally {
setModelListBusy(false);
}
};
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-sources');
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 (targetId === 'ml-options-section-local-db') {
loadModelListStatus(false);
}
});
});
if (modelListCheckBtn) {
modelListCheckBtn.addEventListener('click', () => {
loadModelListStatus(true);
});
}
if (modelListUpdateBtn) {
modelListUpdateBtn.addEventListener('click', () => {
updateModelList();
});
}
loadModelListStatus(false);
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 (sourceEnabledInputs.length && !sourceEnabledInputs.some(input => input.checked)) {
const localInput = sourceEnabledInputs.find(input => input.dataset.source === 'local');
if (localInput) {
localInput.checked = true;
}
}
sourceEnabledInputs.forEach((input) => {
const key = input.dataset.storageKey;
if (key) {
localStorage.setItem(key, input.checked ? 'true' : 'false');
}
});
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, { force = false } = {}) {
const nextTab = this.getValidTab(tab);
const nextTabButton = this.getTabButton(nextTab);
if (!force && nextTab === this.activeTab && nextTabButton?.classList.contains('ml-tab-active')) {
this.hideTooltip();
this.updateQueueVisibility();
return;
}
this.activeTab = nextTab;
this.persistActiveTab(this.activeTab);
this.hideTooltip();
this.animateTabContentTransition();
this.updateTabButtonStates();
this.updateQueueVisibility();
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';
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';
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';
this.displayOptions();
}
}
async loadLoadedModels(workflow = null) {
if (!this.contentElement) return;
this.contentElement.innerHTML = 'Loading loaded models...
';
try {
workflow = workflow || this.getCurrentWorkflow();
if (!workflow) {
this.contentElement.innerHTML = 'No workflow loaded. Please load a workflow first.
';
return;
}
this.syncWorkflowScopedQueue(workflow);
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 = `Error: ${error.message}
`;
}
}
}
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 copyIcon = getSvgIcon('copy', 'currentColor', 'ml-copy-btn-icon');
const stripModelExtension = (value) => String(value || 'Unknown').replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
const modelToken = (model, category) => {
const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown';
const name = stripModelExtension(fullName);
const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : '1.00';
return `<${this.getCategoryTokenName(category)}:${name}:${strength}>`;
};
const copyButtonHtml = (text, extraClass = '') => `
`;
const countText = (count, label) => `${count} ${label}${count === 1 ? '' : 's'}`;
let html = `
`;
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;
const sectionTotal = modelsObj.active.length + modelsObj.inactive.length;
html += `
`;
html += ``;
if (hasActive) {
const activeStr = modelsObj.active.map(m => modelToken(m, category)).join(' ');
html += `
Active ${modelsObj.active.length}
${copyButtonHtml(activeStr)}
`;
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 += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)} ` : ''} `;
}
html += `
`;
}
if (hasInactive) {
const inactiveStr = modelsObj.inactive.map(m => modelToken(m, category)).join(' ');
html += `
Inactive ${modelsObj.inactive.length}
${copyButtonHtml(inactiveStr, 'is-muted')}
`;
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 += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)} ` : ''} `;
}
html += `
`;
}
html += `
`;
}
const copySectionId = 'ml-copy-' + Date.now();
html += `
${this.escapeHtml(allString)}
`;
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()
});
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",
type: "button",
"aria-label": "Toggle queued selections",
onclick: (event) => this.onQueueEdgeClick(event),
onmousedown: (event) => this.startQueueEdgeDrag(event)
});
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 = 'No selections queued.
';
return;
}
let html = '';
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 += `
`;
html += `
${this.escapeHtml(nodeLabel)} #${this.escapeHtml(String(r.node_id))}
`;
html += `
Original ${this.escapeHtml(orig)}
`;
html += `
Selected ${this.escapeHtml(label)}
`;
html += `
Remove
`;
html += `
`;
}
html += '
';
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();
this.savePendingQueueForActiveWorkflow();
// 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.savePendingQueueForActiveWorkflow();
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 = ``;
selectedBar.innerHTML += `✓ Selected: `;
selectedBar.innerHTML += `${label}`;
selectedBar.innerHTML += `Remove `;
selectedBar.innerHTML += `
`;
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.savePendingQueueForActiveWorkflow();
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.queueCollapsed) {
try { localStorage.setItem('model_linker_queue_collapsed', '1'); } catch (e) { }
} else {
try { localStorage.setItem('model_linker_queue_collapsed', '0'); } catch (e) { }
}
this.updateQueueVisibility();
this.updateQueuePanel();
}
updateQueueVisibility() {
const isMissingTab = this.activeTab === 'missing';
const showQueuePanel = isMissingTab && !this.queueCollapsed;
if (this.queueElement) {
this.queueElement.style.display = showQueuePanel ? '' : 'none';
}
if (this.splitterElement) {
this.splitterElement.style.display = showQueuePanel ? '' : 'none';
}
if (this.queueToggleIcon) {
this.queueToggleIcon.style.display = isMissingTab ? '' : 'none';
}
this.updateQueueToggleIcon();
}
updateQueueToggleIcon() {
if (!this.queueToggleIcon) return;
this.queueToggleIcon.style.display = this.activeTab === 'missing' ? '' : 'none';
this.queueToggleIcon.textContent = '';
this.queueToggleIcon.removeAttribute('data-tooltip');
this.queueToggleIcon.removeAttribute('title');
if (this.queueCollapsed) {
this.queueToggleIcon.classList.add('is-collapsed');
this.queueToggleIcon.setAttribute('aria-label', 'Show queued selections');
} else {
this.queueToggleIcon.classList.remove('is-collapsed');
this.queueToggleIcon.setAttribute('aria-label', 'Hide queued selections');
}
}
onQueueEdgeClick(event) {
if (this._suppressQueueEdgeClick) {
event?.preventDefault?.();
event?.stopPropagation?.();
this._suppressQueueEdgeClick = false;
return;
}
this.toggleQueueCollapsed();
}
startQueueEdgeDrag(event) {
if (event.button !== 0 || !this.queueCollapsed || !this.queueElement) return;
const startX = event.clientX;
const previousCursor = document.body.style.cursor;
event.preventDefault();
document.body.style.cursor = 'col-resize';
this._suppressQueueEdgeClick = true;
this.splitterElement.style.display = '';
document.getElementById('model-linker-body')?.classList.add('ml-queue-preview-collapsed');
this.queueElement.style.display = 'none';
this.queueElement.style.width = '0px';
this.startSplitDrag(
{ clientX: startX, preventDefault: () => {} },
{ startWidth: 0, edgeOpen: true, previousCursor, startCollapsedPreview: true }
);
}
// Begin split drag for resizable panels
startSplitDrag(e, { startWidth = null, edgeOpen = false, previousCursor = null, startCollapsedPreview = false } = {}) {
try {
e?.preventDefault?.();
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;
body?.classList.add('ml-is-resizing-queue');
this._splitStart = {
x: e.clientX,
startWidth: Number.isFinite(startWidth) ? startWidth : rect.width,
containerWidth: bodyRect.width
};
this._lastSplitDragApply = 0;
this._splitPreviewCollapsed = !!startCollapsedPreview;
this._splitEdgeOpen = !!edgeOpen;
this._splitEdgeOpenedPastThreshold = false;
this._splitPreviousCursor = previousCursor;
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 = this._splitEdgeOpen ? 0 : 56;
const maxW = Math.max(minW, Math.floor(this._splitStart.containerWidth - 360));
if (newW < minW) newW = minW;
if (newW > maxW) newW = maxW;
this._pendingSplitWidth = Math.round(newW);
if (this._splitEdgeOpen && this._pendingSplitWidth > 140) {
this._splitEdgeOpenedPastThreshold = true;
if (this.queueCollapsed) {
this.queueCollapsed = false;
try { localStorage.setItem('model_linker_queue_collapsed', '0'); } catch (error) { }
this.updateQueueToggleIcon();
this.updateQueuePanel();
}
}
const previewCollapsed = this._splitEdgeOpen
? (!this._splitEdgeOpenedPastThreshold || this._pendingSplitWidth <= 140)
: this._pendingSplitWidth <= 140;
if (previewCollapsed !== this._splitPreviewCollapsed) {
this._splitPreviewCollapsed = previewCollapsed;
const body = document.getElementById('model-linker-body');
body?.classList.toggle('ml-queue-preview-collapsed', previewCollapsed);
this.queueElement.style.display = previewCollapsed ? 'none' : '';
}
const now = performance.now();
if (this._lastSplitDragApply && now - this._lastSplitDragApply < 33) return;
this._lastSplitDragApply = now;
if (this._splitDragFrame) return;
this._splitDragFrame = requestAnimationFrame(() => {
this._splitDragFrame = null;
if (!this._splitDragging || !this.queueElement || !this._pendingSplitWidth || this._splitPreviewCollapsed) return;
this.queueElement.style.width = `${this._pendingSplitWidth}px`;
});
}
endSplitDrag() {
if (!this._splitDragging) return;
this._splitDragging = false;
if (this._splitDragFrame) {
cancelAnimationFrame(this._splitDragFrame);
this._splitDragFrame = null;
}
if (this.queueElement && this._pendingSplitWidth && !this._splitPreviewCollapsed) {
this.queueElement.style.width = `${this._pendingSplitWidth}px`;
}
const body = document.getElementById('model-linker-body');
body?.classList.remove('ml-is-resizing-queue');
body?.classList.remove('ml-queue-preview-collapsed');
document.removeEventListener('mousemove', this._onSplitMove);
if (this._splitEdgeOpen && !this._pendingSplitWidth) {
try {
const restoreWidth = Math.max(240, Math.round(this._splitStart?.startWidth || 320));
this.queueElement.style.width = `${restoreWidth}px`;
this.queueElement.style.display = '';
if (this.splitterElement) this.splitterElement.style.display = '';
localStorage.setItem('model_linker_split_w', String(restoreWidth));
} catch (e) { }
this.queueCollapsed = false;
try { localStorage.setItem('model_linker_queue_collapsed', '0'); } catch (e) { }
this.updateQueueVisibility();
this.updateQueuePanel();
this._splitEdgeOpen = false;
this._pendingSplitWidth = null;
this._lastSplitDragApply = 0;
this._splitPreviewCollapsed = false;
try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { }
try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { }
this._splitPreviousCursor = null;
this._splitEdgeOpenedPastThreshold = false;
setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0);
return;
}
const shouldCollapse = !!this._splitPreviewCollapsed;
if (shouldCollapse) {
try {
const restoreWidth = Math.max(240, Math.round(this._splitStart?.startWidth || 320));
this.queueElement.style.width = `${restoreWidth}px`;
this.queueElement.style.display = '';
localStorage.setItem('model_linker_split_w', String(restoreWidth));
} catch (e) { }
this._pendingSplitWidth = null;
this._lastSplitDragApply = 0;
this._splitPreviewCollapsed = false;
this._splitEdgeOpen = false;
this._splitEdgeOpenedPastThreshold = false;
try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { }
try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { }
this._splitPreviousCursor = null;
this.setQueueCollapsed(true);
setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0);
return;
}
if (this.queueElement) this.queueElement.style.display = '';
try {
const rect = this.queueElement.getBoundingClientRect();
localStorage.setItem('model_linker_split_w', String(Math.round(rect.width)));
} catch (e) { }
this._pendingSplitWidth = null;
this._lastSplitDragApply = 0;
this._splitPreviewCollapsed = false;
this._splitEdgeOpen = false;
this._splitEdgeOpenedPastThreshold = false;
try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { }
if (this._splitPreviousCursor !== null) {
try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { }
this._splitPreviousCursor = null;
}
setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0);
}
/**
* 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);
}
this.savePendingQueueForActiveWorkflow();
// 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.savePendingQueueForActiveWorkflow();
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;
const isEmpty = count === 0;
const tooltip = count > 0
? `Apply ${count} queued model link${count > 1 ? 's' : ''} to the current workflow.`
: 'Apply the model links you selected from local matches or search results.';
this.applyPendingBtn.textContent = `Apply Selected (${count})`;
this.setTooltip(this.applyPendingBtn, tooltip);
this.applyPendingBtn.setAttribute('aria-disabled', String(isEmpty));
this.applyPendingBtn.classList.toggle('ml-btn-is-disabled', isEmpty);
}
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", {
"data-tooltip": "Download every missing model that has a known download source.",
"aria-label": "Download all missing models",
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", {
"data-tooltip": "Automatically link all missing models with a 100% local match.",
"aria-label": "Auto-link all 100 percent local matches",
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",
"data-tooltip": "Apply the model links you selected from local matches or search results.",
"aria-label": "Apply selected model links",
"aria-disabled": "true",
textContent: "Apply Selected (0)",
onclick: () => {
if ((this.pendingResolutions?.length || 0) === 0) return;
this.applyPendingResolutions();
}
});
this.applyPendingBtn.classList.add('ml-btn-is-disabled');
this.downloadAllButton.setAttribute('aria-label', 'Download all missing models');
this.autoResolveButton.setAttribute('aria-label', 'Auto-link all 100 percent local matches');
this.applyPendingBtn.setAttribute('aria-label', 'Apply selected model links');
this.applyPendingBtn.setAttribute('aria-disabled', 'true');
this.setTooltip(this.downloadAllButton, 'Download every missing model that has a known download source.');
this.setTooltip(this.autoResolveButton, 'Automatically link all missing models with a 100% local match.');
this.setTooltip(this.applyPendingBtn, 'Apply the model links you selected from local matches or search results.');
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 = `✕ Cancel All (${activeCount})`;
this.downloadAllButton.setAttribute(
'data-tooltip',
`Cancel ${activeCount} active download${activeCount > 1 ? 's' : ''}.`
);
this.downloadAllButton.setAttribute('aria-label', 'Cancel all active downloads');
this.downloadAllButton.classList.remove('ml-btn-download');
this.downloadAllButton.classList.add('ml-btn-danger');
} else {
this.downloadAllButton.innerHTML = `☁ Download All Missing`;
this.downloadAllButton.setAttribute(
'data-tooltip',
'Download every missing model that has a known download source.'
);
this.downloadAllButton.setAttribute('aria-label', 'Download all missing models');
this.downloadAllButton.classList.remove('ml-btn-danger');
this.downloadAllButton.classList.add('ml-btn-download');
}
}
async show(workflow = null) {
this.undockToFloating({ persist: false });
await this.showContent(workflow, { restoreFullscreen: true });
}
async showDocked(container, workflow = null) {
this.dockTo(container);
await this.showContent(workflow, { restoreFullscreen: false });
}
async showContent(workflow = null, { restoreFullscreen = true } = {}) {
this.backdrop.style.display = "none";
this.element.style.display = "flex";
if (!this.docked) {
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 (!this.docked && restoreFullscreen && fs === '1') this.setFullScreen(true);
} catch (e) { }
// Attach drag handle event listener (only once)
this.attachDragHandleIfNeeded();
this.activeTab = this.restoreActiveTab();
this.updateTabButtonStates();
this.updateQueueVisibility();
this.syncWorkflowScopedQueue(workflow || this.getCurrentWorkflow());
if (this.activeTab === 'missing') {
await this.loadWorkflowData(workflow);
} else if (this.activeTab === 'loaded') {
this.switchTab('loaded', { force: true });
} else {
this.switchTab('options', { force: true });
}
}
// Attach topbar drag listeners while preserving the handle-based viewport clamp.
attachDragHandleIfNeeded() {
if (this._dragHandleAttached) return;
const handle = document.getElementById('model-linker-drag-handle');
const topbar = this.element?.querySelector('.ml-dialog-topbar');
if (!handle || !topbar) return;
const onMouseDown = (e) => {
if (this.fullscreen || this.docked) return; // no drag in fullscreen or docked mode
if (e.button !== 0 || this.isTopbarDragExcluded(e.target)) return;
topbar.classList.add('ml-is-dragging');
this.startDrag(e);
};
const onMouseUp = () => { topbar.classList.remove('ml-is-dragging'); };
topbar.addEventListener('mousedown', onMouseDown);
document.addEventListener('mouseup', onMouseUp);
this._dragHandleAttached = true;
}
isTopbarDragExcluded(target) {
if (!(target instanceof Element)) return false;
return !!target.closest([
'.ml-tabs',
'.ml-dialog-controls',
'button',
'a',
'input',
'select',
'textarea',
'[role="button"]',
'[data-ml-no-drag]'
].join(','));
}
close({ collapseSidebar = true } = {}) {
const wasDocked = this.docked;
const dockContainer = this.dockContainer;
this.rememberSidebarOpenMode(this.docked ? 'docked' : 'floating');
this._hidePreview?.();
this.hideTooltip();
this.backdrop.style.display = "none";
this.element.style.display = "none";
if (wasDocked && collapseSidebar) {
this.closeComfySidebar(dockContainer);
}
}
/**
* 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 = 'No workflow loaded. Please load a workflow first.
';
return;
}
this.syncWorkflowScopedQueue(workflow);
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.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 = `Error: ${error.message}
`;
}
}
}
/**
* Get current workflow from ComfyUI
*/
getCurrentWorkflow() {
// Try to get workflow from app
if (app?.graph) {
try {
// Use ComfyUI's workflow serialization
const workflow = app.graph.serialize();
return workflow;
} catch (e) {
console.warn('Model Linker: Could not serialize workflow from graph:', e);
}
}
return null;
}
/**
* Locate and focus a node in the ComfyUI canvas
*/
locateNodeInGraph(nodeId) {
try {
if (!app?.graph) {
this.showNotification('Cannot locate node - graph not available', 'error');
return;
}
// Find the node in the graph
const node = app.graph.getNodeById(nodeId);
if (!node) {
this.showNotification(`Node #${nodeId} not found in graph`, 'error');
return;
}
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 locateViewport = this.getLocateViewport(htmlCanvas);
const viewportWidth = locateViewport.width;
const viewportHeight = locateViewport.height;
if (!viewportWidth || !viewportHeight) return false;
const nodeWidth = Math.max(rect[2] || 0, 1);
const nodeHeight = Math.max(rect[3] || 0, 1);
const paddingX = 36;
const paddingY = 32;
const maxLocateScale = 2.5;
const fitScaleX = viewportWidth / (nodeWidth + (paddingX * 2));
const fitScaleY = viewportHeight / (nodeHeight + (paddingY * 2));
const targetScale = Math.max(0.15, Math.min(maxLocateScale, fitScaleX, fitScaleY));
const getCenteredOffset = (scale) => {
return {
x: -centerX + (locateViewport.centerX / scale),
y: -centerY + (locateViewport.centerY / scale)
};
};
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;
}
getLocateViewport(htmlCanvas) {
const canvasRect = htmlCanvas.getBoundingClientRect?.();
const dpr = window.devicePixelRatio || 1;
const canvasWidth = canvasRect?.width || htmlCanvas.clientWidth || ((htmlCanvas.width || 0) / dpr);
const canvasHeight = canvasRect?.height || htmlCanvas.clientHeight || ((htmlCanvas.height || 0) / dpr);
const viewport = {
left: 0,
top: 0,
right: canvasWidth,
bottom: canvasHeight
};
if (this.docked && canvasRect?.width && canvasRect?.height) {
const obstructionElement = this.dockContainer instanceof HTMLElement
? this.dockContainer
: this.element;
const obstructionRect = obstructionElement?.getBoundingClientRect?.();
if (obstructionRect?.width && obstructionRect?.height) {
this.subtractDockedObstructionFromLocateViewport(viewport, canvasRect, obstructionRect);
}
}
const width = Math.max(1, viewport.right - viewport.left);
const height = Math.max(1, viewport.bottom - viewport.top);
return {
width,
height,
centerX: viewport.left + (width / 2),
centerY: viewport.top + (height / 2)
};
}
subtractDockedObstructionFromLocateViewport(viewport, canvasRect, obstructionRect) {
const obstruction = {
left: Math.max(0, obstructionRect.left - canvasRect.left),
top: Math.max(0, obstructionRect.top - canvasRect.top),
right: Math.min(canvasRect.width, obstructionRect.right - canvasRect.left),
bottom: Math.min(canvasRect.height, obstructionRect.bottom - canvasRect.top)
};
const overlapWidth = obstruction.right - obstruction.left;
const overlapHeight = obstruction.bottom - obstruction.top;
if (overlapWidth <= 0 || overlapHeight <= 0) return;
const coversMostHeight = overlapHeight >= canvasRect.height * 0.5;
const coversMostWidth = overlapWidth >= canvasRect.width * 0.5;
const edgeTolerance = 2;
if (coversMostHeight) {
if (obstruction.left <= edgeTolerance) {
viewport.left = Math.min(viewport.right, Math.max(viewport.left, obstruction.right));
return;
}
if (obstruction.right >= canvasRect.width - edgeTolerance) {
viewport.right = Math.max(viewport.left, Math.min(viewport.right, obstruction.left));
return;
}
const leftSpace = obstruction.left - viewport.left;
const rightSpace = viewport.right - obstruction.right;
if (rightSpace >= leftSpace) {
viewport.left = Math.min(viewport.right, Math.max(viewport.left, obstruction.right));
} else {
viewport.right = Math.max(viewport.left, Math.min(viewport.right, obstruction.left));
}
return;
}
if (coversMostWidth) {
if (obstruction.top <= edgeTolerance) {
viewport.top = Math.min(viewport.bottom, Math.max(viewport.top, obstruction.bottom));
return;
}
if (obstruction.bottom >= canvasRect.height - edgeTolerance) {
viewport.bottom = Math.max(viewport.top, Math.min(viewport.bottom, obstruction.top));
}
}
}
/**
* 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: 'Downloading... ',
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 === 'civarchive') {
candidates.push(results.civarchive);
} 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 = this.getEnabledSearchSources().map(source => ({ source }));
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 `${iconHtml} `;
}).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);
let savedDetailWidth = null;
try {
savedDetailWidth = parseInt(localStorage.getItem(this.missingBrowserSplitStorageKey) || '', 10);
} catch (e) {}
const splitStyle = Number.isFinite(savedDetailWidth) && savedDetailWidth > 0
? `--ml-missing-detail-track:${savedDetailWidth}px;`
: '';
const listStyle = `--ml-missing-model-col:${listLayout.modelPx}px;--ml-missing-type-col:${listLayout.typePx}px;`;
let html = `
#
Missing Model
Type
Best Local Match
Match
Sources
`;
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 typeColorClass = this.getModelTypeColorClass(missing.category || typeLabel);
const nodeDisplay = this.getMissingNodeDisplay(missing);
const nodeId = missing.node_id ?? '';
const rowNodeHtml = nodeDisplay.canLocate
? `
${this.getLocateIconHtml()}${this.escapeHtml(nodeDisplay.text)} `
: `
${this.escapeHtml(nodeDisplay.text)} `;
html += `
${index + 1}
${this.escapeHtml(formattedFilename.display)}
${rowNodeHtml}
${this.escapeHtml(typeLabel)}
${bestMatch ? this.escapeHtml(matchDisplay) : '-- No local match '}
${bestMatch ? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%` : '--'}
${this.renderMissingSourcesSummary(missing)}
`;
});
html += `
${detailMissing ? this.renderMissingModel(detailMissing, detailIndex) : this.renderStatusMessage('Select a missing model to inspect details.', 'info')}
`;
return html;
}
wireMissingModelsBrowser(container, data, sortedMissingModels) {
this.wireMissingBrowserSplitter(container);
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);
}
wireMissingBrowserSplitter(container) {
const browser = container.querySelector('.ml-missing-browser');
const splitter = browser?.querySelector('.ml-missing-browser-splitter');
if (!(browser instanceof HTMLElement) || !(splitter instanceof HTMLElement)) return;
this.restoreMissingBrowserSplitWidth(browser);
splitter.addEventListener('mousedown', (event) => {
if (event.button !== 0) return;
this.startMissingBrowserSplitDrag(event, browser);
});
splitter.addEventListener('dblclick', () => {
browser.querySelector('.ml-missing-detail-pane')?.style.removeProperty('--ml-missing-detail-track');
try {
localStorage.removeItem(this.missingBrowserSplitStorageKey);
} catch (e) {}
});
splitter.addEventListener('keydown', (event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
this.resizeMissingBrowserDetailBy(browser, event.key === 'ArrowLeft' ? 32 : -32);
});
this._missingBrowserResizeObserver?.disconnect?.();
if (typeof ResizeObserver === 'function') {
this._missingBrowserResizeObserver = new ResizeObserver(() => {
if (!this._missingBrowserSplitDragging) {
this.restoreMissingBrowserSplitWidth(browser);
}
});
this._missingBrowserResizeObserver.observe(browser);
}
}
restoreMissingBrowserSplitWidth(browser) {
try {
const savedWidth = parseInt(localStorage.getItem(this.missingBrowserSplitStorageKey) || '', 10);
if (Number.isFinite(savedWidth) && savedWidth > 0) {
this.setMissingBrowserDetailWidth(browser, savedWidth, { persist: false });
}
} catch (e) {}
}
startMissingBrowserSplitDrag(event, browser) {
if (!(browser instanceof HTMLElement)) return;
const detailPane = browser.querySelector('.ml-missing-detail-pane');
if (!(detailPane instanceof HTMLElement)) return;
event.preventDefault();
this._missingBrowserSplitDragging = true;
this._missingBrowserSplitStart = {
x: event.clientX,
width: detailPane.getBoundingClientRect().width,
bounds: this.getMissingBrowserSplitBounds(browser)
};
this._lastMissingBrowserSplitApply = 0;
this._missingBrowserSplitBrowser = browser;
this._missingBrowserSplitDetailPane = detailPane;
this._missingBrowserPrevUserSelect = document.body.style.userSelect;
this._missingBrowserPrevCursor = document.body.style.cursor;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
browser.classList.add('is-resizing');
this._onMissingBrowserSplitMove = (moveEvent) => this.onMissingBrowserSplitDrag(moveEvent);
this._onMissingBrowserSplitUp = () => this.endMissingBrowserSplitDrag();
document.addEventListener('mousemove', this._onMissingBrowserSplitMove);
document.addEventListener('mouseup', this._onMissingBrowserSplitUp, { once: true });
}
onMissingBrowserSplitDrag(event) {
if (!this._missingBrowserSplitDragging || !this._missingBrowserSplitStart || !this._missingBrowserSplitBrowser) return;
const nextWidth = this._missingBrowserSplitStart.width - (event.clientX - this._missingBrowserSplitStart.x);
const bounds = this._missingBrowserSplitStart.bounds;
this._pendingMissingBrowserSplitWidth = Math.round(Math.max(bounds.min, Math.min(bounds.max, nextWidth)));
const now = performance.now();
if (this._lastMissingBrowserSplitApply && now - this._lastMissingBrowserSplitApply < 33) return;
this._lastMissingBrowserSplitApply = now;
if (this._missingBrowserSplitFrame) return;
this._missingBrowserSplitFrame = requestAnimationFrame(() => {
this._missingBrowserSplitFrame = null;
if (!this._missingBrowserSplitDragging || !this._missingBrowserSplitDetailPane || !this._pendingMissingBrowserSplitWidth) return;
this._missingBrowserSplitDetailPane.style.setProperty('--ml-missing-detail-track', `${this._pendingMissingBrowserSplitWidth}px`);
});
}
endMissingBrowserSplitDrag() {
if (!this._missingBrowserSplitDragging) return;
this._missingBrowserSplitDragging = false;
document.removeEventListener('mousemove', this._onMissingBrowserSplitMove);
if (this._missingBrowserSplitFrame) {
cancelAnimationFrame(this._missingBrowserSplitFrame);
this._missingBrowserSplitFrame = null;
}
if (this._missingBrowserSplitDetailPane && this._pendingMissingBrowserSplitWidth) {
this._missingBrowserSplitDetailPane.style.setProperty('--ml-missing-detail-track', `${this._pendingMissingBrowserSplitWidth}px`);
try {
localStorage.setItem(this.missingBrowserSplitStorageKey, String(this._pendingMissingBrowserSplitWidth));
} catch (e) {}
}
this._missingBrowserSplitBrowser?.classList.remove('is-resizing');
this._missingBrowserSplitBrowser = null;
this._missingBrowserSplitDetailPane = null;
this._missingBrowserSplitStart = null;
this._pendingMissingBrowserSplitWidth = null;
this._lastMissingBrowserSplitApply = 0;
try {
document.body.style.userSelect = this._missingBrowserPrevUserSelect || '';
document.body.style.cursor = this._missingBrowserPrevCursor || '';
} catch (e) {}
}
resizeMissingBrowserDetailBy(browser, delta) {
const detailPane = browser.querySelector('.ml-missing-detail-pane');
if (!(detailPane instanceof HTMLElement)) return;
this.setMissingBrowserDetailWidth(browser, detailPane.getBoundingClientRect().width + delta);
}
setMissingBrowserDetailWidth(browser, width, { persist = true } = {}) {
const bounds = this.getMissingBrowserSplitBounds(browser);
const nextWidth = Math.round(Math.max(bounds.min, Math.min(bounds.max, width)));
const detailPane = browser.querySelector('.ml-missing-detail-pane');
const target = detailPane instanceof HTMLElement ? detailPane : browser;
target.style.setProperty('--ml-missing-detail-track', `${nextWidth}px`);
if (persist) {
try {
localStorage.setItem(this.missingBrowserSplitStorageKey, String(nextWidth));
} catch (e) {}
}
}
getMissingBrowserSplitBounds(browser) {
const browserRect = browser.getBoundingClientRect();
const splitter = browser.querySelector('.ml-missing-browser-splitter');
const splitterWidth = splitter instanceof HTMLElement
? splitter.getBoundingClientRect().width
: 10;
const available = Math.max(1, browserRect.width - splitterWidth);
const minListWidth = Math.min(360, Math.max(240, Math.floor(available * 0.28)));
const max = Math.max(1, available - minListWidth);
const min = Math.min(max, Math.min(420, Math.max(300, Math.floor(available * 0.3))));
return { min, max };
}
wireMissingModelDetail(container, missing, missingIndex) {
this.wireLocalMatchButtons(container, missing, missingIndex);
this.wireDownloadSearchPanel(container, missing);
this.updateSelectedBarForMissing(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 += ``;
html += `
`;
html += `${this.escapeHtml(label)}`;
html += `
`;
if (folderDisplay) {
html += `
Folder ${this.escapeHtml(folderDisplay)}
`;
}
html += `
`;
}
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);
this.scheduleInitialUrnLocalMatchRefresh(sortedMissingModels, container, data);
}
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 = ``;
// Card Header: Filename as headline + node chip
html += ``;
// Selected bar - shows if this slot has a queued selection (BELOW card header)
const selectedBarId = `selected-bar-${missing.node_id}-${missing.widget_index}`;
html += `
`;
// Two-column layout
html += `
`;
// LEFT COLUMN: Local Matches
html += `
`;
html += `
Local Matches
`;
html += `
`;
html += this.renderLocalMatchesContent(missing, missingIndex);
html += `
`;
// Add all-models search picker - combo-style dropdown
const comboId = `combo-${missing.node_id}-${missing.widget_index}`;
html += `
`;
html += `
`;
html += `Model `;
html += ` `;
html += `${getSvgIcon('refreshCw', 'currentColor', 'ml-combo-refresh-icon')} `;
html += `
`;
html += `
`;
html += `
`;
html += `
`; // End left column
// RIGHT COLUMN: Download Option
html += `
`;
html += `
Download
`;
const filename = missing.original_path?.split('/').pop()?.split('\\').pop() || '';
const downloadSource = missing.download_source;
const urnDownloadId = `urn-download-${missing.node_id}-${missing.widget_index}`;
if (downloadSource && downloadSource.url) {
html += this.renderKnownDownloadPanel(missing, downloadSource);
} else if (perfectMatches.length > 0) {
// Has perfect local match - download not needed, but allow online re-check.
html += `
`;
html += this.renderSearchControls(missing, { buttonText: 'Search Online' });
html += this.renderDownloadTargetControls(missing, missing.category || 'checkpoints');
html += `
`;
html += `
`;
} else if (missing.is_urn) {
html += `
`;
html += `
Resolving CivitAI download for this URN...
`;
html += `
`;
} else {
// No known download - offer search
html += `
`;
html += this.renderSearchControls(missing);
html += this.renderDownloadTargetControls(missing, missing.category || 'checkpoints');
html += `
`;
html += `
`;
}
// Progress container (for downloads)
html += `
`;
html += `
`; // End right column
html += `
`; // End columns
html += `
`; // 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: 'Connecting... ',
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 = '☁ 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 = '☁ 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);
let selectedSource = state.selectedSource || 'all';
if (selectedSource !== 'all' && !this.isSearchSourceUsable(selectedSource)) {
selectedSource = 'all';
state.selectedSource = 'all';
this.persistSearchStateForActiveWorkflow();
}
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);
}
this.persistSearchStateForActiveWorkflow();
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
};
}
if (data.civarchive) {
missing.civitai_search_result = {
base_model: data.civarchive.base_model,
tags: data.civarchive.tags || [],
trained_words: data.civarchive.trained_words || [],
filename: data.civarchive.filename,
name: data.civarchive.name,
type: data.civarchive.type
};
}
this.displaySearchResults(missing, state, resultsDiv);
this.persistSearchStateForActiveWorkflow();
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);
this.persistSearchStateForActiveWorkflow();
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);
this.persistSearchStateForActiveWorkflow();
}
} catch (error) {
console.error('Model Linker: Search error:', error);
this.clearSearchProgressTimers(searchRunId);
state.lastAttemptError = error.message;
this.persistSearchStateForActiveWorkflow();
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`;
}
this.persistSearchStateForActiveWorkflow();
}
}
/**
* 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 downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-');
const missing = this.missingModels.find(m =>
`urn-download-${m.node_id}-${m.widget_index}` === downloadContainerId
);
let data = null;
if (missing) {
data = await this.resolveUrnDataForMissing(missing);
} else {
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) {
throw new Error(`URN resolve failed: ${response.status}`);
}
data = await response.json();
}
if (data) {
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 = `${labelHtml} `;
} else if (loadingEl) {
loadingEl.textContent = 'Not found';
loadingEl.classList.remove('ml-is-error');
loadingEl.classList.add('ml-is-muted');
}
const downloadEl = document.getElementById(downloadContainerId);
if (downloadEl && data.civitai?.download_url) {
if (missing) {
this.applyCivitaiUrnResult(missing, data.civitai);
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 = `Unable to resolve direct download for this URN.
`;
}
} 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 = `Failed to resolve URN download.
`;
}
}
} 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 = `Failed to resolve URN download.
`;
}
}
}
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 => `${this.escapeHtml(option.label)}
`)
.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 civarchiveResult = results.civarchive ? (Array.isArray(results.civarchive) ? results.civarchive[0] : results.civarchive) : 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 || civarchiveResult || 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() || '';
const popularSize = popular.size
|| (
modelListResult
&& modelListResult.filename
&& modelListResult.filename.toLowerCase() === popularFilename.toLowerCase()
? modelListResult.size
: ''
);
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, size: popularSize }),
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 (civarchiveResult && civarchiveResult.download_url) {
const archiveFilename = civarchiveResult.filename || missing.original_path?.split('/').pop()?.split('\\').pop() || '';
const archiveName = civarchiveResult.name || archiveFilename || 'Model';
addRow({
sourceKey: 'civarchive',
sourceLabel: 'CivArchive',
model: archiveName,
version: civarchiveResult.version_name || '',
filename: archiveFilename,
secondary: archiveName && archiveName !== archiveFilename ? archiveFilename : (civarchiveResult.platform || civarchiveResult.base_model || ''),
match: this.getSearchResultMatchDisplay(civarchiveResult),
size: this.formatSearchResultSize(civarchiveResult),
downloadUrl: civarchiveResult.download_url,
downloadFilename: archiveFilename,
category: missing.category,
openUrl: civarchiveResult.url
});
}
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: 'Connecting... ',
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 page URL from a download URL
* HuggingFace file: https://huggingface.co/Owner/Repo/resolve/main/file.safetensors -> https://huggingface.co/Owner/Repo/blob/main/file.safetensors
* 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')) {
const fileMatch = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)\/(?:resolve|blob)\/([^\/]+)\/(.+)$/);
if (fileMatch) {
const repo = fileMatch[1];
const revision = fileMatch[2];
const filePath = fileMatch[3].split(/[?#]/)[0];
return `https://huggingface.co/${repo}/blob/${revision}/${filePath}`;
}
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.sidebarTabId = "comfyui-model-linker";
this.sidebarRegistered = false;
this.openTooltip = "Open Model Linker to find or download missing workflow models. Shortcut: Ctrl+Shift+L.";
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();
this.setupActiveWorkflowChangeListeners();
const sidebarRegistered = this.registerSidebarButton();
if (!sidebarRegistered) {
await this.registerTopbarButton();
} else {
this.attachSidebarButtonToggleHandler();
}
}
registerSidebarButton() {
const registerSidebarTab = app.extensionManager?.registerSidebarTab;
if (typeof registerSidebarTab !== "function") {
return false;
}
if (this.sidebarRegistered || window.__modelLinkerSidebarRegistered) {
this.sidebarRegistered = true;
return true;
}
try {
registerSidebarTab.call(app.extensionManager, {
id: this.sidebarTabId,
icon: "mdi mdi-link-variant",
title: "Model Linker",
tooltip: this.openTooltip,
type: "custom",
render: (element) => this.renderSidebarPanel(element),
});
this.sidebarRegistered = true;
window.__modelLinkerSidebarRegistered = true;
return true;
} catch (error) {
console.warn("Model Linker: Sidebar tab registration failed, falling back to top menu button.", error);
return false;
}
}
attachSidebarButtonToggleHandler() {
window.__modelLinkerSidebarToggleOwner = this;
if (window.__modelLinkerSidebarToggleHandlerAttached) return;
window.__modelLinkerSidebarToggleHandlerAttached = true;
window.__modelLinkerSidebarToggleHandler = (event) => {
window.__modelLinkerSidebarToggleOwner?.handleSidebarButtonClick(event);
};
document.addEventListener('click', window.__modelLinkerSidebarToggleHandler, true);
}
handleSidebarButtonClick(event) {
const target = event.target instanceof Element ? event.target : null;
const button = target?.closest(`.${this.sidebarTabId}-tab-button`);
if (!(button instanceof HTMLElement)) return;
if (!this.dialog?.isVisible()) return;
const wasDocked = this.dialog.docked;
this.dialog.close({ collapseSidebar: false });
if (!wasDocked) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation?.();
requestAnimationFrame(() => {
if (this.isSidebarButtonActive(button)) {
button.click();
}
});
}
}
isSidebarButtonActive(button) {
return button.matches([
'[aria-pressed="true"]',
'[aria-selected="true"]',
'[data-active="true"]',
'[data-selected="true"]',
'.active',
'.is-active',
'.selected',
'.p-highlight'
].join(','));
}
renderSidebarPanel(element) {
element.style.height = "100%";
element.classList.add("ml-sidebar-dock-panel");
if (!this.dialog) {
this.dialog = new LinkerManagerDialog();
window.modelLinkerDialog = this.dialog;
}
if (this.dialog.shouldOpenFromSidebarFloating()) {
if (this.dialog.isVisible() && !this.dialog.docked) {
this.dialog.close();
this.dialog.closeComfySidebar(element);
return;
}
this.openLinkerManager({
forceFloating: true,
closeSidebarContainer: element,
});
return;
}
element.replaceChildren();
this.openLinkerManager({ dockContainer: element });
}
async registerTopbarButton() {
// 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 modelLinkerButton = new ComfyButton({
icon: "link-variant",
action: () => this.openLinkerManager(),
content: "Model Linker",
classList: "comfyui-button comfyui-menu-mobile-collapse"
}).element;
modelLinkerButton.id = this.buttonId;
this.dialog.setTooltip(modelLinkerButton, this.openTooltip);
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');
}
setupActiveWorkflowChangeListeners() {
if (window.__modelLinkerWorkflowChangeHandlers) {
for (const { target, event, handler } of window.__modelLinkerWorkflowChangeHandlers) {
target?.removeEventListener?.(event, handler);
}
}
if (!window.__modelLinkerHistoryPatched) {
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function(...args) {
const result = originalPushState.apply(this, args);
window.dispatchEvent(new Event('model-linker-locationchange'));
return result;
};
history.replaceState = function(...args) {
const result = originalReplaceState.apply(this, args);
window.dispatchEvent(new Event('model-linker-locationchange'));
return result;
};
window.__modelLinkerHistoryPatched = true;
}
window.__modelLinkerWorkflowChangeOwner = this;
const routeHandler = () => {
window.__modelLinkerWorkflowChangeOwner?.handleActiveWorkflowRouteChange('route-change');
};
const focusHandler = () => {
window.__modelLinkerWorkflowChangeOwner?.handleActiveWorkflowRouteChange('window-focus');
};
const visibilityHandler = () => {
if (document.visibilityState === 'visible') {
window.__modelLinkerWorkflowChangeOwner?.handleActiveWorkflowRouteChange('visibility-change');
}
};
window.addEventListener('hashchange', routeHandler);
window.addEventListener('popstate', routeHandler);
window.addEventListener('model-linker-locationchange', routeHandler);
window.addEventListener('focus', focusHandler);
document.addEventListener('visibilitychange', visibilityHandler);
window.__modelLinkerWorkflowChangeHandlers = [
{ target: window, event: 'hashchange', handler: routeHandler },
{ target: window, event: 'popstate', handler: routeHandler },
{ target: window, event: 'model-linker-locationchange', handler: routeHandler },
{ target: window, event: 'focus', handler: focusHandler },
{ target: document, event: 'visibilitychange', handler: visibilityHandler }
];
}
handleActiveWorkflowRouteChange(reason = 'workflow-change') {
if (!this.dialog?.isVisible()) return;
this.dialog.scheduleActiveWorkflowRefresh(reason);
}
/**
* 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();
}
document.querySelectorAll('.comfyui-button-group button.comfyui-button').forEach((button) => {
const label = button.querySelector('span')?.textContent?.trim() || button.textContent?.trim();
if (label === 'Model Linker') {
button.closest('.comfyui-button-group')?.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.");
}
async openLinkerManager(options = {}) {
try {
if (!this.dialog) {
this.dialog = new LinkerManagerDialog();
window.modelLinkerDialog = this.dialog;
}
if (options.dockContainer && !options.forceFloating) {
await this.dialog.showDocked(options.dockContainer, options.workflow || null);
return;
}
const wasDocked = this.dialog.docked;
const showPromise = this.dialog.show(options.workflow || null);
if (options.closeSidebarContainer && !wasDocked) {
this.dialog.closeComfySidebar(options.closeSidebarContainer);
}
await showPromise;
} 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.MLSetCopyMode = function(sectionId, mode) {
const section = document.getElementById(sectionId);
if (!section) return;
const normalizedMode = ['all', 'active', 'inactive'].includes(mode) ? mode : 'all';
const codeEl = section.querySelector('.ml-copy-code');
const labelEl = section.querySelector('.ml-copy-label');
const metaEl = section.querySelector('.ml-copy-meta');
const dataKey = normalizedMode === 'all' ? 'mlAll' : normalizedMode === 'active' ? 'mlActive' : 'mlInactive';
const countKey = normalizedMode === 'all' ? 'mlAllCount' : normalizedMode === 'active' ? 'mlActiveCount' : 'mlInactiveCount';
const labelMap = {
all: 'Copy all',
active: 'Copy active',
inactive: 'Copy inactive'
};
if (codeEl) codeEl.textContent = section.dataset[dataKey] || '';
if (labelEl) labelEl.textContent = labelMap[normalizedMode];
if (metaEl) {
const count = Number(section.dataset[countKey] || 0);
metaEl.textContent = `${count} token${count === 1 ? '' : 's'}`;
}
section.querySelectorAll('.ml-copy-mode').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mlCopyMode === normalizedMode);
});
};
window.MLSetCopyButtonFeedback = function(btn, text) {
if (!btn) return;
const originalHtml = btn.dataset.mlOriginalHtml || btn.innerHTML;
btn.dataset.mlOriginalHtml = originalHtml;
btn.textContent = text;
setTimeout(() => {
if (btn.isConnected) btn.innerHTML = originalHtml;
}, 1500);
};
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';
const activeSection = s.querySelector('.ml-model-group-active');
const inactiveSection = s.querySelector('.ml-model-group-inactive');
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) {
window.MLSetCopyMode(copySection.id, filter);
}
};
window.MLCopy = function(text, btn) {
navigator.clipboard.writeText(text).then(() => {
window.MLSetCopyButtonFeedback(btn, 'Copied');
});
};
window.MLCopyCode = function(sectionId, btn) {
const section = document.getElementById(sectionId);
const codeEl = section.querySelector('code');
navigator.clipboard.writeText(codeEl.textContent).then(() => {
window.MLSetCopyButtonFeedback(btn, 'Copied');
});
};
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);
}
};