/** * ComfyUI Model Linker Extension - Frontend * * Provides a menu button and dialog interface for relinking missing models in workflows. */ // Import ComfyUI APIs // These paths are relative to the ComfyUI web directory import { app } from "../../../scripts/app.js"; import { api } from "../../../scripts/api.js"; import { $el, ComfyDialog } from "../../../scripts/ui.js"; import { loadStylesWhenNeeded } from "./utils/css_loader.js"; import { getSvgIcon } from "./utils/icon_utils.js"; class LinkerManagerDialog extends ComfyDialog { constructor() { super(); this.currentWorkflow = null; this.missingModels = []; this.allModels = null; // list of all available models for dropdown this.downloadDirectories = null; this.capabilities = null; this.downloadSubfolders = new Map(); this.pendingResolutions = []; this.pendingIndex = new Map(); // key -> index in pendingResolutions this.activeDownloads = {}; // Track active downloads this.searchResultCache = new Map(); this.searchProgressTimers = new Map(); this.cachedAnalysisData = null; this.cachedWorkflowSignature = null; this.selectedMissingModelKey = null; this.boundHandleOutsideClick = this.handleOutsideClick.bind(this); this.activeTabStorageKey = 'model_linker_active_tab'; this.activeTab = this.restoreActiveTab(); // Default tab this.fullscreen = false; this._dragging = false; this._dragStart = null; this._analysisProgressToken = null; this._locateAnimationFrame = null; this._viewportClampFrame = null; this._boundHandleViewportResize = () => this.scheduleModalViewportClamp(true); // Create backdrop overlay for click-outside-to-close this.backdrop = $el("div.model-linker-backdrop", { parent: document.body }); // Create context menu for model chips this.contextMenu = $el("div.ml-context-menu", { parent: document.body }, [ $el("div.ml-context-menu-item", { onclick: () => this.handleContextMenuAction('showInfo') }, [ $el("span.ml-context-menu-item-icon", { textContent: "ℹ" }), $el("span", { textContent: "Show Info" }) ]), $el("div.ml-context-menu-divider"), $el("div.ml-context-menu-item", { onclick: () => this.handleContextMenuAction('civitai') }, [ $el("span.ml-context-menu-item-icon", { textContent: "🌐" }), $el("span", { textContent: "Open in CivitAI" }) ]), $el("div.ml-context-menu-divider"), $el("div.ml-context-menu-item", { onclick: () => this.handleContextMenuAction('openFolder') }, [ $el("span.ml-context-menu-item-icon", { textContent: "📁" }), $el("span", { textContent: "Open Containing Folder" }) ]) ]); this.tooltipElement = $el("div.ml-global-tooltip", { parent: document.body }); // Selected model for context menu this._contextMenuModel = null; // Create dialog element using $el this.element = $el("div.comfy-modal.model-linker-modal", { id: "model-linker-modal", parent: document.body }, [ this.createHeader(), this.createContent(), this.createFooter() ]); this.tooltipObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { this.bindTooltips(node); } } } }); this.tooltipObserver.observe(this.element, { childList: true, subtree: true }); this.bindTooltips(this.element); // Add click listener to hide context menu when clicking outside this.boundHandleContextMenuClick = (e) => this.handleContextMenuOutsideClick(e); document.addEventListener('click', this.boundHandleContextMenuClick); window.addEventListener('resize', this._boundHandleViewportResize); } /** * Build stable cache key for a missing model entry */ getMissingSearchKey(missing) { return `${missing.node_id}:${missing.widget_index}`; } /** * Get or initialize search state for a missing model entry */ getSearchState(missing) { const key = this.getMissingSearchKey(missing); if (!this.searchResultCache.has(key)) { this.searchResultCache.set(key, { selectedSource: 'all', results: { popular: null, model_list: null, huggingface: null, civitai: null, lora_manager_archive: null }, lastAttemptSources: [], lastAttemptFound: null, lastAttemptError: null, sourceProgress: {}, activeSearchRunId: null }); } return this.searchResultCache.get(key); } /** * Merge new search results into cached per-source results. * Empty responses do not delete previously found results. */ mergeSearchResults(existingResults = {}, newResults = {}) { return { popular: newResults.popular || existingResults.popular || null, model_list: newResults.model_list || existingResults.model_list || null, huggingface: newResults.huggingface || existingResults.huggingface || null, civitai: newResults.civitai || existingResults.civitai || null, lora_manager_archive: newResults.lora_manager_archive || existingResults.lora_manager_archive || null }; } getSearchIconHtml() { return ``; } getLocateIconHtml() { return ``; } showTooltip(target) { if (!target || !this.tooltipElement) return; this.normalizeTooltipTarget(target); const text = target.getAttribute('data-tooltip'); if (!text) return; this.tooltipElement.textContent = text; this.tooltipElement.style.display = 'block'; const rect = target.getBoundingClientRect(); const tooltipRect = this.tooltipElement.getBoundingClientRect(); const margin = 12; const maxLeft = Math.max(margin, window.innerWidth - tooltipRect.width - margin); let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); left = Math.min(Math.max(margin, left), maxLeft); let top = rect.top - tooltipRect.height - 10; if (top < margin) { top = Math.min(window.innerHeight - tooltipRect.height - margin, rect.bottom + 10); } top = Math.max(margin, top); this.tooltipElement.style.left = `${Math.round(left)}px`; this.tooltipElement.style.top = `${Math.round(top)}px`; this.tooltipElement.setAttribute('data-visible', 'true'); } hideTooltip() { if (!this.tooltipElement) return; this.tooltipElement.style.display = 'none'; this.tooltipElement.removeAttribute('data-visible'); } normalizeTooltipTarget(target) { if (!target || !target.getAttribute) return; const title = target.getAttribute('title'); if (title && !target.getAttribute('data-tooltip')) { target.setAttribute('data-tooltip', title); } if (target.hasAttribute('title')) { target.removeAttribute('title'); } if (target.classList?.contains('ml-tooltip-badge') && !target.hasAttribute('tabindex')) { target.setAttribute('tabindex', '0'); } } setTooltip(target, text) { if (!target || !text) return; target.setAttribute('data-tooltip', text); if (target.hasAttribute('title')) { target.removeAttribute('title'); } this.bindTooltips(target); } bindTooltips(container) { if (!container) return; const selector = '[data-tooltip], [title]'; const targets = []; if (container.matches?.(selector)) { targets.push(container); } if (container.querySelectorAll) { targets.push(...container.querySelectorAll(selector)); } targets.forEach((target) => { this.normalizeTooltipTarget(target); if (!target.dataset) return; if (target.dataset.mlTooltipBound === '1') return; target.dataset.mlTooltipBound = '1'; target.addEventListener('mouseenter', () => this.showTooltip(target)); target.addEventListener('focus', () => this.showTooltip(target)); target.addEventListener('mouseleave', () => this.hideTooltip()); target.addEventListener('blur', () => this.hideTooltip()); }); } getValidTab(tab) { return ['missing', 'loaded', 'options'].includes(tab) ? tab : 'missing'; } restoreActiveTab() { try { return this.getValidTab(localStorage.getItem(this.activeTabStorageKey)); } catch (error) { console.warn('Model Linker: Failed to restore active tab:', error); return 'missing'; } } persistActiveTab(tab) { try { localStorage.setItem(this.activeTabStorageKey, this.getValidTab(tab)); } catch (error) { console.warn('Model Linker: Failed to persist active tab:', error); } } /** * Return true when at least one downloadable source was found */ hasSearchResults(data = {}) { return !!(data.popular || data.model_list || data.huggingface || data.civitai || data.lora_manager_archive); } /** * Convert source ids to readable labels */ getSearchSourceLabel(source) { const labels = { all: 'Everything', local: 'Local Database', huggingface: 'HuggingFace', civitai: 'CivitAI', lora_manager_archive: 'LoRA Manager Archive' }; return labels[source] || source; } getSearchSourcesForSelection(selectedSource, missing = {}) { if (selectedSource !== 'all') { return this.isSourceAvailable(selectedSource) ? [selectedSource] : []; } const sources = ['local', 'huggingface', 'civitai']; if (this.isSourceAvailable('lora_manager_archive')) { sources.push('lora_manager_archive'); } return sources; } setSourceProgress(state, source, patch = {}, missing = null) { state.sourceProgress = { ...(state.sourceProgress || {}), [source]: { ...(state.sourceProgress?.[source] || {}), ...patch } }; if (missing) { this.refreshMissingSourcesSummary(missing); } } refreshMissingSourcesSummary(missing = {}) { if (!this.contentElement || !missing) return; const key = this.getMissingModelKey(missing); const rows = this.contentElement.querySelectorAll('.ml-missing-list-row'); for (const row of rows) { if (row.getAttribute('data-missing-key') !== key) continue; const sourcesEl = row.querySelector('.ml-missing-row-sources'); if (sourcesEl) { sourcesEl.innerHTML = this.renderMissingSourcesSummary(missing); } } } getSearchSourceEstimateMs(source, isUrn = false) { if (isUrn && source === 'civitai') return 5000; const estimates = { local: 1400, lora_manager_archive: 3200, huggingface: 18000, civitai: 22000 }; return estimates[source] || 8000; } getEstimatedSearchProgressPercent(elapsedMs, estimateMs) { const safeEstimate = Math.max(1000, Number(estimateMs) || 8000); const elapsed = Math.max(0, Number(elapsedMs) || 0); if (elapsed <= safeEstimate) { const normalized = Math.min(1, elapsed / safeEstimate); const eased = 1 - Math.pow(1 - normalized, 2.1); return 6 + eased * 78; } const overtime = elapsed - safeEstimate; const slowTail = 1 - Math.exp(-overtime / (safeEstimate * 1.6)); return Math.min(94, 84 + slowTail * 10); } getSearchProgressTimerKey(runId, source) { return `${runId || 'search'}:${source}`; } clearSearchProgressTimer(runId, source) { const key = this.getSearchProgressTimerKey(runId, source); const timer = this.searchProgressTimers.get(key); if (timer) { clearInterval(timer); this.searchProgressTimers.delete(key); } } clearSearchProgressTimers(runId) { if (!runId) return; const prefix = `${runId}:`; for (const [key, timer] of this.searchProgressTimers.entries()) { if (key.startsWith(prefix)) { clearInterval(timer); this.searchProgressTimers.delete(key); } } } startEstimatedSearchProgress(state, missing, container, source, runId) { this.clearSearchProgressTimer(runId, source); const tick = () => { if (state.activeSearchRunId !== runId) { this.clearSearchProgressTimer(runId, source); return; } const progress = state.sourceProgress?.[source]; if (!progress || progress.status !== 'running') { this.clearSearchProgressTimer(runId, source); return; } const elapsedMs = Date.now() - (progress.startedAt || Date.now()); const percent = this.getEstimatedSearchProgressPercent( elapsedMs, progress.estimateMs ); if (percent > (Number(progress.percent) || 0) + 0.2) { this.setSourceProgress(state, source, { percent }); this.displaySearchResults(missing, state, container); } }; tick(); const timer = setInterval(tick, 450); this.searchProgressTimers.set( this.getSearchProgressTimerKey(runId, source), timer ); } hasActiveSearchProgress(state = {}) { return Object.values(state.sourceProgress || {}).some(progress => ( progress?.status === 'pending' || progress?.status === 'running' )); } renderSearchProgress(state = {}) { const progressEntries = Object.entries(state.sourceProgress || {}); if (!progressEntries.length) return ''; const isCompact = !this.hasActiveSearchProgress(state); const statusLabels = { pending: 'Queued', running: 'Searching...', found: 'Found', none: 'No match', error: 'Error' }; if (isCompact) { let html = '
'; 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) { this.setDropdownValue(selectEl, state.selectedSource, this.getSearchSourceLabel(state.selectedSource)); } } /** * Set current search source for one card */ setSearchSource(missing, source, container) { const state = this.getSearchState(missing); state.selectedSource = source || 'all'; this.syncSearchSourceUi(missing, container); } getDropdownValue(el) { return el?.dataset?.value || el?.value || ''; } setDropdownValue(el, value, label = value) { if (!el) return; el.dataset.value = value || ''; el.value = label || value || ''; } normalizeVersionName(versionName) { return String(versionName || '').trim().replace(/^v{2,}(?=\d)/i, 'v'); } getModelVersionParts(modelName, versionName) { const name = String(modelName || '').trim(); const version = this.normalizeVersionName(versionName); if (!version || version === name) { return { name: name || version, version: '' }; } if (name && name.toLowerCase().includes(version.toLowerCase())) { return { name, version: '' }; } return { name, version }; } getVersionedModelName(modelName, versionName) { const parts = this.getModelVersionParts(modelName, versionName); if (!parts.version) return parts.name; return parts.name ? `${parts.name} ${parts.version}` : parts.version; } renderVersionedModelNameHtml(modelName, versionName) { const parts = this.getModelVersionParts(modelName, versionName); const nameHtml = parts.name ? this.escapeHtml(parts.name) : ''; const versionHtml = parts.version ? `${this.escapeHtml(parts.version)}` : ''; return [nameHtml, versionHtml].filter(Boolean).join(' '); } getSearchSourceIconName(sourceKey) { const icons = { popular: 'star', 'model-list': 'database', huggingface: 'huggingface', civitai: 'civitai', 'lora-archive': 'loraManager', lora_manager_archive: 'loraManager', 'lora-manager-archive': 'loraManager', local: 'database', 'workflow-url': 'link', workflow: 'link', online: 'globe' }; return icons[sourceKey] || 'globe'; } renderSearchSourcePill(sourceKey, sourceLabel) { const iconName = this.getSearchSourceIconName(sourceKey); const iconHtml = getSvgIcon(iconName, 'currentColor', 'ml-search-source-icon'); return ` ${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', 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 = `
`; 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 += ` `; } if (openUrl) { actions += ` `; } if (!actions) { actions = '-'; } html += ` `; } html += `
Source Model Match Size Actions
${sourcePill}
${modelHtml} ${secondary || filename ? `${secondary || filename}` : ''}
${this.escapeHtml(match.label)} ${size}
${actions}
`; 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 += `
`; html += ``; 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 `
${leftText} ${rightText}
`; } buildContextMenuModelData(model = {}, fallbackName = '') { const resolvedPath = model.path || model.resolved_path || ''; const filename = model.filename || fallbackName || resolvedPath.split(/[\/\\]/).pop() || ''; return { ...model, name: model.name || filename, original_path: model.original_path || filename, resolved_path: resolvedPath, category: model.category || '' }; } renderLocalMatchesContent(missing, missingIndex = 0) { const allMatches = missing.matches || []; const filteredMatches = allMatches.filter(m => m.confidence >= 70); const hasMatches = filteredMatches.length > 0; const perfectMatches = filteredMatches.filter(m => m.confidence === 100); const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70); let html = ''; if (hasMatches) { const matchesToShow = perfectMatches.length > 0 ? perfectMatches : otherMatches.sort((a, b) => b.confidence - a.confidence).slice(0, 5); const sortedMatches = matchesToShow.sort((a, b) => { if (a.confidence === 100 && b.confidence !== 100) return -1; if (a.confidence !== 100 && b.confidence === 100) return 1; return b.confidence - a.confidence; }); for (let matchIndex = 0; matchIndex < sortedMatches.length; matchIndex++) { const match = sortedMatches[matchIndex]; const buttonId = `resolve-${missingIndex}-${missing.node_id}-${missing.widget_index}-${matchIndex}`; const matchPath = match.model?.relative_path || match.filename || ''; const formattedPath = this.formatPath(matchPath, 45); const isBestMatch = matchIndex === 0 && match.confidence >= 95; const contextModel = this.buildContextMenuModelData(match.model || {}, match.filename || ''); const modelData = encodeURIComponent(JSON.stringify(contextModel)); html += `
`; html += this.getConfidenceBadge(match.confidence); html += `${formattedPath.display}`; html += `${match.confidence === 100 ? 'Exact' : 'Partial'}`; 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 += `
`; for (let mIdx = 0; mIdx < otherMatches.length; mIdx++) { const match = otherMatches[mIdx]; const altBtnId = `resolve-alt-${missingIndex}-${missing.node_id}-${missing.widget_index}-${mIdx}`; const contextModel = this.buildContextMenuModelData(match.model || {}, match.filename || ''); const modelData = encodeURIComponent(JSON.stringify(contextModel)); html += `
`; html += this.getConfidenceBadge(match.confidence); html += `${match.filename || match.path?.split(/[/\\]/).pop()}`; html += `Partial`; html += ``; 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); }); } } } } async refreshUrnLocalMatches(missing) { if (!missing?.civitai_info?.expected_filename || !this.contentElement) return; const bodyId = `local-matches-body-${missing.node_id}-${missing.widget_index}`; const container = this.contentElement.querySelector(`#${bodyId}`); if (!container) return; container.innerHTML = `
Searching local matches for "${missing.civitai_info.expected_filename}"...
`; try { const response = await api.fetchApi('/model_linker/local-matches', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: missing.civitai_info.expected_filename, category: missing.category || '' }) }); if (!response.ok) { throw new Error(`Local match search failed: ${response.status}`); } const data = await response.json(); missing.matches = Array.isArray(data.matches) ? data.matches : []; container.innerHTML = this.renderLocalMatchesContent(missing, missing.__displayIndex || 0); this.wireLocalMatchButtons(this.contentElement, missing, missing.__displayIndex || 0); } catch (error) { console.error('Model Linker: URN local match refresh error:', error); container.innerHTML = `
Failed to refresh local matches.
`; } } /** * 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 = `

${loraDisplayName}

Loading...
File ?
Hash (sha256) ?
CivitAI ?
Name ?
Base Model ?
Trained Words ? 0 selected
Click words to select them.
Clip Skip ?
Description ?
`; return dialog; } escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } 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 -type-${modelType.toLowerCase()}`; } // Update base model tag const baseModelTag = dialog.querySelector('.ml-info-basemodel'); if (baseModelTag) { const baseModel = data.base_model || data.baseModel || ''; baseModelTag.textContent = baseModel || ''; if (baseModel) { baseModelTag.style.display = ''; baseModelTag.className = `ml-info-tag ml-info-basemodel -basemodel-${baseModel.toLowerCase().replace(/\s+/g, '-')}`; } else { baseModelTag.style.display = 'none'; } } // Update file const fileEl = dialog.querySelector('.ml-info-file'); if (fileEl && data.filename) { fileEl.textContent = data.filename; } // Update hash const hashEl = dialog.querySelector('.ml-info-hash'); if (hashEl) { hashEl.textContent = data.sha256 || data.hash || ''; } // Update CivitAI link const civitaiLinkEl = dialog.querySelector('.ml-info-civitai-link'); if (civitaiLinkEl) { if (data.url || data.version_url) { const url = data.version_url || data.url; civitaiLinkEl.innerHTML = ` ${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 => ` `).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(` ${this.escapeHtml(img.seed)}`); if (img.steps) captionParts.push(` ${this.escapeHtml(img.steps)}`); if (img.cfg) captionParts.push(` ${this.escapeHtml(img.cfg)}`); if (img.sampler) captionParts.push(` ${this.escapeHtml(img.sampler)}`); if (img.model) captionParts.push(` ${this.escapeHtml(this.truncateText(img.model, 72))}`); if (img.positive) captionParts.push(` ${this.escapeHtml(this.truncateText(img.positive, 180))}`); if (img.negative) captionParts.push(` ${this.escapeHtml(this.truncateText(img.negative, 180))}`); return `
Example
${captionParts.join('')}
`; }; let imagesHtml = '
Example Images
'; 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 `
${leftText} ${rightText}
`; } 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; } } /** * Handle clicks outside the dialog */ handleOutsideClick(e) { // Close if click is on the backdrop (not on the dialog itself) if (e.target === this.backdrop) { this.close(); } } createHeader() { // Create tabs this.missingTab = $el("button.ml-tab.ml-tab-active", { textContent: "Missing Models", onclick: () => this.switchTab('missing') }); this.loadedTab = $el("button.ml-tab", { textContent: "Loaded Models", onclick: () => this.switchTab('loaded') }); this.optionsTab = $el("button.ml-tab", { textContent: "Options", onclick: () => this.switchTab('options') }); if (this.activeTab === 'missing') { this.updateTabButtonStates(); } const dragHandle = $el("div", { id: "model-linker-drag-handle", ondragstart: (e) => e.preventDefault() }, [ $el("span", { textContent: "⠿" }) ]); this.setTooltip(dragHandle, "Drag window"); const fullscreenButton = $el("button", { id: "model-linker-fullscreen-toggle", className: "ml-window-btn ml-window-btn--fullscreen", innerHTML: getSvgIcon('windowMaximize', 'currentColor', 'ml-window-btn-icon'), ariaLabel: "Toggle full screen", onclick: () => this.toggleFullScreen() }); this.setTooltip(fullscreenButton, "Toggle full screen"); return $el("div.ml-dialog-shell", {}, [ $el("div.ml-dialog-topbar", {}, [ $el("div.ml-dialog-brand", {}, [ dragHandle, $el("div.ml-tabs", {}, [ this.missingTab, this.loadedTab, this.optionsTab ]) ]), $el("div.ml-dialog-controls", {}, [ fullscreenButton, $el("button", { className: "ml-window-btn ml-window-btn--close", innerHTML: getSvgIcon('x', 'currentColor', 'ml-window-btn-icon'), ariaLabel: "Close Model Linker", onclick: () => this.close() }) ]) ]) ]); } // Toggle full screen mode for the dialog toggleFullScreen() { this.setFullScreen(!this.fullscreen); } setFullScreen(enable) { this.fullscreen = !!enable; const el = this.element; if (!el) return; const btn = document.getElementById('model-linker-fullscreen-toggle'); if (enable) { // Save current size try { const rect = el.getBoundingClientRect(); localStorage.setItem('model_linker_modal_size_before_fs', JSON.stringify({ w: Math.round(rect.width), h: Math.round(rect.height) })); } catch (e) {} el.style.top = '0'; el.style.left = '0'; el.style.transform = 'none'; el.style.width = '100vw'; el.style.height = '100vh'; el.style.maxWidth = '100vw'; el.style.maxHeight = '100vh'; el.style.borderRadius = '0'; el.style.resize = 'none'; if (btn) { btn.innerHTML = getSvgIcon('windowRestore', 'currentColor', 'ml-window-btn-icon'); btn.setAttribute('aria-label', 'Exit full screen'); this.setTooltip(btn, 'Exit full screen'); } try { localStorage.setItem('model_linker_modal_fullscreen', '1'); } catch (e) {} } else { // Restore centered sizing el.style.maxWidth = '100vw'; el.style.maxHeight = '100vh'; el.style.borderRadius = '8px'; el.style.resize = 'both'; // Restore saved pre-FS size if available let wh = null; try { wh = JSON.parse(localStorage.getItem('model_linker_modal_size_before_fs') || 'null'); } catch (e) {} if (wh && wh.w && wh.h) { el.style.width = `${wh.w}px`; el.style.height = `${wh.h}px`; } else { el.style.width = '1100px'; el.style.height = '700px'; } // Restore last known position if available, else center try { const pos = JSON.parse(localStorage.getItem('model_linker_modal_pos') || 'null'); if (pos && Number.isFinite(pos.top) && Number.isFinite(pos.left)) { el.style.top = `${pos.top}px`; el.style.left = `${pos.left}px`; el.style.transform = 'none'; } else { el.style.top = '50%'; el.style.left = '50%'; el.style.transform = 'translate(-50%, -50%)'; } } catch (e) { el.style.top = '50%'; el.style.left = '50%'; el.style.transform = 'translate(-50%, -50%)'; } if (btn) { btn.innerHTML = getSvgIcon('windowMaximize', 'currentColor', 'ml-window-btn-icon'); btn.setAttribute('aria-label', 'Enter full screen'); this.setTooltip(btn, 'Enter full screen'); } this.ensureModalHandleInViewport({ persist: true }); try { localStorage.setItem('model_linker_modal_fullscreen', '0'); } catch (e) {} } } getViewportClampedModalPosition(top, left) { const el = this.element; if (!el) return { top, left }; const vw = window.innerWidth || document.documentElement.clientWidth || 0; const vh = window.innerHeight || document.documentElement.clientHeight || 0; const pad = 4; const handle = document.getElementById('model-linker-drag-handle'); if (handle) { const elRect = el.getBoundingClientRect(); const handleRect = handle.getBoundingClientRect(); const handleOffsetLeft = handleRect.left - elRect.left; const handleOffsetTop = handleRect.top - elRect.top; const handleWidth = handleRect.width || handle.offsetWidth; const handleHeight = handleRect.height || handle.offsetHeight; const minLeft = pad - handleOffsetLeft; const maxLeft = vw - pad - handleOffsetLeft - handleWidth; const minTop = pad - handleOffsetTop; const maxTop = vh - pad - handleOffsetTop - handleHeight; left = Math.max(minLeft, Math.min(maxLeft, left)); top = Math.max(minTop, Math.min(maxTop, top)); } else { const w = el.offsetWidth; const h = el.offsetHeight; left = Math.max(-w + pad, Math.min(vw - pad, left)); top = Math.max(-h + pad, Math.min(vh - pad, top)); } return { top, left }; } saveModalPosition() { try { const el = this.element; if (!el) return; const rect = el.getBoundingClientRect(); localStorage.setItem('model_linker_modal_pos', JSON.stringify({ top: Math.round(rect.top), left: Math.round(rect.left) })); } catch (e) { /* ignore */ } } ensureModalHandleInViewport({ persist = false } = {}) { if (this.fullscreen) return; const el = this.element; if (!el || getComputedStyle(el).display === 'none') return; const rect = el.getBoundingClientRect(); const { top, left } = this.getViewportClampedModalPosition(rect.top, rect.left); const nextTop = Math.round(top); const nextLeft = Math.round(left); if (Math.round(rect.top) === nextTop && Math.round(rect.left) === nextLeft) return; el.style.top = `${nextTop}px`; el.style.left = `${nextLeft}px`; el.style.transform = 'none'; if (persist) this.saveModalPosition(); } scheduleModalViewportClamp(persist = false) { if (this._viewportClampFrame) { cancelAnimationFrame(this._viewportClampFrame); } this._viewportClampFrame = requestAnimationFrame(() => { this._viewportClampFrame = null; this.ensureModalHandleInViewport({ persist }); }); } // Begin window drag startDrag(e) { try { const el = this.element; if (!el) return; const rect = el.getBoundingClientRect(); // Switch to absolute top/left (no transform) before dragging el.style.top = `${rect.top}px`; el.style.left = `${rect.left}px`; el.style.transform = 'none'; this._dragging = true; this._dragStart = { x: e.clientX, y: e.clientY, top: rect.top, left: rect.left }; // Prevent text selection while dragging this._prevUserSelect = document.body.style.userSelect; document.body.style.userSelect = 'none'; // Attach listeners this._onMouseMove = (ev) => this.onDrag(ev); this._onMouseUp = () => this.endDrag(); document.addEventListener('mousemove', this._onMouseMove); document.addEventListener('mouseup', this._onMouseUp, { once: true }); } catch (err) { /* ignore */ } } onDrag(e) { if (!this._dragging || !this._dragStart) return; const el = this.element; if (!el) return; const dx = e.clientX - this._dragStart.x; const dy = e.clientY - this._dragStart.y; let top = this._dragStart.top + dy; let left = this._dragStart.left + dx; ({ top, left } = this.getViewportClampedModalPosition(top, left)); el.style.top = `${Math.round(top)}px`; el.style.left = `${Math.round(left)}px`; } endDrag() { if (!this._dragging) return; this._dragging = false; document.removeEventListener('mousemove', this._onMouseMove); // Persist position this.saveModalPosition(); // Restore selection try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) {} } /** * Simple debounce helper */ debounce(callback, wait = 250) { let t = null; return (...args) => { if (t) clearTimeout(t); t = setTimeout(() => { callback.apply(this, args); }, wait); }; } /** * Ensure all models are loaded for the dropdown. */ async ensureAllModelsLoaded() { if (this.allModels && this.allModels.length) return; try { const resp = await api.fetchApi('/model_linker/models'); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const models = await resp.json(); const list = Array.isArray(models) ? models : []; // Build labels and sort alphabetically this.allModels = list.map((m) => ({ ...m, __label: `${m.category ? m.category + ': ' : ''}${m.relative_path || m.filename || ''}` })).sort((a, b) => (a.__label || '').localeCompare(b.__label || '')); } catch (e) { console.warn('Model Linker: could not load all models', e); this.allModels = []; } } async ensureDownloadDirectoriesLoaded() { if (this.downloadDirectories) return; try { const resp = await api.fetchApi('/model_linker/directories'); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const directories = await resp.json(); this.downloadDirectories = directories && typeof directories === 'object' ? directories : {}; } catch (e) { console.warn('Model Linker: could not load download directories', e); this.downloadDirectories = {}; } } async ensureCapabilitiesLoaded() { if (this.capabilities) return; try { const resp = await api.fetchApi('/model_linker/capabilities'); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); this.capabilities = data && typeof data === 'object' ? data : { sources: {} }; } catch (e) { console.warn('Model Linker: could not load capabilities', e); this.capabilities = { sources: {} }; } } isSourceAvailable(source) { if (!source || ['all', 'local', 'huggingface', 'civitai'].includes(source)) { return true; } return Boolean(this.capabilities?.sources?.[source]); } getCategoryDisplayName(category = '') { const displayNames = { 'checkpoints': 'checkpoint', 'loras': 'lora', 'vae': 'vae', 'controlnet': 'controlnet', 'embeddings': 'embedding', 'upscale_models': 'upscale_model', 'latent_upscale_models': 'latent_upscale_model', 'diffusion_models': 'unet', 'text_encoders': 'text encoders', 'clip': 'clip', 'clip_vision': 'clip_vision', 'hypernetworks': 'hypernetwork' }; return displayNames[category] || category || 'unknown'; } getCategoryTokenName(category = '') { const tokenNames = { 'checkpoints': 'checkpoint', 'loras': 'lora', 'vae': 'vae', 'controlnet': 'controlnet', 'embeddings': 'embedding', 'upscale_models': 'upscale_model', 'latent_upscale_models': 'latent_upscale_model', 'diffusion_models': 'unet', 'text_encoders': 'text_encoders', 'clip': 'clip', 'clip_vision': 'clip_vision', 'hypernetworks': 'hypernetwork' }; return tokenNames[category] || category || 'unknown'; } getDownloadCategoryOptions(defaultCategory = 'checkpoints') { const directories = this.downloadDirectories || {}; const keys = Object.keys(directories); const preferred = defaultCategory || 'checkpoints'; const ordered = [ preferred, ...keys.filter(key => key !== preferred) ].filter((value, index, arr) => value && arr.indexOf(value) === index); return ordered.length > 0 ? ordered : [preferred]; } getSearchSourceOptions() { const sources = ['all', 'local', 'huggingface', 'civitai']; if (this.isSourceAvailable('lora_manager_archive')) { sources.push('lora_manager_archive'); } return sources.map(source => ({ value: source, label: this.getSearchSourceLabel(source) })); } getAvailableSubfolders(category = '') { return this.downloadSubfolders.get((category || '').toLowerCase()) || []; } normalizeFolderToken(value = '') { return String(value || '') .toLowerCase() .replace(/[\/\\]+/g, ' ') .replace(/[^a-z0-9]+/g, ''); } getSuggestedCivitaiSubfolder(missing, category, folders = []) { if ((category || '').toLowerCase() !== 'loras' || !folders.length) { return ''; } const civitaiData = { ...(missing?.civitai_info || {}), ...(missing?.civitai_search_result || {}), ...(missing?.download_source || {}) }; const baseModel = civitaiData.base_model || ''; const tags = Array.isArray(civitaiData.tags) ? civitaiData.tags.filter(Boolean) : []; if (!baseModel) return ''; const priorityTags = [ 'concept', 'style', 'character', 'clothing', 'pose', 'object', 'vehicle', 'artist', 'celebrity' ]; const normalizedBase = this.normalizeFolderToken(baseModel); if (!normalizedBase) return ''; const folderEntries = folders.map(folder => { const segments = String(folder || '').split(/[\/\\]/).filter(Boolean); return { value: folder, segments, normalizedSegments: segments.map(segment => this.normalizeFolderToken(segment)) }; }); const baseMatches = folderEntries.filter(entry => entry.normalizedSegments[0] === normalizedBase); if (!baseMatches.length) return ''; const exactBase = baseMatches.find(entry => entry.segments.length === 1); const orderedTags = [ ...priorityTags.filter(tag => tags.some(value => this.normalizeFolderToken(value) === this.normalizeFolderToken(tag))), ...tags ].filter((value, index, arr) => value && arr.findIndex(other => this.normalizeFolderToken(other) === this.normalizeFolderToken(value)) === index); for (const tag of orderedTags) { const normalizedTag = this.normalizeFolderToken(tag); if (!normalizedTag) continue; const match = baseMatches.find(entry => entry.normalizedSegments[1] === normalizedTag); if (match) { return match.value; } } return exactBase?.value || ''; } async applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl) { if (!categoryEl || !subfolderEl || subfolderEl.value.trim()) return; const category = this.getDropdownValue(categoryEl); await this.ensureDownloadSubfoldersLoaded(category); const folders = this.getAvailableSubfolders(category); const suggestion = this.getSuggestedCivitaiSubfolder(missing, category, folders); if (suggestion) { subfolderEl.value = suggestion; } } applySearchResultSuggestion(missing) { const categoryEl = this.contentElement?.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`); const subfolderEl = this.contentElement?.querySelector(`#download-subfolder-${missing.node_id}-${missing.widget_index}`); if (!categoryEl || !subfolderEl) return; this.applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl); } async ensureDownloadSubfoldersLoaded(category = '') { const key = (category || '').trim().toLowerCase(); if (!key) return []; if (key === 'unknown') { this.downloadSubfolders.set(key, []); return []; } if (this.downloadSubfolders.has(key)) { return this.downloadSubfolders.get(key) || []; } try { const resp = await api.fetchApi(`/model_linker/subfolders/${encodeURIComponent(key)}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const subfolders = await resp.json(); const list = Array.isArray(subfolders) ? subfolders : []; this.downloadSubfolders.set(key, list); return list; } catch (e) { console.warn(`Model Linker: could not load subfolders for ${key}`, e); this.downloadSubfolders.set(key, []); return []; } } renderDownloadTargetControls(missing, defaultCategory = 'checkpoints') { const selectId = `download-category-${missing.node_id}-${missing.widget_index}`; const subfolderId = `download-subfolder-${missing.node_id}-${missing.widget_index}`; const categoryListId = `download-category-list-${missing.node_id}-${missing.widget_index}`; const subfolderListId = `download-subfolder-list-${missing.node_id}-${missing.widget_index}`; const selectedCategory = defaultCategory || 'checkpoints'; let html = `
`; html += `
`; html += ``; html += ``; 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; return { civitai_key: localStorage.getItem('modelLinker.civitaiApiKey') || '', civitai_session_token: localStorage.getItem('modelLinker.civitaiSessionToken') || '', hf_token: localStorage.getItem('modelLinker.huggingFaceToken') || '', brave_search_api_key: localStorage.getItem('modelLinker.braveSearchApiKey') || '', civitai_use_trpc_search: localStorage.getItem('modelLinker.civitaiUseTrpcSearch') !== 'false', civitai_use_html_fallback: localStorage.getItem('modelLinker.civitaiUseHtmlFallback') !== 'false', hf_use_api_search: localStorage.getItem('modelLinker.hfUseApiSearch') !== 'false', hf_use_comfy_org_fallback: localStorage.getItem('modelLinker.hfUseComfyOrgFallback') !== 'false', hf_use_brave_fallback: localStorage.getItem('modelLinker.hfUseBraveFallback') !== 'false', civitai_candidate_limit }; } async clearSearchCaches() { this.searchResultCache.clear(); try { const response = await api.fetchApi('/model_linker/clear-search-cache', { method: 'POST' }); if (!response.ok) { throw new Error('Failed to clear backend search cache'); } } catch (error) { console.error('Model Linker: Clear search cache error:', error); } } displayOptions() { if (!this.contentElement) return; this.contentElement.style.overflowY = 'hidden'; const tokens = this.getStoredTokens(); this.contentElement.innerHTML = `

CivitAI

CivitAI Models To Inspect ?

HuggingFace

`; const civitaiInput = this.contentElement.querySelector('#ml-options-civitai'); const civitaiSessionInput = this.contentElement.querySelector('#ml-options-civitai-session'); const hfInput = this.contentElement.querySelector('#ml-options-hf'); const braveInput = this.contentElement.querySelector('#ml-options-brave'); const civitaiToggle = this.contentElement.querySelector('#ml-options-civitai-toggle'); const civitaiSessionToggle = this.contentElement.querySelector('#ml-options-civitai-session-toggle'); const hfToggle = this.contentElement.querySelector('#ml-options-hf-toggle'); const braveToggle = this.contentElement.querySelector('#ml-options-brave-toggle'); const civitaiLimitInput = this.contentElement.querySelector('#ml-options-civitai-limit'); const civitaiUseTrpcSearchInput = this.contentElement.querySelector('#ml-options-civitai-use-trpc-search'); const civitaiUseHtmlFallbackInput = this.contentElement.querySelector('#ml-options-civitai-use-html-fallback'); const hfUseApiSearchInput = this.contentElement.querySelector('#ml-options-hf-use-api-search'); const hfUseComfyOrgFallbackInput = this.contentElement.querySelector('#ml-options-hf-use-comfy-org-fallback'); const hfUseBraveFallbackInput = this.contentElement.querySelector('#ml-options-hf-use-brave-fallback'); const status = this.contentElement.querySelector('#ml-options-status'); const saveBtn = this.contentElement.querySelector('#ml-options-save'); const navButtons = Array.from(this.contentElement.querySelectorAll('.ml-options-nav-btn')); const optionSections = Array.from(this.contentElement.querySelectorAll('.ml-options-section')); const trackedInputs = [ civitaiInput, civitaiSessionInput, hfInput, braveInput, civitaiLimitInput, civitaiUseTrpcSearchInput, civitaiUseHtmlFallbackInput, hfUseApiSearchInput, hfUseComfyOrgFallbackInput, hfUseBraveFallbackInput, ].filter(Boolean); const setStatus = (text, mode = '') => { if (!status) return; status.textContent = text; status.classList.remove('is-dirty', 'is-saved'); if (mode) status.classList.add(mode); }; const setActiveNav = (targetId) => { navButtons.forEach((btn) => { btn.classList.toggle('is-active', btn.dataset.target === targetId); }); }; const setVisibleSection = (targetId) => { optionSections.forEach((section) => { section.classList.toggle('is-hidden', section.id !== targetId); }); setActiveNav(targetId); }; const getVisibilityIcon = (visible) => visible ? getSvgIcon('eye') : getSvgIcon('eyeOff'); const syncVisibilityToggle = (input, button) => { if (!input || !button) return; const visible = input.type === 'text'; button.innerHTML = getVisibilityIcon(visible); button.classList.toggle('ml-is-active-text', visible); button.classList.toggle('ml-is-muted', !visible); button.setAttribute('aria-pressed', visible ? 'true' : 'false'); this.setTooltip(button, visible ? 'Hide saved value' : 'Show saved value'); }; const bindVisibilityToggle = (input, button) => { if (!input || !button) return; syncVisibilityToggle(input, button); button.addEventListener('click', () => { input.type = input.type === 'password' ? 'text' : 'password'; syncVisibilityToggle(input, button); }); }; bindVisibilityToggle(civitaiInput, civitaiToggle); bindVisibilityToggle(civitaiSessionInput, civitaiSessionToggle); bindVisibilityToggle(hfInput, hfToggle); bindVisibilityToggle(braveInput, braveToggle); setStatus('Saved only on this machine.'); setVisibleSection('ml-options-section-civitai'); trackedInputs.forEach((input) => { const eventName = input.type === 'checkbox' ? 'change' : 'input'; input.addEventListener(eventName, () => { setStatus('You have unsaved changes.', 'is-dirty'); }); }); this.bindTooltips(this.contentElement); navButtons.forEach((btn) => { btn.addEventListener('click', () => { const targetId = btn.dataset.target; if (!targetId) return; setVisibleSection(targetId); }); }); if (saveBtn) { saveBtn.addEventListener('click', async () => { const civitaiCandidateLimitRaw = parseInt(civitaiLimitInput?.value || `${tokens.civitai_candidate_limit}`, 10); const civitaiCandidateLimit = Number.isFinite(civitaiCandidateLimitRaw) ? Math.min(20, Math.max(1, civitaiCandidateLimitRaw)) : 5; localStorage.setItem('modelLinker.civitaiApiKey', civitaiInput?.value || ''); localStorage.setItem('modelLinker.civitaiSessionToken', civitaiSessionInput?.value || ''); localStorage.setItem('modelLinker.civitaiUseTrpcSearch', civitaiUseTrpcSearchInput?.checked ? 'true' : 'false'); localStorage.setItem('modelLinker.civitaiUseHtmlFallback', civitaiUseHtmlFallbackInput?.checked ? 'true' : 'false'); localStorage.setItem('modelLinker.huggingFaceToken', hfInput?.value || ''); localStorage.setItem('modelLinker.braveSearchApiKey', braveInput?.value || ''); localStorage.setItem('modelLinker.hfUseApiSearch', hfUseApiSearchInput?.checked ? 'true' : 'false'); localStorage.setItem('modelLinker.hfUseComfyOrgFallback', hfUseComfyOrgFallbackInput?.checked ? 'true' : 'false'); localStorage.setItem('modelLinker.hfUseBraveFallback', hfUseBraveFallbackInput?.checked ? 'true' : 'false'); localStorage.setItem('modelLinker.civitaiCandidateLimit', `${civitaiCandidateLimit}`); if (civitaiLimitInput) { civitaiLimitInput.value = `${civitaiCandidateLimit}`; } await this.clearSearchCaches(); setStatus('Options saved locally.', 'is-saved'); this.showNotification('Options saved and search cache cleared', 'success'); }); } } getTabButton(tab) { return { missing: this.missingTab, loaded: this.loadedTab, options: this.optionsTab }[tab] || null; } updateTabButtonStates() { ['missing', 'loaded', 'options'].forEach((tab) => { const button = this.getTabButton(tab); if (!button) return; const isActive = tab === this.activeTab; button.classList.toggle('ml-tab-active', isActive); button.setAttribute('aria-selected', isActive ? 'true' : 'false'); button.setAttribute('aria-disabled', isActive ? 'true' : 'false'); if (isActive) { button.setAttribute('aria-current', 'page'); } else { button.removeAttribute('aria-current'); } }); } switchTab(tab) { const nextTab = this.getValidTab(tab); const nextTabButton = this.getTabButton(nextTab); if (nextTab === this.activeTab && nextTabButton?.classList.contains('ml-tab-active')) { this.hideTooltip(); return; } this.activeTab = nextTab; this.persistActiveTab(this.activeTab); this.hideTooltip(); this.animateTabContentTransition(); this.updateTabButtonStates(); if (this.activeTab === 'missing') { if (this.contentElement) { this.contentElement.style.overflowY = 'auto'; } this.downloadAllButton.style.display = 'inline-flex'; this.autoResolveButton.style.display = 'inline-flex'; this.applyPendingBtn.style.display = 'inline-flex'; // Show queue panel if (this.queueElement && !this.queueCollapsed) { this.queueElement.style.display = ''; } if (this.splitterElement) { this.splitterElement.style.display = ''; } this.loadWorkflowData(); } else if (this.activeTab === 'loaded') { if (this.contentElement) { this.contentElement.style.overflowY = 'auto'; } this.downloadAllButton.style.display = 'none'; this.autoResolveButton.style.display = 'none'; this.applyPendingBtn.style.display = 'none'; // Hide queue panel in loaded models tab if (this.queueElement) { this.queueElement.style.display = 'none'; } if (this.splitterElement) { this.splitterElement.style.display = 'none'; } this.loadLoadedModels(); } else { if (this.contentElement) { this.contentElement.style.overflowY = 'hidden'; } this.downloadAllButton.style.display = 'none'; this.autoResolveButton.style.display = 'none'; this.applyPendingBtn.style.display = 'none'; if (this.queueElement) { this.queueElement.style.display = 'none'; } if (this.splitterElement) { this.splitterElement.style.display = 'none'; } this.displayOptions(); } } async loadLoadedModels() { if (!this.contentElement) return; this.contentElement.innerHTML = '

Loading loaded models...

'; try { const workflow = this.getCurrentWorkflow(); if (!workflow) { this.contentElement.innerHTML = '

No workflow loaded. Please load a workflow first.

'; return; } const response = await api.fetchApi('/model_linker/loaded', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workflow }) }); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const data = await response.json(); this.displayLoadedModels(this.contentElement, data); } catch (error) { console.error('Model Linker: Error loading loaded models:', error); if (this.contentElement) { this.contentElement.innerHTML = `

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 self = this; let html = `

${total} Model${total > 1 ? 's' : ''} in Workflow

LoraManager / LoraLoaderV2 nodes distinguish active/inactive

`; for (const [category, modelsObj] of Object.entries(byCategory)) { const displayName = this.getCategoryDisplayName(category); const hasActive = modelsObj.active.length > 0; const hasInactive = modelsObj.inactive.length > 0; html += `
`; // Add category header html += `
${displayName.toUpperCase()}
`; if (hasActive) { const activeStr = modelsObj.active.map(m => { const fullName = m.name || m.original_path?.split(/[\/\\]/).pop() || 'Unknown'; let name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); const strength = m.strength !== null && m.strength !== undefined ? m.strength.toFixed(2) : '1.00'; return `<${this.getCategoryTokenName(category)}:${name}:${strength}>`; }).join(' '); html += `
● ACTIVE
`; 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 += `${name}${strength !== null ? `${strength}` : ''}`; } html += `
`; } if (hasInactive) { const inactiveStr = modelsObj.inactive.map(m => { const fullName = m.name || m.original_path?.split(/[\/\\]/).pop() || 'Unknown'; let name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); const strength = m.strength !== null && m.strength !== undefined ? m.strength.toFixed(2) : '1.00'; return `<${this.getCategoryTokenName(category)}:${name}:${strength}>`; }).join(' '); html += `
○ INACTIVE
`; for (const model of modelsObj.inactive) { const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown'; const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null; const modelData = encodeURIComponent(JSON.stringify(model)); html += `${name}${strength !== null ? `${strength}` : ''}`; } html += `
`; } html += `
`; } const copySectionId = 'ml-copy-' + Date.now(); html += `
Copy all:
${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() }); this.setTooltip(this.splitterElement, "Drag to resize panels"); body.appendChild(this.contentElement); body.appendChild(this.splitterElement); body.appendChild(this.queueElement); // Restore saved queue width and wire splitter try { const savedSplit = localStorage.getItem('model_linker_split_w'); if (savedSplit) { const w = parseInt(savedSplit, 10); if (!isNaN(w) && w > 0) { this.queueElement.style.width = `${w}px`; } } } catch (e) { } try { const onSplitMouseDown = (e) => this.startSplitDrag(e); this.splitterElement.addEventListener('mousedown', onSplitMouseDown); this._splitterMouseDown = onSplitMouseDown; } catch (e) { } // Toggle icon always visible try { this.queueToggleIcon = $el("button", { id: "queue-toggle-icon", onclick: () => this.toggleQueueCollapsed() }, [document.createTextNode('⮜')]); body.appendChild(this.queueToggleIcon); this.updateQueueToggleIcon(); } catch (e) { } // Restore queue collapsed state try { const col = localStorage.getItem('model_linker_queue_collapsed'); if (col === '1') this.setQueueCollapsed(true); } catch (e) { } return body; } createQueuePanel() { // Header row with title and clear button this.queueHeader = $el("div.ml-queue-header", {}, [ $el("div#queue-title.ml-queue-title", { textContent: "Queued Selections (0)" }), $el("div.ml-queue-actions", {}, [ $el("button", { id: "queue-toggle", className: "ml-btn ml-btn-secondary ml-btn-sm", textContent: "Collapse", onclick: () => this.toggleQueueCollapsed() }), $el("button", { id: "queue-clear", className: "ml-btn ml-btn-secondary ml-btn-sm", textContent: "Clear All", onclick: () => this.clearAllQueued() }) ]) ]); // Scrollable list this.queueList = $el("div#queue-list.ml-queue-list"); const panel = $el("div.ml-queue-panel", {}, [ $el("div.ml-queue-stack", {}, [this.queueHeader, this.queueList]) ]); return panel; } updateQueuePanel() { if (!this.queueList || !this.queueHeader) return; const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : []; // Update title count const title = this.queueHeader.querySelector('#queue-title'); if (title) title.textContent = `Queued Selections (${list.length})`; const toggleBtn = this.queueHeader.querySelector('#queue-toggle'); if (toggleBtn) toggleBtn.textContent = this.queueCollapsed ? 'Expand' : 'Collapse'; if (!list.length) { this.queueList.innerHTML = '
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 += `
${nodeLabel} #${r.node_id}
`; html += `
Original: ${orig}
`; html += `
Selected: ${label}
`; 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(); // Update per-item selected bar const m = { node_id: r.node_id, widget_index: r.widget_index, subgraph_id: r.subgraph_id, is_top_level: r.is_top_level }; this.updateSelectedBarForMissing?.(m); this.updateApplyPendingButton?.(); this.updateQueuePanel(); } // Clear all queued selections clearAllQueued() { this.pendingResolutions = []; this.pendingIndex = new Map(); this.updateApplyPendingButton?.(); this.updateQueuePanel(); try { document.querySelectorAll('.model-linker-selected').forEach(el => { el.style.display = 'none'; el.innerHTML = ''; }); } catch (e) { /* ignore */ } } // Update selected bar for a specific missing model slot updateSelectedBarForMissing(missing) { if (!missing) return; const nodeId = missing.node_id; const widgetIndex = missing.widget_index; const subgraphId = missing.subgraph_id || ''; const isTopLevel = missing.is_top_level !== false; const key = `${nodeId}:${widgetIndex}:${subgraphId}:${isTopLevel ? 'T' : 'F'}`; const selectedBar = document.getElementById(`selected-bar-${nodeId}-${widgetIndex}`); if (!selectedBar) return; // Find selection for this slot let selection = null; let selectionIdx = -1; if (this.pendingIndex.has(key)) { const idx = this.pendingIndex.get(key); if (idx >= 0 && idx < this.pendingResolutions.length) { selection = this.pendingResolutions[idx]; selectionIdx = idx; } } if (!selection) { selectedBar.style.display = 'none'; selectedBar.innerHTML = ''; return; } // Build selected bar content const label = selection.resolved_model?.relative_path || selection.resolved_model?.filename || selection.resolved_path || ''; const resolveBtnId = `selected-remove-${nodeId}-${widgetIndex}`; selectedBar.innerHTML = `
`; selectedBar.innerHTML += `✓ Selected:`; selectedBar.innerHTML += `${label}`; selectedBar.innerHTML += ``; 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.updateSelectedBarForMissing(m); this.updateApplyPendingButton?.(); this.updateQueuePanel(); } } // Rebuild pending index after modification rebuildPendingIndex() { this.pendingIndex = new Map(); for (let i = 0; i < this.pendingResolutions.length; i++) { const r = this.pendingResolutions[i]; const key = `${r.node_id}:${r.widget_index}:${r.subgraph_id || ''}:${r.is_top_level ? 'T' : 'F'}`; this.pendingIndex.set(key, i); } } // Collapse/expand queue panel toggleQueueCollapsed() { this.setQueueCollapsed(!this.queueCollapsed); } setQueueCollapsed(collapsed) { this.queueCollapsed = !!collapsed; if (!this.queueElement || !this.splitterElement) return; if (this.queueCollapsed) { this.queueElement.style.display = 'none'; this.splitterElement.style.display = 'none'; try { localStorage.setItem('model_linker_queue_collapsed', '1'); } catch (e) { } } else { this.queueElement.style.display = ''; this.splitterElement.style.display = ''; try { localStorage.setItem('model_linker_queue_collapsed', '0'); } catch (e) { } } this.updateQueuePanel(); this.updateQueueToggleIcon(); } updateQueueToggleIcon() { if (!this.queueToggleIcon) return; if (this.queueCollapsed) { this.queueToggleIcon.textContent = '⮞'; this.setTooltip(this.queueToggleIcon, 'Show queued selections'); } else { this.queueToggleIcon.textContent = '⮜'; this.setTooltip(this.queueToggleIcon, 'Hide queued selections'); } } // Begin split drag for resizable panels startSplitDrag(e) { try { if (!this.queueElement) return; const rect = this.queueElement.getBoundingClientRect(); const body = document.getElementById('model-linker-body'); const bodyRect = body ? body.getBoundingClientRect() : { width: window.innerWidth }; this._splitDragging = true; this._splitStart = { x: e.clientX, startWidth: rect.width, containerWidth: bodyRect.width }; this._prevUserSelect = document.body.style.userSelect; document.body.style.userSelect = 'none'; this._onSplitMove = (ev) => this.onSplitDrag(ev); this._onSplitUp = () => this.endSplitDrag(); document.addEventListener('mousemove', this._onSplitMove); document.addEventListener('mouseup', this._onSplitUp, { once: true }); } catch (err) { /* ignore */ } } onSplitDrag(e) { if (!this._splitDragging || !this._splitStart || !this.queueElement) return; const dx = e.clientX - this._splitStart.x; let newW = this._splitStart.startWidth - dx; const minW = 240; const maxW = Math.max(minW, Math.floor(this._splitStart.containerWidth - 360)); if (newW < minW) newW = minW; if (newW > maxW) newW = maxW; this.queueElement.style.width = `${Math.round(newW)}px`; } endSplitDrag() { if (!this._splitDragging) return; this._splitDragging = false; document.removeEventListener('mousemove', this._onSplitMove); try { const rect = this.queueElement.getBoundingClientRect(); localStorage.setItem('model_linker_split_w', String(Math.round(rect.width))); } catch (e) { } try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { } } /** * Queue a resolution for later batch apply */ queueResolution(missing, resolvedModel) { if (!resolvedModel) { this.showNotification('No model selected', 'error'); return; } const resolution = { node_id: missing.node_id, widget_index: missing.widget_index, resolved_path: resolvedModel.path, category: missing.category, resolved_model: resolvedModel, original_path: missing.original_path, subgraph_id: missing.subgraph_id, is_top_level: missing.is_top_level, node_type: missing.node_type, node_label: missing.subgraph_name || missing.node_type }; const key = `${resolution.node_id}:${resolution.widget_index}:${resolution.subgraph_id || ''}:${resolution.is_top_level ? 'T' : 'F'}`; if (this.pendingIndex.has(key)) { // replace existing selection for this slot const idx = this.pendingIndex.get(key); this.pendingResolutions[idx] = resolution; } else { this.pendingIndex.set(key, this.pendingResolutions.length); this.pendingResolutions.push(resolution); } // Update selected bar UI this.updateSelectedBarForMissing?.(missing); this.updateQueuePanel(); this.updateApplyPendingButton(); } /** * Apply all pending resolutions in batch */ async applyPendingResolutions() { const list = this.pendingResolutions || []; if (!list.length) { this.showNotification('No selections queued', 'error'); return; } try { const workflow = this.getCurrentWorkflow(); if (!workflow) { this.showNotification('No workflow loaded', 'error'); return; } const response = await api.fetchApi('/model_linker/resolve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workflow, resolutions: list }) }); if (!response.ok) throw new Error(`API error: ${response.status}`); const data = await response.json(); if (data.success) { await this.updateWorkflowInComfyUI(data.workflow); this.showNotification(`✓ Linked ${list.length} selection${list.length>1?'s':''}`, 'success'); // Clear queue and refresh analysis this.pendingResolutions = []; this.pendingIndex = new Map(); this.updateApplyPendingButton(); this.updateQueuePanel(); await this.loadWorkflowData(data.workflow); } else { this.showNotification('Failed to apply selections: ' + (data.error || 'Unknown error'), 'error'); } } catch (e) { console.error('Model Linker: applyPendingResolutions error', e); this.showNotification('Error applying selections: ' + e.message, 'error'); } } updateApplyPendingButton() { if (!this.applyPendingBtn) return; const count = this.pendingResolutions?.length || 0; this.applyPendingBtn.textContent = `Apply Selected (${count})`; this.applyPendingBtn.disabled = count === 0; } createFooter() { // Store reference to download all button so we can update its text this.downloadAllButton = $el("button.ml-btn.ml-btn-download.ml-footer-btn", { onclick: () => this.handleDownloadAllClick() }, [ $el("span.ml-btn-icon", { textContent: "☁" }), $el("span", { textContent: " Download All Missing" }) ]); // Auto-resolve button (secondary style) this.autoResolveButton = $el("button.ml-btn.ml-btn-secondary.ml-footer-btn", { onclick: () => this.autoResolve100Percent() }, [ $el("span.ml-btn-icon", { textContent: "🔗" }), $el("span", { textContent: " Auto-Link 100%" }) ]); // Apply pending resolutions button this.applyPendingBtn = $el("button.ml-btn.ml-btn-primary.ml-footer-btn", { id: "apply-pending-resolutions", textContent: "Apply Selected (0)", onclick: () => this.applyPendingResolutions() }); return $el("div.ml-footer", {}, [ this.autoResolveButton, this.applyPendingBtn, this.downloadAllButton ]); } animateTabContentTransition() { if (!this.contentElement?.animate) return; try { this.contentElement.animate( [ { opacity: 0.72, transform: 'translateY(7px)' }, { opacity: 1, transform: 'translateY(0)' } ], { duration: 180, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' } ); } catch (error) { console.warn('Model Linker: tab content animation failed', error); } } /** * Handle click on Download All / Cancel All button */ handleDownloadAllClick() { if (Object.keys(this.activeDownloads).length > 0) { // Cancel all active downloads this.cancelAllDownloads(); } else { // Start downloading all missing this.downloadAllMissing(); } } /** * Cancel all active downloads */ async cancelAllDownloads() { const downloadIds = Object.keys(this.activeDownloads); if (downloadIds.length === 0) return; this.showNotification(`Cancelling ${downloadIds.length} download${downloadIds.length > 1 ? 's' : ''}...`, 'info'); for (const downloadId of downloadIds) { try { await api.fetchApi(`/model_linker/cancel/${downloadId}`, { method: 'POST' }); } catch (error) { console.error('Model Linker: Error cancelling download:', error); } } } /** * Update the Download All button state based on active downloads */ updateDownloadAllButtonState() { if (!this.downloadAllButton) return; const activeCount = Object.keys(this.activeDownloads).length; if (activeCount > 0) { this.downloadAllButton.innerHTML = `✕ Cancel All (${activeCount})`; this.downloadAllButton.classList.remove('ml-btn-download'); this.downloadAllButton.classList.add('ml-btn-danger'); } else { this.downloadAllButton.innerHTML = `☁ Download All Missing`; this.downloadAllButton.classList.remove('ml-btn-danger'); this.downloadAllButton.classList.add('ml-btn-download'); } } async show(workflow = null) { this.backdrop.style.display = "none"; this.element.style.display = "flex"; this.scheduleModalViewportClamp(); // Update button state in case there are active downloads this.updateDownloadAllButtonState(); // Ensure all models are loaded for dropdown await this.ensureCapabilitiesLoaded(); await this.ensureAllModelsLoaded(); await this.ensureDownloadDirectoriesLoaded(); // Restore fullscreen state if enabled try { const fs = localStorage.getItem('model_linker_modal_fullscreen'); if (fs === '1') this.setFullScreen(true); } catch (e) { } // Attach drag handle event listener (only once) this.attachDragHandleIfNeeded(); this.activeTab = this.restoreActiveTab(); if (this.activeTab === 'missing') { await this.loadWorkflowData(workflow); } else if (this.activeTab === 'loaded') { this.switchTab('loaded'); } else { this.switchTab('options'); } } // Attach drag handle event listeners attachDragHandleIfNeeded() { if (this._dragHandleAttached) return; const handle = document.getElementById('model-linker-drag-handle'); if (!handle) return; const onMouseDown = (e) => { if (this.fullscreen) return; // no drag in fullscreen handle.style.cursor = 'grabbing'; this.startDrag(e); }; const onMouseUp = () => { handle.style.cursor = 'grab'; }; handle.addEventListener('mousedown', onMouseDown); document.addEventListener('mouseup', onMouseUp, { once: true }); this._dragHandleAttached = true; } close() { this._hidePreview?.(); this.hideTooltip(); this.backdrop.style.display = "none"; this.element.style.display = "none"; } /** * Load workflow data and display missing models */ async loadWorkflowData(workflow = null) { if (!this.contentElement) return; // Show loading state try { // Use provided workflow, or get current workflow from ComfyUI if (!workflow) { workflow = this.getCurrentWorkflow(); } if (!workflow) { this._analysisProgressToken = null; this.contentElement.innerHTML = '

No workflow loaded. Please load a workflow first.

'; return; } const workflowSignature = this.getWorkflowSignature(workflow); if ( workflowSignature && this.cachedWorkflowSignature === workflowSignature && this.cachedAnalysisData ) { this.displayMissingModels(this.contentElement, this.cachedAnalysisData); this.reconnectActiveDownloads(); return; } const analysisId = `an-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; this._analysisProgressToken = analysisId; this.contentElement.innerHTML = this.renderAnalysisProgress({ status: 'starting', message: 'Starting analysis...', current: 0, total: 0 }); // Call analyze endpoint const progressPromise = this.pollAnalysisProgress(analysisId, analysisId); const response = await api.fetchApi('/model_linker/analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workflow, analysis_id: analysisId }) }); this._analysisProgressToken = null; await progressPromise; if (!response.ok) { throw new Error(`API error: ${response.status}`); } const data = await response.json(); this.cachedWorkflowSignature = workflowSignature; this.cachedAnalysisData = data; this.searchResultCache.clear(); this.displayMissingModels(this.contentElement, data); // Reconnect any active downloads to their new progress divs this.reconnectActiveDownloads(); } catch (error) { this._analysisProgressToken = null; console.error('Model Linker: Error loading workflow data:', error); if (this.contentElement) { this.contentElement.innerHTML = `

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 dpr = window.devicePixelRatio || 1; const canvasWidth = htmlCanvas.width || htmlCanvas.clientWidth || 0; const canvasHeight = htmlCanvas.height || htmlCanvas.clientHeight || 0; if (!canvasWidth || !canvasHeight) return false; const viewportWidth = canvasWidth / dpr; const viewportHeight = canvasHeight / dpr; const nodeWidth = Math.max(rect[2] || 0, 1); const nodeHeight = Math.max(rect[3] || 0, 1); const paddingX = 140; const paddingY = 96; const fitScaleX = viewportWidth / (nodeWidth + (paddingX * 2)); const fitScaleY = viewportHeight / (nodeHeight + (paddingY * 2)); const targetScale = Math.max(0.15, Math.min(1, fitScaleX, fitScaleY)); const getCenteredOffset = (scale) => { const viewWidth = canvasWidth / (scale * dpr); const viewHeight = canvasHeight / (scale * dpr); return { x: -centerX + (viewWidth / 2), y: -centerY + (viewHeight / 2) }; }; const startOffset = { x: Number.isFinite(ds.offset?.[0]) ? ds.offset[0] : getCenteredOffset(startScale).x, y: Number.isFinite(ds.offset?.[1]) ? ds.offset[1] : getCenteredOffset(startScale).y }; const targetOffset = getCenteredOffset(targetScale); if (this._locateAnimationFrame) { cancelAnimationFrame(this._locateAnimationFrame); this._locateAnimationFrame = null; } const durationMs = 380; const startTime = performance.now(); const easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; const tick = (now) => { const progress = Math.min(1, (now - startTime) / durationMs); const eased = easeInOutCubic(progress); ds.scale = startScale + ((targetScale - startScale) * eased); ds.offset[0] = startOffset.x + ((targetOffset.x - startOffset.x) * eased); ds.offset[1] = startOffset.y + ((targetOffset.y - startOffset.y) * eased); canvas.setDirty?.(true, true); app.graph?.setDirtyCanvas?.(true, true); if (progress < 1) { this._locateAnimationFrame = requestAnimationFrame(tick); } else { this._locateAnimationFrame = null; } }; this._locateAnimationFrame = requestAnimationFrame(tick); return true; } /** * Reconnect active downloads to their new progress div elements after UI refresh */ reconnectActiveDownloads() { if (!this.contentElement) return; for (const [downloadId, info] of Object.entries(this.activeDownloads)) { const { missing } = info; if (!missing) continue; // Find the new progress div by ID const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`; const newProgressDiv = this.contentElement.querySelector(`#${progressId}`); const newDownloadBtn = this.contentElement.querySelector(`#download-${missing.node_id}-${missing.widget_index}`); if (newProgressDiv) { // Update the reference info.progressDiv = newProgressDiv; info.downloadBtn = newDownloadBtn; // Show that download is in progress newProgressDiv.classList.remove('ml-is-hidden'); newProgressDiv.classList.add('ml-is-visible'); newProgressDiv.innerHTML = this.renderProgressWithAction({ percent: 0, leftText: '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 === 'lora_manager_archive') { candidates.push(results.lora_manager_archive); } const downloadSource = missing.download_source || {}; const mappedDownloadSource = { model_list: 'local', popular: 'local', workflow: 'civitai' }[downloadSource.source] || downloadSource.source; if (mappedDownloadSource === source && downloadSource.url) { candidates.push(downloadSource); } return this.getSearchResultStatusLevel(candidates); } getSearchResultStatusLevel(resultOrResults) { const results = Array.isArray(resultOrResults) ? resultOrResults.flatMap(result => Array.isArray(result) ? result : [result]) : [resultOrResults]; let hasPartial = false; let hasUnknownFound = false; for (const result of results) { if (!result) continue; const confidence = Number(result.confidence); const hasConfidence = Number.isFinite(confidence); const matchType = String(result.match_type || '').toLowerCase(); if (hasConfidence) { if (confidence >= 100) return 'exact'; if (confidence > 0) { hasPartial = true; continue; } } if (matchType === 'exact' || result.source === 'popular') { return 'exact'; } if (matchType === 'partial' || matchType === 'fuzzy' || matchType === 'similar') { hasPartial = true; continue; } if (result.url || result.download_url) { hasUnknownFound = true; } } if (hasPartial) return 'partial'; if (hasUnknownFound) return 'found'; return ''; } renderMissingSourcesSummary(missing = {}) { const sourceItems = [ { source: 'local' }, { source: 'huggingface' }, { source: 'civitai' } ]; if (this.isSourceAvailable('lora_manager_archive')) { sourceItems.push({ source: 'lora_manager_archive' }); } return sourceItems.map(item => { const status = this.getMissingSourceStatus(missing, item.source); const statusClass = String(status || 'idle').replace(/[^a-z0-9_-]/gi, ''); const label = this.getSearchSourceLabel(item.source); const statusLabels = { pending: 'Queued', running: 'Searching', exact: 'Exact match', partial: 'Partial match', found: 'Found', none: 'No match', error: 'Error', idle: 'Not searched' }; const title = `${label}: ${statusLabels[status] || status}`; const iconName = this.getSearchSourceIconName(item.source); const iconHtml = getSvgIcon(iconName, 'currentColor', 'ml-missing-source-icon'); return `${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); const listStyle = `--ml-missing-model-col:${listLayout.modelPx}px;--ml-missing-type-col:${listLayout.typePx}px;`; let html = `
${totalMissing} missing model${totalMissing === 1 ? '' : 's'}
${this.escapeHtml(activeHint)}
${stats.exact} exact ${stats.partial} partial ${stats.none} no match
# 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 nodeDisplay = this.getMissingNodeDisplay(missing); const nodeId = missing.node_id ?? ''; const rowNodeHtml = nodeDisplay.canLocate ? `` : `${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) { const selectRow = (row) => { const key = row.dataset.missingKey; if (!key || key === this.selectedMissingModelKey) return; this.selectedMissingModelKey = key; this.displayMissingModels(container, data); }; container.querySelectorAll('.ml-missing-list-row').forEach(row => { row.addEventListener('click', (event) => { const clickedLocate = event.target instanceof Element && event.target.closest('.ml-missing-row-locate'); if (clickedLocate) return; selectRow(row); }); row.addEventListener('keydown', (event) => { if (event.target !== row || (event.key !== 'Enter' && event.key !== ' ')) return; event.preventDefault(); selectRow(row); }); }); container.querySelectorAll('.ml-missing-row-locate').forEach(button => { button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); const rawNodeId = button.dataset.nodeId; const numericNodeId = Number(rawNodeId); this.locateNodeInGraph(Number.isNaN(numericNodeId) ? rawNodeId : numericNodeId); }); }); const selectedMissing = sortedMissingModels.find(missing => this.getMissingModelKey(missing) === this.selectedMissingModelKey); if (!selectedMissing) return; const selectedIndex = sortedMissingModels.indexOf(selectedMissing); this.wireMissingModelDetail(container, selectedMissing, selectedIndex); } wireMissingModelDetail(container, missing, missingIndex) { this.wireLocalMatchButtons(container, missing, missingIndex); this.wireDownloadSearchPanel(container, missing); const locateId = `locate-${missing.node_id}-${missing.widget_index}`; const locateBtn = container.querySelector(`#${locateId}`); if (locateBtn && missing.is_top_level !== false) { locateBtn.addEventListener('click', () => { this.locateNodeInGraph(missing.node_id); }); } const comboId = `combo-${missing.node_id}-${missing.widget_index}`; const comboInput = container.querySelector(`#combo-input-${comboId}`); const comboList = container.querySelector(`#combo-list-${comboId}`); const comboRefresh = container.querySelector(`#combo-refresh-${comboId}`); if (comboList) { this.enableWheelScrollChaining(comboList); } const allModels = Array.isArray(this.allModels) ? this.allModels : []; const buildLabel = (m) => `${m.category ? m.category + ': ' : ''}${m.relative_path || m.filename || ''}`; const getFolder = (m) => m.path || m.base_directory || ''; const populateComboOptions = (filterText, highlightIdx = -1) => { if (!comboList) return; const f = (filterText || '').toLowerCase(); const filtered = f ? allModels.filter(m => buildLabel(m).toLowerCase().includes(f)) : allModels.slice(); let html = ''; for (let i = 0; i < filtered.length; i++) { const m = filtered[i]; const label = buildLabel(m); const folder = getFolder(m); const isHighlighted = i === highlightIdx; const folderDisplay = folder ? folder.replace(/\\/g, '/').replace(/:/, '') : ''; html += `
`; 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); } 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 += `
`; html += `
`; const titleMetaParts = []; let titlePrimaryHtml = `${missingFilename.display}`; let titleSecondaryHtml = ''; const modelId = missing.urn_model_id || missing.urn?.model_id; const versionId = missing.urn_version_id || missing.urn?.version_id; const modelUrl = missing.is_urn && modelId ? `https://civitai.com/models/${modelId}${versionId ? '?modelVersionId=' + versionId : ''}` : ''; const urnLoadingId = `urn-loading-${missing.node_id}-${missing.widget_index}`; if (missing.is_urn) { titleMetaParts.push(`${missingFilename.display}`); } if (missing.is_urn && !missing.civitai_info) { // URN without info - show Loading and fetch async in background titlePrimaryHtml = `Resolving CivitAI model...`; setTimeout(() => this.resolveUrnAsync(modelId, versionId, urnLoadingId, modelUrl), 10); } else if (missing.is_urn && missing.civitai_info) { // URN with resolved info - show model name/version const civitaiInfo = missing.civitai_info; const civitaiLabelHtml = this.renderVersionedModelNameHtml(civitaiInfo.model_name, civitaiInfo.version_name); if (civitaiLabelHtml) { const linkHtml = modelUrl ? `${civitaiLabelHtml}` : `${civitaiLabelHtml}`; titlePrimaryHtml = `${linkHtml}`; } if (civitaiInfo.expected_filename) { titleSecondaryHtml = `Expected file: ${civitaiInfo.expected_filename}`; } } html += `
`; html += titleMetaParts.join(''); html += `

${titlePrimaryHtml}

`; if (titleSecondaryHtml) { html += titleSecondaryHtml; } html += `
`; const locateId = `locate-${missing.node_id}-${missing.widget_index}`; const nodeChipClasses = missing.is_top_level !== false ? 'ml-node-chip is-locatable' : 'ml-node-chip'; const nodeChipTitle = missing.is_top_level !== false ? 'Center this node in the ComfyUI graph.' : ''; html += `
`; if (missing.category) { html += `${this.getCategoryDisplayName(missing.category)}`; } html += ``; if (missing.is_top_level !== false) { html += this.getLocateIconHtml(); } html += `${nodeChipText}`; html += `
`; html += `
`; html += `
`; // Selected bar - shows if this slot has a queued selection (BELOW card header) const selectedBarId = `selected-bar-${missing.node_id}-${missing.widget_index}`; html += `
`; // Two-column layout html += `
`; // LEFT COLUMN: Local Matches html += `
`; html += `
Local Matches
`; 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 += ``; html += ``; html += ``; 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 (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 (downloadSource && downloadSource.url) { html += this.renderKnownDownloadPanel(missing, downloadSource); } 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); const selectedSource = state.selectedSource || 'all'; const selectedSourceLabel = this.getSearchSourceLabel(selectedSource); const sourceIds = this.getSearchSourcesForSelection(selectedSource, missing); // For URNs, use the resolved file/model name for searching instead of the URN itself // and pass the URN type as category (CivitAI expects specific type names) if (missing.is_urn) { const urnSearchName = missing.civitai_info?.expected_filename || missing.download_source?.filename || missing.civitai_info?.model_name; if (urnSearchName) { filename = urnSearchName; } // Pass URN type directly - CivitAPI expects types like 'Upscaler', 'Checkpoint' const urnType = missing.urn_type || ''; if (urnType) { // Map URN types to CivitAI type names const typeMap = { 'checkpoint': 'Checkpoint', 'lora': 'LORA', 'vae': 'VAE', 'upscaler': 'Upscaler', 'upscale_model': 'Upscaler', 'embedding': 'TextualInversion', 'controlnet': 'Controlnet' }; const civitaiType = typeMap[urnType.toLowerCase()]; if (civitaiType) { category = civitaiType; } } } const isUrn = missing.is_urn || false; const resultsId = `search-results-${missing.node_id}-${missing.widget_index}`; const resultsDiv = this.contentElement?.querySelector(`#${resultsId}`); const searchBtn = this.contentElement?.querySelector(`#search-${missing.node_id}-${missing.widget_index}`); let searchRunId = null; try { if (!sourceIds.length) { throw new Error(`${selectedSourceLabel} is not available in this install`); } this.clearSearchProgressTimers(state.activeSearchRunId); searchRunId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; state.activeSearchRunId = searchRunId; state.lastAttemptSources = sourceIds; state.lastAttemptFound = null; state.lastAttemptError = null; state.sourceProgress = {}; for (const source of sourceIds) { this.setSourceProgress(state, source, { status: 'running', percent: 6, startedAt: Date.now(), estimateMs: this.getSearchSourceEstimateMs(source, isUrn) }, missing); } if (searchBtn) { searchBtn.disabled = true; searchBtn.innerHTML = `${this.getSearchIconHtml()} Searching ${selectedSourceLabel}...`; } if (resultsDiv) { resultsDiv.classList.remove('ml-is-hidden'); resultsDiv.classList.add('ml-is-visible'); this.displaySearchResults(missing, state, resultsDiv); } for (const source of sourceIds) { this.startEstimatedSearchProgress( state, missing, resultsDiv, source, searchRunId ); } // For URNs, include model_id and version_id for direct download const tokens = this.getStoredTokens(); const baseSearchData = { filename, category, is_urn: isUrn, civitai_session_token: tokens.civitai_session_token, civitai_candidate_limit: tokens.civitai_candidate_limit, civitai_use_trpc_search: tokens.civitai_use_trpc_search, civitai_use_html_fallback: tokens.civitai_use_html_fallback, hf_token: tokens.hf_token, brave_search_api_key: tokens.brave_search_api_key, hf_use_api_search: tokens.hf_use_api_search, hf_use_comfy_org_fallback: tokens.hf_use_comfy_org_fallback, hf_use_brave_fallback: tokens.hf_use_brave_fallback }; if (isUrn && missing.urn) { baseSearchData.model_id = missing.urn.model_id; baseSearchData.version_id = missing.urn.version_id; } const attemptedSources = new Set(); let anyFound = false; let hadError = false; const searchPromises = sourceIds.map(async (source) => { const sourceIsUrn = isUrn && source !== 'lora_manager_archive'; const searchData = { ...baseSearchData, is_urn: sourceIsUrn, sources: [source] }; try { console.log('Model Linker: Search request:', JSON.stringify(searchData)); const response = await api.fetchApi('/model_linker/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(searchData) }); if (!response.ok) { throw new Error(`Search failed: ${response.status}`); } const data = await response.json(); console.log('Model Linker: Search response:', JSON.stringify(data)); if (state.activeSearchRunId !== searchRunId) { return { source, stale: true }; } const responseSources = Array.isArray(data.searched_sources) && data.searched_sources.length ? data.searched_sources : [source]; responseSources.forEach(responseSource => attemptedSources.add(responseSource)); const found = this.hasSearchResults(data); anyFound = anyFound || found; state.results = this.mergeSearchResults(state.results, data); state.lastAttemptSources = Array.from(attemptedSources); state.lastAttemptFound = anyFound; this.clearSearchProgressTimer(searchRunId, source); this.setSourceProgress(state, source, { status: found ? 'found' : 'none', percent: 100, message: found ? 'Found' : 'No match' }, missing); if (data.civitai) { missing.civitai_search_result = { base_model: data.civitai.base_model, tags: data.civitai.tags || [], trained_words: data.civitai.trained_words || [], filename: data.civitai.filename, name: data.civitai.name, type: data.civitai.type }; } this.displaySearchResults(missing, state, resultsDiv); this.applySearchResultSuggestion(missing); return { source, data }; } catch (error) { console.error(`Model Linker: Search error for ${source}:`, error); if (state.activeSearchRunId !== searchRunId) { return { source, stale: true, error }; } hadError = true; attemptedSources.add(source); state.lastAttemptSources = Array.from(attemptedSources); state.lastAttemptFound = anyFound; this.clearSearchProgressTimer(searchRunId, source); this.setSourceProgress(state, source, { status: 'error', percent: 100, message: error.message || 'Error' }, missing); this.displaySearchResults(missing, state, resultsDiv); return { source, error }; } }); await Promise.all(searchPromises); this.clearSearchProgressTimers(searchRunId); if (state.activeSearchRunId === searchRunId) { state.activeSearchRunId = null; state.lastAttemptSources = attemptedSources.size ? Array.from(attemptedSources) : sourceIds; state.lastAttemptFound = anyFound; state.lastAttemptError = hadError && !anyFound ? 'Search finished with errors. Check source statuses above.' : null; this.displaySearchResults(missing, state, resultsDiv); } } catch (error) { console.error('Model Linker: Search error:', error); this.clearSearchProgressTimers(searchRunId); state.lastAttemptError = error.message; if (resultsDiv) { resultsDiv.innerHTML = this.renderStatusMessage(`Search failed: ${error.message}`, 'error'); } } finally { if (!searchRunId || state.activeSearchRunId === searchRunId) { this.clearSearchProgressTimers(searchRunId); state.activeSearchRunId = null; } if (searchBtn) { searchBtn.disabled = false; searchBtn.innerHTML = `${this.getSearchIconHtml()} Search Again`; } } } /** * Resolve URN asynchronously - fetch CivitAI info and update UI */ async resolveUrnAsync(modelId, versionId, loadingElementId, modelUrl) { console.log('resolveUrnAsync called:', modelId, versionId); if (!modelId || !versionId) { console.log('resolveUrnAsync: missing modelId or versionId'); return; } try { const tokens = this.getStoredTokens(); const payload = { filename: modelId + '_' + versionId, category: '', is_urn: true, sources: ['civitai'], model_id: modelId, version_id: versionId, civitai_candidate_limit: tokens.civitai_candidate_limit }; console.log('resolveUrnAsync payload:', JSON.stringify(payload)); const response = await api.fetchApi('/model_linker/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); console.log('resolveUrnAsync response status:', response.status); if (response.ok) { const data = await response.json(); const loadingEl = document.getElementById(loadingElementId); if (loadingEl && data.civitai) { loadingEl.classList.remove('ml-is-muted', 'ml-is-error'); const civitai = data.civitai; const labelHtml = this.renderVersionedModelNameHtml(civitai.name, civitai.version_name) || this.escapeHtml(civitai.filename || 'Model'); const url = modelUrl || `https://civitai.com/models/${modelId}?modelVersionId=${versionId}`; loadingEl.innerHTML = `${labelHtml}`; } else if (loadingEl) { loadingEl.textContent = 'Not found'; loadingEl.classList.remove('ml-is-error'); loadingEl.classList.add('ml-is-muted'); } const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-'); const downloadEl = document.getElementById(downloadContainerId); if (downloadEl && data.civitai?.download_url) { const missing = this.missingModels.find(m => `urn-download-${m.node_id}-${m.widget_index}` === downloadContainerId ); if (missing) { missing.civitai_info = { model_name: data.civitai.name, version_name: data.civitai.version_name, expected_filename: data.civitai.filename, base_model: data.civitai.base_model, tags: data.civitai.tags || [] }; missing.download_source = { source: 'civitai', url: data.civitai.download_url, filename: data.civitai.filename, name: data.civitai.name, version_name: data.civitai.version_name, type: data.civitai.type, directory: missing.category || 'checkpoints', match_type: 'exact', size: data.civitai.size, model_id: data.civitai.model_id || modelId, version_id: data.civitai.version_id || versionId, model_url: data.civitai.url || `https://civitai.com/models/${modelId}?modelVersionId=${versionId}`, base_model: data.civitai.base_model, tags: data.civitai.tags || [] }; const downloadParent = downloadEl.parentElement; downloadEl.outerHTML = this.renderKnownDownloadPanel(missing, missing.download_source); this.wireDownloadSearchPanel(downloadParent || this.contentElement, missing); this.refreshUrnLocalMatches(missing); } } else if (downloadEl) { downloadEl.innerHTML = `
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 loraManagerArchiveResult = results.lora_manager_archive ? (Array.isArray(results.lora_manager_archive) ? results.lora_manager_archive[0] : results.lora_manager_archive) : null; const knownDownloadRow = this.getDownloadSourceTableRow(missing, missing.download_source); const hasResults = knownDownloadRow || popular || modelListResult || hfResult || civitaiResult || loraManagerArchiveResult; const progressHtml = this.renderSearchProgress(state); const hasActiveProgress = this.hasActiveSearchProgress(state); if (!hasResults) { if (hasActiveProgress) { container.innerHTML = progressHtml; return; } if (state?.lastAttemptError) { container.innerHTML = `${progressHtml}${this.renderStatusMessage(state.lastAttemptError, 'error')}`; return; } container.innerHTML = progressHtml || this.renderStatusMessage( 'No matches found online for this model.', 'warning' ); return; } const rows = []; const rowKeys = new Set(); const addRow = (row) => { if (!row) return; const rowKey = row.downloadUrl || row.openUrl || `${row.sourceKey}:${row.model}:${row.filename}`; if (rowKeys.has(rowKey)) return; rowKeys.add(rowKey); rows.push(row); }; let statusHtml = ''; if (!hasActiveProgress && state?.lastAttemptError) { statusHtml += this.renderStatusMessage(state.lastAttemptError, 'error'); } addRow(knownDownloadRow); if (popular) { const popularFilename = popular.filename || missing.original_path?.split('/').pop()?.split('\\').pop() || ''; addRow({ sourceKey: 'popular', sourceLabel: 'Popular', model: popular.name || popularFilename, filename: popularFilename, secondary: popular.name && popular.name !== popularFilename ? popularFilename : '', match: this.getSearchResultMatchDisplay(popular, 'Known', 'strong'), size: this.formatSearchResultSize(popular), downloadUrl: popular.url, downloadFilename: popularFilename, category: popular.directory || missing.category, openUrl: this.getModelCardUrl(popular.url) }); } if (modelListResult && modelListResult.url) { addRow({ sourceKey: 'model-list', sourceLabel: 'Local Database', model: modelListResult.name || modelListResult.filename, filename: modelListResult.filename, secondary: modelListResult.name && modelListResult.name !== modelListResult.filename ? modelListResult.filename : '', match: this.getSearchResultMatchDisplay(modelListResult), size: this.formatSearchResultSize(modelListResult), downloadUrl: modelListResult.url, downloadFilename: modelListResult.filename, category: modelListResult.directory || missing.category, openUrl: this.getModelCardUrl(modelListResult.url) }); } if (hfResult && hfResult.url) { const hfRepo = hfResult.repo_id || hfResult.repo || ''; const hfModelUrl = hfRepo ? `https://huggingface.co/${hfRepo}` : this.getModelCardUrl(hfResult.url); addRow({ sourceKey: 'huggingface', sourceLabel: 'HuggingFace', model: hfRepo || hfResult.filename, filename: hfResult.filename, secondary: hfResult.path && hfResult.path !== hfResult.filename ? hfResult.path : '', match: this.getSearchResultMatchDisplay(hfResult), size: this.formatSearchResultSize(hfResult), downloadUrl: hfResult.url, downloadFilename: hfResult.filename, category: missing.category, openUrl: hfModelUrl }); } if (loraManagerArchiveResult && loraManagerArchiveResult.url) { const archiveFilename = loraManagerArchiveResult.filename || missing.original_path?.split('/').pop()?.split('\\').pop() || ''; const archiveName = loraManagerArchiveResult.name || archiveFilename; addRow({ sourceKey: 'lora-archive', sourceLabel: 'LoRA Archive', model: archiveName, version: loraManagerArchiveResult.version_name || '', filename: archiveFilename, secondary: archiveName && archiveName !== archiveFilename ? archiveFilename : '', match: this.getSearchResultMatchDisplay(loraManagerArchiveResult), size: this.formatSearchResultSize(loraManagerArchiveResult), downloadUrl: loraManagerArchiveResult.download_url || '', downloadFilename: archiveFilename, category: missing.category, openUrl: loraManagerArchiveResult.url }); } if (civitaiResult && civitaiResult.download_url) { const modelUrl = civitaiResult.url || (civitaiResult.model_id ? `https://civitai.com/models/${civitaiResult.model_id}${civitaiResult.version_id ? `?modelVersionId=${civitaiResult.version_id}` : ''}` : ''); const downloadFilename = missing.civitai_info?.expected_filename || civitaiResult.filename || civitaiResult.name; const modelName = missing.civitai_info?.model_name || civitaiResult.name || downloadFilename || 'Model'; addRow({ sourceKey: 'civitai', sourceLabel: 'CivitAI', model: modelName, version: missing.civitai_info?.version_name || civitaiResult.version_name || '', filename: downloadFilename, secondary: civitaiResult.type || civitaiResult.base_model || '', match: this.getSearchResultMatchDisplay(civitaiResult), size: this.formatSearchResultSize(civitaiResult), downloadUrl: civitaiResult.download_url, downloadFilename, category: missing.category, openUrl: modelUrl }); } const html = `${progressHtml}${statusHtml}${this.renderSearchResultsTable(rows)}`; container.innerHTML = html; this.wireSearchDownloadButtons(container, missing); } /** * Download from search results */ async downloadFromSearch(missing, url, filename, category, btn) { const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`; const progressDiv = this.contentElement?.querySelector(`#${progressId}`); const tokens = this.getStoredTokens(); const targetSelection = this.getDownloadTargetSelection(missing, category || missing.category || 'checkpoints'); try { btn.disabled = true; btn.classList.remove('ml-is-success-action', 'ml-btn-primary'); btn.textContent = 'Starting...'; if (progressDiv) { progressDiv.classList.remove('ml-is-hidden'); progressDiv.classList.add('ml-is-visible'); // Show progress bar with cancel button immediately progressDiv.innerHTML = this.renderProgressWithAction({ percent: 0, leftText: '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 card URL from a download URL * HuggingFace: https://huggingface.co/Owner/Repo/resolve/main/file.safetensors -> https://huggingface.co/Owner/Repo * CivitAI: https://civitai.com/api/download/models/123?type=Model -> https://civitai.com/models/123 */ getModelCardUrl(downloadUrl) { if (!downloadUrl) return null; try { // HuggingFace URLs if (downloadUrl.includes('huggingface.co')) { // Extract owner/repo from URL const match = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)/); if (match) { return `https://huggingface.co/${match[1]}`; } } // CivitAI URLs if (downloadUrl.includes('civitai.com')) { // Format: /api/download/models/123456 or /models/123456/... const modelIdMatch = downloadUrl.match(/models\/(\d+)/); if (modelIdMatch) { return `https://civitai.com/models/${modelIdMatch[1]}`; } } } catch (e) { console.error('Error parsing model card URL:', e); } return null; } /** * Update workflow in ComfyUI's UI/memory * Updates the current workflow in place instead of creating a new tab */ async updateWorkflowInComfyUI(workflow) { if (!app || !app.graph) { console.warn('Model Linker: Could not update workflow - app or app.graph not available'); return; } try { // Method 1: Try to directly update the current graph using configure // This is the most direct way to update in place if (app.graph && typeof app.graph.configure === 'function') { app.graph.configure(workflow); return; } // Method 2: Try deserialize to update the graph in place if (app.graph && typeof app.graph.deserialize === 'function') { app.graph.deserialize(workflow); return; } // Method 3: Use loadGraphData with explicit parameters to update current tab // The key is to NOT create a new workflow - pass null or undefined for the workflow parameter // clean=false means don't clear the graph first // restore_view=false means don't restore the viewport // workflow=null means update current workflow instead of creating new one if (app.loadGraphData) { // Try with null as 4th parameter first await app.loadGraphData(workflow, false, false, null); return; } console.warn('Model Linker: No method available to update workflow'); } catch (error) { console.error('Model Linker: Error updating workflow in ComfyUI:', error); // Don't throw - allow the workflow update to continue even if UI update fails // The backend has already updated the workflow data } } } // Main extension class class ModelLinker { constructor() { this.linkerButton = null; this.buttonGroup = null; this.buttonId = "model-linker-button"; this.dialog = null; this.isCheckingMissing = false; // Prevent multiple simultaneous checks this.lastCheckedWorkflow = null; // Track to avoid duplicate checks } setup = async () => { loadStylesWhenNeeded(); // Remove any existing button this.removeExistingButton(); // Create dialog instance if (!this.dialog) { this.dialog = new LinkerManagerDialog(); window.modelLinkerDialog = this.dialog; } // Register keyboard shortcut (Ctrl+Shift+L) document.addEventListener('keydown', (e) => { if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'l') { e.preventDefault(); this.openLinkerManager(); } }); // Listen for workflow load events to auto-check for missing models this.setupAutoOpenOnMissingModels(); // Try to use new ComfyUI button system (like ComfyUI Manager does) try { // Dynamic imports for ComfyUI's button components const { ComfyButtonGroup } = await import("../../../scripts/ui/components/buttonGroup.js"); const { ComfyButton } = await import("../../../scripts/ui/components/button.js"); // Create button group with Model Linker button const modelLinkerTooltip = "Open Model Linker to find or download missing workflow models. Shortcut: Ctrl+Shift+L."; const modelLinkerButton = new ComfyButton({ icon: "link-variant", action: () => this.openLinkerManager(), content: "Model Linker", classList: "comfyui-button comfyui-menu-mobile-collapse" }).element; this.dialog.setTooltip(modelLinkerButton, modelLinkerTooltip); this.buttonGroup = new ComfyButtonGroup( modelLinkerButton ); // Insert before settings group in the menu app.menu?.settingsGroup.element.before(this.buttonGroup.element); } catch (e) { // Fallback for older ComfyUI versions without the new button system console.log('Model Linker: New button system not available, using floating button fallback.'); this.createFloatingButton(); } } /** * Setup auto-open functionality when workflow is loaded with missing models */ setupAutoOpenOnMissingModels() { // Watch for ComfyUI's Missing Models popup and inject our button this.setupMissingModelsPopupObserver(); console.log('Model Linker: Missing models popup button injection enabled'); } /** * Setup MutationObserver to detect ComfyUI's Missing Models popup and inject our button */ setupMissingModelsPopupObserver() { const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { this.checkAndInjectButton(node); } } } }); // Observe the entire document for added nodes observer.observe(document.body, { childList: true, subtree: true }); } /** * Check if a node is the Missing Models popup and inject our buttons */ checkAndInjectButton(node) { // Look for the Missing Models popup by finding elements with "Missing Models" text const findMissingModelsDialog = (element) => { // Check if this element or its children contain "Missing Models" heading const headings = element.querySelectorAll ? element.querySelectorAll('h2, h3, [class*="title"], [class*="header"]') : []; for (const heading of headings) { if (heading.textContent?.includes('Missing Models')) { return element; } } // Also check text content directly if (element.textContent?.includes('Missing Models') && element.textContent?.includes('following models were not found')) { return element; } return null; }; const dialog = findMissingModelsDialog(node); if (!dialog) return; // Check if we already injected buttons if (dialog.querySelector('#model-linker-btn-container')) return; // Find a suitable place to inject the button const injectButtons = () => { // Auto-resolve button (green) const autoResolveBtn = document.createElement('button'); autoResolveBtn.id = 'model-linker-btn-container'; // Use this ID to prevent duplicate injection autoResolveBtn.className = 'ml-popup-auto-resolve-btn'; autoResolveBtn.textContent = '🔗 Auto-resolve 100%'; this.dialog?.setTooltip(autoResolveBtn, 'Link every missing model that already has an exact local match, then open Model Linker for the rest.'); autoResolveBtn.addEventListener('click', async () => { await this.handleAutoResolveInPopup(dialog, autoResolveBtn); }); // Find the "Don't show this again" checkbox row and add button next to it const checkbox = dialog.querySelector('input[type="checkbox"]'); if (checkbox) { const checkboxRow = checkbox.closest('label') || checkbox.parentElement; if (checkboxRow && checkboxRow.parentElement) { // Make the parent a flex container to align checkbox and button checkboxRow.parentElement.classList.add('ml-popup-inline-actions'); // Insert button at the beginning (left side) checkboxRow.parentElement.insertBefore(autoResolveBtn, checkboxRow); return; } } // Fallback: Find the list of models and insert before it const modelList = dialog.querySelector('[style*="overflow"]') || dialog.querySelector('[class*="list"]') || dialog.querySelector('[class*="content"]'); if (modelList) { // Create a wrapper and insert before the model list const wrapper = document.createElement('div'); wrapper.className = 'ml-popup-actions-wrap'; wrapper.appendChild(autoResolveBtn); modelList.parentElement?.insertBefore(wrapper, modelList); } else { // Find after the description text const allElements = dialog.querySelectorAll('*'); for (const el of allElements) { if (el.textContent?.includes('following models were not found') && el.children.length === 0) { el.parentElement?.insertBefore(btnContainer, el.nextSibling); break; } } } console.log('Model Linker: Injected buttons into Missing Models popup'); }; // Small delay to ensure popup is fully rendered setTimeout(injectButtons, 100); } /** * Handle auto-resolve in the popup - resolve 100% matches and open Model Linker for remaining */ async handleAutoResolveInPopup(dialog, button) { button.textContent = '⏳ Resolving...'; button.disabled = true; // Close the popup first const closeBtn = dialog.querySelector('button[class*="close"]') || dialog.querySelector('svg')?.closest('button') || Array.from(dialog.querySelectorAll('button')).find(b => b.textContent === '×' || b.innerHTML.includes('×') || b.innerHTML.includes('close')); if (closeBtn) { closeBtn.click(); } // Small delay to let popup close await new Promise(r => setTimeout(r, 200)); // Create dialog if needed if (!this.dialog) { this.dialog = new LinkerManagerDialog(); window.modelLinkerDialog = this.dialog; } // Run auto-resolve for 100% matches - returns the updated workflow const updatedWorkflow = await this.dialog.autoResolve100Percent(); // Always open Model Linker to show remaining unresolved models // Pass the updated workflow if available to avoid race condition this.dialog.show(updatedWorkflow || null); } /** * Mark resolved model items in the popup as linked (green) and hide download buttons */ removeResolvedFromPopup(dialog, resolvedFilenames) { console.log('Model Linker: Looking for resolved filenames:', resolvedFilenames); // Strategy: For each filename, find text nodes containing it, // then find the nearest Download button and mark that row for (const filename of resolvedFilenames) { // Get all text in the dialog and find elements containing our filename const walker = document.createTreeWalker( dialog, NodeFilter.SHOW_TEXT, null, false ); let node; while (node = walker.nextNode()) { if (node.textContent?.toLowerCase().includes(filename)) { // Found text containing filename - now find parent with Download button let parent = node.parentElement; let attempts = 0; while (parent && parent !== dialog && attempts < 10) { // Look for Download button at this level const downloadBtn = Array.from(parent.querySelectorAll('button')) .find(btn => btn.textContent?.includes('Download') && !btn.id?.includes('model-linker')); if (downloadBtn) { console.log('Model Linker: Found entry for', filename); this.markEntryAsResolved(parent, downloadBtn); break; } parent = parent.parentElement; attempts++; } // Only process first match for this filename break; } } } } /** * Mark a model entry as resolved with visual feedback */ markEntryAsResolved(container, downloadBtn) { // Already marked? if (container.dataset.resolved === 'true') return; container.dataset.resolved = 'true'; console.log('Model Linker: Marking entry as resolved', container); // Add green background/styling to the container container.classList.add('ml-resolved-entry'); // Hide the Download button and replace with badge if (downloadBtn) { // Create badge const badge = document.createElement('span'); badge.className = 'ml-resolved-badge'; badge.textContent = '✓ Linked'; // Replace download button with badge downloadBtn.style.display = 'none'; downloadBtn.parentElement?.insertBefore(badge, downloadBtn); } // Find and hide Copy URL button const allBtns = container.querySelectorAll('button'); for (const btn of allBtns) { if (btn.textContent?.includes('Copy URL')) { btn.style.display = 'none'; } } } /** * Count remaining model items in the popup */ countRemainingItems(dialog) { // Count elements that look like model entries (have Download buttons) const downloadButtons = dialog.querySelectorAll('button'); let count = 0; for (const btn of downloadButtons) { if (btn.textContent?.includes('Download') && !btn.id?.includes('model-linker')) { count++; } } return count; } /** * Update nodes directly in the graph without triggering a full workflow reload * This prevents the Missing Models popup from closing */ updateNodesDirectly(resolutions) { if (!app?.graph) { console.warn('Model Linker: Cannot update nodes - graph not available'); return; } for (const resolution of resolutions) { const nodeId = resolution.node_id; const widgetIndex = resolution.widget_index; const resolvedPath = resolution.resolved_path; // Find the node in the graph const node = app.graph.getNodeById(nodeId); if (!node) { console.warn(`Model Linker: Node ${nodeId} not found in graph`); continue; } // Update the widget value if (node.widgets && node.widgets[widgetIndex]) { const widget = node.widgets[widgetIndex]; widget.value = resolvedPath; // Trigger widget callback if it exists if (widget.callback) { widget.callback(resolvedPath, app.graph, node, null, null); } console.log(`Model Linker: Updated node ${nodeId} widget ${widgetIndex} to ${resolvedPath}`); } else if (node.widgets_values) { // Fallback: update widgets_values array directly node.widgets_values[widgetIndex] = resolvedPath; console.log(`Model Linker: Updated node ${nodeId} widgets_values[${widgetIndex}] to ${resolvedPath}`); } // Mark node as dirty to trigger redraw if (node.setDirtyCanvas) { node.setDirtyCanvas(true, true); } } // Trigger canvas redraw if (app.graph.setDirtyCanvas) { app.graph.setDirtyCanvas(true, true); } } /** * Check if auto-open is enabled in user settings */ isAutoOpenEnabled() { return localStorage.getItem('modelLinker.autoOpenOnMissing') !== 'false'; } /** * Set auto-open preference */ setAutoOpenEnabled(enabled) { localStorage.setItem('modelLinker.autoOpenOnMissing', enabled ? 'true' : 'false'); } /** * Check for missing models and auto-open dialog if any are found */ async checkAndOpenForMissingModels() { // Check if auto-open is enabled if (!this.isAutoOpenEnabled()) { return; } // Prevent multiple simultaneous checks if (this.isCheckingMissing) { return; } this.isCheckingMissing = true; try { // Small delay to let workflow fully load await new Promise(r => setTimeout(r, 500)); // Get current workflow const workflow = app?.graph?.serialize(); if (!workflow) { return; } // Create a simple hash to detect if workflow changed const workflowHash = JSON.stringify(workflow.nodes?.map(n => n.type + ':' + JSON.stringify(n.widgets_values || []))); // Skip if we already checked this exact workflow if (this.lastCheckedWorkflow === workflowHash) { return; } this.lastCheckedWorkflow = workflowHash; // Call analyze endpoint to check for missing models const response = await api.fetchApi('/model_linker/analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workflow }) }); if (!response.ok) { console.warn('Model Linker: Failed to analyze workflow for missing models'); return; } const data = await response.json(); // Auto-open dialog if there are missing models if (data.total_missing > 0) { console.log(`Model Linker: Found ${data.total_missing} missing model(s), opening dialog...`); this.openLinkerManager(); } } catch (error) { console.error('Model Linker: Error checking for missing models:', error); } finally { this.isCheckingMissing = false; } } removeExistingButton() { // Remove any existing button by ID const existingButton = document.getElementById(this.buttonId); if (existingButton) { existingButton.remove(); } // Remove button group if it exists if (this.buttonGroup?.element?.parentNode) { this.buttonGroup.element.remove(); this.buttonGroup = null; } // Also remove the stored reference if it exists if (this.linkerButton && this.linkerButton.parentNode) { this.linkerButton.remove(); this.linkerButton = null; } } createFloatingButton() { // Create a floating button as fallback for legacy ComfyUI versions this.linkerButton = $el("button", { id: this.buttonId, textContent: "🔗 Model Linker", onclick: () => { this.openLinkerManager(); }, className: "model-linker-floating-button" }); document.body.appendChild(this.linkerButton); this.dialog?.setTooltip(this.linkerButton, "Open Model Linker to find or download missing workflow models. Shortcut: Ctrl+Shift+L."); } openLinkerManager() { try { if (!this.dialog) { this.dialog = new LinkerManagerDialog(); window.modelLinkerDialog = this.dialog; } this.dialog.show(); } catch (error) { console.error("🔗 Model Linker: Error creating/showing dialog:", error); alert("Error opening Model Linker: " + error.message); } } static switchFilter(filter) { document.querySelectorAll('.ml-btn-filter').forEach(b => b.classList.remove('active')); document.getElementById('filter-' + filter).classList.add('active'); document.querySelectorAll('.ml-model-section').forEach(s => { const hasActive = s.dataset.mlActive === 'true'; const hasInactive = s.dataset.mlInactive === 'true'; if (filter === 'all') { s.style.display = 'block'; } else if (filter === 'active') { s.style.display = hasActive ? 'block' : 'none'; } else if (filter === 'inactive') { s.style.display = hasInactive ? 'block' : 'none'; } }); const copySection = document.querySelector('[id^="ml-copy-"]'); if (copySection) { const codeEl = copySection.querySelector('code'); const labelEl = copySection.querySelector('div'); if (filter === 'all') { codeEl.textContent = copySection.dataset.mlAll; labelEl.textContent = 'Copy all:'; } else if (filter === 'active') { codeEl.textContent = copySection.dataset.mlActive; labelEl.textContent = 'Copy active:'; } else if (filter === 'inactive') { codeEl.textContent = copySection.dataset.mlInactive; labelEl.textContent = 'Copy inactive:'; } } } static copyToClipboard(text, btn) { navigator.clipboard.writeText(text).then(() => { const orig = btn.textContent; btn.textContent = '✓'; setTimeout(() => btn.textContent = orig, 1500); }); } static copyFromCode(sectionId, btn) { const section = document.getElementById(sectionId); const codeEl = section.querySelector('code'); navigator.clipboard.writeText(codeEl.textContent).then(() => { const orig = btn.textContent; btn.textContent = '✓ Copied!'; setTimeout(() => btn.textContent = orig, 1500); }); } } const modelLinker = new ModelLinker(); // Register the extension app.registerExtension({ name: "Model Linker", setup: modelLinker.setup }); // Global helper functions for inline onclick handlers window.MLToggleHidden = function(id, trigger, collapsedText, expandedText) { const element = document.getElementById(id); if (!element) return; const isHidden = element.classList.toggle('ml-hidden'); if (trigger) { trigger.setAttribute('aria-expanded', String(!isHidden)); const label = trigger.querySelector?.('.ml-local-alternatives-label'); const state = trigger.querySelector?.('.ml-local-alternatives-state'); if (label || state) { if (label) label.textContent = isHidden ? collapsedText : expandedText; if (state) state.textContent = isHidden ? 'Show' : 'Hide'; } else { trigger.textContent = isHidden ? collapsedText : expandedText; } } }; window.MLFilterSwitch = function(filter) { const filterBtn = document.getElementById('filter-' + filter); if (!filterBtn) return; // Update button states document.querySelectorAll('.ml-btn-filter').forEach(b => b.classList.remove('active')); filterBtn.classList.add('active'); // Filter model sections document.querySelectorAll('.ml-model-section').forEach(s => { const hasActive = s.getAttribute('data-ml-active') === 'true'; const hasInactive = s.getAttribute('data-ml-inactive') === 'true'; // Get child divs: category header, active section, inactive section const childDivs = Array.from(s.children).filter(c => c.tagName === 'DIV'); const activeSection = childDivs[1]; const inactiveSection = childDivs[2]; if (filter === 'all') { s.style.display = 'block'; if (activeSection) activeSection.style.display = 'block'; if (inactiveSection) inactiveSection.style.display = 'block'; } else if (filter === 'active') { s.style.display = hasActive ? 'block' : 'none'; if (activeSection) activeSection.style.display = hasActive ? 'block' : 'none'; if (inactiveSection) inactiveSection.style.display = 'none'; } else if (filter === 'inactive') { s.style.display = hasInactive ? 'block' : 'none'; if (activeSection) activeSection.style.display = 'none'; if (inactiveSection) inactiveSection.style.display = hasInactive ? 'block' : 'none'; } }); const copySection = document.querySelector('[id^="ml-copy-"]'); if (copySection) { const codeEl = copySection.querySelector('code'); const labelEl = copySection.querySelector('div'); if (filter === 'all') { codeEl.textContent = copySection.dataset.mlAll; labelEl.textContent = 'Copy all:'; } else if (filter === 'active') { codeEl.textContent = copySection.dataset.mlActive; labelEl.textContent = 'Copy active:'; } else if (filter === 'inactive') { codeEl.textContent = copySection.dataset.mlInactive; labelEl.textContent = 'Copy inactive:'; } } }; window.MLCopy = function(text, btn) { navigator.clipboard.writeText(text).then(() => { const orig = btn.textContent; btn.textContent = '✓'; setTimeout(() => btn.textContent = orig, 1500); }); }; window.MLCopyCode = function(sectionId, btn) { const section = document.getElementById(sectionId); const codeEl = section.querySelector('code'); navigator.clipboard.writeText(codeEl.textContent).then(() => { const orig = btn.textContent; btn.textContent = '✓ Copied!'; setTimeout(() => btn.textContent = orig, 1500); }); }; window.MLOpenContextMenu = function(event, element) { event.preventDefault(); event.stopPropagation(); try { const modelData = element.getAttribute('data-model'); if (!modelData) return; const model = JSON.parse(decodeURIComponent(modelData)); // Get dialog instance const dialog = window.modelLinkerDialog; if (dialog && dialog.showContextMenu) { dialog.showContextMenu(event.clientX, event.clientY, model); } } catch (e) { console.error('Model Linker: Error opening context menu:', e); } };