From 51ebb17f892c74e6c9932fb926a5e404dcb5b7a4 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Fri, 5 Jun 2026 04:23:41 +0200 Subject: [PATCH] refactor: modularize web/linker.js into separate ES module files --- web/linker.js | 9527 +----------------------- web/linker/dialog_shell_methods.js | 625 ++ web/linker/download_target_methods.js | 508 ++ web/linker/globals.js | 129 + web/linker/lifecycle_graph_methods.js | 453 ++ web/linker/linker_dialog.js | 163 + web/linker/missing_browser_methods.js | 948 +++ web/linker/model_info_methods.js | 823 ++ web/linker/model_linker.js | 730 ++ web/linker/options_methods.js | 752 ++ web/linker/queue_methods.js | 829 +++ web/linker/render_format_methods.js | 179 + web/linker/resolve_download_methods.js | 1450 ++++ web/linker/search_panel.js | 1195 +++ web/linker/selection_methods.js | 220 + web/linker/tabs_loaded_methods.js | 283 + web/linker/workflow_state_methods.js | 256 + web/linker/workflow_update_methods.js | 88 + 18 files changed, 9635 insertions(+), 9523 deletions(-) create mode 100644 web/linker/dialog_shell_methods.js create mode 100644 web/linker/download_target_methods.js create mode 100644 web/linker/globals.js create mode 100644 web/linker/lifecycle_graph_methods.js create mode 100644 web/linker/linker_dialog.js create mode 100644 web/linker/missing_browser_methods.js create mode 100644 web/linker/model_info_methods.js create mode 100644 web/linker/model_linker.js create mode 100644 web/linker/options_methods.js create mode 100644 web/linker/queue_methods.js create mode 100644 web/linker/render_format_methods.js create mode 100644 web/linker/resolve_download_methods.js create mode 100644 web/linker/search_panel.js create mode 100644 web/linker/selection_methods.js create mode 100644 web/linker/tabs_loaded_methods.js create mode 100644 web/linker/workflow_state_methods.js create mode 100644 web/linker/workflow_update_methods.js diff --git a/web/linker.js b/web/linker.js index 92d440c..818923d 100644 --- a/web/linker.js +++ b/web/linker.js @@ -1,9537 +1,18 @@ /** * 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"; +import { ModelLinker } from "./linker/model_linker.js"; +import { registerGlobalHelpers } from "./linker/globals.js"; -class LinkerManagerDialog extends ComfyDialog { - constructor() { - super(); - this.currentWorkflow = null; - this.missingModels = []; - this.allModels = null; // list of all available models for dropdown - this.downloadDirectories = null; - this.capabilities = null; - this.downloadSubfolders = new Map(); - this.pendingResolutions = []; - this.pendingIndex = new Map(); // key -> index in pendingResolutions - this.workflowPendingSelections = new Map(); // workflow key -> queued selections - this.workflowSearchResultCaches = new Map(); // workflow key -> search results by missing model - this.activeDownloads = {}; // Track active downloads - this.searchResultCache = new Map(); - this.searchProgressTimers = new Map(); - this.urnResolvePromises = new Map(); - this.urnLocalMatchPromises = new Map(); - this.cachedAnalysisData = null; - this.cachedWorkflowSignature = null; - this.selectedMissingModelKey = null; - this.batchSelectedMissingKeys = new Set(); - this.lastBatchSelectedMissingKey = null; - this.activeFooterMenu = null; - this.batchSearchRunning = false; - this.boundHandleOutsideClick = this.handleOutsideClick.bind(this); - this.activeTabStorageKey = 'model_linker_active_tab'; - this.activeTab = this.restoreActiveTab(); // Default tab - this.fullscreen = false; - this.returnToDockedAfterFullscreen = false; - this.docked = false; - this.dockContainer = null; - this.lastDockContainer = null; - this.pendingDockToSidebar = false; - this.sidebarTabId = "comfyui-model-linker"; - this.sidebarOpenModeStorageKey = "model_linker_sidebar_open_mode"; - this.missingBrowserSplitStorageKey = "model_linker_missing_browser_detail_w"; - this.dockButton = null; - this.undockButton = null; - this._floatingRectBeforeDock = null; - this._dragging = false; - this._dragStart = null; - this._analysisProgressToken = null; - this._locateAnimationFrame = null; - this._viewportClampFrame = null; - this.activeWorkflowRouteKey = this.getActiveWorkflowRouteKey(); - this.activeWorkflowSignature = null; - this._workflowRefreshTimer = null; - this._workflowRefreshExpectedRoute = null; - this._workflowRefreshPreviousSignature = null; - this._boundHandleViewportResize = () => this.scheduleModalViewportClamp(true); - - // Create backdrop overlay for click-outside-to-close - this.backdrop = $el("div.model-linker-backdrop", { - parent: document.body - }); - - // Create context menu for model chips - this.contextMenu = $el("div.ml-context-menu", { - parent: document.body - }, [ - $el("div.ml-context-menu-item", { - onclick: () => this.handleContextMenuAction('showInfo') - }, [ - $el("span.ml-context-menu-item-icon", { textContent: "ℹ" }), - $el("span", { textContent: "Show Info" }) - ]), - $el("div.ml-context-menu-divider"), - $el("div.ml-context-menu-item", { - onclick: () => this.handleContextMenuAction('civitai') - }, [ - $el("span.ml-context-menu-item-icon", { textContent: "🌐" }), - $el("span", { textContent: "Open in CivitAI" }) - ]), - $el("div.ml-context-menu-divider"), - $el("div.ml-context-menu-item", { - onclick: () => this.handleContextMenuAction('openFolder') - }, [ - $el("span.ml-context-menu-item-icon", { textContent: "📁" }), - $el("span", { textContent: "Open Containing Folder" }) - ]) - ]); - - this.tooltipElement = $el("div.ml-global-tooltip", { parent: document.body }); - - // Selected model for context menu - this._contextMenuModel = null; - - // Create dialog element using $el - this.element = $el("div.comfy-modal.model-linker-modal", { - id: "model-linker-modal", - parent: document.body - }, [ - this.createHeader(), - this.createContent(), - this.createFooter() - ]); - - this.tooltipObserver = new MutationObserver((mutations) => { - for (const mutation of mutations) { - for (const node of mutation.addedNodes) { - if (node.nodeType === Node.ELEMENT_NODE) { - this.bindTooltips(node); - } - } - } - }); - this.tooltipObserver.observe(this.element, { - childList: true, - subtree: true - }); - this.bindTooltips(this.element); - - // Add click listener to hide context menu when clicking outside - this.boundHandleContextMenuClick = (e) => this.handleContextMenuOutsideClick(e); - document.addEventListener('click', this.boundHandleContextMenuClick); - this.boundHandleFooterMenuClick = (e) => this.handleFooterMenuOutsideClick(e); - document.addEventListener('click', this.boundHandleFooterMenuClick); - window.addEventListener('resize', this._boundHandleViewportResize); - } - - /** - * Build stable cache key for a missing model entry - */ - getMissingSearchKey(missing) { - const nodeId = missing?.node_id ?? ''; - const widgetIndex = missing?.widget_index ?? ''; - const subgraphId = missing?.subgraph_id ?? ''; - const category = missing?.category ?? ''; - const modelPath = missing?.original_path || missing?.name || missing?.filename || missing?.urn_string || ''; - return [nodeId, widgetIndex, subgraphId, category, modelPath].map(value => String(value)).join(':'); - } - - /** - * Get or initialize search state for a missing model entry - */ - getSearchState(missing) { - const key = this.getMissingSearchKey(missing); - if (!this.searchResultCache.has(key)) { - this.searchResultCache.set(key, { - selectedSource: 'all', - results: { - popular: null, - model_list: null, - huggingface: null, - civitai: null, - civarchive: null, - lora_manager_archive: null - }, - lastAttemptSources: [], - lastAttemptFound: null, - lastAttemptError: null, - sourceProgress: {}, - activeSearchRunId: null - }); - } - return this.searchResultCache.get(key); - } - - /** - * Merge new search results into cached per-source results. - * Empty responses do not delete previously found results. - */ - mergeSearchResults(existingResults = {}, newResults = {}) { - return { - popular: newResults.popular || existingResults.popular || null, - model_list: newResults.model_list || existingResults.model_list || null, - huggingface: newResults.huggingface || existingResults.huggingface || null, - civitai: newResults.civitai || existingResults.civitai || null, - civarchive: newResults.civarchive || existingResults.civarchive || null, - lora_manager_archive: newResults.lora_manager_archive || existingResults.lora_manager_archive || null - }; - } - - getSearchIconHtml() { - return ``; - } - - 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.civarchive || data.lora_manager_archive); - } - - /** - * Convert source ids to readable labels - */ - getSearchSourceLabel(source) { - const labels = { - all: 'Everything', - local: 'Local Database', - huggingface: 'HuggingFace', - civitai: 'CivitAI', - civarchive: 'CivArchive', - lora_manager_archive: 'LoRA Manager Archive' - }; - return labels[source] || source; - } - - getSearchSourceDefinitions() { - return [ - { - source: 'local', - storageKey: 'modelLinker.searchSource.localEnabled', - tooltip: 'Searches bundled known-model data before online providers.' - }, - { - source: 'huggingface', - storageKey: 'modelLinker.searchSource.huggingFaceEnabled', - tooltip: 'Searches Hugging Face when Everything is selected.' - }, - { - source: 'civitai', - storageKey: 'modelLinker.searchSource.civitaiEnabled', - tooltip: 'Searches CivitAI when Everything is selected.' - }, - { - source: 'civarchive', - storageKey: 'modelLinker.searchSource.civArchiveEnabled', - tooltip: 'Searches CivArchive when Everything is selected.' - }, - { - source: 'lora_manager_archive', - storageKey: 'modelLinker.searchSource.loraManagerArchiveEnabled', - tooltip: 'Searches the local LoRA Manager archive when Everything is selected.' - } - ]; - } - - getSearchSourceDefinition(source) { - return this.getSearchSourceDefinitions().find(def => def.source === source) || null; - } - - isSearchSourceEnabled(source) { - if (!source || source === 'all') return true; - const definition = this.getSearchSourceDefinition(source); - if (!definition) return true; - return localStorage.getItem(definition.storageKey) !== 'false'; - } - - isSearchSourceUsable(source) { - return this.isSourceAvailable(source) && this.isSearchSourceEnabled(source); - } - - getEnabledSearchSources() { - const sources = this.getSearchSourceDefinitions() - .filter(def => this.isSearchSourceUsable(def.source)) - .map(def => def.source); - return sources.length ? sources : ['local']; - } - - getSearchSourceEnabledMap() { - return this.getSearchSourceDefinitions().reduce((enabled, def) => { - enabled[def.source] = this.isSearchSourceEnabled(def.source); - return enabled; - }, {}); - } - - getSearchSourcesForSelection(selectedSource, missing = {}) { - if (selectedSource !== 'all') { - return this.isSearchSourceUsable(selectedSource) ? [selectedSource] : []; - } - - return this.getEnabledSearchSources(); - } - - setSourceProgress(state, source, patch = {}, missing = null) { - state.sourceProgress = { - ...(state.sourceProgress || {}), - [source]: { - ...(state.sourceProgress?.[source] || {}), - ...patch - } - }; - if (missing) { - this.refreshMissingSourcesSummary(missing); - } - } - - refreshMissingSourcesSummary(missing = {}) { - if (!this.contentElement || !missing) return; - - const key = this.getMissingModelKey(missing); - const rows = this.contentElement.querySelectorAll('.ml-missing-list-row'); - for (const row of rows) { - if (row.getAttribute('data-missing-key') !== key) continue; - - const sourcesEl = row.querySelector('.ml-missing-row-sources'); - if (sourcesEl) { - sourcesEl.innerHTML = this.renderMissingSourcesSummary(missing); - } - } - } - - getSearchSourceEstimateMs(source, isUrn = false) { - if (isUrn && source === 'civitai') return 5000; - if (isUrn && source === 'civarchive') return 6000; - - const estimates = { - local: 1400, - lora_manager_archive: 3200, - civarchive: 10000, - huggingface: 18000, - civitai: 22000 - }; - return estimates[source] || 8000; - } - - getEstimatedSearchProgressPercent(elapsedMs, estimateMs) { - const safeEstimate = Math.max(1000, Number(estimateMs) || 8000); - const elapsed = Math.max(0, Number(elapsedMs) || 0); - - if (elapsed <= safeEstimate) { - const normalized = Math.min(1, elapsed / safeEstimate); - const eased = 1 - Math.pow(1 - normalized, 2.1); - return 6 + eased * 78; - } - - const overtime = elapsed - safeEstimate; - const slowTail = 1 - Math.exp(-overtime / (safeEstimate * 1.6)); - return Math.min(94, 84 + slowTail * 10); - } - - getSearchProgressTimerKey(runId, source) { - return `${runId || 'search'}:${source}`; - } - - clearSearchProgressTimer(runId, source) { - const key = this.getSearchProgressTimerKey(runId, source); - const timer = this.searchProgressTimers.get(key); - if (timer) { - clearInterval(timer); - this.searchProgressTimers.delete(key); - } - } - - clearSearchProgressTimers(runId) { - if (!runId) return; - const prefix = `${runId}:`; - for (const [key, timer] of this.searchProgressTimers.entries()) { - if (key.startsWith(prefix)) { - clearInterval(timer); - this.searchProgressTimers.delete(key); - } - } - } - - startEstimatedSearchProgress(state, missing, container, source, runId) { - this.clearSearchProgressTimer(runId, source); - - const tick = () => { - if (state.activeSearchRunId !== runId) { - this.clearSearchProgressTimer(runId, source); - return; - } - - const progress = state.sourceProgress?.[source]; - if (!progress || progress.status !== 'running') { - this.clearSearchProgressTimer(runId, source); - return; - } - - const elapsedMs = Date.now() - (progress.startedAt || Date.now()); - const percent = this.getEstimatedSearchProgressPercent( - elapsedMs, - progress.estimateMs - ); - - if (percent > (Number(progress.percent) || 0) + 0.2) { - this.setSourceProgress(state, source, { percent }); - this.displaySearchResults(missing, state, container); - } - }; - - tick(); - const timer = setInterval(tick, 450); - this.searchProgressTimers.set( - this.getSearchProgressTimerKey(runId, source), - timer - ); - } - - hasActiveSearchProgress(state = {}) { - return Object.values(state.sourceProgress || {}).some(progress => ( - progress?.status === 'pending' || progress?.status === 'running' - )); - } - - renderSearchProgress(state = {}) { - const progressEntries = Object.entries(state.sourceProgress || {}); - if (!progressEntries.length) return ''; - const isCompact = !this.hasActiveSearchProgress(state); - - const statusLabels = { - pending: 'Queued', - running: 'Searching...', - found: 'Found', - none: 'No match', - error: 'Error' - }; - - if (isCompact) { - let html = '
'; - for (const [source, progress] of progressEntries) { - const status = progress?.status || 'pending'; - const statusClass = String(status).replace(/[^a-z0-9_-]/gi, ''); - const label = this.getSearchSourceLabel(source); - const statusLabel = progress?.message || statusLabels[status] || status; - const title = `${label}: ${statusLabel}`; - html += ` -
- ${this.escapeHtml(label)} - ${this.escapeHtml(statusLabel)} -
- `; - } - html += '
'; - return html; - } - - let html = '
'; - for (const [source, progress] of progressEntries) { - const status = progress?.status || 'pending'; - const statusClass = String(status).replace(/[^a-z0-9_-]/gi, ''); - const label = this.getSearchSourceLabel(source); - const percent = status === 'pending' - ? 0 - : (status === 'running' ? progress?.percent : 100); - const safePercent = Math.max(0, Math.min(100, Number(percent) || 0)); - const statusLabel = status === 'running' - ? `Searching... ${Math.round(safePercent)}%` - : (progress?.message || statusLabels[status] || status); - html += ` -
-
- ${this.escapeHtml(label)} - ${this.escapeHtml(statusLabel)} -
-
-
-
-
- `; - } - html += '
'; - return html; - } - - /** - * Update source selector buttons and helper text for one card - */ - syncSearchSourceUi(missing, container) { - if (!container) return; - - const state = this.getSearchState(missing); - const selectEl = container.querySelector(`#search-source-select-${missing.node_id}-${missing.widget_index}`); - if (selectEl) { - const options = this.getSearchSourceOptions(); - if (!options.some(option => option.value === state.selectedSource)) { - state.selectedSource = 'all'; - } - this.setDropdownValue(selectEl, state.selectedSource, this.getSearchSourceLabel(state.selectedSource)); - } - } - - /** - * Set current search source for one card - */ - setSearchSource(missing, source, container) { - const state = this.getSearchState(missing); - state.selectedSource = source || 'all'; - this.persistSearchStateForActiveWorkflow(); - this.syncSearchSourceUi(missing, container); - } - - getDropdownValue(el) { - return el?.dataset?.value || el?.value || ''; - } - - setDropdownValue(el, value, label = value) { - if (!el) return; - el.dataset.value = value || ''; - el.value = label || value || ''; - } - - normalizeVersionName(versionName) { - return String(versionName || '').trim().replace(/^v{2,}(?=\d)/i, 'v'); - } - - getModelVersionParts(modelName, versionName) { - const name = String(modelName || '').trim(); - const version = this.normalizeVersionName(versionName); - if (!version || version === name) { - return { name: name || version, version: '' }; - } - if (name && name.toLowerCase().includes(version.toLowerCase())) { - return { name, version: '' }; - } - return { name, version }; - } - - getVersionedModelName(modelName, versionName) { - const parts = this.getModelVersionParts(modelName, versionName); - if (!parts.version) return parts.name; - return parts.name ? `${parts.name} ${parts.version}` : parts.version; - } - - renderVersionedModelNameHtml(modelName, versionName) { - const parts = this.getModelVersionParts(modelName, versionName); - const nameHtml = parts.name ? this.escapeHtml(parts.name) : ''; - const versionHtml = parts.version - ? `${this.escapeHtml(parts.version)}` - : ''; - return [nameHtml, versionHtml].filter(Boolean).join(' '); - } - - getSearchSourceIconName(sourceKey) { - const icons = { - popular: 'star', - 'model-list': 'comfyui', - huggingface: 'huggingface', - civitai: 'civitai', - civarchive: 'civarchive', - 'lora-archive': 'loraManager', - lora_manager_archive: 'loraManager', - 'lora-manager-archive': 'loraManager', - local: 'comfyui', - 'workflow-url': 'link', - workflow: 'link', - online: 'globe' - }; - return icons[sourceKey] || 'globe'; - } - - renderSearchSourcePill(sourceKey, sourceLabel) { - const iconName = this.getSearchSourceIconName(sourceKey); - const iconHtml = getSvgIcon(iconName, 'currentColor', 'ml-search-source-icon'); - return ` - - ${iconHtml} - ${this.escapeHtml(sourceLabel)} - - `; - } - - getSearchResultsTableLayout(rows = []) { - const textWidth = (value, charPx = 6, minPx = 40, maxPx = 180) => { - const length = String(value || '').length; - return Math.max(minPx, Math.min(maxPx, Math.ceil(length * charPx))); - }; - - const maxSourceLabel = rows.reduce( - (max, row) => Math.max(max, String(row.sourceLabel || row.sourceKey || '').length), - 'Source'.length - ); - const maxMatchLabel = rows.reduce( - (max, row) => Math.max(max, String(row.match?.label || '').length), - 'Match'.length - ); - const maxSizeLabel = rows.reduce( - (max, row) => Math.max(max, String(row.size || '-').length), - 'Size'.length - ); - const maxActions = rows.reduce((max, row) => { - const count = (row.downloadUrl ? 1 : 0) + (row.openUrl ? 1 : 0); - return Math.max(max, count); - }, 1); - - const sourcePx = textWidth('x'.repeat(maxSourceLabel), 6, 34, 100) + 52; - const matchPx = textWidth('x'.repeat(maxMatchLabel), 7, 34, 76) + 22; - const sizePx = textWidth('x'.repeat(maxSizeLabel), 6.5, 28, 72) + 22; - const actionsPx = Math.max(60, (maxActions * 24) + (Math.max(0, maxActions - 1) * 6) + 22); - const modelMinPx = 210; - const tableMinPx = Math.ceil(sourcePx + matchPx + sizePx + actionsPx + modelMinPx); - - return { sourcePx, matchPx, sizePx, actionsPx, tableMinPx }; - } - - formatSearchResultSize(result = {}) { - if (result.size === 0) return '0 B'; - if (!result.size) return ''; - return typeof result.size === 'number' ? this.formatBytes(result.size) : String(result.size); - } - - getSearchResultMatchDisplay(result = {}, fallbackLabel = 'Match', fallbackClass = 'neutral') { - const matchType = String(result.match_type || '').toLowerCase(); - if (matchType === 'exact') { - return { label: 'Exact', className: 'strong' }; - } - - const confidence = Number(result.confidence); - if (Number.isFinite(confidence) && confidence > 0) { - const className = confidence >= 95 ? 'strong' : (confidence >= 70 ? 'medium' : 'weak'); - return { label: `${Math.round(confidence)}%`, className }; - } - - if (matchType === 'partial') { - return { label: 'Partial', className: 'medium' }; - } - if (matchType === 'fuzzy' || matchType === 'similar') { - return { label: matchType === 'fuzzy' ? 'Fuzzy' : 'Similar', className: 'medium' }; - } - - return { label: fallbackLabel, className: fallbackClass }; - } - - getDownloadSourceTableRow(missing, downloadSource = {}) { - if (!downloadSource?.url) return null; - - const originalFilename = missing.original_path?.split('/').pop()?.split('\\').pop() || ''; - const downloadFilename = downloadSource.filename || originalFilename || 'model'; - const isFromWorkflow = downloadSource.url_source === 'workflow'; - const source = downloadSource.source || (isFromWorkflow ? 'workflow' : 'online'); - const sourceLabels = { - popular: 'Popular', - model_list: 'Local Database', - huggingface: 'HuggingFace', - civitai: 'CivitAI', - civarchive: 'CivArchive', - lora_manager_archive: 'LoRA Archive', - workflow: 'Workflow', - online: 'Online' - }; - const sourceLabel = isFromWorkflow ? 'Workflow URL' : (sourceLabels[source] || source); - const sourceKey = isFromWorkflow - ? 'workflow-url' - : String(source).replace(/_/g, '-'); - const sourceSecondary = isFromWorkflow && sourceLabels[source] && source !== 'workflow' - ? sourceLabels[source] - : ''; - const modelUrl = downloadSource.model_url - || downloadSource.workflow_model_url - || this.getModelCardUrl(downloadSource.url); - const versionName = downloadSource.version_name || missing.civitai_info?.version_name || ''; - const modelParts = this.getModelVersionParts( - downloadSource.name || missing.civitai_info?.model_name || '', - versionName - ); - const modelName = modelParts.name || downloadFilename; - const fullModelName = this.getVersionedModelName(modelName, modelParts.version); - - return { - sourceKey, - sourceLabel, - model: modelName, - version: modelParts.version, - filename: downloadFilename, - secondary: sourceSecondary || (fullModelName && fullModelName !== downloadFilename ? downloadFilename : ''), - match: isFromWorkflow - ? { label: 'Provided', className: 'strong' } - : this.getSearchResultMatchDisplay(downloadSource, 'Known', 'strong'), - size: this.formatSearchResultSize(downloadSource), - downloadUrl: downloadSource.url, - downloadFilename, - category: downloadSource.directory || downloadSource.category || missing.category || 'checkpoints', - openUrl: modelUrl - }; - } - - renderKnownDownloadPanel(missing, downloadSource) { - let html = `
`; - html += this.renderSearchControls(missing, { buttonText: 'Search Again' }); - html += this.renderDownloadTargetControls( - missing, - downloadSource.directory || downloadSource.category || missing.category || 'checkpoints' - ); - html += `
`; - - const downloadSourceRow = this.getDownloadSourceTableRow(missing, downloadSource); - html += `
`; - html += this.renderSearchResultsTable(downloadSourceRow ? [downloadSourceRow] : []); - html += `
`; - return html; - } - - renderSearchResultsTable(rows = []) { - if (!rows.length) return ''; - const layout = this.getSearchResultsTableLayout(rows); - const tableStyle = [ - `--ml-source-col:${layout.sourcePx}px`, - `--ml-match-col:${layout.matchPx}px`, - `--ml-size-col:${layout.sizePx}px`, - `--ml-actions-col:${layout.actionsPx}px`, - `--ml-table-min:${layout.tableMinPx}px` - ].join(';'); - - let html = ` -
- - - - - - - - - - - - - - - - - - - `; - - 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 += ` - -
SourceModelMatchSizeActions
${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.model?.path || match.path || match.filename || ''; - const isBestMatch = matchIndex === 0 && match.confidence >= 95; - const contextModel = this.buildContextMenuModelData(match.model || {}, match.filename || ''); - const modelData = encodeURIComponent(JSON.stringify(contextModel)); - - html += `
`; - html += this.getConfidenceBadge(match.confidence); - html += `${this.escapeHtml(matchPath)}`; - html += `${match.confidence === 100 ? 'Exact' : 'Partial'}`; - html += ``; - html += `
`; - } - - 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)); - const matchPath = match.model?.relative_path || match.model?.path || match.path || match.filename || ''; - html += `
`; - html += this.getConfidenceBadge(match.confidence); - html += `${this.escapeHtml(matchPath)}`; - html += `Partial`; - html += ``; - html += `
`; - } - html += `
`; - } - } else if (missing.is_urn && !missing.civitai_info) { - html += `
Waiting for CivitAI filename to search local models...
`; - } else if (allMatches.length > 0 && filteredMatches.length === 0) { - html += `
No matches above 70% confidence
`; - } else { - html += `
No local matches found
`; - } - - return html; - } - - wireLocalMatchButtons(container, missing, missingIndex = 0) { - const allMatches = missing.matches || []; - const filteredMatches = allMatches.filter(m => m.confidence >= 70); - const perfectMatches = filteredMatches.filter(m => m.confidence === 100); - const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70); - const matchesToShow = perfectMatches.length > 0 - ? perfectMatches - : otherMatches.sort((a, b) => b.confidence - a.confidence).slice(0, 5); - - const sortedMatches = matchesToShow.sort((a, b) => { - if (a.confidence === 100 && b.confidence !== 100) return -1; - if (a.confidence !== 100 && b.confidence === 100) return 1; - return b.confidence - a.confidence; - }); - - sortedMatches.forEach((match, matchIndex) => { - const buttonId = `resolve-${missingIndex}-${missing.node_id}-${missing.widget_index}-${matchIndex}`; - const resolveButton = container.querySelector(`#${buttonId}`); - if (resolveButton) { - resolveButton.onclick = null; - resolveButton.addEventListener('click', () => { - this.queueResolution(missing, match.model); - }); - } - }); - - if (otherMatches && otherMatches.length > 0) { - for (let mIdx = 0; mIdx < otherMatches.length; mIdx++) { - const match = otherMatches[mIdx]; - const altBtnId = `resolve-alt-${missingIndex}-${missing.node_id}-${missing.widget_index}-${mIdx}`; - const altBtn = container.querySelector(`#${altBtnId}`); - if (altBtn) { - altBtn.addEventListener('click', () => { - this.queueResolution(missing, match.model); - }); - } - } - } - } - - getUrnIds(missing = {}) { - return { - modelId: missing.urn_model_id || missing.urn?.model_id, - versionId: missing.urn_version_id || missing.urn?.version_id - }; - } - - getUrnResolveKey(missing = {}) { - const ids = this.getUrnIds(missing); - return `${ids.modelId || ''}:${ids.versionId || ''}:${this.getMissingModelKey(missing)}`; - } - - getCivitaiResultFromMissing(missing = {}) { - if (!missing.civitai_info?.expected_filename && !missing.download_source?.url) return null; - const ids = this.getUrnIds(missing); - return { - name: missing.civitai_info?.model_name || missing.download_source?.name, - version_name: missing.civitai_info?.version_name || missing.download_source?.version_name, - filename: missing.civitai_info?.expected_filename || missing.download_source?.filename, - download_url: missing.download_source?.url, - url: missing.download_source?.model_url, - type: missing.download_source?.type || missing.category, - size: missing.download_source?.size, - model_id: missing.download_source?.model_id || ids.modelId, - version_id: missing.download_source?.version_id || ids.versionId, - base_model: missing.civitai_info?.base_model || missing.download_source?.base_model, - tags: missing.civitai_info?.tags || missing.download_source?.tags || [] - }; - } - - applyCivitaiUrnResult(missing, civitai = {}) { - if (!missing || !civitai) return; - const ids = this.getUrnIds(missing); - missing.civitai_info = { - model_name: civitai.name, - version_name: civitai.version_name, - expected_filename: civitai.filename, - base_model: civitai.base_model, - tags: civitai.tags || [] - }; - missing.download_source = { - source: 'civitai', - url: civitai.download_url, - filename: civitai.filename, - name: civitai.name, - version_name: civitai.version_name, - type: civitai.type, - directory: missing.category || 'checkpoints', - match_type: 'exact', - size: civitai.size, - model_id: civitai.model_id || ids.modelId, - version_id: civitai.version_id || ids.versionId, - model_url: civitai.url || `https://civitai.com/models/${ids.modelId}?modelVersionId=${ids.versionId}`, - base_model: civitai.base_model, - tags: civitai.tags || [] - }; - } - - async resolveUrnDataForMissing(missing) { - if (!missing?.is_urn) return null; - - const existing = this.getCivitaiResultFromMissing(missing); - if (existing?.filename) { - return { civitai: existing }; - } - - const ids = this.getUrnIds(missing); - if (!ids.modelId || !ids.versionId) return null; - - const key = this.getUrnResolveKey(missing); - if (this.urnResolvePromises.has(key)) { - return this.urnResolvePromises.get(key); - } - - const promise = (async () => { - const tokens = this.getStoredTokens(); - const payload = { - filename: `${ids.modelId}_${ids.versionId}`, - category: '', - is_urn: true, - sources: ['civitai'], - model_id: ids.modelId, - version_id: ids.versionId, - civitai_candidate_limit: tokens.civitai_candidate_limit - }; - - const response = await api.fetchApi('/model_linker/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - - if (!response.ok) { - throw new Error(`URN resolve failed: ${response.status}`); - } - - const data = await response.json(); - if (data.civitai) { - this.applyCivitaiUrnResult(missing, data.civitai); - } - return data; - })().finally(() => { - this.urnResolvePromises.delete(key); - }); - - this.urnResolvePromises.set(key, promise); - return promise; - } - - refreshMissingListStats() { - if (!this.contentElement) return; - const statsEl = this.contentElement.querySelector('.ml-missing-list-stats'); - if (!statsEl) return; - - const stats = this.getMissingModelSummaryStats(this.missingModels || []); - statsEl.innerHTML = ` - ${stats.exact} exact - ${stats.partial} partial - ${stats.none} no match - `; - } - - refreshMissingListRowLocalMatch(missing = {}) { - if (!this.contentElement) return; - - const key = this.getMissingModelKey(missing); - const rows = this.contentElement.querySelectorAll('.ml-missing-list-row'); - for (const row of rows) { - if (row.getAttribute('data-missing-key') !== key) continue; - - const bestMatch = this.getBestLocalMatch(missing, 70); - const confidence = bestMatch ? Number(bestMatch.confidence || 0) : 0; - const matchName = bestMatch?.model?.relative_path || bestMatch?.filename || bestMatch?.path || ''; - const matchDisplay = matchName || 'No local match'; - const matchClass = confidence === 100 ? 'exact' : (bestMatch ? 'partial' : 'none'); - - const bestEl = row.querySelector('.ml-missing-row-best'); - if (bestEl) { - bestEl.setAttribute('data-tooltip', matchDisplay); - bestEl.innerHTML = bestMatch - ? this.escapeHtml(matchDisplay) - : '-- No local match'; - } - - const matchEl = row.querySelector('.ml-missing-row-match'); - if (matchEl) { - matchEl.classList.remove( - 'ml-missing-row-match-exact', - 'ml-missing-row-match-partial', - 'ml-missing-row-match-none' - ); - matchEl.classList.add(`ml-missing-row-match-${matchClass}`); - const valueEl = matchEl.querySelector('strong'); - if (valueEl) { - valueEl.textContent = bestMatch - ? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%` - : '--'; - } - } - } - - this.refreshMissingListStats(); - } - - async fetchUrnLocalMatches(missing) { - if (!missing?.civitai_info?.expected_filename) return []; - - const filename = missing.civitai_info.expected_filename; - if (missing.__urnLocalMatchesFilename === filename && Array.isArray(missing.matches)) { - return missing.matches; - } - - const key = this.getUrnResolveKey(missing); - if (this.urnLocalMatchPromises.has(key)) { - return this.urnLocalMatchPromises.get(key); - } - - const promise = (async () => { - const response = await api.fetchApi('/model_linker/local-matches', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename, - category: missing.category || '' - }) - }); - - if (!response.ok) { - throw new Error(`Local match search failed: ${response.status}`); - } - - const data = await response.json(); - missing.matches = Array.isArray(data.matches) ? data.matches : []; - missing.__urnLocalMatchesFilename = filename; - return missing.matches; - })().finally(() => { - this.urnLocalMatchPromises.delete(key); - }); - - this.urnLocalMatchPromises.set(key, promise); - return promise; - } - - async refreshUrnLocalMatches(missing) { - if (!missing?.civitai_info?.expected_filename || !this.contentElement) return; - - const bodyId = `local-matches-body-${missing.node_id}-${missing.widget_index}`; - const container = this.contentElement.querySelector(`#${bodyId}`); - if (container) { - container.innerHTML = `
Searching local matches for "${missing.civitai_info.expected_filename}"...
`; - } - - try { - await this.fetchUrnLocalMatches(missing); - if (container) { - container.innerHTML = this.renderLocalMatchesContent(missing, missing.__displayIndex || 0); - this.wireLocalMatchButtons(this.contentElement, missing, missing.__displayIndex || 0); - } - this.refreshMissingListRowLocalMatch(missing); - } catch (error) { - console.error('Model Linker: URN local match refresh error:', error); - if (container) { - container.innerHTML = `
Failed to refresh local matches.
`; - } - } - } - - scheduleInitialUrnLocalMatchRefresh(missingModels = [], container = null, data = null) { - const refreshTasks = []; - - missingModels.forEach((missing) => { - if (!missing?.is_urn || missing.__urnLocalRefreshQueued) return; - if (this.getBestLocalMatch(missing, 70)) return; - - missing.__urnLocalRefreshQueued = true; - const task = (async () => { - try { - await this.resolveUrnDataForMissing(missing); - await this.refreshUrnLocalMatches(missing); - } catch (error) { - missing.__urnLocalRefreshFailed = true; - console.error('Model Linker: initial URN local match refresh error:', error); - } - })(); - refreshTasks.push(task); - }); - - if (!refreshTasks.length || !container || !data) return; - - Promise.allSettled(refreshTasks).then(() => { - if (!container.isConnected || this.activeTab !== 'missing') return; - this.displayMissingModels(container, data); - }); - } - - /** - * Handle click outside context menu to hide it - */ - handleContextMenuOutsideClick(e) { - if (!this.contextMenu) return; - if (this.contextMenu.style.display === 'none') return; - - // Check if click is outside the context menu - if (!this.contextMenu.contains(e.target)) { - this.hideContextMenu(); - } - } - - /** - * Show context menu at the specified position - */ - showContextMenu(x, y, model) { - if (!this.contextMenu) return; - - this._contextMenuModel = model; - - // Position the menu - this.contextMenu.style.left = `${x}px`; - this.contextMenu.style.top = `${y}px`; - this.contextMenu.style.display = 'block'; - - // Adjust position if menu would go off screen - const rect = this.contextMenu.getBoundingClientRect(); - if (rect.right > window.innerWidth) { - this.contextMenu.style.left = `${window.innerWidth - rect.width - 10}px`; - } - if (rect.bottom > window.innerHeight) { - this.contextMenu.style.top = `${window.innerHeight - rect.height - 10}px`; - } - } - - /** - * Hide context menu - */ - hideContextMenu() { - if (!this.contextMenu) return; - this.contextMenu.style.display = 'none'; - this._contextMenuModel = null; - } - - /** - * Handle context menu item click - */ - handleContextMenuAction(action) { - const model = this._contextMenuModel; - this.hideContextMenu(); - - if (!model) return; - - if (action === 'civitai') { - this.openInCivitAI(model); - } else if (action === 'openFolder') { - this.openContainingFolder(model); - } else if (action === 'showInfo') { - this.showModelInfo(model); - } - } - - async openContainingFolder(model) { - const path = model?.path || model?.resolved_path || ''; - if (!path) { - this.showNotification('No local file path available', 'error'); - return; - } - - try { - const response = await api.fetchApi('/model_linker/open-containing-folder', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path }) - }); - - if (!response.ok) { - throw new Error(`Open folder failed: ${response.status}`); - } - } catch (error) { - console.error('Model Linker: Open folder error:', error); - this.showNotification('Failed to open containing folder', 'error'); - } - } - - /** - * Open model in CivitAI - */ - async openInCivitAI(model) { - if (!model) return; - - const name = model.name || model.original_path?.split(/[\/\\]/).pop() || ''; - if (!name) return; - - try { - // Search CivitAI for this model using hash (pass resolved_path for hash lookup) - const response = await api.fetchApi('/model_linker/civitai-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename: name, - category: model.category, - resolved_path: model.resolved_path || '' - }) - }); - - if (!response.ok) { - this.showNotification('Nie znaleziono modelu na CivitAI', 'error'); - return; - } - - const data = await response.json(); - if (data.url) { - window.open(data.url, '_blank'); - } else { - // Try direct search on CivitAI - const searchName = name.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); - const searchUrl = `https://civitai.com/search?q=${encodeURIComponent(searchName)}`; - window.open(searchUrl, '_blank'); - } - } catch (e) { - console.error('Model Linker: Error searching CivitAI:', e); - // Fall back to direct search - const searchName = name.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); - const searchUrl = `https://civitai.com/search?q=${encodeURIComponent(searchName)}`; - window.open(searchUrl, '_blank'); - } - } - - /** - * Show model info dialog (similar to rgthree's RgthreeLoraInfoDialog) - */ - async showModelInfo(model) { - if (!model) return; - - const name = model.name || model.original_path?.split(/[\/\\]/).pop() || ''; - if (!name) return; - - // Create and show the info dialog - this.showModelInfoDialog(name, model); - } - - /** - * Show the model info dialog - */ - showModelInfoDialog(loraName, modelData) { - // Create info dialog element - const dialog = this.createInfoDialog(loraName, modelData); - this.restoreInfoDialogSize(dialog); - - // Show the dialog - document.body.appendChild(dialog); - this.bindTooltips(dialog); - this.bindInfoDialogResizePersistence(dialog); - - // Add close handlers - const closeBtn = dialog.querySelector('.ml-info-dialog-close'); - const footerCloseBtn = dialog.querySelector('.ml-info-dialog-close-btn'); - const backdrop = dialog.querySelector('.ml-info-dialog-backdrop'); - - if (closeBtn) { - closeBtn.addEventListener('click', () => this.closeInfoDialog(dialog)); - } - if (footerCloseBtn) { - footerCloseBtn.addEventListener('click', () => this.closeInfoDialog(dialog)); - } - if (backdrop) { - backdrop.addEventListener('click', (e) => { - // Only close if clicking backdrop itself, not its children - if (e.target === backdrop) { - this.closeInfoDialog(dialog); - } - }); - } - - // Fetch CivitAI info - this.fetchModelInfoForDialog(loraName, modelData, dialog); - } - - /** - * Create the info dialog element - */ - createInfoDialog(loraName, modelData) { - const loraDisplayName = loraName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); - - const dialog = document.createElement('div'); - dialog.className = 'ml-info-dialog-backdrop'; - dialog._selectedTrainedWords = new Set(); - dialog.innerHTML = ` -
-
-

${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, '''); - } - - escapeJsString(value) { - return this.escapeHtml(JSON.stringify(String(value ?? ''))); - } - - truncateText(value, maxLength = 160) { - const text = String(value ?? '').trim(); - if (!text) return ''; - return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; - } - - sanitizeDescriptionHtml(html) { - const raw = String(html ?? '').trim(); - if (!raw) return ''; - - const parser = new DOMParser(); - const doc = parser.parseFromString(`
${raw}
`, 'text/html'); - const root = doc.body.firstElementChild; - if (!root) return this.escapeHtml(raw); - - const allowedTags = new Set([ - 'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'pre', - 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', - 'a', 'span' - ]); - const allowedStyles = new Set(['color']); - - const sanitizeNode = (node) => { - if (node.nodeType === Node.TEXT_NODE) { - return; - } - - if (node.nodeType !== Node.ELEMENT_NODE) { - node.remove(); - return; - } - - const tag = node.tagName.toLowerCase(); - if (!allowedTags.has(tag)) { - const parent = node.parentNode; - if (!parent) { - node.remove(); - return; - } - - while (node.firstChild) { - parent.insertBefore(node.firstChild, node); - } - parent.removeChild(node); - return; - } - - const attrs = Array.from(node.attributes); - for (const attr of attrs) { - const name = attr.name.toLowerCase(); - if (name.startsWith('on') || name === 'id' || name === 'class' || name.startsWith('data-')) { - node.removeAttribute(attr.name); - continue; - } - - if (name === 'href' && tag === 'a') { - const href = node.getAttribute('href') || ''; - if (!/^https?:\/\//i.test(href)) { - node.removeAttribute('href'); - } else { - node.setAttribute('target', '_blank'); - node.setAttribute('rel', 'noopener noreferrer'); - } - continue; - } - - if (name === 'style') { - const safeStyles = []; - const styleValue = node.getAttribute('style') || ''; - for (const part of styleValue.split(';')) { - const [prop, value] = part.split(':').map(v => v?.trim()); - if (!prop || !value) continue; - if (allowedStyles.has(prop.toLowerCase())) { - safeStyles.push(`${prop}: ${value}`); - } - } - if (safeStyles.length) { - node.setAttribute('style', safeStyles.join('; ')); - } else { - node.removeAttribute('style'); - } - continue; - } - - if (!(tag === 'a' && (name === 'target' || name === 'rel')) ) { - node.removeAttribute(attr.name); - } - } - - Array.from(node.childNodes).forEach(child => sanitizeNode(child)); - }; - - Array.from(root.childNodes).forEach(child => sanitizeNode(child)); - return root.innerHTML; - } - - normalizeTrainedWords(words) { - if (Array.isArray(words)) { - return [...new Set(words.map(word => String(word || '').trim()).filter(Boolean))]; - } - - if (typeof words === 'string') { - return [...new Set( - words - .split(/[\n,|;]/) - .map(word => word.trim()) - .filter(Boolean) - )]; - } - - return []; - } - - updateSelectedTrainedWordsSummary(dialog) { - if (!dialog) return; - - const countEl = dialog.querySelector('.ml-info-trained-words-count'); - const copyBtn = dialog.querySelector('.ml-info-copy-trained-words'); - const selected = dialog._selectedTrainedWords instanceof Set - ? Array.from(dialog._selectedTrainedWords) - : []; - - if (countEl) { - countEl.textContent = `${selected.length} selected`; - } - if (copyBtn) { - copyBtn.disabled = selected.length === 0; - copyBtn.textContent = 'Copy'; - } - } - - bindInfoDialogInteractions(dialog) { - if (!dialog || dialog.dataset.mlInfoBound === 'true') return; - dialog.dataset.mlInfoBound = 'true'; - - dialog.addEventListener('click', async (event) => { - const wordBtn = event.target.closest('.ml-info-trained-word'); - if (wordBtn && dialog.contains(wordBtn)) { - const word = wordBtn.dataset.word || ''; - if (word) { - if (!(dialog._selectedTrainedWords instanceof Set)) { - dialog._selectedTrainedWords = new Set(); - } - - if (dialog._selectedTrainedWords.has(word)) { - dialog._selectedTrainedWords.delete(word); - wordBtn.classList.remove('is-selected'); - wordBtn.setAttribute('aria-pressed', 'false'); - } else { - dialog._selectedTrainedWords.add(word); - wordBtn.classList.add('is-selected'); - wordBtn.setAttribute('aria-pressed', 'true'); - } - - this.updateSelectedTrainedWordsSummary(dialog); - } - return; - } - - const copyBtn = event.target.closest('.ml-info-copy-trained-words'); - if (copyBtn && dialog.contains(copyBtn)) { - const words = dialog._selectedTrainedWords instanceof Set - ? Array.from(dialog._selectedTrainedWords) - : []; - - if (!words.length) return; - - try { - await navigator.clipboard.writeText(words.join(', ')); - copyBtn.textContent = 'Copied'; - } catch (error) { - console.error('Model Linker: Failed to copy trained words:', error); - copyBtn.textContent = 'Failed'; - } - - setTimeout(() => { - if (copyBtn.isConnected) { - copyBtn.textContent = 'Copy'; - } - }, 1200); - return; - } - - const descToggleBtn = event.target.closest('.ml-info-description-toggle'); - if (descToggleBtn && dialog.contains(descToggleBtn)) { - const descEl = dialog.querySelector('.ml-info-description'); - if (!descEl) return; - - const isExpanded = descEl.classList.toggle('is-expanded'); - descToggleBtn.textContent = isExpanded ? 'Show less' : 'Show more'; - } - }); - } - - getInfoDialogElement(dialog) { - return dialog?.querySelector?.('.ml-info-dialog') || null; - } - - restoreInfoDialogSize(dialog) { - const panel = this.getInfoDialogElement(dialog); - if (!panel) return; - - try { - const saved = JSON.parse(localStorage.getItem('model_linker_info_dialog_size') || 'null'); - if (!saved || typeof saved !== 'object') return; - - const width = Number(saved.w); - const height = Number(saved.h); - if (!Number.isFinite(width) || !Number.isFinite(height)) return; - - const maxWidth = Math.floor(window.innerWidth * 0.9); - const maxHeight = Math.floor(window.innerHeight * 0.8); - const clampedWidth = Math.max(420, Math.min(width, maxWidth)); - const clampedHeight = Math.max(320, Math.min(height, maxHeight)); - - panel.style.width = `${clampedWidth}px`; - panel.style.height = `${clampedHeight}px`; - } catch (error) { - console.warn('Model Linker: Failed to restore info dialog size:', error); - } - } - - saveInfoDialogSize(dialog) { - const panel = this.getInfoDialogElement(dialog); - if (!panel) return; - - const rect = panel.getBoundingClientRect(); - const width = Math.round(rect.width); - const height = Math.round(rect.height); - if (!width || !height) return; - - try { - localStorage.setItem('model_linker_info_dialog_size', JSON.stringify({ w: width, h: height })); - } catch (error) { - console.warn('Model Linker: Failed to save info dialog size:', error); - } - } - - bindInfoDialogResizePersistence(dialog) { - const panel = this.getInfoDialogElement(dialog); - if (!panel || typeof ResizeObserver === 'undefined') return; - if (dialog._infoDialogResizeObserver) return; - - let resizeSaveTimer = null; - const observer = new ResizeObserver(() => { - clearTimeout(resizeSaveTimer); - resizeSaveTimer = setTimeout(() => this.saveInfoDialogSize(dialog), 180); - dialog._infoDialogResizeSaveTimer = resizeSaveTimer; - }); - - observer.observe(panel); - dialog._infoDialogResizeObserver = observer; - dialog._infoDialogResizeSaveTimer = resizeSaveTimer; - } - - /** - * Fetch model info and update the dialog - */ - async fetchModelInfoForDialog(loraName, modelData, dialog) { - try { - const response = await api.fetchApi('/model_linker/civitai-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename: loraName, - category: modelData?.category || '', - resolved_path: modelData?.resolved_path || '' - }) - }); - - if (response.ok) { - const data = await response.json(); - this.updateInfoDialogWithData(dialog, data); - } else { - this.updateInfoDialogError(dialog, 'Model not found on CivitAI'); - } - } catch (e) { - console.error('Model Linker: Error fetching model info:', e); - this.updateInfoDialogError(dialog, 'Error fetching info'); - } - } - - /** - * Update the info dialog with data - */ - updateInfoDialogWithData(dialog, data) { - const loadingDiv = dialog.querySelector('.ml-info-dialog-loading'); - const bodyDiv = dialog.querySelector('.ml-info-dialog-body'); - this.bindInfoDialogInteractions(dialog); - - if (loadingDiv) loadingDiv.style.display = 'none'; - if (bodyDiv) bodyDiv.style.display = 'block'; - - if (!data) { - this.updateInfoDialogError(dialog, 'No data received'); - return; - } - - // Update title - const titleEl = dialog.querySelector('.ml-info-dialog-title'); - if (titleEl) { - const modelName = data.model_name || data.modelName || 'Unknown Model'; - const versionName = data.version_name || data.versionName || ''; - titleEl.innerHTML = this.renderVersionedModelNameHtml(modelName, versionName) || this.escapeHtml(modelName); - } - - // Update type tag - const typeTag = dialog.querySelector('.ml-info-type'); - if (typeTag) { - const modelType = data.model_type || data.modelType || ''; - typeTag.textContent = modelType.toUpperCase(); - typeTag.className = `ml-info-tag ml-info-type ${this.getModelTypeColorClass(modelType)}`; - } - - // Update base model tag - const baseModelTag = dialog.querySelector('.ml-info-basemodel'); - if (baseModelTag) { - const baseModel = data.base_model || data.baseModel || ''; - baseModelTag.textContent = baseModel || ''; - if (baseModel) { - baseModelTag.style.display = ''; - baseModelTag.className = `ml-info-tag ml-info-basemodel -basemodel-${baseModel.toLowerCase().replace(/\s+/g, '-')}`; - } else { - baseModelTag.style.display = 'none'; - } - } - - // Update file - const fileEl = dialog.querySelector('.ml-info-file'); - if (fileEl && data.filename) { - fileEl.textContent = data.filename; - } - - // Update hash - const hashEl = dialog.querySelector('.ml-info-hash'); - if (hashEl) { - hashEl.textContent = data.sha256 || data.hash || ''; - } - - // Update CivitAI link - const civitaiLinkEl = dialog.querySelector('.ml-info-civitai-link'); - if (civitaiLinkEl) { - if (data.url || data.version_url) { - const url = data.version_url || data.url; - civitaiLinkEl.innerHTML = ` - - ${getSvgIcon('civitai', 'currentColor', 'ml-info-civitai-logo')} - View on Civitai - - `; - } else { - const searchName = data.model_name || data.modelName || 'Unknown'; - civitaiLinkEl.innerHTML = ` - Model not found - - ${this.getSearchIconHtml()} Search on CivitAI - - `; - } - } - - // Update name - const nameEl = dialog.querySelector('.ml-info-name'); - if (nameEl) { - nameEl.textContent = data.model_name || data.modelName || ''; - } - - // Update base model row - const baseModelRowEl = dialog.querySelector('.ml-info-base-model'); - if (baseModelRowEl) { - const baseModel = data.base_model || data.baseModel || ''; - baseModelRowEl.textContent = baseModel; - const row = baseModelRowEl.closest('tr'); - if (row && baseModel) { - row.style.display = ''; - } else if (row) { - row.style.display = 'none'; - } - } - - // Update trained words - const trainedWordsEl = dialog.querySelector('.ml-info-trained-words'); - if (trainedWordsEl) { - const words = this.normalizeTrainedWords(data.trained_words || data.trainedWords || []); - if (words.length > 0) { - dialog._selectedTrainedWords = new Set(); - trainedWordsEl.innerHTML = `
${words.map(word => ` - - `).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; - } - } - - getActiveWorkflowRouteKey() { - try { - return window.location?.hash || ''; - } catch (error) { - return ''; - } - } - - rememberActiveWorkflow(workflow = null) { - const currentWorkflow = workflow || this.getCurrentWorkflow(); - this.activeWorkflowRouteKey = this.getActiveWorkflowRouteKey(); - this.activeWorkflowSignature = this.getWorkflowSignature(currentWorkflow); - } - - getWorkflowScopedQueueKey(route = this.activeWorkflowRouteKey, signature = this.activeWorkflowSignature) { - return route || signature || null; - } - - clonePendingResolutions(list = this.pendingResolutions) { - try { - return JSON.parse(JSON.stringify(Array.isArray(list) ? list : [])); - } catch (error) { - console.warn('Model Linker: failed to clone queued selections', error); - return Array.isArray(list) ? list.map(item => ({ ...item })) : []; - } - } - - savePendingQueueForActiveWorkflow() { - const key = this.getWorkflowScopedQueueKey(); - if (!key) return; - - const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : []; - if (list.length) { - this.workflowPendingSelections.set(key, this.clonePendingResolutions(list)); - } else { - this.workflowPendingSelections.delete(key); - } - } - - restorePendingQueueForActiveWorkflow() { - const key = this.getWorkflowScopedQueueKey(); - const saved = key ? this.workflowPendingSelections.get(key) : null; - this.pendingResolutions = saved ? this.clonePendingResolutions(saved) : []; - this.rebuildPendingIndex(); - this.updateApplyPendingButton?.(); - this.updateQueuePanel?.(); - this.updateQueueVisibility?.(); - } - - cloneSearchState(state = {}) { - let clone = {}; - try { - clone = JSON.parse(JSON.stringify(state || {})); - } catch (error) { - console.warn('Model Linker: failed to clone search state', error); - clone = { ...state }; - } - - clone.selectedSource = clone.selectedSource || 'all'; - clone.results = this.mergeSearchResults({}, clone.results || {}); - clone.lastAttemptSources = Array.isArray(clone.lastAttemptSources) - ? clone.lastAttemptSources - : []; - clone.lastAttemptFound = clone.lastAttemptFound ?? null; - clone.lastAttemptError = clone.lastAttemptError || null; - clone.sourceProgress = clone.sourceProgress || {}; - clone.activeSearchRunId = null; - - for (const progress of Object.values(clone.sourceProgress)) { - if (!progress || (progress.status !== 'pending' && progress.status !== 'running')) continue; - progress.status = 'error'; - progress.percent = 100; - progress.message = 'Search interrupted'; - } - - return clone; - } - - cloneSearchResultCache(cache = this.searchResultCache) { - const cloned = new Map(); - for (const [key, state] of cache.entries()) { - if (!this.isPersistableSearchState(state)) { - continue; - } - cloned.set(key, this.cloneSearchState(state)); - } - return cloned; - } - - isPersistableSearchState(state = {}) { - if ((state?.selectedSource || 'all') !== 'all') return true; - if (state?.lastAttemptError) return true; - if (this.hasSearchResults(state?.results || {})) return true; - - const progressEntries = Object.values(state?.sourceProgress || {}); - return progressEntries.some(progress => ( - progress?.status && progress.status !== 'pending' && progress.status !== 'running' - )); - } - - saveSearchCacheForActiveWorkflow() { - const key = this.getWorkflowScopedQueueKey(); - if (!key) return; - - const cloned = this.cloneSearchResultCache(); - if (cloned.size) { - this.workflowSearchResultCaches.set(key, cloned); - } else { - this.workflowSearchResultCaches.delete(key); - } - } - - restoreSearchCacheForActiveWorkflow() { - const key = this.getWorkflowScopedQueueKey(); - const saved = key ? this.workflowSearchResultCaches.get(key) : null; - this.searchResultCache = saved ? this.cloneSearchResultCache(saved) : new Map(); - } - - persistSearchStateForActiveWorkflow() { - this.saveSearchCacheForActiveWorkflow(); - } - - clearAllSearchProgressTimers() { - for (const timer of this.searchProgressTimers.values()) { - clearInterval(timer); - } - this.searchProgressTimers.clear(); - } - - syncWorkflowScopedQueue(workflow = null) { - const currentWorkflow = workflow || this.getCurrentWorkflow(); - const nextRoute = this.getActiveWorkflowRouteKey(); - const nextSignature = this.getWorkflowSignature(currentWorkflow); - const previousKey = this.getWorkflowScopedQueueKey(); - const nextKey = this.getWorkflowScopedQueueKey(nextRoute, nextSignature); - - if (!nextKey || nextKey === previousKey) { - this.activeWorkflowRouteKey = nextRoute; - this.activeWorkflowSignature = nextSignature; - return; - } - - this.savePendingQueueForActiveWorkflow(); - this.saveSearchCacheForActiveWorkflow(); - this.clearWorkflowScopedState(); - this.activeWorkflowRouteKey = nextRoute; - this.activeWorkflowSignature = nextSignature; - this.restorePendingQueueForActiveWorkflow(); - this.restoreSearchCacheForActiveWorkflow(); - } - - clearWorkflowScopedState() { - this.cachedWorkflowSignature = null; - this.cachedAnalysisData = null; - for (const state of this.searchResultCache.values()) { - state.activeSearchRunId = null; - } - this.clearAllSearchProgressTimers(); - this.searchResultCache.clear(); - this.urnResolvePromises.clear(); - this.urnLocalMatchPromises.clear(); - this.pendingResolutions = []; - this.rebuildPendingIndex(); - this.updateApplyPendingButton?.(); - this.updateQueuePanel?.(); - this.updateQueueVisibility(); - } - - scheduleActiveWorkflowRefresh(reason = 'workflow-change') { - if (!this.isVisible()) return; - - this._workflowRefreshExpectedRoute = this.getActiveWorkflowRouteKey(); - this._workflowRefreshPreviousSignature = this.activeWorkflowSignature; - - if (this._workflowRefreshTimer) { - clearTimeout(this._workflowRefreshTimer); - } - - this._workflowRefreshTimer = setTimeout(() => { - this._workflowRefreshTimer = null; - this.refreshForActiveWorkflowChange({ - reason, - expectedRoute: this._workflowRefreshExpectedRoute, - previousSignature: this._workflowRefreshPreviousSignature, - attempt: 0 - }); - }, 180); - } - - async refreshForActiveWorkflowChange({ reason = 'workflow-change', expectedRoute = '', previousSignature = null, attempt = 0 } = {}) { - if (!this.isVisible()) return; - - const currentRoute = this.getActiveWorkflowRouteKey(); - if (expectedRoute && currentRoute !== expectedRoute) { - this.scheduleActiveWorkflowRefresh(reason); - return; - } - - const workflow = this.getCurrentWorkflow(); - const signature = this.getWorkflowSignature(workflow); - const routeChanged = currentRoute !== this.activeWorkflowRouteKey; - const signatureChanged = signature && signature !== this.activeWorkflowSignature; - const graphStillLooksOld = routeChanged && previousSignature && signature === previousSignature; - - if ((!signature || graphStillLooksOld) && attempt < 8) { - setTimeout(() => { - this.refreshForActiveWorkflowChange({ - reason, - expectedRoute, - previousSignature, - attempt: attempt + 1 - }); - }, 180 + (attempt * 120)); - return; - } - - if (!routeChanged && !signatureChanged) return; - - console.log('Model Linker: active workflow changed, refreshing current tab', { - reason, - route: currentRoute, - tab: this.activeTab - }); - - this.savePendingQueueForActiveWorkflow(); - this.saveSearchCacheForActiveWorkflow(); - this.clearWorkflowScopedState(); - this.activeWorkflowRouteKey = currentRoute; - this.activeWorkflowSignature = signature; - this.restorePendingQueueForActiveWorkflow(); - this.restoreSearchCacheForActiveWorkflow(); - - if (this.activeTab === 'missing') { - if (this.contentElement) this.contentElement.style.overflowY = 'auto'; - await this.loadWorkflowData(workflow); - } else if (this.activeTab === 'loaded') { - if (this.contentElement) this.contentElement.style.overflowY = 'auto'; - await this.loadLoadedModels(workflow); - } - } - - /** - * Handle clicks outside the dialog - */ - handleOutsideClick(e) { - // Close if click is on the backdrop (not on the dialog itself) - if (e.target === this.backdrop) { - this.close(); - } - } - - handleFooterMenuOutsideClick(e) { - if (!this.activeFooterMenu) return; - if (e.target?.closest?.('.ml-footer-menu-wrap')) return; - this.closeFooterMenus(); - } - - closeFooterMenus() { - this.activeFooterMenu = null; - this.footerMenus?.forEach?.((menu, name) => { - menu.classList.remove('is-open'); - const button = this.footerMenuButtons?.get?.(name); - button?.setAttribute('aria-expanded', 'false'); - }); - } - - setMissingFooterControlsVisible(visible) { - const display = visible ? 'inline-flex' : 'none'; - [ - this.selectMenuWrap, - this.searchMenuWrap, - this.queueExactButton, - this.applyPendingBtn, - this.autoResolveButton, - this.downloadMenuWrap - ].forEach(element => { - if (element) element.style.display = display; - }); - if (!visible) { - this.closeFooterMenus(); - } - } - - toggleFooterMenu(name) { - const menu = this.footerMenus?.get?.(name); - const button = this.footerMenuButtons?.get?.(name); - if (!menu || !button) return; - - const shouldOpen = this.activeFooterMenu !== name; - this.closeFooterMenus(); - if (shouldOpen) { - this.activeFooterMenu = name; - menu.classList.add('is-open'); - button.setAttribute('aria-expanded', 'true'); - } - } - - syncBatchSelectionForMissingModels(missingModels = this.missingModels || []) { - if (!(this.batchSelectedMissingKeys instanceof Set)) { - this.batchSelectedMissingKeys = new Set(); - } - - const validKeys = new Set(missingModels.map(missing => this.getMissingModelKey(missing))); - for (const key of Array.from(this.batchSelectedMissingKeys)) { - if (!validKeys.has(key)) { - this.batchSelectedMissingKeys.delete(key); - } - } - if (this.lastBatchSelectedMissingKey && !validKeys.has(this.lastBatchSelectedMissingKey)) { - this.lastBatchSelectedMissingKey = null; - } - } - - getSelectedMissingModels() { - const selectedKeys = this.batchSelectedMissingKeys || new Set(); - return (this.missingModels || []).filter(missing => selectedKeys.has(this.getMissingModelKey(missing))); - } - - getMissingWithExactLocalMatches(missingModels = this.missingModels || []) { - return missingModels.filter(missing => this.getBestLocalMatch(missing, 100)); - } - - getMissingWithoutExactLocalMatches(missingModels = this.missingModels || []) { - return missingModels.filter(missing => !this.getBestLocalMatch(missing, 100)); - } - - getMissingWithPartialLocalMatches(missingModels = this.missingModels || []) { - return missingModels.filter(missing => { - const bestMatch = this.getBestLocalMatch(missing, 70); - return bestMatch && Number(bestMatch.confidence || 0) < 100; - }); - } - - getMissingWithoutLocalMatches(missingModels = this.missingModels || []) { - return missingModels.filter(missing => !this.getBestLocalMatch(missing, 70)); - } - - getMissingWithDownloadSources(missingModels = this.missingModels || []) { - return missingModels.filter(missing => this.getBestDownloadSourceForMissing(missing)); - } - - getMissingWithoutDownloadSources(missingModels = this.missingModels || []) { - return missingModels.filter(missing => !this.getBestDownloadSourceForMissing(missing)); - } - - getSearchedMissingModels(missingModels = this.missingModels || []) { - return missingModels.filter(missing => this.hasRenderableSearchState(this.getSearchState(missing))); - } - - getUnsearchedMissingModels(missingModels = this.missingModels || []) { - return missingModels.filter(missing => !this.hasRenderableSearchState(this.getSearchState(missing))); - } - - selectBatchMissingModels(mode) { - const missingModels = this.missingModels || []; - if (!missingModels.length) { - this.showNotification('No missing models to select.', 'info'); - return; - } - - if (!(this.batchSelectedMissingKeys instanceof Set)) { - this.batchSelectedMissingKeys = new Set(); - } - - if (mode === 'none') { - this.batchSelectedMissingKeys.clear(); - } else if (mode === 'invert') { - const nextSelection = new Set(); - for (const missing of missingModels) { - const key = this.getMissingModelKey(missing); - if (!this.batchSelectedMissingKeys.has(key)) { - nextSelection.add(key); - } - } - this.batchSelectedMissingKeys = nextSelection; - } else { - let models = missingModels; - if (mode === 'exact') { - models = this.getMissingWithExactLocalMatches(missingModels); - } else if (mode === 'no_exact') { - models = this.getMissingWithoutExactLocalMatches(missingModels); - } else if (mode === 'partial') { - models = this.getMissingWithPartialLocalMatches(missingModels); - } else if (mode === 'no_local') { - models = this.getMissingWithoutLocalMatches(missingModels); - } else if (mode === 'downloadable') { - models = this.getMissingWithDownloadSources(missingModels); - } else if (mode === 'no_download') { - models = this.getMissingWithoutDownloadSources(missingModels); - } else if (mode === 'searched') { - models = this.getSearchedMissingModels(missingModels); - } else if (mode === 'unsearched') { - models = this.getUnsearchedMissingModels(missingModels); - } - this.batchSelectedMissingKeys = new Set(models.map(missing => this.getMissingModelKey(missing))); - } - - this.refreshBatchSelectionUi(); - this.updateBatchFooterButtons(); - this.closeFooterMenus(); - } - - refreshBatchSelectionUi() { - const selectedKeys = this.batchSelectedMissingKeys || new Set(); - this.contentElement?.querySelectorAll?.('.ml-missing-list-row')?.forEach(row => { - const key = row.getAttribute('data-missing-key'); - const selected = selectedKeys.has(key); - row.classList.toggle('is-batch-selected', selected); - const checkbox = row.querySelector('.ml-missing-row-check'); - if (checkbox) { - checkbox.checked = selected; - } - }); - this.updateBatchSelectAllCheckbox(); - } - - updateBatchSelectAllCheckbox() { - const checkbox = this.contentElement?.querySelector?.('.ml-missing-select-all-check'); - if (!checkbox) return; - - const totalCount = (this.missingModels || []).length; - const selectedCount = this.getSelectedMissingModels().length; - checkbox.checked = totalCount > 0 && selectedCount === totalCount; - checkbox.indeterminate = selectedCount > 0 && selectedCount < totalCount; - checkbox.disabled = totalCount === 0; - } - - setBatchSelectionForKey(key, selected) { - if (!key) return; - if (!(this.batchSelectedMissingKeys instanceof Set)) { - this.batchSelectedMissingKeys = new Set(); - } - if (selected) { - this.batchSelectedMissingKeys.add(key); - } else { - this.batchSelectedMissingKeys.delete(key); - } - } - - applyBatchSelectionRange(sortedMissingModels, fromKey, toKey, selected) { - const models = sortedMissingModels || this.missingModels || []; - const fromIndex = models.findIndex(missing => this.getMissingModelKey(missing) === fromKey); - const toIndex = models.findIndex(missing => this.getMissingModelKey(missing) === toKey); - - if (fromIndex < 0 || toIndex < 0) { - this.setBatchSelectionForKey(toKey, selected); - return; - } - - const start = Math.min(fromIndex, toIndex); - const end = Math.max(fromIndex, toIndex); - for (let index = start; index <= end; index += 1) { - this.setBatchSelectionForKey(this.getMissingModelKey(models[index]), selected); - } - } - - createHeader() { - // Create tabs - this.missingTab = $el("button.ml-tab.ml-tab-active", { - onclick: () => this.switchTab('missing') - }, [$el("span.ml-tab-label", { textContent: "Missing Models" })]); - - this.loadedTab = $el("button.ml-tab", { - onclick: () => this.switchTab('loaded') - }, [$el("span.ml-tab-label", { textContent: "Loaded Models" })]); - - this.optionsTab = $el("button.ml-tab", { - onclick: () => this.switchTab('options') - }, [$el("span.ml-tab-label", { textContent: "Options" })]); - if (this.activeTab === 'missing') { - this.updateTabButtonStates(); - } - - const dragHandle = $el("div", { - id: "model-linker-drag-handle", - ondragstart: (e) => e.preventDefault() - }, [ - $el("span", { textContent: "⠿" }) - ]); - this.setTooltip(dragHandle, "Drag window"); - - const fullscreenButton = $el("button", { - id: "model-linker-fullscreen-toggle", - className: "ml-window-btn ml-window-btn--fullscreen", - innerHTML: getSvgIcon('windowMaximize', 'currentColor', 'ml-window-btn-icon'), - ariaLabel: "Toggle full screen", - onclick: () => this.toggleFullScreen() - }); - this.setTooltip(fullscreenButton, "Toggle full screen"); - - this.dockButton = $el("button", { - id: "model-linker-dock-toggle", - className: "ml-window-btn ml-window-btn--dock", - innerHTML: getSvgIcon('internalLink', 'currentColor', 'ml-window-btn-icon'), - ariaLabel: "Dock Model Linker to sidebar", - onclick: () => this.dockToSidebar() - }); - this.setTooltip(this.dockButton, "Dock to sidebar"); - - this.undockButton = $el("button", { - id: "model-linker-undock-toggle", - className: "ml-window-btn ml-window-btn--undock", - innerHTML: getSvgIcon('externalLink', 'currentColor', 'ml-window-btn-icon'), - ariaLabel: "Undock Model Linker", - onclick: () => this.undockToFloating() - }); - this.setTooltip(this.undockButton, "Undock to floating window"); - - return $el("div.ml-dialog-shell", {}, [ - $el("div.ml-dialog-topbar", {}, [ - $el("div.ml-dialog-brand", {}, [ - dragHandle, - $el("div.ml-tabs", {}, [ - this.missingTab, - this.loadedTab, - this.optionsTab - ]) - ]), - $el("div.ml-dialog-controls", {}, [ - this.dockButton, - this.undockButton, - fullscreenButton, - $el("button", { - className: "ml-window-btn ml-window-btn--close", - innerHTML: getSvgIcon('x', 'currentColor', 'ml-window-btn-icon'), - ariaLabel: "Close Model Linker", - onclick: () => this.close() - }) - ]) - ]) - ]); - } - - captureFloatingRect() { - if (!this.element || this.docked || getComputedStyle(this.element).display === 'none') return; - - const rect = this.element.getBoundingClientRect(); - if (!rect.width || !rect.height) return; - - this._floatingRectBeforeDock = { - top: Math.round(rect.top), - left: Math.round(rect.left), - width: Math.round(rect.width), - height: Math.round(rect.height) - }; - } - - clearModalPlacementStyles() { - if (!this.element) return; - - [ - 'position', - 'top', - 'left', - 'right', - 'bottom', - 'inset', - 'transform', - 'width', - 'height', - 'maxWidth', - 'maxHeight', - 'minWidth', - 'minHeight', - 'borderRadius', - 'resize' - ].forEach((property) => { - this.element.style[property] = ''; - }); - } - - restoreFloatingGeometry() { - const el = this.element; - if (!el) return; - - el.style.width = '1100px'; - el.style.height = '700px'; - el.style.maxWidth = '100vw'; - el.style.maxHeight = '100vh'; - el.style.minWidth = '640px'; - el.style.minHeight = '420px'; - el.style.resize = 'both'; - el.style.borderRadius = '7px'; - - const rect = this._floatingRectBeforeDock; - if (rect?.width && rect?.height) { - el.style.width = `${rect.width}px`; - el.style.height = `${rect.height}px`; - el.style.top = `${rect.top}px`; - el.style.left = `${rect.left}px`; - el.style.transform = 'none'; - return; - } - - try { - const wh = JSON.parse(localStorage.getItem('model_linker_modal_size_before_fs') || 'null'); - if (wh?.w && wh?.h) { - el.style.width = `${wh.w}px`; - el.style.height = `${wh.h}px`; - } - - const pos = JSON.parse(localStorage.getItem('model_linker_modal_pos') || 'null'); - if (pos && Number.isFinite(pos.top) && Number.isFinite(pos.left)) { - el.style.top = `${pos.top}px`; - el.style.left = `${pos.left}px`; - el.style.transform = 'none'; - return; - } - } catch (e) { - // Fall back to centered floating geometry below. - } - - el.style.top = '50%'; - el.style.left = '50%'; - el.style.transform = 'translate(-50%, -50%)'; - } - - rememberSidebarOpenMode(mode) { - try { - localStorage.setItem(this.sidebarOpenModeStorageKey, mode === 'floating' ? 'floating' : 'docked'); - } catch (e) {} - } - - shouldOpenFromSidebarFloating() { - try { - return localStorage.getItem(this.sidebarOpenModeStorageKey) === 'floating'; - } catch (e) { - return false; - } - } - - isVisible() { - if (!this.element?.isConnected) return false; - return getComputedStyle(this.element).display !== 'none'; - } - - dockTo(container) { - if (!container || !this.element) return; - - if (!this.docked) { - this.captureFloatingRect(); - } - - if (this.fullscreen) { - this.setFullScreen(false); - } - - this.docked = true; - this.dockContainer = container; - this.lastDockContainer = container; - this.pendingDockToSidebar = false; - this.rememberSidebarOpenMode('docked'); - this.backdrop.style.display = 'none'; - container.classList.add('ml-sidebar-dock-panel'); - this.element.classList.add('ml-is-docked'); - this.clearModalPlacementStyles(); - this.element.style.display = 'flex'; - - if (this.element.parentNode !== container) { - container.appendChild(this.element); - } - } - - dockToSidebar() { - if (this.docked) return; - - this.rememberSidebarOpenMode('docked'); - this.pendingDockToSidebar = true; - - if (this.isUsableDockContainer(this.lastDockContainer)) { - this.dockTo(this.lastDockContainer); - return; - } - - this.tryOpenComfySidebarState(); - - requestAnimationFrame(() => { - if (!this.pendingDockToSidebar || this.docked) return; - - const button = document.querySelector(`.${this.sidebarTabId}-tab-button`); - if (button instanceof HTMLElement) { - const isActive = button.matches([ - '[aria-pressed="true"]', - '[aria-selected="true"]', - '[data-active="true"]', - '[data-selected="true"]', - '.active', - '.is-active', - '.selected', - '.p-highlight' - ].join(',')); - - if (!isActive) { - button.click(); - } - } - }); - } - - isUsableDockContainer(container) { - if (!(container instanceof HTMLElement) || !container.isConnected) return false; - - const style = getComputedStyle(container); - if (style.display === 'none' || style.visibility === 'hidden') return false; - - const rect = container.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - } - - trySetSidebarStateProperty(target, property, value) { - try { - if (!(property in target)) return false; - return Reflect.set(target, property, value); - } catch (error) { - return false; - } - } - - tryOpenComfySidebarState() { - const extensionManager = app.extensionManager; - let opened = false; - const candidates = [ - extensionManager?.sidebarTab, - extensionManager?.sidebarTabs, - extensionManager?.sidebar, - extensionManager - ].filter(Boolean); - - for (const candidate of candidates) { - try { - if (typeof candidate.openSidebar === 'function') { - candidate.openSidebar(this.sidebarTabId); - opened = true; - } - if (typeof candidate.openSidebarTab === 'function') { - candidate.openSidebarTab(this.sidebarTabId); - opened = true; - } - if (typeof candidate.setActiveSidebarTab === 'function') { - candidate.setActiveSidebarTab(this.sidebarTabId); - opened = true; - } - if (typeof candidate.setActiveSidebarTabId === 'function') { - candidate.setActiveSidebarTabId(this.sidebarTabId); - opened = true; - } - if (this.trySetSidebarStateProperty(candidate, 'activeSidebarTabId', this.sidebarTabId)) { - opened = true; - } - } catch (error) { - // Sidebar internals differ between ComfyUI versions; unsupported state APIs are ignored. - } - } - - return opened; - } - - undockToFloating({ persist = true, closeSidebar = true } = {}) { - if (!this.element) return; - - const wasDocked = this.docked; - const dockContainer = this.dockContainer; - this.docked = false; - this.dockContainer = null; - this.element.classList.remove('ml-is-docked'); - this.clearModalPlacementStyles(); - - if (this.element.parentNode !== document.body) { - document.body.appendChild(this.element); - } - - this.element.style.display = 'flex'; - this.restoreFloatingGeometry(); - this.ensureModalHandleInViewport({ persist }); - - if (wasDocked && closeSidebar) { - this.closeComfySidebar(dockContainer); - } - } - - closeComfySidebar(dockContainer = null) { - const closedByState = this.tryCloseComfySidebarState(); - - requestAnimationFrame(() => { - if (dockContainer && !dockContainer.isConnected) return; - - const button = document.querySelector(`.${this.sidebarTabId}-tab-button`); - if (!(button instanceof HTMLElement)) return; - - const isActive = button.matches([ - '[aria-pressed="true"]', - '[aria-selected="true"]', - '[data-active="true"]', - '[data-selected="true"]', - '.active', - '.is-active', - '.selected', - '.p-highlight' - ].join(',')); - - if (!closedByState || isActive) { - button.click(); - } - }); - } - - tryCloseComfySidebarState() { - const extensionManager = app.extensionManager; - let closed = false; - const candidates = [ - extensionManager?.sidebarTab, - extensionManager?.sidebarTabs, - extensionManager?.sidebar, - extensionManager - ].filter(Boolean); - - for (const candidate of candidates) { - try { - if (typeof candidate.closeSidebar === 'function') { - candidate.closeSidebar(); - closed = true; - } - if (typeof candidate.closeSidebarTab === 'function') { - candidate.closeSidebarTab(this.sidebarTabId); - closed = true; - } - if (typeof candidate.setActiveSidebarTab === 'function') { - candidate.setActiveSidebarTab(null); - closed = true; - } - if (typeof candidate.setActiveSidebarTabId === 'function') { - candidate.setActiveSidebarTabId(null); - closed = true; - } - if ( - this.trySetSidebarStateProperty(candidate, 'activeSidebarTabId', null) || - this.trySetSidebarStateProperty(candidate, 'activeSidebarTab', null) - ) { - closed = true; - } - } catch (error) { - // Sidebar internals differ between ComfyUI versions; unsupported state APIs are ignored. - } - } - - return closed; - } - - // Toggle full screen mode for the dialog - toggleFullScreen() { - this.setFullScreen(!this.fullscreen); - } - - setFullScreen(enable) { - const shouldReturnToDocked = !enable && this.returnToDockedAfterFullscreen; - if (enable && this.docked) { - this.returnToDockedAfterFullscreen = true; - this.undockToFloating({ persist: false }); - } else if (enable) { - this.returnToDockedAfterFullscreen = false; - } - - this.fullscreen = !!enable; - const el = this.element; - if (!el) return; - const btn = document.getElementById('model-linker-fullscreen-toggle'); - if (enable) { - // Save current size - try { - const rect = el.getBoundingClientRect(); - localStorage.setItem('model_linker_modal_size_before_fs', JSON.stringify({ w: Math.round(rect.width), h: Math.round(rect.height) })); - } catch (e) {} - el.style.top = '0'; - el.style.left = '0'; - el.style.transform = 'none'; - el.style.width = '100vw'; - el.style.height = '100vh'; - el.style.maxWidth = '100vw'; - el.style.maxHeight = '100vh'; - el.style.borderRadius = '0'; - el.style.resize = 'none'; - if (btn) { - btn.innerHTML = getSvgIcon('windowRestore', 'currentColor', 'ml-window-btn-icon'); - btn.setAttribute('aria-label', 'Exit full screen'); - this.setTooltip(btn, 'Exit full screen'); - } - try { localStorage.setItem('model_linker_modal_fullscreen', '1'); } catch (e) {} - } else { - this.returnToDockedAfterFullscreen = false; - // Restore centered sizing - el.style.maxWidth = '100vw'; - el.style.maxHeight = '100vh'; - el.style.borderRadius = '8px'; - el.style.resize = 'both'; - // Restore saved pre-FS size if available - let wh = null; - try { wh = JSON.parse(localStorage.getItem('model_linker_modal_size_before_fs') || 'null'); } catch (e) {} - if (wh && wh.w && wh.h) { - el.style.width = `${wh.w}px`; - el.style.height = `${wh.h}px`; - } else { - el.style.width = '1100px'; - el.style.height = '700px'; - } - // Restore last known position if available, else center - try { - const pos = JSON.parse(localStorage.getItem('model_linker_modal_pos') || 'null'); - if (pos && Number.isFinite(pos.top) && Number.isFinite(pos.left)) { - el.style.top = `${pos.top}px`; - el.style.left = `${pos.left}px`; - el.style.transform = 'none'; - } else { - el.style.top = '50%'; - el.style.left = '50%'; - el.style.transform = 'translate(-50%, -50%)'; - } - } catch (e) { - el.style.top = '50%'; - el.style.left = '50%'; - el.style.transform = 'translate(-50%, -50%)'; - } - if (btn) { - btn.innerHTML = getSvgIcon('windowMaximize', 'currentColor', 'ml-window-btn-icon'); - btn.setAttribute('aria-label', 'Enter full screen'); - this.setTooltip(btn, 'Enter full screen'); - } - this.ensureModalHandleInViewport({ persist: true }); - try { localStorage.setItem('model_linker_modal_fullscreen', '0'); } catch (e) {} - - if (shouldReturnToDocked) { - this.dockToSidebar(); - } - } - } - - getViewportClampedModalPosition(top, left) { - const el = this.element; - if (!el) return { top, left }; - - const vw = window.innerWidth || document.documentElement.clientWidth || 0; - const vh = window.innerHeight || document.documentElement.clientHeight || 0; - const pad = 4; - const handle = document.getElementById('model-linker-drag-handle'); - - if (handle) { - const elRect = el.getBoundingClientRect(); - const handleRect = handle.getBoundingClientRect(); - const handleOffsetLeft = handleRect.left - elRect.left; - const handleOffsetTop = handleRect.top - elRect.top; - const handleWidth = handleRect.width || handle.offsetWidth; - const handleHeight = handleRect.height || handle.offsetHeight; - const minLeft = pad - handleOffsetLeft; - const maxLeft = vw - pad - handleOffsetLeft - handleWidth; - const minTop = pad - handleOffsetTop; - const maxTop = vh - pad - handleOffsetTop - handleHeight; - - left = Math.max(minLeft, Math.min(maxLeft, left)); - top = Math.max(minTop, Math.min(maxTop, top)); - } else { - const w = el.offsetWidth; - const h = el.offsetHeight; - left = Math.max(-w + pad, Math.min(vw - pad, left)); - top = Math.max(-h + pad, Math.min(vh - pad, top)); - } - - return { top, left }; - } - - saveModalPosition() { - if (this.docked) return; - - try { - const el = this.element; - if (!el) return; - const rect = el.getBoundingClientRect(); - localStorage.setItem('model_linker_modal_pos', JSON.stringify({ top: Math.round(rect.top), left: Math.round(rect.left) })); - } catch (e) { /* ignore */ } - } - - ensureModalHandleInViewport({ persist = false } = {}) { - if (this.docked) return; - if (this.fullscreen) return; - const el = this.element; - if (!el || getComputedStyle(el).display === 'none') return; - - const rect = el.getBoundingClientRect(); - const { top, left } = this.getViewportClampedModalPosition(rect.top, rect.left); - const nextTop = Math.round(top); - const nextLeft = Math.round(left); - - if (Math.round(rect.top) === nextTop && Math.round(rect.left) === nextLeft) return; - - el.style.top = `${nextTop}px`; - el.style.left = `${nextLeft}px`; - el.style.transform = 'none'; - - if (persist) this.saveModalPosition(); - } - - scheduleModalViewportClamp(persist = false) { - if (this._viewportClampFrame) { - cancelAnimationFrame(this._viewportClampFrame); - } - - this._viewportClampFrame = requestAnimationFrame(() => { - this._viewportClampFrame = null; - this.ensureModalHandleInViewport({ persist }); - }); - } - - // Begin window drag - startDrag(e) { - if (this.docked) return; - - try { - const el = this.element; - if (!el) return; - const rect = el.getBoundingClientRect(); - // Switch to absolute top/left (no transform) before dragging - el.style.top = `${rect.top}px`; - el.style.left = `${rect.left}px`; - el.style.transform = 'none'; - this._dragging = true; - this._dragStart = { - x: e.clientX, - y: e.clientY, - top: rect.top, - left: rect.left - }; - // Prevent text selection while dragging - this._prevUserSelect = document.body.style.userSelect; - document.body.style.userSelect = 'none'; - // Attach listeners - this._onMouseMove = (ev) => this.onDrag(ev); - this._onMouseUp = () => this.endDrag(); - document.addEventListener('mousemove', this._onMouseMove); - document.addEventListener('mouseup', this._onMouseUp, { once: true }); - } catch (err) { /* ignore */ } - } - - onDrag(e) { - if (!this._dragging || !this._dragStart) return; - const el = this.element; - if (!el) return; - const dx = e.clientX - this._dragStart.x; - const dy = e.clientY - this._dragStart.y; - let top = this._dragStart.top + dy; - let left = this._dragStart.left + dx; - ({ top, left } = this.getViewportClampedModalPosition(top, left)); - el.style.top = `${Math.round(top)}px`; - el.style.left = `${Math.round(left)}px`; - } - - endDrag() { - if (!this._dragging) return; - this._dragging = false; - document.removeEventListener('mousemove', this._onMouseMove); - // Persist position - this.saveModalPosition(); - // Restore selection - try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) {} - } - - /** - * Simple debounce helper - */ - debounce(callback, wait = 250) { - let t = null; - return (...args) => { - if (t) clearTimeout(t); - t = setTimeout(() => { - callback.apply(this, args); - }, wait); - }; - } - - /** - * Ensure all models are loaded for the dropdown. - */ - async ensureAllModelsLoaded() { - if (this.allModels && this.allModels.length) return; - try { - const resp = await api.fetchApi('/model_linker/models'); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const models = await resp.json(); - const list = Array.isArray(models) ? models : []; - // Build labels and sort alphabetically - this.allModels = list.map((m) => ({ - ...m, - __label: `${m.category ? m.category + ': ' : ''}${m.relative_path || m.filename || ''}` - })).sort((a, b) => (a.__label || '').localeCompare(b.__label || '')); - } catch (e) { - console.warn('Model Linker: could not load all models', e); - this.allModels = []; - } - } - - async ensureDownloadDirectoriesLoaded() { - if (this.downloadDirectories) return; - try { - const resp = await api.fetchApi('/model_linker/directories'); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const directories = await resp.json(); - this.downloadDirectories = directories && typeof directories === 'object' ? directories : {}; - } catch (e) { - console.warn('Model Linker: could not load download directories', e); - this.downloadDirectories = {}; - } - } - - async ensureCapabilitiesLoaded() { - if (this.capabilities) return; - try { - const resp = await api.fetchApi('/model_linker/capabilities'); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - this.capabilities = data && typeof data === 'object' ? data : { sources: {} }; - } catch (e) { - console.warn('Model Linker: could not load capabilities', e); - this.capabilities = { sources: {} }; - } - } - - isSourceAvailable(source) { - if (!source || ['all', 'local', 'huggingface', 'civitai', 'civarchive'].includes(source)) { - return true; - } - return Boolean(this.capabilities?.sources?.[source]); - } - - getCategoryDisplayName(category = '') { - const displayNames = { - 'checkpoints': 'checkpoint', - 'loras': 'lora', - 'vae': 'vae', - 'controlnet': 'controlnet', - 'embeddings': 'embedding', - 'upscale_models': 'upscale_model', - 'latent_upscale_models': 'latent_upscale_model', - 'diffusion_models': 'unet', - 'text_encoders': 'text encoders', - 'clip': 'clip', - 'clip_vision': 'clip_vision', - 'hypernetworks': 'hypernetwork' - }; - return displayNames[category] || category || 'unknown'; - } - - getModelTypeColorClass(value = '') { - const token = String(value || '') - .trim() - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .toLowerCase() - .replace(/[\s./\\-]+/g, '_') - .replace(/[^a-z0-9_]/g, '') - .replace(/_+/g, '_') - .replace(/^_|_$/g, ''); - const colorNames = { - checkpoints: 'model', - checkpoint: 'model', - ckpt: 'model', - model: 'model', - diffusion_models: 'model', - diffusion_model: 'model', - unet: 'model', - loras: 'lora', - lora: 'lora', - locon: 'lora', - lycoris: 'lora', - dora: 'lora', - hypernetworks: 'lora', - hypernetwork: 'lora', - style_models: 'style-model', - style_model: 'style-model', - vae: 'vae', - vaes: 'vae', - vae_approx: 'taesd', - taesd: 'taesd', - controlnet: 'controlnet', - control_net: 'controlnet', - controlnets: 'controlnet', - control_nets: 'controlnet', - t2i_adapter: 'controlnet', - t2i_adapters: 'controlnet', - embeddings: 'conditioning', - embedding: 'conditioning', - textualinversion: 'conditioning', - textual_inversion: 'conditioning', - aesthetic_gradient: 'conditioning', - text_encoders: 'clip', - text_encoder: 'clip', - clip: 'clip', - clips: 'clip', - clip_vision: 'clip-vision', - clipvision: 'clip-vision', - clip_vision_output: 'clip-vision-output', - upscale_models: 'image', - upscale_model: 'image', - upscaler: 'image', - upscalers: 'image', - latent_upscale_models: 'latent', - latent_upscale_model: 'latent', - latent: 'latent', - image: 'image', - images: 'image', - mask: 'mask', - masks: 'mask', - noise: 'noise', - sampler: 'sampler', - samplers: 'sampler', - sigmas: 'sigmas', - guider: 'guider', - guiders: 'guider' - }; - return `ml-type-chip ml-type-chip--${colorNames[token] || 'generic'}`; - } - - getCategoryTokenName(category = '') { - const tokenNames = { - 'checkpoints': 'checkpoint', - 'loras': 'lora', - 'vae': 'vae', - 'controlnet': 'controlnet', - 'embeddings': 'embedding', - 'upscale_models': 'upscale_model', - 'latent_upscale_models': 'latent_upscale_model', - 'diffusion_models': 'unet', - 'text_encoders': 'text_encoders', - 'clip': 'clip', - 'clip_vision': 'clip_vision', - 'hypernetworks': 'hypernetwork' - }; - return tokenNames[category] || category || 'unknown'; - } - - getDownloadCategoryOptions(defaultCategory = 'checkpoints') { - const directories = this.downloadDirectories || {}; - const keys = Object.keys(directories); - const preferred = defaultCategory || 'checkpoints'; - const ordered = [ - preferred, - ...keys.filter(key => key !== preferred) - ].filter((value, index, arr) => value && arr.indexOf(value) === index); - - return ordered.length > 0 ? ordered : [preferred]; - } - - getSearchSourceOptions() { - const sources = ['all', ...this.getEnabledSearchSources()]; - return sources.map(source => ({ - value: source, - label: this.getSearchSourceLabel(source) - })); - } - - getAvailableSubfolders(category = '') { - return this.downloadSubfolders.get((category || '').toLowerCase()) || []; - } - - normalizeFolderToken(value = '') { - return String(value || '') - .toLowerCase() - .replace(/[\/\\]+/g, ' ') - .replace(/[^a-z0-9]+/g, ''); - } - - getSuggestedCivitaiSubfolder(missing, category, folders = []) { - if ((category || '').toLowerCase() !== 'loras' || !folders.length) { - return ''; - } - - const civitaiData = { - ...(missing?.civitai_info || {}), - ...(missing?.civitai_search_result || {}), - ...(missing?.download_source || {}) - }; - const baseModel = civitaiData.base_model || ''; - const tags = Array.isArray(civitaiData.tags) ? civitaiData.tags.filter(Boolean) : []; - if (!baseModel) return ''; - - const priorityTags = [ - 'concept', - 'style', - 'character', - 'clothing', - 'pose', - 'object', - 'vehicle', - 'artist', - 'celebrity' - ]; - const normalizedBase = this.normalizeFolderToken(baseModel); - if (!normalizedBase) return ''; - - const folderEntries = folders.map(folder => { - const segments = String(folder || '').split(/[\/\\]/).filter(Boolean); - return { - value: folder, - segments, - normalizedSegments: segments.map(segment => this.normalizeFolderToken(segment)) - }; - }); - - const baseMatches = folderEntries.filter(entry => entry.normalizedSegments[0] === normalizedBase); - if (!baseMatches.length) return ''; - - const exactBase = baseMatches.find(entry => entry.segments.length === 1); - const orderedTags = [ - ...priorityTags.filter(tag => tags.some(value => this.normalizeFolderToken(value) === this.normalizeFolderToken(tag))), - ...tags - ].filter((value, index, arr) => value && arr.findIndex(other => this.normalizeFolderToken(other) === this.normalizeFolderToken(value)) === index); - - for (const tag of orderedTags) { - const normalizedTag = this.normalizeFolderToken(tag); - if (!normalizedTag) continue; - const match = baseMatches.find(entry => entry.normalizedSegments[1] === normalizedTag); - if (match) { - return match.value; - } - } - - return exactBase?.value || ''; - } - - async applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl) { - if (!categoryEl || !subfolderEl || subfolderEl.value.trim()) return; - - const category = this.getDropdownValue(categoryEl); - await this.ensureDownloadSubfoldersLoaded(category); - const folders = this.getAvailableSubfolders(category); - const suggestion = this.getSuggestedCivitaiSubfolder(missing, category, folders); - if (suggestion) { - subfolderEl.value = suggestion; - } - } - - applySearchResultSuggestion(missing) { - const categoryEl = this.contentElement?.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`); - const subfolderEl = this.contentElement?.querySelector(`#download-subfolder-${missing.node_id}-${missing.widget_index}`); - if (!categoryEl || !subfolderEl) return; - this.applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl); - } - - async ensureDownloadSubfoldersLoaded(category = '') { - const key = (category || '').trim().toLowerCase(); - if (!key) return []; - if (key === 'unknown') { - this.downloadSubfolders.set(key, []); - return []; - } - if (this.downloadSubfolders.has(key)) { - return this.downloadSubfolders.get(key) || []; - } - - try { - const resp = await api.fetchApi(`/model_linker/subfolders/${encodeURIComponent(key)}`); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const subfolders = await resp.json(); - const list = Array.isArray(subfolders) ? subfolders : []; - this.downloadSubfolders.set(key, list); - return list; - } catch (e) { - console.warn(`Model Linker: could not load subfolders for ${key}`, e); - this.downloadSubfolders.set(key, []); - return []; - } - } - - renderDownloadTargetControls(missing, defaultCategory = 'checkpoints') { - const selectId = `download-category-${missing.node_id}-${missing.widget_index}`; - const subfolderId = `download-subfolder-${missing.node_id}-${missing.widget_index}`; - const categoryListId = `download-category-list-${missing.node_id}-${missing.widget_index}`; - const subfolderListId = `download-subfolder-list-${missing.node_id}-${missing.widget_index}`; - const selectedCategory = defaultCategory || 'checkpoints'; - - let html = `
`; - html += `
`; - html += ``; - 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; - const search_source_enabled = this.getSearchSourceEnabledMap(); - - return { - civitai_key: localStorage.getItem('modelLinker.civitaiApiKey') || '', - civitai_session_token: localStorage.getItem('modelLinker.civitaiSessionToken') || '', - hf_token: localStorage.getItem('modelLinker.huggingFaceToken') || '', - brave_search_api_key: localStorage.getItem('modelLinker.braveSearchApiKey') || '', - civitai_use_trpc_search: localStorage.getItem('modelLinker.civitaiUseTrpcSearch') !== 'false', - civitai_use_html_fallback: localStorage.getItem('modelLinker.civitaiUseHtmlFallback') !== 'false', - hf_use_api_search: localStorage.getItem('modelLinker.hfUseApiSearch') !== 'false', - hf_use_comfy_org_fallback: localStorage.getItem('modelLinker.hfUseComfyOrgFallback') !== 'false', - hf_use_brave_fallback: localStorage.getItem('modelLinker.hfUseBraveFallback') !== 'false', - civitai_candidate_limit, - search_source_enabled - }; - } - - async clearSearchCaches() { - for (const state of this.searchResultCache.values()) { - state.activeSearchRunId = null; - } - this.clearAllSearchProgressTimers(); - this.searchResultCache.clear(); - const key = this.getWorkflowScopedQueueKey(); - if (key) { - this.workflowSearchResultCaches.delete(key); - } - try { - const response = await api.fetchApi('/model_linker/clear-search-cache', { - method: 'POST' - }); - if (!response.ok) { - throw new Error('Failed to clear backend search cache'); - } - } catch (error) { - console.error('Model Linker: Clear search cache error:', error); - } - } - - displayOptions() { - if (!this.contentElement) return; - this.contentElement.style.overflowY = 'hidden'; - - const tokens = this.getStoredTokens(); - const sourceDefaultsRows = this.getSearchSourceDefinitions() - .filter(def => this.isSourceAvailable(def.source)) - .map(def => { - const iconName = this.getSearchSourceIconName(def.source); - const checked = tokens.search_source_enabled?.[def.source] !== false; - const inputId = `ml-options-source-${def.source.replace(/_/g, '-')}`; - return ` - - `; - }) - .join(''); - this.contentElement.innerHTML = ` -
-
- -
-
-
-

Download Sources

-
-
-
-
- ${sourceDefaultsRows} -
-
-
-
- -
-
-

CivitAI

-
-
-
-
- -
-
-
- - - -
-
- - Not checked -
-
-
-
- - - -
-
- How to find the session token -
    -
  1. Sign in on civitai.com in your browser.
  2. -
  3. Open DevTools, then Application or Storage.
  4. -
  5. Open Cookies for civitai.com and copy __Secure-civitai-token.
  6. -
-

Treat it like a password. It is only needed when session-aware CivitAI search is required.

-
-
- - Not checked -
-
-
- -
-
-
- CivitAI Models To Inspect ? -
- -
-
-
-
-
-
-
-
-

HuggingFace

-
-
-
-
- -
-
-
- - - -
-
- - Not checked -
-
-
- -
-
-
- Comfy-Org files - Loading... -
-
- Last refresh - Loading... -
-
- Status - Checking local index... -
-
-
Comfy-Org fallback uses a cached HuggingFace file index.
-
- -
-
- -
-
-
- - - -
-
- - Not checked -
-
-
-
-
-
-
-
-
-
- `; - - 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 civitaiKeyCheckBtn = this.contentElement.querySelector('#ml-options-civitai-key-check'); - const civitaiKeyCheckStatus = this.contentElement.querySelector('#ml-options-civitai-key-check-status'); - const civitaiSessionCheckBtn = this.contentElement.querySelector('#ml-options-civitai-session-check'); - const civitaiSessionCheckStatus = this.contentElement.querySelector('#ml-options-civitai-session-check-status'); - 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 hfTokenCheckBtn = this.contentElement.querySelector('#ml-options-hf-token-check'); - const hfTokenCheckStatus = this.contentElement.querySelector('#ml-options-hf-token-check-status'); - const braveKeyCheckBtn = this.contentElement.querySelector('#ml-options-brave-key-check'); - const braveKeyCheckStatus = this.contentElement.querySelector('#ml-options-brave-key-check-status'); - const sourceEnabledInputs = Array.from(this.contentElement.querySelectorAll('.ml-options-source-enabled')); - const status = this.contentElement.querySelector('#ml-options-status'); - const saveBtn = this.contentElement.querySelector('#ml-options-save'); - const modelListCountEl = this.contentElement.querySelector('#ml-options-model-list-count'); - const modelListUpdatedEl = this.contentElement.querySelector('#ml-options-model-list-updated'); - const modelListStateEl = this.contentElement.querySelector('#ml-options-model-list-state'); - const modelListMessageEl = this.contentElement.querySelector('#ml-options-model-list-message'); - const modelListCheckBtn = this.contentElement.querySelector('#ml-options-model-list-check'); - const modelListUpdateBtn = this.contentElement.querySelector('#ml-options-model-list-update'); - const hfIndexCountEl = this.contentElement.querySelector('#ml-options-hf-index-count'); - const hfIndexUpdatedEl = this.contentElement.querySelector('#ml-options-hf-index-updated'); - const hfIndexStateEl = this.contentElement.querySelector('#ml-options-hf-index-state'); - const hfIndexMessageEl = this.contentElement.querySelector('#ml-options-hf-index-message'); - const hfIndexRefreshBtn = this.contentElement.querySelector('#ml-options-hf-index-refresh'); - const navButtons = Array.from(this.contentElement.querySelectorAll('.ml-options-nav-btn')); - const optionSections = Array.from(this.contentElement.querySelectorAll('.ml-options-section')); - const trackedInputs = [ - civitaiInput, - civitaiSessionInput, - hfInput, - braveInput, - civitaiLimitInput, - civitaiUseTrpcSearchInput, - civitaiUseHtmlFallbackInput, - hfUseApiSearchInput, - hfUseComfyOrgFallbackInput, - hfUseBraveFallbackInput, - ...sourceEnabledInputs, - ].filter(Boolean); - - const setStatus = (text, mode = '') => { - if (!status) return; - status.textContent = text; - status.classList.remove('is-dirty', 'is-saved'); - if (mode) status.classList.add(mode); - }; - - const formatOptionDate = (value) => { - if (!value) return 'Bundled'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString(); - }; - - const shortSha = (value) => value ? String(value).slice(0, 8) : ''; - - const setModelListBusy = (busy) => { - if (modelListCheckBtn) modelListCheckBtn.disabled = busy; - if (modelListUpdateBtn) modelListUpdateBtn.disabled = busy; - }; - - const setHfIndexBusy = (busy) => { - if (hfIndexRefreshBtn) hfIndexRefreshBtn.disabled = busy; - }; - - const setTokenCheckStatus = (statusEl, text, mode = '') => { - if (!statusEl) return; - statusEl.textContent = text; - statusEl.classList.remove('is-valid', 'is-invalid', 'is-pending'); - if (mode) statusEl.classList.add(mode); - }; - - const checkCredential = async ({ input, button, statusEl, endpoint, payloadKey, missingText }) => { - const value = (input?.value || '').trim(); - if (!value) { - setTokenCheckStatus(statusEl, missingText, 'is-invalid'); - return; - } - - try { - if (button) button.disabled = true; - setTokenCheckStatus(statusEl, 'Checking...', 'is-pending'); - const response = await api.fetchApi(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ [payloadKey]: value }) - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || `Check failed: ${response.status}`); - } - setTokenCheckStatus( - statusEl, - data.message || (data.valid ? 'Valid' : 'Invalid'), - data.valid ? 'is-valid' : 'is-invalid' - ); - } catch (error) { - console.error('Model Linker: Credential check error:', error); - setTokenCheckStatus(statusEl, error.message || 'Check failed', 'is-invalid'); - } finally { - if (button) button.disabled = false; - } - }; - - const renderModelListStatus = (data = {}, checkedRemote = false) => { - if (modelListCountEl) { - modelListCountEl.textContent = Number.isFinite(Number(data.local_count)) - ? Number(data.local_count).toLocaleString() - : '-'; - } - if (modelListUpdatedEl) { - modelListUpdatedEl.textContent = formatOptionDate(data.local_updated_at); - } - if (modelListStateEl) { - if (checkedRemote && data.can_compare && data.update_available) { - modelListStateEl.textContent = 'Update available'; - } else if (checkedRemote && data.can_compare) { - modelListStateEl.textContent = 'Up to date'; - } else if (checkedRemote && !data.can_compare) { - modelListStateEl.textContent = 'Remote checked'; - } else { - modelListStateEl.textContent = data.local_sha ? 'Tracked' : 'Bundled'; - } - } - if (modelListMessageEl) { - const localSha = shortSha(data.local_sha); - const remoteSha = shortSha(data.remote_sha); - if (checkedRemote && data.update_available) { - modelListMessageEl.textContent = `A newer ComfyUI-Manager model-list.json is available (${localSha || 'local'} -> ${remoteSha}).`; - } else if (checkedRemote && data.can_compare) { - modelListMessageEl.textContent = `Local Database is current (${localSha}).`; - } else if (checkedRemote) { - modelListMessageEl.textContent = `Remote file was checked. Local SHA is not recorded yet; run update once to track future changes.`; - } else { - modelListMessageEl.textContent = `Local Database uses ComfyUI-Manager model-list.json.`; - } - } - }; - - const loadModelListStatus = async (checkRemote = false) => { - try { - setModelListBusy(true); - if (modelListStateEl) { - modelListStateEl.textContent = checkRemote ? 'Checking GitHub...' : 'Loading...'; - } - const response = await api.fetchApi(`/model_linker/model-list/status${checkRemote ? '?check_remote=1' : ''}`); - if (!response.ok) { - throw new Error(`Status failed: ${response.status}`); - } - const data = await response.json(); - renderModelListStatus(data, checkRemote); - return data; - } catch (error) { - console.error('Model Linker: model-list status error:', error); - if (modelListStateEl) modelListStateEl.textContent = 'Error'; - if (modelListMessageEl) modelListMessageEl.textContent = error.message || 'Failed to read Local Database status.'; - return null; - } finally { - setModelListBusy(false); - } - }; - - const updateModelList = async () => { - try { - setModelListBusy(true); - if (modelListStateEl) modelListStateEl.textContent = 'Updating...'; - if (modelListMessageEl) modelListMessageEl.textContent = 'Downloading latest ComfyUI-Manager model-list.json...'; - const response = await api.fetchApi('/model_linker/model-list/update', { - method: 'POST' - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || `Update failed: ${response.status}`); - } - await this.clearSearchCaches(); - renderModelListStatus(data, false); - if (modelListStateEl) modelListStateEl.textContent = 'Updated'; - if (modelListMessageEl) modelListMessageEl.textContent = `Local Database updated. ${Number(data.local_count || 0).toLocaleString()} models loaded.`; - this.showNotification('Local Database updated', 'success'); - } catch (error) { - console.error('Model Linker: model-list update error:', error); - if (modelListStateEl) modelListStateEl.textContent = 'Error'; - if (modelListMessageEl) modelListMessageEl.textContent = error.message || 'Failed to update Local Database.'; - this.showNotification('Local Database update failed', 'error'); - } finally { - setModelListBusy(false); - } - }; - - const renderHfIndexStatus = (data = {}) => { - if (hfIndexCountEl) { - hfIndexCountEl.textContent = Number.isFinite(Number(data.file_count)) - ? Number(data.file_count).toLocaleString() - : '-'; - } - if (hfIndexUpdatedEl) { - hfIndexUpdatedEl.textContent = data.updated_at - ? formatOptionDate(Number(data.updated_at) * 1000) - : 'Never'; - } - if (hfIndexStateEl) { - if (!data.exists) { - hfIndexStateEl.textContent = 'Not cached'; - } else if (data.stale) { - hfIndexStateEl.textContent = 'Refresh due'; - } else { - hfIndexStateEl.textContent = 'Cached'; - } - } - if (hfIndexMessageEl) { - if (!data.exists) { - hfIndexMessageEl.textContent = 'The first Comfy-Org fallback search will build the local HuggingFace file index.'; - } else if (data.stale) { - hfIndexMessageEl.textContent = `Index has ${Number(data.repo_count || 0).toLocaleString()} repos and should be refreshed.`; - } else { - hfIndexMessageEl.textContent = `Index has ${Number(data.repo_count || 0).toLocaleString()} repos and is used before per-repo fallback checks.`; - } - } - }; - - const loadHfIndexStatus = async () => { - try { - setHfIndexBusy(true); - if (hfIndexStateEl) hfIndexStateEl.textContent = 'Loading...'; - const response = await api.fetchApi('/model_linker/huggingface/author-index/status'); - if (!response.ok) { - throw new Error(`Status failed: ${response.status}`); - } - const data = await response.json(); - renderHfIndexStatus(data); - return data; - } catch (error) { - console.error('Model Linker: HuggingFace index status error:', error); - if (hfIndexStateEl) hfIndexStateEl.textContent = 'Error'; - if (hfIndexMessageEl) hfIndexMessageEl.textContent = error.message || 'Failed to read HuggingFace index status.'; - return null; - } finally { - setHfIndexBusy(false); - } - }; - - const refreshHfIndex = async () => { - try { - setHfIndexBusy(true); - if (hfIndexStateEl) hfIndexStateEl.textContent = 'Refreshing...'; - if (hfIndexMessageEl) hfIndexMessageEl.textContent = 'Downloading Comfy-Org file index from HuggingFace...'; - const response = await api.fetchApi('/model_linker/huggingface/author-index/refresh', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ hf_token: hfInput?.value || '' }) - }); - const data = await response.json(); - if (!response.ok || data.success === false) { - throw new Error(data.error || `Refresh failed: ${response.status}`); - } - await this.clearSearchCaches(); - renderHfIndexStatus(data); - if (hfIndexStateEl) hfIndexStateEl.textContent = 'Refreshed'; - this.showNotification('HuggingFace Comfy-Org index refreshed', 'success'); - } catch (error) { - console.error('Model Linker: HuggingFace index refresh error:', error); - if (hfIndexStateEl) hfIndexStateEl.textContent = 'Error'; - if (hfIndexMessageEl) hfIndexMessageEl.textContent = error.message || 'Failed to refresh HuggingFace index.'; - this.showNotification('HuggingFace index refresh failed', 'error'); - } finally { - setHfIndexBusy(false); - } - }; - - const setActiveNav = (targetId) => { - navButtons.forEach((btn) => { - btn.classList.toggle('is-active', btn.dataset.target === targetId); - }); - }; - - const setVisibleSection = (targetId) => { - optionSections.forEach((section) => { - section.classList.toggle('is-hidden', section.id !== targetId); - }); - setActiveNav(targetId); - }; - - const getVisibilityIcon = (visible) => visible - ? getSvgIcon('eye') - : getSvgIcon('eyeOff'); - - const syncVisibilityToggle = (input, button) => { - if (!input || !button) return; - const visible = input.type === 'text'; - button.innerHTML = getVisibilityIcon(visible); - button.classList.toggle('ml-is-active-text', visible); - button.classList.toggle('ml-is-muted', !visible); - button.setAttribute('aria-pressed', visible ? 'true' : 'false'); - this.setTooltip(button, visible ? 'Hide saved value' : 'Show saved value'); - }; - - const bindVisibilityToggle = (input, button) => { - if (!input || !button) return; - syncVisibilityToggle(input, button); - button.addEventListener('click', () => { - input.type = input.type === 'password' ? 'text' : 'password'; - syncVisibilityToggle(input, button); - }); - }; - - bindVisibilityToggle(civitaiInput, civitaiToggle); - bindVisibilityToggle(civitaiSessionInput, civitaiSessionToggle); - bindVisibilityToggle(hfInput, hfToggle); - bindVisibilityToggle(braveInput, braveToggle); - setStatus('Saved only on this machine.'); - setVisibleSection('ml-options-section-sources'); - - trackedInputs.forEach((input) => { - const eventName = input.type === 'checkbox' ? 'change' : 'input'; - input.addEventListener(eventName, () => { - setStatus('You have unsaved changes.', 'is-dirty'); - }); - }); - - this.bindTooltips(this.contentElement); - - navButtons.forEach((btn) => { - btn.addEventListener('click', () => { - const targetId = btn.dataset.target; - if (!targetId) return; - setVisibleSection(targetId); - if (targetId === 'ml-options-section-local-db') { - loadModelListStatus(false); - } else if (targetId === 'ml-options-section-hf') { - loadHfIndexStatus(); - } - }); - }); - - if (modelListCheckBtn) { - modelListCheckBtn.addEventListener('click', () => { - loadModelListStatus(true); - }); - } - - if (modelListUpdateBtn) { - modelListUpdateBtn.addEventListener('click', () => { - updateModelList(); - }); - } - - if (hfIndexRefreshBtn) { - hfIndexRefreshBtn.addEventListener('click', () => { - refreshHfIndex(); - }); - } - - if (civitaiSessionCheckBtn) { - civitaiSessionCheckBtn.addEventListener('click', () => { - checkCredential({ - input: civitaiSessionInput, - button: civitaiSessionCheckBtn, - statusEl: civitaiSessionCheckStatus, - endpoint: '/model_linker/civitai/session-token/check', - payloadKey: 'civitai_session_token', - missingText: 'Paste token first' - }); - }); - } - - if (civitaiKeyCheckBtn) { - civitaiKeyCheckBtn.addEventListener('click', () => { - checkCredential({ - input: civitaiInput, - button: civitaiKeyCheckBtn, - statusEl: civitaiKeyCheckStatus, - endpoint: '/model_linker/civitai/api-key/check', - payloadKey: 'civitai_key', - missingText: 'Paste API key first' - }); - }); - } - - if (hfTokenCheckBtn) { - hfTokenCheckBtn.addEventListener('click', () => { - checkCredential({ - input: hfInput, - button: hfTokenCheckBtn, - statusEl: hfTokenCheckStatus, - endpoint: '/model_linker/huggingface/token/check', - payloadKey: 'hf_token', - missingText: 'Paste token first' - }); - }); - } - - if (braveKeyCheckBtn) { - braveKeyCheckBtn.addEventListener('click', () => { - checkCredential({ - input: braveInput, - button: braveKeyCheckBtn, - statusEl: braveKeyCheckStatus, - endpoint: '/model_linker/brave/api-key/check', - payloadKey: 'brave_search_api_key', - missingText: 'Paste API key first' - }); - }); - } - - loadModelListStatus(false); - loadHfIndexStatus(); - - if (saveBtn) { - saveBtn.addEventListener('click', async () => { - const civitaiCandidateLimitRaw = parseInt(civitaiLimitInput?.value || `${tokens.civitai_candidate_limit}`, 10); - const civitaiCandidateLimit = Number.isFinite(civitaiCandidateLimitRaw) - ? Math.min(20, Math.max(1, civitaiCandidateLimitRaw)) - : 5; - localStorage.setItem('modelLinker.civitaiApiKey', civitaiInput?.value || ''); - localStorage.setItem('modelLinker.civitaiSessionToken', civitaiSessionInput?.value || ''); - localStorage.setItem('modelLinker.civitaiUseTrpcSearch', civitaiUseTrpcSearchInput?.checked ? 'true' : 'false'); - localStorage.setItem('modelLinker.civitaiUseHtmlFallback', civitaiUseHtmlFallbackInput?.checked ? 'true' : 'false'); - localStorage.setItem('modelLinker.huggingFaceToken', hfInput?.value || ''); - localStorage.setItem('modelLinker.braveSearchApiKey', braveInput?.value || ''); - localStorage.setItem('modelLinker.hfUseApiSearch', hfUseApiSearchInput?.checked ? 'true' : 'false'); - localStorage.setItem('modelLinker.hfUseComfyOrgFallback', hfUseComfyOrgFallbackInput?.checked ? 'true' : 'false'); - localStorage.setItem('modelLinker.hfUseBraveFallback', hfUseBraveFallbackInput?.checked ? 'true' : 'false'); - localStorage.setItem('modelLinker.civitaiCandidateLimit', `${civitaiCandidateLimit}`); - if (sourceEnabledInputs.length && !sourceEnabledInputs.some(input => input.checked)) { - const localInput = sourceEnabledInputs.find(input => input.dataset.source === 'local'); - if (localInput) { - localInput.checked = true; - } - } - sourceEnabledInputs.forEach((input) => { - const key = input.dataset.storageKey; - if (key) { - localStorage.setItem(key, input.checked ? 'true' : 'false'); - } - }); - if (civitaiLimitInput) { - civitaiLimitInput.value = `${civitaiCandidateLimit}`; - } - await this.clearSearchCaches(); - setStatus('Options saved locally.', 'is-saved'); - this.showNotification('Options saved and search cache cleared', 'success'); - }); - } - - } - - getTabButton(tab) { - return { - missing: this.missingTab, - loaded: this.loadedTab, - options: this.optionsTab - }[tab] || null; - } - - updateTabButtonStates() { - ['missing', 'loaded', 'options'].forEach((tab) => { - const button = this.getTabButton(tab); - if (!button) return; - const isActive = tab === this.activeTab; - button.classList.toggle('ml-tab-active', isActive); - button.setAttribute('aria-selected', isActive ? 'true' : 'false'); - button.setAttribute('aria-disabled', isActive ? 'true' : 'false'); - if (isActive) { - button.setAttribute('aria-current', 'page'); - } else { - button.removeAttribute('aria-current'); - } - }); - } - - switchTab(tab, { force = false } = {}) { - const nextTab = this.getValidTab(tab); - const nextTabButton = this.getTabButton(nextTab); - if (!force && nextTab === this.activeTab && nextTabButton?.classList.contains('ml-tab-active')) { - this.hideTooltip(); - this.updateQueueVisibility(); - return; - } - - this.activeTab = nextTab; - this.persistActiveTab(this.activeTab); - this.hideTooltip(); - this.animateTabContentTransition(); - this.updateTabButtonStates(); - this.updateQueueVisibility(); - - if (this.activeTab === 'missing') { - if (this.contentElement) { - this.contentElement.style.overflowY = 'auto'; - } - this.setMissingFooterControlsVisible(true); - this.loadWorkflowData(); - } else if (this.activeTab === 'loaded') { - if (this.contentElement) { - this.contentElement.style.overflowY = 'auto'; - } - this.setMissingFooterControlsVisible(false); - this.loadLoadedModels(); - } else { - if (this.contentElement) { - this.contentElement.style.overflowY = 'hidden'; - } - this.setMissingFooterControlsVisible(false); - this.displayOptions(); - } - } - - async loadLoadedModels(workflow = null) { - if (!this.contentElement) return; - - this.contentElement.innerHTML = '

Loading loaded models...

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

No workflow loaded. Please load a workflow first.

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

Error: ${error.message}

`; - } - } - } - - displayLoadedModels(container, data) { - const loadedModels = data.loaded_models || []; - const total = data.total || 0; - - if (total === 0) { - container.innerHTML = this.renderStatusMessage('No models found in workflow.', 'info'); - return; - } - - const byCategory = {}; - - for (const model of loadedModels) { - const cat = model.category || 'unknown'; - if (!byCategory[cat]) { - byCategory[cat] = { active: [], inactive: [] }; - } - - // Determine if model is active or inactive - // For LoraLoaderV2/LoraManager: check model.active field - // For other nodes: check model.connected field (false means not connected or bypassed) - let isActive = true; - if (model.is_lora_v2) { - // For text-based lora loaders, check both active flag AND connected status - isActive = model.active !== false && model.connected !== false; - } else { - // For regular nodes, check connected status - isActive = model.connected !== false; - } - - if (isActive) { - byCategory[cat].active.push(model); - } else { - byCategory[cat].inactive.push(model); - } - } - - const activeCount = Object.values(byCategory).reduce((sum, cat) => sum + cat.active.length, 0); - const inactiveCount = Object.values(byCategory).reduce((sum, cat) => sum + cat.inactive.length, 0); - - const buildCategoryStrings = (filter) => { - const result = {}; - for (const [category, modelsObj] of Object.entries(byCategory)) { - const displayCat = this.getCategoryTokenName(category); - const models = filter === 'active' ? modelsObj.active : filter === 'inactive' ? modelsObj.inactive : [...modelsObj.active, ...modelsObj.inactive]; - const parts = models.map(model => { - const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown'; - let name = fullName; - if (fullName.match(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i)) { - name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); - } - const strength = model.strength !== null && model.strength !== undefined - ? model.strength.toFixed(2) - : '1.00'; - return `<${displayCat}:${name}:${strength}>`; - }); - result[category] = parts.join(' '); - } - return Object.values(result).join(' '); - }; - - const activeString = buildCategoryStrings('active'); - const inactiveString = buildCategoryStrings('inactive'); - const allString = buildCategoryStrings('all'); - - const copyIcon = getSvgIcon('copy', 'currentColor', 'ml-copy-btn-icon'); - const stripModelExtension = (value) => String(value || 'Unknown').replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); - const modelToken = (model, category) => { - const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown'; - const name = stripModelExtension(fullName); - const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : '1.00'; - return `<${this.getCategoryTokenName(category)}:${name}:${strength}>`; - }; - const copyButtonHtml = (text, extraClass = '') => ` - `; - const countText = (count, label) => `${count} ${label}${count === 1 ? '' : 's'}`; - - let html = ` -
-
-

Loaded Models ${total}

-

${countText(activeCount, 'active')} / ${countText(inactiveCount, '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; - const sectionTotal = modelsObj.active.length + modelsObj.inactive.length; - - html += `
`; - - html += `
-
- ${this.escapeHtml(displayName.toUpperCase())} - ${countText(sectionTotal, 'model')} -
-
- ${hasActive ? `${countText(modelsObj.active.length, 'active')}` : ''} - ${hasInactive ? `${countText(modelsObj.inactive.length, 'inactive')}` : ''} -
-
`; - - if (hasActive) { - const activeStr = modelsObj.active.map(m => modelToken(m, category)).join(' '); - - html += `
-
- Active ${modelsObj.active.length} - ${copyButtonHtml(activeStr)} -
-
`; - - for (const model of modelsObj.active) { - const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown'; - const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); - const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null; - const modelData = encodeURIComponent(JSON.stringify(model)); - html += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; - } - html += `
`; - } - - if (hasInactive) { - const inactiveStr = modelsObj.inactive.map(m => modelToken(m, category)).join(' '); - - html += `
-
- Inactive ${modelsObj.inactive.length} - ${copyButtonHtml(inactiveStr, 'is-muted')} -
-
`; - - for (const model of modelsObj.inactive) { - const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown'; - const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); - const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null; - const modelData = encodeURIComponent(JSON.stringify(model)); - html += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; - } - html += `
`; - } - - html += `
`; - } - - const copySectionId = 'ml-copy-' + Date.now(); - html += ` -
-
-
-
-
Copy all
-
${countText(activeCount + inactiveCount, 'token')}
-
-
- - - - -
-
- ${this.escapeHtml(allString)} -
- `; - - container.innerHTML = html; - - // Store data on container for filter function - container.dataset.mlActiveString = activeString; - container.dataset.mlInactiveString = inactiveString; - container.dataset.mlAllString = allString; - } - - createContent() { - // Wrap the body in a two-column layout: left = items, right = queued panel - const body = $el("div", { - id: "model-linker-body" - }); - - this.contentElement = $el("div.ml-scrollable", { - id: "model-linker-content" - }); - - this.queueElement = $el("div", { - id: "model-linker-queue" - }, [ - this.createQueuePanel() - ]); - - // Splitter between content and queue - this.splitterElement = $el("div", { - id: "model-linker-splitter", - ondragstart: (e) => e.preventDefault() - }); - - body.appendChild(this.contentElement); - body.appendChild(this.splitterElement); - body.appendChild(this.queueElement); - - // Restore saved queue width and wire splitter - try { - const savedSplit = localStorage.getItem('model_linker_split_w'); - if (savedSplit) { - const w = parseInt(savedSplit, 10); - if (!isNaN(w) && w > 0) { - this.queueElement.style.width = `${w}px`; - } - } - } catch (e) { } - - try { - const onSplitMouseDown = (e) => this.startSplitDrag(e); - this.splitterElement.addEventListener('mousedown', onSplitMouseDown); - this._splitterMouseDown = onSplitMouseDown; - } catch (e) { } - - // Toggle icon always visible - try { - this.queueToggleIcon = $el("button", { - id: "queue-toggle-icon", - type: "button", - "aria-label": "Toggle queued selections", - onclick: (event) => this.onQueueEdgeClick(event), - onmousedown: (event) => this.startQueueEdgeDrag(event) - }); - body.appendChild(this.queueToggleIcon); - this.updateQueueToggleIcon(); - } catch (e) { } - - // Restore queue collapsed state - try { - const col = localStorage.getItem('model_linker_queue_collapsed'); - if (col === '1') this.setQueueCollapsed(true); - } catch (e) { } - - return body; - } - - createQueuePanel() { - // Header row with title and clear button - this.queueHeader = $el("div.ml-queue-header", {}, [ - $el("div#queue-title.ml-queue-title", { textContent: "Queued Selections (0)" }), - $el("div.ml-queue-actions", {}, [ - $el("button", { - id: "queue-toggle", - className: "ml-btn ml-btn-secondary ml-btn-sm", - textContent: "Collapse", - onclick: () => this.toggleQueueCollapsed() - }), - $el("button", { - id: "queue-clear", - className: "ml-btn ml-btn-secondary ml-btn-sm", - textContent: "Clear All", - onclick: () => this.clearAllQueued() - }) - ]) - ]); - - // Scrollable list - this.queueList = $el("div#queue-list.ml-queue-list"); - - const panel = $el("div.ml-queue-panel", {}, [ - $el("div.ml-queue-stack", {}, [this.queueHeader, this.queueList]) - ]); - return panel; - } - - updateQueuePanel() { - if (!this.queueList || !this.queueHeader) return; - const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : []; - // Update title count - const title = this.queueHeader.querySelector('#queue-title'); - if (title) title.textContent = `Queued Selections (${list.length})`; - const toggleBtn = this.queueHeader.querySelector('#queue-toggle'); - if (toggleBtn) toggleBtn.textContent = this.queueCollapsed ? 'Expand' : 'Collapse'; - - if (!list.length) { - this.queueList.innerHTML = '
No selections queued.
'; - return; - } - - let html = '
'; - for (let i = 0; i < list.length; i++) { - const r = list[i]; - const label = (r.resolved_model?.relative_path || r.resolved_model?.filename || r.resolved_path || '').toString(); - const nodeLabel = r.node_label || r.node_type || (r.subgraph_id ? 'Subgraph' : 'Node'); - const orig = (r.original_path || '').toString(); - const rmId = `queue-remove-${i}`; - html += `
`; - html += `
${this.escapeHtml(nodeLabel)} #${this.escapeHtml(String(r.node_id))}
`; - html += `
Original${this.escapeHtml(orig)}
`; - html += `
Selected${this.escapeHtml(label)}
`; - html += `
`; - html += `
`; - } - html += '
'; - this.queueList.innerHTML = html; - - // Wire remove buttons - for (let i = 0; i < list.length; i++) { - const rmId = `queue-remove-${i}`; - const btn = this.queueList.querySelector(`#${rmId}`); - if (btn) { - btn.addEventListener('click', () => this.removeQueuedByIndex(i)); - } - } - } - - // Remove queued by index - removeQueuedByIndex(i) { - const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : []; - if (i < 0 || i >= list.length) return; - const r = list[i]; - // Remove - this.pendingResolutions.splice(i, 1); - this.rebuildPendingIndex(); - this.savePendingQueueForActiveWorkflow(); - // Update per-item selected bar - const m = { node_id: r.node_id, widget_index: r.widget_index, subgraph_id: r.subgraph_id, is_top_level: r.is_top_level }; - this.updateSelectedBarForMissing?.(m); - this.updateApplyPendingButton?.(); - this.updateQueuePanel(); - } - - // Clear all queued selections - clearAllQueued() { - this.pendingResolutions = []; - this.pendingIndex = new Map(); - this.savePendingQueueForActiveWorkflow(); - this.updateApplyPendingButton?.(); - this.updateQueuePanel(); - try { - document.querySelectorAll('.model-linker-selected').forEach(el => { el.style.display = 'none'; el.innerHTML = ''; }); - } catch (e) { /* ignore */ } - } - - // Update selected bar for a specific missing model slot - updateSelectedBarForMissing(missing) { - if (!missing) return; - const nodeId = missing.node_id; - const widgetIndex = missing.widget_index; - const subgraphId = missing.subgraph_id || ''; - const isTopLevel = missing.is_top_level !== false; - const key = `${nodeId}:${widgetIndex}:${subgraphId}:${isTopLevel ? 'T' : 'F'}`; - - const selectedBar = document.getElementById(`selected-bar-${nodeId}-${widgetIndex}`); - if (!selectedBar) return; - - // Find selection for this slot - let selection = null; - let selectionIdx = -1; - if (this.pendingIndex.has(key)) { - const idx = this.pendingIndex.get(key); - if (idx >= 0 && idx < this.pendingResolutions.length) { - selection = this.pendingResolutions[idx]; - selectionIdx = idx; - } - } - - if (!selection) { - selectedBar.style.display = 'none'; - selectedBar.innerHTML = ''; - return; - } - - // Build selected bar content - const label = selection.resolved_model?.relative_path || selection.resolved_model?.filename || selection.resolved_path || ''; - const resolveBtnId = `selected-remove-${nodeId}-${widgetIndex}`; - - selectedBar.innerHTML = `
`; - selectedBar.innerHTML += `✓ Selected:`; - selectedBar.innerHTML += `${label}`; - selectedBar.innerHTML += ``; - selectedBar.innerHTML += `
`; - selectedBar.style.display = 'block'; - - // Wire remove button - use key-based removal - const removeBtn = selectedBar.querySelector(`#${resolveBtnId}`); - if (removeBtn) { - removeBtn.addEventListener('click', () => { - this.removeQueuedByKey(key); - }); - } - } - - // Remove queued by key (more reliable than index) - removeQueuedByKey(key) { - if (!key || !this.pendingIndex.has(key)) return; - - // Find and remove the item with this key - const idx = this.pendingIndex.get(key); - if (idx >= 0 && idx < this.pendingResolutions.length) { - const r = this.pendingResolutions[idx]; - // Update the selected bar before removing - const m = { node_id: r.node_id, widget_index: r.widget_index, subgraph_id: r.subgraph_id, is_top_level: r.is_top_level }; - - // Remove from array - this.pendingResolutions.splice(idx, 1); - this.rebuildPendingIndex(); - this.savePendingQueueForActiveWorkflow(); - this.updateSelectedBarForMissing(m); - this.updateApplyPendingButton?.(); - this.updateQueuePanel(); - } - } - - // Rebuild pending index after modification - rebuildPendingIndex() { - this.pendingIndex = new Map(); - for (let i = 0; i < this.pendingResolutions.length; i++) { - const r = this.pendingResolutions[i]; - const key = `${r.node_id}:${r.widget_index}:${r.subgraph_id || ''}:${r.is_top_level ? 'T' : 'F'}`; - this.pendingIndex.set(key, i); - } - } - - // Collapse/expand queue panel - toggleQueueCollapsed() { - this.setQueueCollapsed(!this.queueCollapsed); - } - - setQueueCollapsed(collapsed) { - this.queueCollapsed = !!collapsed; - if (this.queueCollapsed) { - try { localStorage.setItem('model_linker_queue_collapsed', '1'); } catch (e) { } - } else { - try { localStorage.setItem('model_linker_queue_collapsed', '0'); } catch (e) { } - } - this.updateQueueVisibility(); - this.updateQueuePanel(); - } - - updateQueueVisibility() { - const isMissingTab = this.activeTab === 'missing'; - const showQueuePanel = isMissingTab && !this.queueCollapsed; - - if (this.queueElement) { - this.queueElement.style.display = showQueuePanel ? '' : 'none'; - } - if (this.splitterElement) { - this.splitterElement.style.display = showQueuePanel ? '' : 'none'; - } - if (this.queueToggleIcon) { - this.queueToggleIcon.style.display = isMissingTab ? '' : 'none'; - } - - this.updateQueueToggleIcon(); - } - - updateQueueToggleIcon() { - if (!this.queueToggleIcon) return; - this.queueToggleIcon.style.display = this.activeTab === 'missing' ? '' : 'none'; - this.queueToggleIcon.textContent = ''; - this.queueToggleIcon.removeAttribute('data-tooltip'); - this.queueToggleIcon.removeAttribute('title'); - if (this.queueCollapsed) { - this.queueToggleIcon.classList.add('is-collapsed'); - this.queueToggleIcon.setAttribute('aria-label', 'Show queued selections'); - } else { - this.queueToggleIcon.classList.remove('is-collapsed'); - this.queueToggleIcon.setAttribute('aria-label', 'Hide queued selections'); - } - } - - onQueueEdgeClick(event) { - if (this._suppressQueueEdgeClick) { - event?.preventDefault?.(); - event?.stopPropagation?.(); - this._suppressQueueEdgeClick = false; - return; - } - this.toggleQueueCollapsed(); - } - - startQueueEdgeDrag(event) { - if (event.button !== 0 || !this.queueCollapsed || !this.queueElement) return; - - const startX = event.clientX; - const previousCursor = document.body.style.cursor; - event.preventDefault(); - document.body.style.cursor = 'col-resize'; - this._suppressQueueEdgeClick = true; - this.splitterElement.style.display = ''; - document.getElementById('model-linker-body')?.classList.add('ml-queue-preview-collapsed'); - this.queueElement.style.display = 'none'; - this.queueElement.style.width = '0px'; - this.startSplitDrag( - { clientX: startX, preventDefault: () => {} }, - { startWidth: 0, edgeOpen: true, previousCursor, startCollapsedPreview: true } - ); - } - - // Begin split drag for resizable panels - startSplitDrag(e, { startWidth = null, edgeOpen = false, previousCursor = null, startCollapsedPreview = false } = {}) { - try { - e?.preventDefault?.(); - if (!this.queueElement) return; - const rect = this.queueElement.getBoundingClientRect(); - const body = document.getElementById('model-linker-body'); - const bodyRect = body ? body.getBoundingClientRect() : { width: window.innerWidth }; - this._splitDragging = true; - body?.classList.add('ml-is-resizing-queue'); - this._splitStart = { - x: e.clientX, - startWidth: Number.isFinite(startWidth) ? startWidth : rect.width, - containerWidth: bodyRect.width - }; - this._lastSplitDragApply = 0; - this._splitPreviewCollapsed = !!startCollapsedPreview; - this._splitEdgeOpen = !!edgeOpen; - this._splitEdgeOpenedPastThreshold = false; - this._splitPreviousCursor = previousCursor; - this._prevUserSelect = document.body.style.userSelect; - document.body.style.userSelect = 'none'; - this._onSplitMove = (ev) => this.onSplitDrag(ev); - this._onSplitUp = () => this.endSplitDrag(); - document.addEventListener('mousemove', this._onSplitMove); - document.addEventListener('mouseup', this._onSplitUp, { once: true }); - } catch (err) { /* ignore */ } - } - - onSplitDrag(e) { - if (!this._splitDragging || !this._splitStart || !this.queueElement) return; - const dx = e.clientX - this._splitStart.x; - let newW = this._splitStart.startWidth - dx; - const minW = this._splitEdgeOpen ? 0 : 56; - const maxW = Math.max(minW, Math.floor(this._splitStart.containerWidth - 360)); - if (newW < minW) newW = minW; - if (newW > maxW) newW = maxW; - this._pendingSplitWidth = Math.round(newW); - if (this._splitEdgeOpen && this._pendingSplitWidth > 140) { - this._splitEdgeOpenedPastThreshold = true; - if (this.queueCollapsed) { - this.queueCollapsed = false; - try { localStorage.setItem('model_linker_queue_collapsed', '0'); } catch (error) { } - this.updateQueueToggleIcon(); - this.updateQueuePanel(); - } - } - const previewCollapsed = this._splitEdgeOpen - ? (!this._splitEdgeOpenedPastThreshold || this._pendingSplitWidth <= 140) - : this._pendingSplitWidth <= 140; - if (previewCollapsed !== this._splitPreviewCollapsed) { - this._splitPreviewCollapsed = previewCollapsed; - const body = document.getElementById('model-linker-body'); - body?.classList.toggle('ml-queue-preview-collapsed', previewCollapsed); - this.queueElement.style.display = previewCollapsed ? 'none' : ''; - } - const now = performance.now(); - if (this._lastSplitDragApply && now - this._lastSplitDragApply < 33) return; - this._lastSplitDragApply = now; - if (this._splitDragFrame) return; - this._splitDragFrame = requestAnimationFrame(() => { - this._splitDragFrame = null; - if (!this._splitDragging || !this.queueElement || !this._pendingSplitWidth || this._splitPreviewCollapsed) return; - this.queueElement.style.width = `${this._pendingSplitWidth}px`; - }); - } - - endSplitDrag() { - if (!this._splitDragging) return; - this._splitDragging = false; - if (this._splitDragFrame) { - cancelAnimationFrame(this._splitDragFrame); - this._splitDragFrame = null; - } - if (this.queueElement && this._pendingSplitWidth && !this._splitPreviewCollapsed) { - this.queueElement.style.width = `${this._pendingSplitWidth}px`; - } - const body = document.getElementById('model-linker-body'); - body?.classList.remove('ml-is-resizing-queue'); - body?.classList.remove('ml-queue-preview-collapsed'); - document.removeEventListener('mousemove', this._onSplitMove); - if (this._splitEdgeOpen && !this._pendingSplitWidth) { - try { - const restoreWidth = Math.max(240, Math.round(this._splitStart?.startWidth || 320)); - this.queueElement.style.width = `${restoreWidth}px`; - this.queueElement.style.display = ''; - if (this.splitterElement) this.splitterElement.style.display = ''; - localStorage.setItem('model_linker_split_w', String(restoreWidth)); - } catch (e) { } - this.queueCollapsed = false; - try { localStorage.setItem('model_linker_queue_collapsed', '0'); } catch (e) { } - this.updateQueueVisibility(); - this.updateQueuePanel(); - this._splitEdgeOpen = false; - this._pendingSplitWidth = null; - this._lastSplitDragApply = 0; - this._splitPreviewCollapsed = false; - try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { } - try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { } - this._splitPreviousCursor = null; - this._splitEdgeOpenedPastThreshold = false; - setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0); - return; - } - const shouldCollapse = !!this._splitPreviewCollapsed; - if (shouldCollapse) { - try { - const restoreWidth = Math.max(240, Math.round(this._splitStart?.startWidth || 320)); - this.queueElement.style.width = `${restoreWidth}px`; - this.queueElement.style.display = ''; - localStorage.setItem('model_linker_split_w', String(restoreWidth)); - } catch (e) { } - this._pendingSplitWidth = null; - this._lastSplitDragApply = 0; - this._splitPreviewCollapsed = false; - this._splitEdgeOpen = false; - this._splitEdgeOpenedPastThreshold = false; - try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { } - try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { } - this._splitPreviousCursor = null; - this.setQueueCollapsed(true); - setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0); - return; - } - if (this.queueElement) this.queueElement.style.display = ''; - try { - const rect = this.queueElement.getBoundingClientRect(); - localStorage.setItem('model_linker_split_w', String(Math.round(rect.width))); - } catch (e) { } - this._pendingSplitWidth = null; - this._lastSplitDragApply = 0; - this._splitPreviewCollapsed = false; - this._splitEdgeOpen = false; - this._splitEdgeOpenedPastThreshold = false; - try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { } - if (this._splitPreviousCursor !== null) { - try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { } - this._splitPreviousCursor = null; - } - setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0); - } - - /** - * Queue a resolution for later batch apply - */ - queueResolution(missing, resolvedModel) { - if (!resolvedModel) { - this.showNotification('No model selected', 'error'); - return; - } - - const resolution = { - node_id: missing.node_id, - widget_index: missing.widget_index, - resolved_path: resolvedModel.path, - category: missing.category, - resolved_model: resolvedModel, - original_path: missing.original_path, - subgraph_id: missing.subgraph_id, - is_top_level: missing.is_top_level, - node_type: missing.node_type, - node_label: missing.subgraph_name || missing.node_type - }; - - const key = `${resolution.node_id}:${resolution.widget_index}:${resolution.subgraph_id || ''}:${resolution.is_top_level ? 'T' : 'F'}`; - if (this.pendingIndex.has(key)) { - // replace existing selection for this slot - const idx = this.pendingIndex.get(key); - this.pendingResolutions[idx] = resolution; - } else { - this.pendingIndex.set(key, this.pendingResolutions.length); - this.pendingResolutions.push(resolution); - } - this.savePendingQueueForActiveWorkflow(); - - // Update selected bar UI - this.updateSelectedBarForMissing?.(missing); - this.updateQueuePanel(); - this.updateApplyPendingButton(); - } - - /** - * Apply all pending resolutions in batch - */ - async applyPendingResolutions() { - const list = this.pendingResolutions || []; - if (!list.length) { - this.showNotification('No selections queued', 'error'); - return; - } - - try { - const workflow = this.getCurrentWorkflow(); - if (!workflow) { - this.showNotification('No workflow loaded', 'error'); - return; - } - - const response = await api.fetchApi('/model_linker/resolve', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workflow, resolutions: list }) - }); - - if (!response.ok) throw new Error(`API error: ${response.status}`); - - const data = await response.json(); - if (data.success) { - await this.updateWorkflowInComfyUI(data.workflow); - this.showNotification(`✓ Linked ${list.length} selection${list.length>1?'s':''}`, 'success'); - // Clear queue and refresh analysis - this.pendingResolutions = []; - this.pendingIndex = new Map(); - this.savePendingQueueForActiveWorkflow(); - this.updateApplyPendingButton(); - this.updateQueuePanel(); - await this.loadWorkflowData(data.workflow); - } else { - this.showNotification('Failed to apply selections: ' + (data.error || 'Unknown error'), 'error'); - } - } catch (e) { - console.error('Model Linker: applyPendingResolutions error', e); - this.showNotification('Error applying selections: ' + e.message, 'error'); - } - } - - updateApplyPendingButton() { - if (!this.applyPendingBtn) return; - const count = this.pendingResolutions?.length || 0; - const isEmpty = count === 0; - const tooltip = count > 0 - ? `Apply ${count} queued model link${count > 1 ? 's' : ''} to the current workflow.` - : 'Apply the model links you selected from local matches or search results.'; - this.applyPendingBtn.textContent = `Apply Selected (${count})`; - this.setTooltip(this.applyPendingBtn, tooltip); - this.applyPendingBtn.setAttribute('aria-disabled', String(isEmpty)); - this.applyPendingBtn.classList.toggle('ml-btn-is-disabled', isEmpty); - this.updateBatchFooterButtons(); - } - - updateBatchFooterButtons() { - const missingModels = this.missingModels || []; - const selectedCount = this.getSelectedMissingModels().length; - const totalCount = missingModels.length; - const pendingCount = this.pendingResolutions?.length || 0; - const activeCount = Object.keys(this.activeDownloads || {}).length; - const downloadableSelected = this.getMissingWithDownloadSources(this.getSelectedMissingModels()).length; - const downloadableAll = this.getMissingWithDownloadSources(missingModels).length; - - if (this.selectMenuButton) { - this.selectMenuButton.textContent = `Select (${selectedCount}/${totalCount})`; - this.selectMenuButton.classList.toggle('ml-btn-is-disabled', totalCount === 0); - } - if (this.searchMenuButton) { - this.searchMenuButton.textContent = this.batchSearchRunning ? 'Searching...' : 'Search'; - this.searchMenuButton.classList.toggle('ml-btn-is-disabled', totalCount === 0 || this.batchSearchRunning); - } - if (this.downloadMenuButton) { - const label = activeCount > 0 ? `Cancel Downloads (${activeCount})` : 'Download'; - this.downloadMenuButton.textContent = label; - this.downloadMenuButton.classList.toggle('ml-btn-danger', activeCount > 0); - this.downloadMenuButton.classList.toggle('ml-btn-download', activeCount === 0); - this.downloadMenuButton.classList.toggle('ml-btn-is-disabled', activeCount === 0 && downloadableAll === 0); - this.setTooltip( - this.downloadMenuButton, - activeCount > 0 - ? `Cancel ${activeCount} active download${activeCount > 1 ? 's' : ''}.` - : `${downloadableSelected || downloadableAll} missing model${(downloadableSelected || downloadableAll) === 1 ? '' : 's'} currently have download sources.` - ); - } - if (this.applyPendingBtn) { - this.applyPendingBtn.textContent = `Apply Selected (${pendingCount})`; - } - } - - createFooterMenu(name, label, items = [], buttonClass = 'ml-btn-secondary') { - const button = $el(`button.ml-btn.${buttonClass}.ml-footer-btn.ml-footer-menu-button`, { - type: 'button', - 'aria-haspopup': 'menu', - 'aria-expanded': 'false', - onclick: (event) => { - event.stopPropagation(); - if (button.classList.contains('ml-btn-is-disabled')) { - if (name === 'search' && this.batchSearchRunning) { - this.showNotification('Batch search is already running.', 'info'); - } else if (name === 'download') { - this.showNotification('No missing models have downloadable sources yet.', 'info'); - } else { - this.showNotification('No missing models available.', 'info'); - } - return; - } - this.toggleFooterMenu(name); - } - }, [$el("span", { textContent: label })]); - - const menu = $el("div.ml-footer-menu", { role: 'menu' }, items.map(item => ( - item === 'divider' - ? $el("div.ml-footer-menu-divider", {}) - : $el("button.ml-footer-menu-item", { - type: 'button', - role: 'menuitem', - onclick: (event) => { - event.stopPropagation(); - item.action(); - } - }, [$el("span", { textContent: item.label })]) - ))); - - this.footerMenuButtons.set(name, button); - this.footerMenus.set(name, menu); - - return $el("div.ml-footer-menu-wrap", {}, [button, menu]); - } - - createFooter() { - this.footerMenus = new Map(); - this.footerMenuButtons = new Map(); - - const selectMenu = this.createFooterMenu('select', 'Select (0/0)', [ - { label: 'Select All', action: () => this.selectBatchMissingModels('all') }, - { label: 'Select None', action: () => this.selectBatchMissingModels('none') }, - { label: 'Invert Selection', action: () => this.selectBatchMissingModels('invert') }, - 'divider', - { label: 'Select Exact Local', action: () => this.selectBatchMissingModels('exact') }, - { label: 'Select No Exact Local', action: () => this.selectBatchMissingModels('no_exact') }, - { label: 'Select Partial Local', action: () => this.selectBatchMissingModels('partial') }, - { label: 'Select No Local Match', action: () => this.selectBatchMissingModels('no_local') }, - 'divider', - { label: 'Select With Download Source', action: () => this.selectBatchMissingModels('downloadable') }, - { label: 'Select Without Download Source', action: () => this.selectBatchMissingModels('no_download') }, - 'divider', - { label: 'Select Searched', action: () => this.selectBatchMissingModels('searched') }, - { label: 'Select Unsearched', action: () => this.selectBatchMissingModels('unsearched') } - ]); - this.selectMenuButton = this.footerMenuButtons.get('select'); - this.selectMenuWrap = selectMenu; - - const searchMenu = this.createFooterMenu('search', 'Search', [ - { label: 'Search Selected', action: () => this.searchMissingBatch('selected', 'all') }, - { label: 'Search All Missing', action: () => this.searchMissingBatch('all', 'all') }, - { label: 'Search Unsearched', action: () => this.searchMissingBatch('unsearched', 'all') }, - 'divider', - { label: 'Selected: CivitAI', action: () => this.searchMissingBatch('selected', 'civitai') }, - { label: 'Selected: HuggingFace', action: () => this.searchMissingBatch('selected', 'huggingface') } - ]); - this.searchMenuButton = this.footerMenuButtons.get('search'); - this.searchMenuWrap = searchMenu; - - // Store reference to download all button so we can update its text - this.downloadAllButton = $el("button.ml-btn.ml-btn-download.ml-footer-btn", { - "data-tooltip": "Download every missing model that has a known download source.", - "aria-label": "Download all missing models", - onclick: () => this.handleDownloadAllClick() - }, [ - $el("span.ml-btn-icon", { textContent: "☁" }), - $el("span", { textContent: " Download All Missing" }) - ]); - - // Auto-resolve button (secondary style) - this.autoResolveButton = $el("button.ml-btn.ml-btn-secondary.ml-footer-btn", { - "data-tooltip": "Automatically link all missing models with a 100% local match.", - "aria-label": "Auto-link all 100 percent local matches", - onclick: () => this.autoResolve100Percent() - }, [ - $el("span.ml-btn-icon", { textContent: "🔗" }), - $el("span", { textContent: " Auto-Link 100%" }) - ]); - this.queueExactButton = $el("button.ml-btn.ml-btn-secondary.ml-footer-btn", { - type: 'button', - onclick: () => this.queueExactLocalMatchesBatch('selected') - }, [ - $el("span", { textContent: "Queue Exact" }) - ]); - - // Apply pending resolutions button - this.applyPendingBtn = $el("button.ml-btn.ml-btn-primary.ml-footer-btn", { - id: "apply-pending-resolutions", - "data-tooltip": "Apply the model links you selected from local matches or search results.", - "aria-label": "Apply selected model links", - "aria-disabled": "true", - textContent: "Apply Selected (0)", - onclick: () => { - if ((this.pendingResolutions?.length || 0) === 0) return; - this.applyPendingResolutions(); - } - }); - this.applyPendingBtn.classList.add('ml-btn-is-disabled'); - this.downloadAllButton.setAttribute('aria-label', 'Download all missing models'); - this.autoResolveButton.setAttribute('aria-label', 'Auto-link all 100 percent local matches'); - this.queueExactButton.setAttribute('aria-label', 'Queue exact local matches'); - this.applyPendingBtn.setAttribute('aria-label', 'Apply selected model links'); - this.applyPendingBtn.setAttribute('aria-disabled', 'true'); - - this.setTooltip(this.downloadAllButton, 'Download every missing model that has a known download source.'); - this.setTooltip(this.autoResolveButton, 'Automatically link all missing models with a 100% local match.'); - this.setTooltip(this.queueExactButton, 'Queue exact local matches for the selected rows, or all exact matches if nothing is selected.'); - this.setTooltip(this.applyPendingBtn, 'Apply the model links you selected from local matches or search results.'); - - const downloadMenu = this.createFooterMenu('download', 'Download', [ - { label: 'Download Selected', action: () => this.downloadMissingBatch('selected') }, - { label: 'Download All With Sources', action: () => this.downloadMissingBatch('all') }, - 'divider', - { label: 'Cancel Downloads', action: () => { this.closeFooterMenus(); this.cancelAllDownloads(); } } - ], 'ml-btn-download'); - this.downloadMenuButton = this.footerMenuButtons.get('download'); - this.downloadMenuWrap = downloadMenu; - this.downloadAllButton = this.downloadMenuButton; - this.downloadAllButton.setAttribute('aria-label', 'Download missing models'); - this.setTooltip(this.downloadAllButton, 'Download selected models or all missing models with known download sources.'); - - return $el("div.ml-footer", {}, [ - selectMenu, - searchMenu, - this.queueExactButton, - this.applyPendingBtn, - this.autoResolveButton, - downloadMenu - ]); - } - - 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; - if (this.downloadMenuButton && this.downloadAllButton === this.downloadMenuButton) { - this.updateBatchFooterButtons(); - return; - } - - const activeCount = Object.keys(this.activeDownloads).length; - if (activeCount > 0) { - this.downloadAllButton.innerHTML = `✕ Cancel All (${activeCount})`; - this.downloadAllButton.setAttribute( - 'data-tooltip', - `Cancel ${activeCount} active download${activeCount > 1 ? 's' : ''}.` - ); - this.downloadAllButton.setAttribute('aria-label', 'Cancel all active downloads'); - this.downloadAllButton.classList.remove('ml-btn-download'); - this.downloadAllButton.classList.add('ml-btn-danger'); - } else { - this.downloadAllButton.innerHTML = `☁ Download All Missing`; - this.downloadAllButton.setAttribute( - 'data-tooltip', - 'Download every missing model that has a known download source.' - ); - this.downloadAllButton.setAttribute('aria-label', 'Download all missing models'); - this.downloadAllButton.classList.remove('ml-btn-danger'); - this.downloadAllButton.classList.add('ml-btn-download'); - } - } - - async show(workflow = null) { - this.undockToFloating({ persist: false }); - await this.showContent(workflow, { restoreFullscreen: true }); - } - - async showDocked(container, workflow = null) { - this.dockTo(container); - await this.showContent(workflow, { restoreFullscreen: false }); - } - - async showContent(workflow = null, { restoreFullscreen = true } = {}) { - this.backdrop.style.display = "none"; - this.element.style.display = "flex"; - if (!this.docked) { - this.scheduleModalViewportClamp(); - } - - // Update button state in case there are active downloads - this.updateDownloadAllButtonState(); - - // Ensure all models are loaded for dropdown - await this.ensureCapabilitiesLoaded(); - await this.ensureAllModelsLoaded(); - await this.ensureDownloadDirectoriesLoaded(); - - // Restore fullscreen state if enabled - try { - const fs = localStorage.getItem('model_linker_modal_fullscreen'); - if (!this.docked && restoreFullscreen && fs === '1') this.setFullScreen(true); - } catch (e) { } - - // Attach drag handle event listener (only once) - this.attachDragHandleIfNeeded(); - - this.activeTab = this.restoreActiveTab(); - this.updateTabButtonStates(); - this.updateQueueVisibility(); - this.syncWorkflowScopedQueue(workflow || this.getCurrentWorkflow()); - - if (this.activeTab === 'missing') { - await this.loadWorkflowData(workflow); - } else if (this.activeTab === 'loaded') { - this.switchTab('loaded', { force: true }); - } else { - this.switchTab('options', { force: true }); - } - } - - // Attach topbar drag listeners while preserving the handle-based viewport clamp. - attachDragHandleIfNeeded() { - if (this._dragHandleAttached) return; - - const handle = document.getElementById('model-linker-drag-handle'); - const topbar = this.element?.querySelector('.ml-dialog-topbar'); - if (!handle || !topbar) return; - - const onMouseDown = (e) => { - if (this.fullscreen || this.docked) return; // no drag in fullscreen or docked mode - if (e.button !== 0 || this.isTopbarDragExcluded(e.target)) return; - topbar.classList.add('ml-is-dragging'); - this.startDrag(e); - }; - const onMouseUp = () => { topbar.classList.remove('ml-is-dragging'); }; - - topbar.addEventListener('mousedown', onMouseDown); - document.addEventListener('mouseup', onMouseUp); - - this._dragHandleAttached = true; - } - - isTopbarDragExcluded(target) { - if (!(target instanceof Element)) return false; - return !!target.closest([ - '.ml-tabs', - '.ml-dialog-controls', - 'button', - 'a', - 'input', - 'select', - 'textarea', - '[role="button"]', - '[data-ml-no-drag]' - ].join(',')); - } - - close({ collapseSidebar = true } = {}) { - const wasDocked = this.docked; - const dockContainer = this.dockContainer; - this.rememberSidebarOpenMode(this.docked ? 'docked' : 'floating'); - this._hidePreview?.(); - this.hideTooltip(); - this.backdrop.style.display = "none"; - this.element.style.display = "none"; - - if (wasDocked && collapseSidebar) { - this.closeComfySidebar(dockContainer); - } - } - - /** - * Load workflow data and display missing models - */ - async loadWorkflowData(workflow = null) { - if (!this.contentElement) return; - - // Show loading state - try { - // Use provided workflow, or get current workflow from ComfyUI - if (!workflow) { - workflow = this.getCurrentWorkflow(); - } - - if (!workflow) { - this._analysisProgressToken = null; - this.contentElement.innerHTML = '

No workflow loaded. Please load a workflow first.

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

Error: ${error.message}

`; - } - } - } - - /** - * Get current workflow from ComfyUI - */ - getCurrentWorkflow() { - // Try to get workflow from app - if (app?.graph) { - try { - // Use ComfyUI's workflow serialization - const workflow = app.graph.serialize(); - return workflow; - } catch (e) { - console.warn('Model Linker: Could not serialize workflow from graph:', e); - } - } - return null; - } - - /** - * Locate and focus a node in the ComfyUI canvas - */ - locateNodeInGraph(nodeId) { - try { - if (!app?.graph) { - this.showNotification('Cannot locate node - graph not available', 'error'); - return; - } - - // Find the node in the graph - const node = app.graph.getNodeById(nodeId); - if (!node) { - this.showNotification(`Node #${nodeId} not found in graph`, 'error'); - return; - } - - let locatedWithAnimation = false; - - // Focus on the node in the canvas with animated panning when possible - if (app.canvas?.ds && app.canvas?.canvas) { - locatedWithAnimation = this.animateCanvasToNode(node); - } - - if (!locatedWithAnimation && app.graph._nodes && app.graph._nodes.get(nodeId)) { - // Alternative method for older versions - const canvasNode = app.graph._nodes.get(nodeId); - if (canvasNode && canvasNode.setSelected && canvasNode.graph) { - canvasNode.setSelected(true); - // Scroll to node - app.canvas.scrollToNode(canvasNode); - } - } else if (!locatedWithAnimation && app.ui && app.ui.nodeGraph && typeof app.ui.nodeGraph.scrollToNode === 'function') { - // Alternative for other versions - app.ui.nodeGraph.scrollToNode(node); - } else if (!locatedWithAnimation && app.canvas && typeof app.canvas.centerOnNode === 'function') { - // Final fallback for versions where animation path is unavailable - app.canvas.centerOnNode(node); - } - - // Also try to flash/select the node - // Deselect all nodes first - if (app.graph && app.graph.nodes) { - app.graph.nodes.forEach(n => { - if (n.selected) n.selected = false; - }); - } - - // Select and highlight our node - node.selected = true; - - this.showNotification(`Focused on Node #${nodeId} (${node.type})`, 'info'); - } catch (e) { - console.error('Model Linker: Error locating node:', e); - this.showNotification('Error locating node: ' + e.message, 'error'); - } - } - - animateCanvasToNode(node) { - const canvas = app?.canvas; - const ds = canvas?.ds; - const htmlCanvas = canvas?.canvas; - if (!canvas || !ds || !htmlCanvas) return false; - - const rect = node.boundingRect || [node.pos?.[0] || 0, node.pos?.[1] || 0, node.size?.[0] || 0, node.size?.[1] || 0]; - const centerX = rect[0] + (rect[2] || 0) / 2; - const centerY = rect[1] + (rect[3] || 0) / 2; - const startScale = Number.isFinite(ds.scale) && ds.scale > 0 ? ds.scale : 1; - const locateViewport = this.getLocateViewport(htmlCanvas); - const viewportWidth = locateViewport.width; - const viewportHeight = locateViewport.height; - - if (!viewportWidth || !viewportHeight) return false; - - const nodeWidth = Math.max(rect[2] || 0, 1); - const nodeHeight = Math.max(rect[3] || 0, 1); - const paddingX = 36; - const paddingY = 32; - const maxLocateScale = 2.5; - const fitScaleX = viewportWidth / (nodeWidth + (paddingX * 2)); - const fitScaleY = viewportHeight / (nodeHeight + (paddingY * 2)); - const targetScale = Math.max(0.15, Math.min(maxLocateScale, fitScaleX, fitScaleY)); - - const getCenteredOffset = (scale) => { - return { - x: -centerX + (locateViewport.centerX / scale), - y: -centerY + (locateViewport.centerY / scale) - }; - }; - - const startOffset = { - x: Number.isFinite(ds.offset?.[0]) ? ds.offset[0] : getCenteredOffset(startScale).x, - y: Number.isFinite(ds.offset?.[1]) ? ds.offset[1] : getCenteredOffset(startScale).y - }; - const targetOffset = getCenteredOffset(targetScale); - - if (this._locateAnimationFrame) { - cancelAnimationFrame(this._locateAnimationFrame); - this._locateAnimationFrame = null; - } - - const durationMs = 380; - const startTime = performance.now(); - const easeInOutCubic = (t) => t < 0.5 - ? 4 * t * t * t - : 1 - Math.pow(-2 * t + 2, 3) / 2; - - const tick = (now) => { - const progress = Math.min(1, (now - startTime) / durationMs); - const eased = easeInOutCubic(progress); - - ds.scale = startScale + ((targetScale - startScale) * eased); - ds.offset[0] = startOffset.x + ((targetOffset.x - startOffset.x) * eased); - ds.offset[1] = startOffset.y + ((targetOffset.y - startOffset.y) * eased); - canvas.setDirty?.(true, true); - app.graph?.setDirtyCanvas?.(true, true); - - if (progress < 1) { - this._locateAnimationFrame = requestAnimationFrame(tick); - } else { - this._locateAnimationFrame = null; - } - }; - - this._locateAnimationFrame = requestAnimationFrame(tick); - return true; - } - - getLocateViewport(htmlCanvas) { - const canvasRect = htmlCanvas.getBoundingClientRect?.(); - const dpr = window.devicePixelRatio || 1; - const canvasWidth = canvasRect?.width || htmlCanvas.clientWidth || ((htmlCanvas.width || 0) / dpr); - const canvasHeight = canvasRect?.height || htmlCanvas.clientHeight || ((htmlCanvas.height || 0) / dpr); - const viewport = { - left: 0, - top: 0, - right: canvasWidth, - bottom: canvasHeight - }; - - if (this.docked && canvasRect?.width && canvasRect?.height) { - const obstructionElement = this.dockContainer instanceof HTMLElement - ? this.dockContainer - : this.element; - const obstructionRect = obstructionElement?.getBoundingClientRect?.(); - if (obstructionRect?.width && obstructionRect?.height) { - this.subtractDockedObstructionFromLocateViewport(viewport, canvasRect, obstructionRect); - } - } - - const width = Math.max(1, viewport.right - viewport.left); - const height = Math.max(1, viewport.bottom - viewport.top); - return { - width, - height, - centerX: viewport.left + (width / 2), - centerY: viewport.top + (height / 2) - }; - } - - subtractDockedObstructionFromLocateViewport(viewport, canvasRect, obstructionRect) { - const obstruction = { - left: Math.max(0, obstructionRect.left - canvasRect.left), - top: Math.max(0, obstructionRect.top - canvasRect.top), - right: Math.min(canvasRect.width, obstructionRect.right - canvasRect.left), - bottom: Math.min(canvasRect.height, obstructionRect.bottom - canvasRect.top) - }; - const overlapWidth = obstruction.right - obstruction.left; - const overlapHeight = obstruction.bottom - obstruction.top; - if (overlapWidth <= 0 || overlapHeight <= 0) return; - - const coversMostHeight = overlapHeight >= canvasRect.height * 0.5; - const coversMostWidth = overlapWidth >= canvasRect.width * 0.5; - const edgeTolerance = 2; - - if (coversMostHeight) { - if (obstruction.left <= edgeTolerance) { - viewport.left = Math.min(viewport.right, Math.max(viewport.left, obstruction.right)); - return; - } - if (obstruction.right >= canvasRect.width - edgeTolerance) { - viewport.right = Math.max(viewport.left, Math.min(viewport.right, obstruction.left)); - return; - } - - const leftSpace = obstruction.left - viewport.left; - const rightSpace = viewport.right - obstruction.right; - if (rightSpace >= leftSpace) { - viewport.left = Math.min(viewport.right, Math.max(viewport.left, obstruction.right)); - } else { - viewport.right = Math.max(viewport.left, Math.min(viewport.right, obstruction.left)); - } - return; - } - - if (coversMostWidth) { - if (obstruction.top <= edgeTolerance) { - viewport.top = Math.min(viewport.bottom, Math.max(viewport.top, obstruction.bottom)); - return; - } - if (obstruction.bottom >= canvasRect.height - edgeTolerance) { - viewport.bottom = Math.max(viewport.top, Math.min(viewport.bottom, obstruction.top)); - } - } - } - - /** - * Reconnect active downloads to their new progress div elements after UI refresh - */ - reconnectActiveDownloads() { - if (!this.contentElement) return; - - for (const [downloadId, info] of Object.entries(this.activeDownloads)) { - const { missing } = info; - if (!missing) continue; - - // Find the new progress div by ID - const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`; - const newProgressDiv = this.contentElement.querySelector(`#${progressId}`); - const newDownloadBtn = this.contentElement.querySelector(`#download-${missing.node_id}-${missing.widget_index}`); - - if (newProgressDiv) { - // Update the reference - info.progressDiv = newProgressDiv; - info.downloadBtn = newDownloadBtn; - - // Show that download is in progress - newProgressDiv.classList.remove('ml-is-hidden'); - newProgressDiv.classList.add('ml-is-visible'); - newProgressDiv.innerHTML = this.renderProgressWithAction({ - percent: 0, - leftText: 'Downloading...', - rightText: '', - actionClass: 'cancel-download-btn ml-btn ml-btn-danger ml-btn-sm', - actionText: 'Cancel', - actionDataAttr: `data-download-id="${downloadId}"` - }); - - // Attach cancel handler - const cancelBtn = newProgressDiv.querySelector('.cancel-download-btn'); - if (cancelBtn) { - cancelBtn.addEventListener('click', () => this.cancelDownload(downloadId)); - } - - // Update download button if exists - if (newDownloadBtn) { - newDownloadBtn.disabled = true; - newDownloadBtn.textContent = 'Downloading...'; - } - } - } - } - - getMissingModelKey(missing = {}) { - const nodeId = missing.node_id ?? ''; - const widgetIndex = missing.widget_index ?? ''; - const subgraphId = missing.subgraph_id || ''; - const scope = missing.is_top_level !== false ? 'T' : 'F'; - return `${nodeId}:${widgetIndex}:${subgraphId}:${scope}`; - } - - getMissingFilename(missing = {}) { - return missing.original_path?.split('/').pop()?.split('\\').pop() || missing.name || 'Missing model'; - } - - getMissingNodeDisplay(missing = {}) { - const isSubgraphNode = missing.node_type && missing.node_type.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); - let nodeLabel; - if (missing.subgraph_name) { - nodeLabel = missing.subgraph_name; - } else if (isSubgraphNode) { - nodeLabel = 'Subgraph'; - } else { - nodeLabel = missing.node_type || 'Node'; - } - - const nodeId = missing.node_id ?? ''; - const customNodeTitle = String(missing.node_title || '').trim(); - const hasCustomNodeTitle = customNodeTitle && customNodeTitle !== nodeLabel; - const text = hasCustomNodeTitle - ? `${nodeLabel} #${nodeId} · ${customNodeTitle}` - : `${nodeLabel} #${nodeId}`; - - return { - label: nodeLabel, - text, - canLocate: missing.is_top_level !== false && nodeId !== '' - }; - } - - getBestLocalMatch(missing = {}, minConfidence = 0) { - const matches = Array.isArray(missing.matches) ? missing.matches : []; - return matches - .filter(match => Number(match.confidence) >= minConfidence) - .sort((a, b) => Number(b.confidence || 0) - Number(a.confidence || 0))[0] || null; - } - - getMissingModelSummaryStats(missingModels = []) { - return missingModels.reduce((stats, missing) => { - const best = this.getBestLocalMatch(missing, 70); - if (best?.confidence === 100) { - stats.exact += 1; - } else if (best) { - stats.partial += 1; - } else { - stats.none += 1; - } - return stats; - }, { exact: 0, partial: 0, none: 0 }); - } - - getMissingSourceStatus(missing = {}, source = '') { - const state = this.searchResultCache.get(this.getMissingSearchKey(missing)); - const progress = state?.sourceProgress?.[source]; - const resultStatus = this.getMissingSourceResultStatus(missing, source, state); - if (progress?.status === 'found') return resultStatus || 'found'; - if (progress?.status) return progress.status; - - if (resultStatus) return resultStatus; - - return 'idle'; - } - - getMissingSourceResultStatus(missing = {}, source = '', state = null) { - const results = state?.results || {}; - const candidates = []; - - if (source === 'local') { - candidates.push(results.model_list, results.popular); - } else if (source === 'huggingface') { - candidates.push(results.huggingface); - } else if (source === 'civitai') { - candidates.push(results.civitai); - } else if (source === 'civarchive') { - candidates.push(results.civarchive); - } else if (source === 'lora_manager_archive') { - candidates.push(results.lora_manager_archive); - } - - const downloadSource = missing.download_source || {}; - const mappedDownloadSource = { - model_list: 'local', - popular: 'local', - workflow: 'civitai' - }[downloadSource.source] || downloadSource.source; - if (mappedDownloadSource === source && downloadSource.url) { - candidates.push(downloadSource); - } - - return this.getSearchResultStatusLevel(candidates); - } - - getSearchResultStatusLevel(resultOrResults) { - const results = Array.isArray(resultOrResults) - ? resultOrResults.flatMap(result => Array.isArray(result) ? result : [result]) - : [resultOrResults]; - let hasPartial = false; - let hasUnknownFound = false; - - for (const result of results) { - if (!result) continue; - - const confidence = Number(result.confidence); - const hasConfidence = Number.isFinite(confidence); - const matchType = String(result.match_type || '').toLowerCase(); - - if (hasConfidence) { - if (confidence >= 100) return 'exact'; - if (confidence > 0) { - hasPartial = true; - continue; - } - } - - if (matchType === 'exact' || result.source === 'popular') { - return 'exact'; - } - if (matchType === 'partial' || matchType === 'fuzzy' || matchType === 'similar') { - hasPartial = true; - continue; - } - if (result.url || result.download_url) { - hasUnknownFound = true; - } - } - - if (hasPartial) return 'partial'; - if (hasUnknownFound) return 'found'; - return ''; - } - - renderMissingSourcesSummary(missing = {}) { - const sourceItems = this.getEnabledSearchSources().map(source => ({ source })); - - return sourceItems.map(item => { - const status = this.getMissingSourceStatus(missing, item.source); - const statusClass = String(status || 'idle').replace(/[^a-z0-9_-]/gi, ''); - const label = this.getSearchSourceLabel(item.source); - const statusLabels = { - pending: 'Queued', - running: 'Searching', - exact: 'Exact match', - partial: 'Partial match', - found: 'Found', - none: 'No match', - error: 'Error', - idle: 'Not searched' - }; - const title = `${label}: ${statusLabels[status] || status}`; - const iconName = this.getSearchSourceIconName(item.source); - const iconHtml = getSvgIcon(iconName, 'currentColor', 'ml-missing-source-icon'); - return `${iconHtml}`; - }).join(''); - } - - hasRenderableSearchState(state = {}) { - return Boolean( - (state.lastAttemptSources || []).length - || Object.keys(state.sourceProgress || {}).length - || this.hasSearchResults(state.results || {}) - || state.lastAttemptError - ); - } - - getMissingModelsListLayout(missingModels = []) { - const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); - const textWidth = (value, charPx) => Math.ceil(String(value || '').length * charPx); - - let modelPx = 180; - let typePx = 66; - for (const missing of missingModels) { - const filename = this.getMissingFilename(missing); - const nodeLabel = this.getMissingNodeDisplay(missing).text; - const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown'; - modelPx = Math.max( - modelPx, - textWidth(filename, 7.2) + 12, - textWidth(nodeLabel, 5.5) + 34 - ); - typePx = Math.max(typePx, textWidth(typeLabel, 5.8) + 18); - } - - return { - modelPx: clamp(modelPx, 180, 430), - typePx: clamp(typePx, 66, 120) - }; - } - - renderMissingModelsBrowser(missingModels, selectedKey, totalMissing, activeCount, hasAny100Match) { - const stats = this.getMissingModelSummaryStats(missingModels); - const detailIndex = missingModels.findIndex(missing => this.getMissingModelKey(missing) === selectedKey); - const detailMissing = detailIndex >= 0 ? missingModels[detailIndex] : null; - const activeHint = activeCount > 0 - ? `${activeCount} downloading` - : (hasAny100Match ? 'Auto-link ready for exact matches' : 'Review matches or search online'); - const listLayout = this.getMissingModelsListLayout(missingModels); - let savedDetailWidth = null; - try { - savedDetailWidth = parseInt(localStorage.getItem(this.missingBrowserSplitStorageKey) || '', 10); - } catch (e) {} - const splitStyle = Number.isFinite(savedDetailWidth) && savedDetailWidth > 0 - ? `--ml-missing-detail-track:${savedDetailWidth}px;` - : ''; - const listStyle = `--ml-missing-model-col:${listLayout.modelPx}px;--ml-missing-type-col:${listLayout.typePx}px;`; - - let html = ` -
-
-
-
-
${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 isBatchSelected = this.batchSelectedMissingKeys?.has(key); - const filename = this.getMissingFilename(missing); - const formattedFilename = this.formatFilename(filename, 46); - const bestMatch = this.getBestLocalMatch(missing, 70); - const confidence = bestMatch ? Number(bestMatch.confidence || 0) : 0; - const matchName = bestMatch?.model?.relative_path || bestMatch?.filename || bestMatch?.path || ''; - const matchDisplay = matchName || 'No local match'; - const matchClass = confidence === 100 ? 'exact' : (bestMatch ? 'partial' : 'none'); - const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown'; - const typeColorClass = this.getModelTypeColorClass(missing.category || typeLabel); - const nodeDisplay = this.getMissingNodeDisplay(missing); - const nodeId = missing.node_id ?? ''; - const rowNodeHtml = nodeDisplay.canLocate - ? `` - : `${this.escapeHtml(nodeDisplay.text)}`; - - html += ` -
- - - - ${index + 1} - - ${this.escapeHtml(formattedFilename.display)} - ${rowNodeHtml} - - ${this.escapeHtml(typeLabel)} - - ${bestMatch ? this.escapeHtml(matchDisplay) : '-- No local match'} - - - ${bestMatch ? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%` : '--'} - - ${this.renderMissingSourcesSummary(missing)} -
- `; - }); - - html += ` -
-
- -
- ${detailMissing ? this.renderMissingModel(detailMissing, detailIndex) : this.renderStatusMessage('Select a missing model to inspect details.', 'info')} -
-
- `; - return html; - } - - wireMissingModelsBrowser(container, data, sortedMissingModels) { - this.wireMissingBrowserSplitter(container); - - const selectRow = (row) => { - const key = row.dataset.missingKey; - if (!key || key === this.selectedMissingModelKey) return; - this.selectedMissingModelKey = key; - this.displayMissingModels(container, data); - }; - - const selectAllCheckbox = container.querySelector('.ml-missing-select-all-check'); - if (selectAllCheckbox) { - this.updateBatchSelectAllCheckbox(); - selectAllCheckbox.addEventListener('click', (event) => { - event.stopPropagation(); - }); - selectAllCheckbox.addEventListener('change', () => { - const shouldSelectAll = selectAllCheckbox.checked; - this.batchSelectedMissingKeys = shouldSelectAll - ? new Set((sortedMissingModels || []).map(missing => this.getMissingModelKey(missing))) - : new Set(); - this.lastBatchSelectedMissingKey = null; - this.refreshBatchSelectionUi(); - this.updateBatchFooterButtons(); - }); - } - - container.querySelectorAll('.ml-missing-list-row').forEach(row => { - const checkbox = row.querySelector('.ml-missing-row-check'); - if (checkbox) { - checkbox.addEventListener('click', (event) => { - event.stopPropagation(); - checkbox.dataset.shiftClick = event.shiftKey ? '1' : '0'; - }); - checkbox.addEventListener('change', (event) => { - const key = row.dataset.missingKey; - if (!key) return; - const selected = checkbox.checked; - const isShiftRange = event.shiftKey || checkbox.dataset.shiftClick === '1'; - - if (isShiftRange && this.lastBatchSelectedMissingKey) { - this.applyBatchSelectionRange( - sortedMissingModels, - this.lastBatchSelectedMissingKey, - key, - selected - ); - } else { - this.setBatchSelectionForKey(key, selected); - } - - this.lastBatchSelectedMissingKey = key; - this.refreshBatchSelectionUi(); - this.updateBatchFooterButtons(); - }); - } - - row.addEventListener('click', (event) => { - const clickedLocate = event.target instanceof Element && event.target.closest('.ml-missing-row-locate'); - if (clickedLocate) return; - selectRow(row); - }); - - row.addEventListener('keydown', (event) => { - if (event.target !== row || (event.key !== 'Enter' && event.key !== ' ')) return; - event.preventDefault(); - selectRow(row); - }); - }); - - container.querySelectorAll('.ml-missing-row-locate').forEach(button => { - button.addEventListener('click', (event) => { - event.preventDefault(); - event.stopPropagation(); - const rawNodeId = button.dataset.nodeId; - const numericNodeId = Number(rawNodeId); - this.locateNodeInGraph(Number.isNaN(numericNodeId) ? rawNodeId : numericNodeId); - }); - }); - - const selectedMissing = sortedMissingModels.find(missing => this.getMissingModelKey(missing) === this.selectedMissingModelKey); - if (!selectedMissing) return; - - const selectedIndex = sortedMissingModels.indexOf(selectedMissing); - this.wireMissingModelDetail(container, selectedMissing, selectedIndex); - } - - wireMissingBrowserSplitter(container) { - const browser = container.querySelector('.ml-missing-browser'); - const splitter = browser?.querySelector('.ml-missing-browser-splitter'); - if (!(browser instanceof HTMLElement) || !(splitter instanceof HTMLElement)) return; - - this.restoreMissingBrowserSplitWidth(browser); - - splitter.addEventListener('mousedown', (event) => { - if (event.button !== 0) return; - this.startMissingBrowserSplitDrag(event, browser); - }); - - splitter.addEventListener('dblclick', () => { - browser.querySelector('.ml-missing-detail-pane')?.style.removeProperty('--ml-missing-detail-track'); - try { - localStorage.removeItem(this.missingBrowserSplitStorageKey); - } catch (e) {} - }); - - splitter.addEventListener('keydown', (event) => { - if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; - event.preventDefault(); - this.resizeMissingBrowserDetailBy(browser, event.key === 'ArrowLeft' ? 32 : -32); - }); - - this._missingBrowserResizeObserver?.disconnect?.(); - if (typeof ResizeObserver === 'function') { - this._missingBrowserResizeObserver = new ResizeObserver(() => { - if (!this._missingBrowserSplitDragging) { - this.restoreMissingBrowserSplitWidth(browser); - } - }); - this._missingBrowserResizeObserver.observe(browser); - } - } - - restoreMissingBrowserSplitWidth(browser) { - try { - const savedWidth = parseInt(localStorage.getItem(this.missingBrowserSplitStorageKey) || '', 10); - if (Number.isFinite(savedWidth) && savedWidth > 0) { - this.setMissingBrowserDetailWidth(browser, savedWidth, { persist: false }); - } - } catch (e) {} - } - - startMissingBrowserSplitDrag(event, browser) { - if (!(browser instanceof HTMLElement)) return; - - const detailPane = browser.querySelector('.ml-missing-detail-pane'); - if (!(detailPane instanceof HTMLElement)) return; - - event.preventDefault(); - this._missingBrowserSplitDragging = true; - this._missingBrowserSplitStart = { - x: event.clientX, - width: detailPane.getBoundingClientRect().width, - bounds: this.getMissingBrowserSplitBounds(browser) - }; - this._lastMissingBrowserSplitApply = 0; - this._missingBrowserSplitBrowser = browser; - this._missingBrowserSplitDetailPane = detailPane; - this._missingBrowserPrevUserSelect = document.body.style.userSelect; - this._missingBrowserPrevCursor = document.body.style.cursor; - document.body.style.userSelect = 'none'; - document.body.style.cursor = 'col-resize'; - browser.classList.add('is-resizing'); - - this._onMissingBrowserSplitMove = (moveEvent) => this.onMissingBrowserSplitDrag(moveEvent); - this._onMissingBrowserSplitUp = () => this.endMissingBrowserSplitDrag(); - document.addEventListener('mousemove', this._onMissingBrowserSplitMove); - document.addEventListener('mouseup', this._onMissingBrowserSplitUp, { once: true }); - } - - onMissingBrowserSplitDrag(event) { - if (!this._missingBrowserSplitDragging || !this._missingBrowserSplitStart || !this._missingBrowserSplitBrowser) return; - - const nextWidth = this._missingBrowserSplitStart.width - (event.clientX - this._missingBrowserSplitStart.x); - const bounds = this._missingBrowserSplitStart.bounds; - this._pendingMissingBrowserSplitWidth = Math.round(Math.max(bounds.min, Math.min(bounds.max, nextWidth))); - const now = performance.now(); - if (this._lastMissingBrowserSplitApply && now - this._lastMissingBrowserSplitApply < 33) return; - this._lastMissingBrowserSplitApply = now; - if (this._missingBrowserSplitFrame) return; - this._missingBrowserSplitFrame = requestAnimationFrame(() => { - this._missingBrowserSplitFrame = null; - if (!this._missingBrowserSplitDragging || !this._missingBrowserSplitDetailPane || !this._pendingMissingBrowserSplitWidth) return; - this._missingBrowserSplitDetailPane.style.setProperty('--ml-missing-detail-track', `${this._pendingMissingBrowserSplitWidth}px`); - }); - } - - endMissingBrowserSplitDrag() { - if (!this._missingBrowserSplitDragging) return; - - this._missingBrowserSplitDragging = false; - document.removeEventListener('mousemove', this._onMissingBrowserSplitMove); - if (this._missingBrowserSplitFrame) { - cancelAnimationFrame(this._missingBrowserSplitFrame); - this._missingBrowserSplitFrame = null; - } - if (this._missingBrowserSplitDetailPane && this._pendingMissingBrowserSplitWidth) { - this._missingBrowserSplitDetailPane.style.setProperty('--ml-missing-detail-track', `${this._pendingMissingBrowserSplitWidth}px`); - try { - localStorage.setItem(this.missingBrowserSplitStorageKey, String(this._pendingMissingBrowserSplitWidth)); - } catch (e) {} - } - this._missingBrowserSplitBrowser?.classList.remove('is-resizing'); - this._missingBrowserSplitBrowser = null; - this._missingBrowserSplitDetailPane = null; - this._missingBrowserSplitStart = null; - this._pendingMissingBrowserSplitWidth = null; - this._lastMissingBrowserSplitApply = 0; - try { - document.body.style.userSelect = this._missingBrowserPrevUserSelect || ''; - document.body.style.cursor = this._missingBrowserPrevCursor || ''; - } catch (e) {} - } - - resizeMissingBrowserDetailBy(browser, delta) { - const detailPane = browser.querySelector('.ml-missing-detail-pane'); - if (!(detailPane instanceof HTMLElement)) return; - - this.setMissingBrowserDetailWidth(browser, detailPane.getBoundingClientRect().width + delta); - } - - setMissingBrowserDetailWidth(browser, width, { persist = true } = {}) { - const bounds = this.getMissingBrowserSplitBounds(browser); - const nextWidth = Math.round(Math.max(bounds.min, Math.min(bounds.max, width))); - const detailPane = browser.querySelector('.ml-missing-detail-pane'); - const target = detailPane instanceof HTMLElement ? detailPane : browser; - target.style.setProperty('--ml-missing-detail-track', `${nextWidth}px`); - - if (persist) { - try { - localStorage.setItem(this.missingBrowserSplitStorageKey, String(nextWidth)); - } catch (e) {} - } - } - - getMissingBrowserSplitBounds(browser) { - const browserRect = browser.getBoundingClientRect(); - const splitter = browser.querySelector('.ml-missing-browser-splitter'); - const splitterWidth = splitter instanceof HTMLElement - ? splitter.getBoundingClientRect().width - : 10; - const available = Math.max(1, browserRect.width - splitterWidth); - const minListWidth = Math.min(360, Math.max(240, Math.floor(available * 0.28))); - const max = Math.max(1, available - minListWidth); - const min = Math.min(max, Math.min(420, Math.max(300, Math.floor(available * 0.3)))); - - return { min, max }; - } - - wireMissingModelDetail(container, missing, missingIndex) { - this.wireLocalMatchButtons(container, missing, missingIndex); - this.wireDownloadSearchPanel(container, missing); - this.updateSelectedBarForMissing(missing); - - const locateId = `locate-${missing.node_id}-${missing.widget_index}`; - const locateBtn = container.querySelector(`#${locateId}`); - if (locateBtn && missing.is_top_level !== false) { - locateBtn.addEventListener('click', () => { - this.locateNodeInGraph(missing.node_id); - }); - } - - const comboId = `combo-${missing.node_id}-${missing.widget_index}`; - const comboInput = container.querySelector(`#combo-input-${comboId}`); - const comboList = container.querySelector(`#combo-list-${comboId}`); - const comboRefresh = container.querySelector(`#combo-refresh-${comboId}`); - - if (comboList) { - this.enableWheelScrollChaining(comboList); - } - - const allModels = Array.isArray(this.allModels) ? this.allModels : []; - const buildLabel = (m) => `${m.category ? m.category + ': ' : ''}${m.relative_path || m.filename || ''}`; - const getFolder = (m) => m.path || m.base_directory || ''; - - const populateComboOptions = (filterText, highlightIdx = -1) => { - if (!comboList) return; - const f = (filterText || '').toLowerCase(); - const filtered = f - ? allModels.filter(m => buildLabel(m).toLowerCase().includes(f)) - : allModels.slice(); - - let html = ''; - for (let i = 0; i < filtered.length; i++) { - const m = filtered[i]; - const label = buildLabel(m); - const folder = getFolder(m); - const isHighlighted = i === highlightIdx; - const folderDisplay = folder ? folder.replace(/\\/g, '/').replace(/:/, '') : ''; - html += `
`; - html += `
`; - html += `${this.escapeHtml(label)}`; - html += `
`; - if (folderDisplay) { - html += `
Folder ${this.escapeHtml(folderDisplay)}
`; - } - html += `
`; - } - comboList.innerHTML = html; - - comboList.querySelectorAll('div[data-idx]').forEach(el => { - el.addEventListener('click', () => { - const idx = parseInt(el.dataset.idx, 10); - if (!Number.isNaN(idx) && idx >= 0 && idx < allModels.length) { - const chosenModel = allModels[idx]; - if (chosenModel) { - this.queueResolution(missing, chosenModel); - } - } - }); - }); - }; - - if (comboList) { - populateComboOptions(''); - } - - if (comboInput) { - const debouncedFilter = this.debounce(() => { - populateComboOptions(comboInput.value); - }, 200); - comboInput.addEventListener('input', debouncedFilter); - comboInput.addEventListener('focus', () => { - if (comboList) { - comboList.classList.remove('ml-is-hidden'); - comboList.classList.add('ml-is-visible'); - } - populateComboOptions(comboInput.value); - }); - comboInput.addEventListener('blur', () => { - setTimeout(() => { - if (comboList) { - comboList.classList.remove('ml-is-visible'); - comboList.classList.add('ml-is-hidden'); - } - }, 200); - }); - } - - if (comboRefresh) { - comboRefresh.addEventListener('click', async () => { - this.allModels = null; - await this.ensureAllModelsLoaded(); - populateComboOptions(comboInput?.value || ''); - }); - } - - const state = this.searchResultCache.get(this.getMissingSearchKey(missing)); - const searchResultsDiv = container.querySelector(`#search-results-${missing.node_id}-${missing.widget_index}`); - if (state && searchResultsDiv && this.hasRenderableSearchState(state)) { - searchResultsDiv.classList.remove('ml-is-hidden'); - searchResultsDiv.classList.add('ml-is-visible'); - this.displaySearchResults(missing, state, searchResultsDiv); - } - } - - /** - * Display missing models in the dialog - */ - displayMissingModels(container, data) { - const missingModels = data.missing_models || []; - const totalMissing = data.total_missing || 0; - this.missingModels = missingModels; - this.syncBatchSelectionForMissingModels(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 - this.setMissingFooterControlsVisible(totalMissing > 0 || activeCount > 0); - 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 || activeCount > 0) ? 'inline-flex' : 'none'; - } - - if (totalMissing === 0 && activeCount === 0) { - container.innerHTML = this.renderStatusMessage('All models are available! No missing models found.', 'success'); - return; - } - - // If no missing models but downloads are active, show a waiting message - if (totalMissing === 0 && activeCount > 0) { - container.innerHTML = this.renderStatusMessage( - `${activeCount} download${activeCount > 1 ? 's' : ''} in progress. Models will be auto-linked when complete.`, - 'info' - ); - return; - } - - // Skip rendering if active tab is not "missing" - if (this.activeTab !== 'missing') { - container.innerHTML = ''; - return; - } - - // Sort missing models: those with 100% confidence matches first, then others - const sortedMissingModels = [...missingModels].sort((a, b) => { - const aMatches = a.matches || []; - const bMatches = b.matches || []; - - // Filter to 70%+ confidence - const aFiltered = aMatches.filter(m => m.confidence >= 70); - const bFiltered = bMatches.filter(m => m.confidence >= 70); - - // Check if they have 100% matches - const aHas100 = aFiltered.some(m => m.confidence === 100); - const bHas100 = bFiltered.some(m => m.confidence === 100); - - // If one has 100% and the other doesn't, prioritize the one with 100% - if (aHas100 && !bHas100) return -1; - if (!aHas100 && bHas100) return 1; - - // If both have 100% or neither has 100%, sort by best confidence - const aBestConf = aFiltered.length > 0 ? Math.max(...aFiltered.map(m => m.confidence)) : 0; - const bBestConf = bFiltered.length > 0 ? Math.max(...bFiltered.map(m => m.confidence)) : 0; - - return bBestConf - aBestConf; // Higher confidence first - }); - - for (let mi = 0; mi < sortedMissingModels.length; mi++) { - sortedMissingModels[mi].__displayIndex = mi; - } - - const selectedStillExists = sortedMissingModels.some(missing => ( - this.getMissingModelKey(missing) === this.selectedMissingModelKey - )); - if (!selectedStillExists) { - this.selectedMissingModelKey = sortedMissingModels.length - ? this.getMissingModelKey(sortedMissingModels[0]) - : null; - } - - this.missingModels = sortedMissingModels; - container.innerHTML = this.renderMissingModelsBrowser( - sortedMissingModels, - this.selectedMissingModelKey, - totalMissing, - activeCount, - hasAny100Match - ); - this.wireMissingModelsBrowser(container, data, sortedMissingModels); - this.scheduleInitialUrnLocalMatchRefresh(sortedMissingModels, container, data); - this.updateBatchFooterButtons(); - } - - 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 (downloadSource && downloadSource.url) { - html += this.renderKnownDownloadPanel(missing, downloadSource); - } else if (perfectMatches.length > 0) { - // Has perfect local match - download not needed, but allow online re-check. - html += `
`; - html += this.renderSearchControls(missing, { buttonText: 'Search Online' }); - html += this.renderDownloadTargetControls(missing, missing.category || 'checkpoints'); - html += `
`; - html += `
`; - } else if (missing.is_urn) { - html += `
`; - html += `
Resolving CivitAI download for this URN...
`; - html += `
`; - } else { - // No known download - offer search - html += `
`; - html += this.renderSearchControls(missing); - html += this.renderDownloadTargetControls(missing, missing.category || 'checkpoints'); - html += `
`; - html += `
`; - } - - // Progress container (for downloads) - html += `
`; - - html += `
`; // End right column - html += `
`; // End columns - - html += `
`; // End card - return html; - } - - /** - * Show a notification banner (similar to ComfyUI's "Reconnecting" banner) - */ - showNotification(message, type = 'success') { - // Build children array, filtering out nulls - const children = []; - - if (type === 'success') { - children.push($el("span", { - textContent: "✓", - className: "ml-notification-icon" - })); - } else if (type === 'error') { - children.push($el("span", { - textContent: "×", - className: "ml-notification-icon" - })); - } else if (type === 'info') { - children.push($el("span", { - textContent: "ℹ", - className: "ml-notification-icon" - })); - } - - // Create notification banner - const notification = $el("div", { - className: `ml-notification ml-notification--${type}` - }, [ - ...children, - $el("span", { - textContent: message - }), - $el("button", { - className: "ml-notification-close", - textContent: "×", - onclick: () => { - if (notification.parentNode) { - notification.style.opacity = "0"; - notification.style.transform = "translateX(-50%) translateY(-100%)"; - setTimeout(() => { - if (notification.parentNode) { - notification.parentNode.removeChild(notification); - } - }, 300); - } - } - }) - ]); - - document.body.appendChild(notification); - - // Auto-dismiss after 4 seconds for success, 6 seconds for errors - const dismissTime = type === 'success' ? 4000 : 6000; - setTimeout(() => { - if (notification.parentNode) { - notification.style.opacity = "0"; - notification.style.transform = "translateX(-50%) translateY(-100%)"; - setTimeout(() => { - if (notification.parentNode) { - notification.parentNode.removeChild(notification); - } - }, 300); - } - }, dismissTime); - } - - /** - * Resolve a model - resolves ALL nodes that reference this model - */ - async resolveModel(missing, resolvedModel) { - console.log('resolveModel called:', missing?.original_path, '->', resolvedModel?.filename); - - if (!resolvedModel) { - this.showNotification('No resolved model selected', 'error'); - return; - } - - try { - const workflow = this.getCurrentWorkflow(); - if (!workflow) { - this.showNotification('No workflow loaded', 'error'); - return; - } - - // Resolve ALL nodes that need this model (all_node_refs contains deduplicated refs) - const nodeRefs = missing.all_node_refs || [missing]; - console.log('nodeRefs count:', nodeRefs?.length, 'is_lora_v2:', nodeRefs?.[0]?.is_lora_v2); - - const resolutions = nodeRefs.map(ref => ({ - node_id: ref.node_id, - widget_index: ref.widget_index, - resolved_path: resolvedModel.path, - category: ref.category, - resolved_model: resolvedModel, - subgraph_id: ref.subgraph_id, - is_top_level: ref.is_top_level, - is_lora_v2: ref.is_lora_v2, - original_lora_name: ref.name || ref.original_path - })); - - const response = await api.fetchApi('/model_linker/resolve', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workflow, - resolutions: resolutions - }) - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = await response.json(); - console.log('Resolve response: success=', data.success, ' missing count:', data.workflow?.nodes?.length); - - if (data.success) { - // Update workflow in ComfyUI - await this.updateWorkflowInComfyUI(data.workflow); - - // Show success notification - const modelName = resolvedModel.relative_path || resolvedModel.filename || 'model'; - const count = resolutions.length; - const refText = count > 1 ? ` (${count} references)` : ''; - this.showNotification(`✓ Model linked successfully: ${modelName}${refText}`, 'success'); - - // Reload dialog using the updated workflow from API response - // This ensures we're analyzing the correct updated workflow - await this.loadWorkflowData(data.workflow); - } else { - this.showNotification('Failed to resolve model: ' + (data.error || 'Unknown error'), 'error'); - } - - } catch (error) { - console.error('Model Linker: Error resolving model:', error); - this.showNotification('Error resolving model: ' + error.message, 'error'); - } - } - - /** - * Auto-resolve all 100% confidence matches - * @returns {object|null} The updated workflow if successful, null otherwise - */ - async autoResolve100Percent() { - try { - const workflow = this.getCurrentWorkflow(); - if (!workflow) { - this.showNotification('No workflow loaded', 'error'); - return null; - } - - // Analyze workflow first - const analyzeResponse = await api.fetchApi('/model_linker/analyze', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workflow }) - }); - - if (!analyzeResponse.ok) { - throw new Error(`API error: ${analyzeResponse.status}`); - } - - const analyzeData = await analyzeResponse.json(); - const missingModels = analyzeData.missing_models || []; - - // Collect all 100% matches - const resolutions = []; - for (const missing of missingModels) { - const matches = missing.matches || []; - const perfectMatch = matches.find((m) => m.confidence === 100); - - if (perfectMatch && perfectMatch.model) { - resolutions.push({ - node_id: missing.node_id, - widget_index: missing.widget_index, - resolved_path: perfectMatch.model.path, - category: missing.category, - resolved_model: perfectMatch.model, - subgraph_id: missing.subgraph_id, // Include subgraph_id for subgraph nodes - is_top_level: missing.is_top_level, // True for top-level nodes, False for nodes in subgraph definitions - is_lora_v2: missing.is_lora_v2, - original_lora_name: missing.name || missing.original_path - }); - } - } - - if (resolutions.length === 0) { - this.showNotification('No 100% confidence matches found to auto-resolve.', 'error'); - return null; - } - - // Apply resolutions - const resolveResponse = await api.fetchApi('/model_linker/resolve', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workflow, - resolutions - }) - }); - - if (!resolveResponse.ok) { - throw new Error(`API error: ${resolveResponse.status}`); - } - - const resolveData = await resolveResponse.json(); - - if (resolveData.success) { - // Update workflow in ComfyUI - await this.updateWorkflowInComfyUI(resolveData.workflow); - - // Show success notification - this.showNotification( - `✓ Successfully linked ${resolutions.length} model${resolutions.length > 1 ? 's' : ''}!`, - 'success' - ); - - // Reload dialog using the updated workflow from API response (if dialog is visible) - if (this.contentElement) { - await this.loadWorkflowData(resolveData.workflow); - } - - // Return the updated workflow for callers who need it - return resolveData.workflow; - } else { - this.showNotification('Failed to resolve models: ' + (resolveData.error || 'Unknown error'), 'error'); - return null; - } - - } catch (error) { - console.error('Model Linker: Error auto-resolving:', error); - this.showNotification('Error auto-resolving: ' + error.message, 'error'); - return null; - } - } - - /** - * Download all missing models that have download sources but no 100% local match - */ - async downloadAllMissing() { - if (!this.contentElement) return; - - try { - const workflow = this.getCurrentWorkflow(); - if (!workflow) { - this.showNotification('No workflow loaded', 'error'); - return; - } - - // Analyze workflow first - const analyzeResponse = await api.fetchApi('/model_linker/analyze', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workflow }) - }); - - if (!analyzeResponse.ok) { - throw new Error(`API error: ${analyzeResponse.status}`); - } - - const analyzeData = await analyzeResponse.json(); - const missingModels = analyzeData.missing_models || []; - - // Collect models that need downloading: - // - Have a download_source with valid URL - // - Do NOT have any 100% confidence local matches - const toDownload = []; - for (const missing of missingModels) { - const perfectMatches = (missing.matches || []).filter(m => m.confidence === 100); - - // Skip if has 100% local match or no download source - if (perfectMatches.length > 0 || !missing.download_source?.url) { - continue; - } - - toDownload.push(missing); - } - - if (toDownload.length === 0) { - this.showNotification('No models available for download (all have local matches or no download URLs).', 'info'); - return; - } - - // Start all downloads - this.showNotification(`Starting ${toDownload.length} download${toDownload.length > 1 ? 's' : ''}...`, 'info'); - - for (const missing of toDownload) { - // Use downloadModel which handles progress tracking - this.downloadModel(missing); - } - - // Update button state to show Cancel All - this.updateDownloadAllButtonState(); - - } catch (error) { - console.error('Model Linker: Error in downloadAllMissing:', error); - this.showNotification('Error starting downloads: ' + error.message, 'error'); - } - } - - getBestDownloadSourceForMissing(missing) { - if (!missing) return null; - - if (missing.download_source?.url) { - return { - ...missing.download_source, - directory: missing.download_source.directory || missing.download_source.category || missing.category || 'checkpoints' - }; - } - - const state = this.searchResultCache.get(this.getMissingSearchKey(missing)); - const results = state?.results || {}; - const filename = missing.original_path?.split('/').pop()?.split('\\').pop() || ''; - const first = (value) => Array.isArray(value) ? value[0] : value; - const candidates = [ - { - source: 'popular', - result: first(results.popular), - urlKey: 'url', - filenameKey: 'filename', - categoryKey: 'directory' - }, - { - source: 'model_list', - result: first(results.model_list), - urlKey: 'url', - filenameKey: 'filename', - categoryKey: 'directory' - }, - { - source: 'huggingface', - result: first(results.huggingface), - urlKey: 'url', - filenameKey: 'filename' - }, - { - source: 'civarchive', - result: first(results.civarchive), - urlKey: 'download_url', - filenameKey: 'filename' - }, - { - source: 'lora_manager_archive', - result: first(results.lora_manager_archive), - urlKey: 'download_url', - filenameKey: 'filename' - }, - { - source: 'civitai', - result: first(results.civitai), - urlKey: 'download_url', - filenameKey: 'filename' - } - ]; - - for (const candidate of candidates) { - const result = candidate.result; - const url = result?.[candidate.urlKey]; - if (!url) continue; - - return { - source: candidate.source, - url, - filename: result[candidate.filenameKey] || missing.civitai_info?.expected_filename || filename, - directory: result[candidate.categoryKey] || result.directory || result.category || missing.category || 'checkpoints', - model_url: result.url || result.model_url || this.getModelCardUrl(url), - name: result.name || result.repo_id || result.repo || result.filename || filename, - size: result.size, - type: result.type || missing.category - }; - } - - return null; - } - - getBatchSearchTargets(mode) { - const missingModels = this.missingModels || []; - if (mode === 'selected') { - return this.getSelectedMissingModels(); - } - if (mode === 'unsearched') { - return missingModels.filter(missing => !this.hasRenderableSearchState(this.getSearchState(missing))); - } - return missingModels; - } - - async searchMissingBatch(mode = 'selected', source = 'all') { - if (this.batchSearchRunning) { - this.showNotification('Batch search is already running.', 'info'); - return; - } - - const targets = this.getBatchSearchTargets(mode); - if (!targets.length) { - this.showNotification(mode === 'selected' ? 'No missing models selected.' : 'No missing models to search.', 'info'); - this.closeFooterMenus(); - return; - } - - this.batchSearchRunning = true; - this.updateBatchFooterButtons(); - this.closeFooterMenus(); - this.showNotification(`Searching ${targets.length} missing model${targets.length > 1 ? 's' : ''}...`, 'info'); - - let completed = 0; - let failed = 0; - try { - for (const missing of targets) { - const state = this.getSearchState(missing); - state.selectedSource = source || 'all'; - try { - await this.searchOnline(missing); - } catch (error) { - failed += 1; - console.error('Model Linker: batch search item failed:', error); - } - completed += 1; - this.updateBatchFooterButtons(); - } - } finally { - this.batchSearchRunning = false; - this.persistSearchStateForActiveWorkflow(); - this.updateBatchFooterButtons(); - } - - const suffix = failed ? `, ${failed} failed` : ''; - this.showNotification(`Finished search for ${completed} model${completed === 1 ? '' : 's'}${suffix}.`, failed ? 'error' : 'success'); - } - - async downloadMissingBatch(mode = 'selected') { - const targets = mode === 'selected' - ? this.getSelectedMissingModels() - : (this.missingModels || []); - - if (!targets.length) { - this.showNotification(mode === 'selected' ? 'No missing models selected.' : 'No missing models to download.', 'info'); - this.closeFooterMenus(); - return; - } - - const toDownload = []; - for (const missing of targets) { - if (this.getBestLocalMatch(missing, 100)) continue; - const source = this.getBestDownloadSourceForMissing(missing); - if (!source?.url) continue; - missing.download_source = source; - toDownload.push(missing); - } - - if (!toDownload.length) { - this.showNotification('No selected models have downloadable sources.', 'info'); - this.closeFooterMenus(); - return; - } - - this.closeFooterMenus(); - this.showNotification(`Starting ${toDownload.length} download${toDownload.length > 1 ? 's' : ''}...`, 'info'); - for (const missing of toDownload) { - await this.downloadModel(missing); - } - this.updateDownloadAllButtonState(); - this.updateBatchFooterButtons(); - } - - queueExactLocalMatchesBatch(mode = 'selected') { - const selected = this.getSelectedMissingModels(); - const targets = mode === 'selected' && selected.length ? selected : (this.missingModels || []); - let queued = 0; - - for (const missing of targets) { - const match = this.getBestLocalMatch(missing, 100); - if (!match?.model) continue; - this.queueResolution(missing, match.model); - queued += 1; - } - - this.closeFooterMenus(); - if (!queued) { - this.showNotification('No exact local matches found to queue.', 'info'); - return; - } - this.showNotification(`Queued ${queued} exact local match${queued === 1 ? '' : 'es'}.`, 'success'); - this.updateBatchFooterButtons(); - } - - /** - * Auto-resolve a model after download completes - * Reloads the workflow analysis and resolves if the downloaded model is found - */ - async autoResolveAfterDownload(missing, downloadedFilename) { - try { - const workflow = this.getCurrentWorkflow(); - if (!workflow) { - // Just reload the UI to show updated state - await this.loadWorkflowData(); - return; - } - - // Re-analyze workflow to find the newly downloaded model - const analyzeResponse = await api.fetchApi('/model_linker/analyze', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workflow }) - }); - - if (!analyzeResponse.ok) { - // Just reload UI - await this.loadWorkflowData(); - return; - } - - const analyzeData = await analyzeResponse.json(); - const missingModels = analyzeData.missing_models || []; - - // Find the missing model entry that matches our download by filename - const targetMissing = missingModels.find(m => { - const missingFilename = m.original_path?.split('/').pop()?.split('\\').pop() || ''; - return missingFilename.toLowerCase() === downloadedFilename.toLowerCase(); - }); - - if (!targetMissing) { - // Model no longer missing - already resolved or workflow changed - await this.loadWorkflowData(); - return; - } - - // Look for a 100% match with the downloaded filename - const matches = targetMissing.matches || []; - const perfectMatch = matches.find(m => { - const matchFilename = m.filename || m.model?.filename || ''; - // Check for exact match or 100% confidence - return m.confidence === 100 || - matchFilename.toLowerCase() === downloadedFilename.toLowerCase(); - }); - - if (perfectMatch && perfectMatch.model) { - // Auto-resolve ALL nodes that need this model - // all_node_refs contains all nodes referencing this model (deduplicated) - const nodeRefs = targetMissing.all_node_refs || [targetMissing]; - const resolutions = nodeRefs.map(ref => ({ - node_id: ref.node_id, - widget_index: ref.widget_index, - resolved_path: perfectMatch.model.path, - category: ref.category, - resolved_model: perfectMatch.model, - subgraph_id: ref.subgraph_id, - is_top_level: ref.is_top_level, - is_lora_v2: ref.is_lora_v2, - original_lora_name: ref.name || ref.original_path - })); - - const resolveResponse = await api.fetchApi('/model_linker/resolve', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workflow, - resolutions: resolutions - }) - }); - - if (resolveResponse.ok) { - const resolveData = await resolveResponse.json(); - if (resolveData.success) { - await this.updateWorkflowInComfyUI(resolveData.workflow); - const count = resolutions.length; - this.showNotification(`✓ Auto-resolved: ${downloadedFilename} (${count} reference${count > 1 ? 's' : ''})`, 'success'); - await this.loadWorkflowData(resolveData.workflow); - return; - } - } - } - - // If we couldn't auto-resolve, just reload the UI - await this.loadWorkflowData(); - - } catch (error) { - console.error('Model Linker: Error auto-resolving after download:', error); - // Still reload UI even on error - await this.loadWorkflowData(); - } - } - - /** - * Download a model from a known source - */ - async downloadModel(missing) { - const source = missing.download_source; - if (!source || !source.url) { - this.showNotification('No download URL available', 'error'); - return; - } - - // Use filename from download source if available (may be different from original) - const originalFilename = missing.original_path?.split('/').pop()?.split('\\').pop() || 'model.safetensors'; - const filename = source.filename || originalFilename; - const targetSelection = this.getDownloadTargetSelection(missing, source.directory || missing.category || 'checkpoints'); - const category = targetSelection.category; - const subfolder = targetSelection.subfolder; - const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`; - const progressDiv = this.contentElement?.querySelector(`#${progressId}`); - const downloadBtn = this.contentElement?.querySelector(`#download-${missing.node_id}-${missing.widget_index}`); - const tokens = this.getStoredTokens(); - - try { - // Disable button and show progress with cancel button immediately - if (downloadBtn) { - downloadBtn.disabled = true; - downloadBtn.classList.remove('ml-is-success-action', 'ml-btn-primary'); - downloadBtn.textContent = 'Starting...'; - } - if (progressDiv) { - progressDiv.classList.remove('ml-is-hidden'); - progressDiv.classList.add('ml-is-visible'); - // Show progress bar with cancel button immediately - progressDiv.innerHTML = this.renderProgressWithAction({ - percent: 0, - leftText: 'Connecting...', - rightText: '', - actionClass: 'cancel-download-btn-pending ml-btn ml-btn-danger ml-btn-sm', - actionText: 'Cancel' - }); - } - - // Start download - const response = await api.fetchApi('/model_linker/download', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - url: source.url, - filename: filename, - category: category, - subfolder: subfolder, - hf_token: tokens.hf_token, - civitai_key: tokens.civitai_key - }) - }); - - if (!response.ok) { - throw new Error(`Download failed: ${response.status}`); - } - - const data = await response.json(); - if (!data.success) { - throw new Error(data.error || 'Download failed'); - } - - // Track download and poll for progress - const downloadId = data.download_id; - this.activeDownloads[downloadId] = { missing, progressDiv, downloadBtn }; - - // Update the Download All button state - this.updateDownloadAllButtonState(); - - // Attach cancel handler to pending button (before polling replaces it) - const pendingCancelBtn = progressDiv?.querySelector('.cancel-download-btn-pending'); - if (pendingCancelBtn) { - pendingCancelBtn.addEventListener('click', () => this.cancelDownload(downloadId)); - } - - this.pollDownloadProgress(downloadId); - - } catch (error) { - console.error('Model Linker: Download error:', error); - if (progressDiv) { - progressDiv.innerHTML = this.renderStatusMessage(error.message, 'error'); - } - if (downloadBtn) { - downloadBtn.disabled = false; - downloadBtn.classList.remove('ml-is-success-action', 'ml-btn-primary'); - downloadBtn.innerHTML = '☁ Retry'; - } - this.showNotification('Download failed: ' + error.message, 'error'); - } - } - - /** - * Poll download progress - */ - async pollDownloadProgress(downloadId) { - const info = this.activeDownloads[downloadId]; - if (!info) return; - - try { - const response = await api.fetchApi(`/model_linker/progress/${downloadId}`); - if (!response.ok) { - throw new Error('Failed to get progress'); - } - - const progress = await response.json(); - const { progressDiv, downloadBtn, missing } = info; - - if (progress.status === 'downloading' || progress.status === 'starting') { - const percent = progress.progress || 0; - const downloaded = this.formatBytes(progress.downloaded || 0); - const total = this.formatBytes(progress.total_size || 0); - const speed = progress.speed ? this.formatBytes(progress.speed) + '/s' : ''; - - if (progressDiv) { - progressDiv.innerHTML = this.renderProgressWithAction({ - percent, - leftText: `${downloaded} / ${total} (${percent}%)`, - rightText: speed, - actionClass: 'cancel-download-btn ml-btn ml-btn-danger ml-btn-sm', - actionText: 'Cancel', - actionDataAttr: `data-download-id="${downloadId}"` - }); - // Attach cancel handler - const cancelBtn = progressDiv.querySelector('.cancel-download-btn'); - if (cancelBtn && !cancelBtn._hasListener) { - cancelBtn._hasListener = true; - cancelBtn.addEventListener('click', () => this.cancelDownload(downloadId)); - } - } - if (downloadBtn) { - downloadBtn.textContent = `${percent}%`; - } - - // Continue polling - setTimeout(() => this.pollDownloadProgress(downloadId), 1000); - - } else if (progress.status === 'completed') { - if (progressDiv) { - progressDiv.innerHTML = this.renderStatusMessage('Download complete! Auto-linking...', 'success'); - } - if (downloadBtn) { - downloadBtn.textContent = '✓ Done'; - downloadBtn.classList.remove('ml-is-success-action'); - downloadBtn.classList.add('ml-btn-primary'); - } - delete this.activeDownloads[downloadId]; - this.updateDownloadAllButtonState(); - this.showNotification(`Downloaded: ${progress.filename}`, 'success'); - - // Auto-resolve: Reload workflow data and try to resolve the downloaded model - // Small delay to ensure file system is updated - setTimeout(async () => { - await this.autoResolveAfterDownload(missing, progress.filename); - }, 500); - - } else if (progress.status === 'error') { - if (progressDiv) { - progressDiv.innerHTML = this.renderStatusMessage(progress.error || 'Download failed', 'error'); - } - if (downloadBtn) { - downloadBtn.disabled = false; - downloadBtn.classList.remove('ml-is-success-action', 'ml-btn-primary'); - downloadBtn.textContent = 'Retry'; - } - delete this.activeDownloads[downloadId]; - this.updateDownloadAllButtonState(); - - } else if (progress.status === 'cancelled') { - if (progressDiv) { - progressDiv.innerHTML = this.renderStatusMessage('Download cancelled - incomplete file removed', 'warning'); - } - if (downloadBtn) { - downloadBtn.disabled = false; - downloadBtn.classList.remove('ml-is-success-action', 'ml-btn-primary'); - downloadBtn.innerHTML = '☁ Download'; - } - delete this.activeDownloads[downloadId]; - this.updateDownloadAllButtonState(); - this.showNotification('Download cancelled', 'info'); - - } else { - // Unknown status, keep polling - setTimeout(() => this.pollDownloadProgress(downloadId), 500); - } - - } catch (error) { - console.error('Model Linker: Progress poll error:', error); - const info = this.activeDownloads[downloadId]; - // Update UI to show error state instead of just disappearing - if (info) { - const { progressDiv, downloadBtn } = info; - if (progressDiv) { - progressDiv.innerHTML = this.renderStatusMessage('Connection lost - download may have failed', 'error'); - } - if (downloadBtn) { - downloadBtn.disabled = false; - downloadBtn.textContent = 'Retry'; - downloadBtn.classList.remove('ml-btn-primary'); - downloadBtn.classList.add('ml-is-success-action'); - } - } - delete this.activeDownloads[downloadId]; - this.updateDownloadAllButtonState(); - } - } - - /** - * Cancel an active download - */ - async cancelDownload(downloadId) { - try { - const response = await api.fetchApi(`/model_linker/cancel/${downloadId}`, { - method: 'POST' - }); - - if (!response.ok) { - throw new Error('Failed to cancel download'); - } - - const info = this.activeDownloads[downloadId]; - if (info?.progressDiv) { - info.progressDiv.innerHTML = this.renderStatusMessage('Cancelling download...', 'info'); - } - - } catch (error) { - console.error('Model Linker: Cancel error:', error); - this.showNotification('Failed to cancel download', 'error'); - } - } - - /** - * Search online for a model - */ - async searchOnline(missing) { - let filename = missing.original_path?.split('/').pop()?.split('\\').pop() || ''; - let category = missing.category || ''; - const state = this.getSearchState(missing); - let selectedSource = state.selectedSource || 'all'; - if (selectedSource !== 'all' && !this.isSearchSourceUsable(selectedSource)) { - selectedSource = 'all'; - state.selectedSource = 'all'; - this.persistSearchStateForActiveWorkflow(); - } - const selectedSourceLabel = this.getSearchSourceLabel(selectedSource); - const sourceIds = this.getSearchSourcesForSelection(selectedSource, missing); - - // For URNs, use the resolved file/model name for searching instead of the URN itself - // and pass the URN type as category (CivitAI expects specific type names) - if (missing.is_urn) { - const urnSearchName = missing.civitai_info?.expected_filename - || missing.download_source?.filename - || missing.civitai_info?.model_name; - if (urnSearchName) { - filename = urnSearchName; - } - // Pass URN type directly - CivitAPI expects types like 'Upscaler', 'Checkpoint' - const urnType = missing.urn_type || ''; - if (urnType) { - // Map URN types to CivitAI type names - const typeMap = { - 'checkpoint': 'Checkpoint', - 'lora': 'LORA', - 'vae': 'VAE', - 'upscaler': 'Upscaler', - 'upscale_model': 'Upscaler', - 'embedding': 'TextualInversion', - 'controlnet': 'Controlnet' - }; - const civitaiType = typeMap[urnType.toLowerCase()]; - if (civitaiType) { - category = civitaiType; - } - } - } - - const isUrn = missing.is_urn || false; - const resultsId = `search-results-${missing.node_id}-${missing.widget_index}`; - const resultsDiv = this.contentElement?.querySelector(`#${resultsId}`); - const searchBtn = this.contentElement?.querySelector(`#search-${missing.node_id}-${missing.widget_index}`); - let searchRunId = null; - - try { - if (!sourceIds.length) { - throw new Error(`${selectedSourceLabel} is not available in this install`); - } - - this.clearSearchProgressTimers(state.activeSearchRunId); - searchRunId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - state.activeSearchRunId = searchRunId; - state.lastAttemptSources = sourceIds; - state.lastAttemptFound = null; - state.lastAttemptError = null; - state.sourceProgress = {}; - for (const source of sourceIds) { - this.setSourceProgress(state, source, { - status: 'running', - percent: 6, - startedAt: Date.now(), - estimateMs: this.getSearchSourceEstimateMs(source, isUrn) - }, missing); - } - this.persistSearchStateForActiveWorkflow(); - - if (searchBtn) { - searchBtn.disabled = true; - searchBtn.innerHTML = `${this.getSearchIconHtml()} Searching ${selectedSourceLabel}...`; - } - if (resultsDiv) { - resultsDiv.classList.remove('ml-is-hidden'); - resultsDiv.classList.add('ml-is-visible'); - this.displaySearchResults(missing, state, resultsDiv); - } - for (const source of sourceIds) { - this.startEstimatedSearchProgress( - state, - missing, - resultsDiv, - source, - searchRunId - ); - } - - // For URNs, include model_id and version_id for direct download - const tokens = this.getStoredTokens(); - const baseSearchData = { - filename, - category, - is_urn: isUrn, - civitai_session_token: tokens.civitai_session_token, - civitai_candidate_limit: tokens.civitai_candidate_limit, - civitai_use_trpc_search: tokens.civitai_use_trpc_search, - civitai_use_html_fallback: tokens.civitai_use_html_fallback, - hf_token: tokens.hf_token, - brave_search_api_key: tokens.brave_search_api_key, - hf_use_api_search: tokens.hf_use_api_search, - hf_use_comfy_org_fallback: tokens.hf_use_comfy_org_fallback, - hf_use_brave_fallback: tokens.hf_use_brave_fallback - }; - if (isUrn && missing.urn) { - baseSearchData.model_id = missing.urn.model_id; - baseSearchData.version_id = missing.urn.version_id; - } - - const attemptedSources = new Set(); - let anyFound = false; - let hadError = false; - - const searchPromises = sourceIds.map(async (source) => { - const sourceIsUrn = isUrn && source !== 'lora_manager_archive'; - const searchData = { - ...baseSearchData, - is_urn: sourceIsUrn, - sources: [source] - }; - - try { - console.log('Model Linker: Search request:', JSON.stringify(searchData)); - - const response = await api.fetchApi('/model_linker/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(searchData) - }); - - if (!response.ok) { - throw new Error(`Search failed: ${response.status}`); - } - - const data = await response.json(); - console.log('Model Linker: Search response:', JSON.stringify(data)); - - if (state.activeSearchRunId !== searchRunId) { - return { source, stale: true }; - } - - const responseSources = Array.isArray(data.searched_sources) && data.searched_sources.length - ? data.searched_sources - : [source]; - responseSources.forEach(responseSource => attemptedSources.add(responseSource)); - - const found = this.hasSearchResults(data); - anyFound = anyFound || found; - state.results = this.mergeSearchResults(state.results, data); - state.lastAttemptSources = Array.from(attemptedSources); - state.lastAttemptFound = anyFound; - this.clearSearchProgressTimer(searchRunId, source); - this.setSourceProgress(state, source, { - status: found ? 'found' : 'none', - percent: 100, - message: found ? 'Found' : 'No match' - }, missing); - - if (data.civitai) { - missing.civitai_search_result = { - base_model: data.civitai.base_model, - tags: data.civitai.tags || [], - trained_words: data.civitai.trained_words || [], - filename: data.civitai.filename, - name: data.civitai.name, - type: data.civitai.type - }; - } - if (data.civarchive) { - missing.civitai_search_result = { - base_model: data.civarchive.base_model, - tags: data.civarchive.tags || [], - trained_words: data.civarchive.trained_words || [], - filename: data.civarchive.filename, - name: data.civarchive.name, - type: data.civarchive.type - }; - } - - this.displaySearchResults(missing, state, resultsDiv); - this.persistSearchStateForActiveWorkflow(); - this.applySearchResultSuggestion(missing); - return { source, data }; - } catch (error) { - console.error(`Model Linker: Search error for ${source}:`, error); - if (state.activeSearchRunId !== searchRunId) { - return { source, stale: true, error }; - } - - hadError = true; - attemptedSources.add(source); - state.lastAttemptSources = Array.from(attemptedSources); - state.lastAttemptFound = anyFound; - this.clearSearchProgressTimer(searchRunId, source); - this.setSourceProgress(state, source, { - status: 'error', - percent: 100, - message: error.message || 'Error' - }, missing); - this.displaySearchResults(missing, state, resultsDiv); - this.persistSearchStateForActiveWorkflow(); - return { source, error }; - } - }); - - await Promise.all(searchPromises); - this.clearSearchProgressTimers(searchRunId); - - if (state.activeSearchRunId === searchRunId) { - state.activeSearchRunId = null; - state.lastAttemptSources = attemptedSources.size ? Array.from(attemptedSources) : sourceIds; - state.lastAttemptFound = anyFound; - state.lastAttemptError = hadError && !anyFound - ? 'Search finished with errors. Check source statuses above.' - : null; - this.displaySearchResults(missing, state, resultsDiv); - this.persistSearchStateForActiveWorkflow(); - } - - } catch (error) { - console.error('Model Linker: Search error:', error); - this.clearSearchProgressTimers(searchRunId); - state.lastAttemptError = error.message; - this.persistSearchStateForActiveWorkflow(); - if (resultsDiv) { - resultsDiv.innerHTML = this.renderStatusMessage(`Search failed: ${error.message}`, 'error'); - } - } finally { - if (!searchRunId || state.activeSearchRunId === searchRunId) { - this.clearSearchProgressTimers(searchRunId); - state.activeSearchRunId = null; - } - if (searchBtn) { - searchBtn.disabled = false; - searchBtn.innerHTML = `${this.getSearchIconHtml()} Search Again`; - } - this.persistSearchStateForActiveWorkflow(); - } - } - - /** - * Resolve URN asynchronously - fetch CivitAI info and update UI - */ - async resolveUrnAsync(modelId, versionId, loadingElementId, modelUrl) { - console.log('resolveUrnAsync called:', modelId, versionId); - if (!modelId || !versionId) { - console.log('resolveUrnAsync: missing modelId or versionId'); - return; - } - - try { - const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-'); - const missing = this.missingModels.find(m => - `urn-download-${m.node_id}-${m.widget_index}` === downloadContainerId - ); - - let data = null; - if (missing) { - data = await this.resolveUrnDataForMissing(missing); - } else { - const tokens = this.getStoredTokens(); - const payload = { - filename: `${modelId}_${versionId}`, - category: '', - is_urn: true, - sources: ['civitai'], - model_id: modelId, - version_id: versionId, - civitai_candidate_limit: tokens.civitai_candidate_limit - }; - console.log('resolveUrnAsync payload:', JSON.stringify(payload)); - - const response = await api.fetchApi('/model_linker/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - - console.log('resolveUrnAsync response status:', response.status); - if (!response.ok) { - throw new Error(`URN resolve failed: ${response.status}`); - } - data = await response.json(); - } - - if (data) { - const loadingEl = document.getElementById(loadingElementId); - if (loadingEl && data.civitai) { - loadingEl.classList.remove('ml-is-muted', 'ml-is-error'); - const civitai = data.civitai; - const labelHtml = this.renderVersionedModelNameHtml(civitai.name, civitai.version_name) - || this.escapeHtml(civitai.filename || 'Model'); - const url = modelUrl || `https://civitai.com/models/${modelId}?modelVersionId=${versionId}`; - loadingEl.innerHTML = `${labelHtml}`; - } else if (loadingEl) { - loadingEl.textContent = 'Not found'; - loadingEl.classList.remove('ml-is-error'); - loadingEl.classList.add('ml-is-muted'); - } - - const downloadEl = document.getElementById(downloadContainerId); - if (downloadEl && data.civitai?.download_url) { - if (missing) { - this.applyCivitaiUrnResult(missing, data.civitai); - const downloadParent = downloadEl.parentElement; - downloadEl.outerHTML = this.renderKnownDownloadPanel(missing, missing.download_source); - this.wireDownloadSearchPanel(downloadParent || this.contentElement, missing); - this.refreshUrnLocalMatches(missing); - } - } else if (downloadEl) { - downloadEl.innerHTML = `
Unable to resolve direct download for this URN.
`; - } - } else { - const loadingEl = document.getElementById(loadingElementId); - if (loadingEl) { - loadingEl.textContent = 'Error'; - loadingEl.classList.remove('ml-is-muted'); - loadingEl.classList.add('ml-is-error'); - } - const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-'); - const downloadEl = document.getElementById(downloadContainerId); - if (downloadEl) { - downloadEl.innerHTML = `
Failed to resolve URN download.
`; - } - } - } catch (error) { - console.error('Model Linker: URN resolve error:', error); - const loadingEl = document.getElementById(loadingElementId); - if (loadingEl) { - loadingEl.textContent = 'Error'; - loadingEl.classList.remove('ml-is-muted'); - loadingEl.classList.add('ml-is-error'); - } - const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-'); - const downloadEl = document.getElementById(downloadContainerId); - if (downloadEl) { - downloadEl.innerHTML = `
Failed to resolve URN download.
`; - } - } - } - - wireSearchDownloadButtons(container, missing) { - if (!container) return; - - const downloadBtns = container.querySelectorAll('.search-download-btn'); - downloadBtns.forEach(btn => { - btn.addEventListener('click', () => { - const url = btn.dataset.url; - const filename = btn.dataset.filename; - const category = btn.dataset.category; - this.downloadFromSearch(missing, url, filename, category, btn); - }); - }); - - const openPageBtns = container.querySelectorAll('.search-open-page-btn'); - openPageBtns.forEach(btn => { - btn.addEventListener('click', () => { - const url = btn.dataset.url; - if (!url) return; - - const opened = window.open(url, '_blank', 'noopener,noreferrer'); - if (opened) { - opened.opener = null; - } - }); - }); - } - - wireDownloadSearchPanel(container, missing) { - if (!container) return; - - const searchResultsId = `search-results-${missing.node_id}-${missing.widget_index}`; - const searchResultsDiv = container.querySelector(`#${searchResultsId}`); - if (searchResultsDiv) { - this.wireSearchDownloadButtons(searchResultsDiv, missing); - } - - const searchBtn = container.querySelector(`#search-${missing.node_id}-${missing.widget_index}`); - if (searchBtn && searchBtn.dataset.mlSearchBound !== 'true') { - searchBtn.dataset.mlSearchBound = 'true'; - searchBtn.addEventListener('click', () => { - this.searchOnline(missing); - }); - } - - const sourceSelect = container.querySelector(`#search-source-select-${missing.node_id}-${missing.widget_index}`); - const sourceList = container.querySelector(`#search-source-list-${missing.node_id}-${missing.widget_index}`); - if (sourceSelect && sourceList && sourceSelect.dataset.mlSearchSourceBound !== 'true') { - sourceSelect.dataset.mlSearchSourceBound = 'true'; - - const renderSourceOptions = () => { - const options = this.getSearchSourceOptions(); - sourceList.innerHTML = options - .map(option => `
${this.escapeHtml(option.label)}
`) - .join(''); - sourceList.style.display = 'block'; - sourceList.querySelectorAll('.ml-download-target-option').forEach(optionEl => { - optionEl.addEventListener('mousedown', (event) => { - event.preventDefault(); - const value = decodeURIComponent(optionEl.dataset.value || ''); - const label = decodeURIComponent(optionEl.dataset.label || optionEl.dataset.value || ''); - this.setDropdownValue(sourceSelect, value, label); - this.setSearchSource(missing, value, container); - sourceList.style.display = 'none'; - }); - }); - }; - - const hideSourceList = () => { - setTimeout(() => { - sourceList.style.display = 'none'; - }, 150); - }; - - this.enableWheelScrollChaining(sourceList); - sourceSelect.addEventListener('focus', () => renderSourceOptions()); - sourceSelect.addEventListener('click', () => renderSourceOptions()); - sourceSelect.addEventListener('keydown', (event) => { - if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') { - event.preventDefault(); - renderSourceOptions(); - } - }); - sourceSelect.addEventListener('blur', hideSourceList); - this.syncSearchSourceUi(missing, container); - } - - this.wireDownloadTargetAutocomplete(container, missing); - } - - /** - * Display search results - */ - displaySearchResults(missing, state, container) { - if (!container) return; - - const results = state?.results || {}; - const popular = results.popular; - const modelListResult = results.model_list; - const hfResult = results.huggingface ? (Array.isArray(results.huggingface) ? results.huggingface[0] : results.huggingface) : null; - const civitaiResult = results.civitai ? (Array.isArray(results.civitai) ? results.civitai[0] : results.civitai) : null; - const civarchiveResult = results.civarchive ? (Array.isArray(results.civarchive) ? results.civarchive[0] : results.civarchive) : null; - const loraManagerArchiveResult = results.lora_manager_archive ? (Array.isArray(results.lora_manager_archive) ? results.lora_manager_archive[0] : results.lora_manager_archive) : null; - const knownDownloadRow = this.getDownloadSourceTableRow(missing, missing.download_source); - const hasResults = knownDownloadRow || popular || modelListResult || hfResult || civitaiResult || civarchiveResult || loraManagerArchiveResult; - const progressHtml = this.renderSearchProgress(state); - const hasActiveProgress = this.hasActiveSearchProgress(state); - - if (!hasResults) { - if (hasActiveProgress) { - container.innerHTML = progressHtml; - return; - } - - if (state?.lastAttemptError) { - container.innerHTML = `${progressHtml}${this.renderStatusMessage(state.lastAttemptError, 'error')}`; - return; - } - - container.innerHTML = progressHtml || this.renderStatusMessage( - 'No matches found online for this model.', - 'warning' - ); - return; - } - - const rows = []; - const rowKeys = new Set(); - const addRow = (row) => { - if (!row) return; - const rowKey = row.downloadUrl || row.openUrl || `${row.sourceKey}:${row.model}:${row.filename}`; - if (rowKeys.has(rowKey)) return; - rowKeys.add(rowKey); - rows.push(row); - }; - - let statusHtml = ''; - if (!hasActiveProgress && state?.lastAttemptError) { - statusHtml += this.renderStatusMessage(state.lastAttemptError, 'error'); - } - - addRow(knownDownloadRow); - - if (popular) { - const popularFilename = popular.filename || missing.original_path?.split('/').pop()?.split('\\').pop() || ''; - const popularSize = popular.size - || ( - modelListResult - && modelListResult.filename - && modelListResult.filename.toLowerCase() === popularFilename.toLowerCase() - ? modelListResult.size - : '' - ); - addRow({ - sourceKey: 'popular', - sourceLabel: 'Popular', - model: popular.name || popularFilename, - filename: popularFilename, - secondary: popular.name && popular.name !== popularFilename ? popularFilename : '', - match: this.getSearchResultMatchDisplay(popular, 'Known', 'strong'), - size: this.formatSearchResultSize({ ...popular, size: popularSize }), - downloadUrl: popular.url, - downloadFilename: popularFilename, - category: popular.directory || missing.category, - openUrl: this.getModelCardUrl(popular.url) - }); - } - - if (modelListResult && modelListResult.url) { - addRow({ - sourceKey: 'model-list', - sourceLabel: 'Local Database', - model: modelListResult.name || modelListResult.filename, - filename: modelListResult.filename, - secondary: modelListResult.name && modelListResult.name !== modelListResult.filename ? modelListResult.filename : '', - match: this.getSearchResultMatchDisplay(modelListResult), - size: this.formatSearchResultSize(modelListResult), - downloadUrl: modelListResult.url, - downloadFilename: modelListResult.filename, - category: modelListResult.directory || missing.category, - openUrl: this.getModelCardUrl(modelListResult.url) - }); - } - - if (hfResult && hfResult.url) { - const hfRepo = hfResult.repo_id || hfResult.repo || ''; - const hfModelUrl = hfRepo ? `https://huggingface.co/${hfRepo}` : this.getModelCardUrl(hfResult.url); - addRow({ - sourceKey: 'huggingface', - sourceLabel: 'HuggingFace', - model: hfRepo || hfResult.filename, - filename: hfResult.filename, - secondary: hfResult.path && hfResult.path !== hfResult.filename ? hfResult.path : '', - match: this.getSearchResultMatchDisplay(hfResult), - size: this.formatSearchResultSize(hfResult), - downloadUrl: hfResult.url, - downloadFilename: hfResult.filename, - category: missing.category, - openUrl: hfModelUrl - }); - } - - if (civarchiveResult && civarchiveResult.download_url) { - const archiveFilename = civarchiveResult.filename || missing.original_path?.split('/').pop()?.split('\\').pop() || ''; - const archiveName = civarchiveResult.name || archiveFilename || 'Model'; - addRow({ - sourceKey: 'civarchive', - sourceLabel: 'CivArchive', - model: archiveName, - version: civarchiveResult.version_name || '', - filename: archiveFilename, - secondary: archiveName && archiveName !== archiveFilename ? archiveFilename : (civarchiveResult.platform || civarchiveResult.base_model || ''), - match: this.getSearchResultMatchDisplay(civarchiveResult), - size: this.formatSearchResultSize(civarchiveResult), - downloadUrl: civarchiveResult.download_url, - downloadFilename: archiveFilename, - category: missing.category, - openUrl: civarchiveResult.url - }); - } - - if (loraManagerArchiveResult && loraManagerArchiveResult.url) { - const archiveFilename = loraManagerArchiveResult.filename || missing.original_path?.split('/').pop()?.split('\\').pop() || ''; - const archiveName = loraManagerArchiveResult.name || archiveFilename; - addRow({ - sourceKey: 'lora-archive', - sourceLabel: 'LoRA Archive', - model: archiveName, - version: loraManagerArchiveResult.version_name || '', - filename: archiveFilename, - secondary: archiveName && archiveName !== archiveFilename ? archiveFilename : '', - match: this.getSearchResultMatchDisplay(loraManagerArchiveResult), - size: this.formatSearchResultSize(loraManagerArchiveResult), - downloadUrl: loraManagerArchiveResult.download_url || '', - downloadFilename: archiveFilename, - category: missing.category, - openUrl: loraManagerArchiveResult.url - }); - } - - if (civitaiResult && civitaiResult.download_url) { - const modelUrl = civitaiResult.url || (civitaiResult.model_id ? `https://civitai.com/models/${civitaiResult.model_id}${civitaiResult.version_id ? `?modelVersionId=${civitaiResult.version_id}` : ''}` : ''); - const downloadFilename = missing.civitai_info?.expected_filename || civitaiResult.filename || civitaiResult.name; - const modelName = missing.civitai_info?.model_name || civitaiResult.name || downloadFilename || 'Model'; - addRow({ - sourceKey: 'civitai', - sourceLabel: 'CivitAI', - model: modelName, - version: missing.civitai_info?.version_name || civitaiResult.version_name || '', - filename: downloadFilename, - secondary: civitaiResult.type || civitaiResult.base_model || '', - match: this.getSearchResultMatchDisplay(civitaiResult), - size: this.formatSearchResultSize(civitaiResult), - downloadUrl: civitaiResult.download_url, - downloadFilename, - category: missing.category, - openUrl: modelUrl - }); - } - - const html = `${progressHtml}${statusHtml}${this.renderSearchResultsTable(rows)}`; - container.innerHTML = html; - - this.wireSearchDownloadButtons(container, missing); - } - - /** - * Download from search results - */ - async downloadFromSearch(missing, url, filename, category, btn) { - const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`; - const progressDiv = this.contentElement?.querySelector(`#${progressId}`); - const tokens = this.getStoredTokens(); - const targetSelection = this.getDownloadTargetSelection(missing, category || missing.category || 'checkpoints'); - - try { - btn.disabled = true; - btn.classList.remove('ml-is-success-action', 'ml-btn-primary'); - btn.textContent = 'Starting...'; - - if (progressDiv) { - progressDiv.classList.remove('ml-is-hidden'); - progressDiv.classList.add('ml-is-visible'); - // Show progress bar with cancel button immediately - progressDiv.innerHTML = this.renderProgressWithAction({ - percent: 0, - leftText: 'Connecting...', - rightText: '', - actionClass: 'cancel-download-btn-pending ml-btn ml-btn-danger ml-btn-sm', - actionText: 'Cancel' - }); - } - - const response = await api.fetchApi('/model_linker/download', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - url, - filename, - category: targetSelection.category, - subfolder: targetSelection.subfolder, - hf_token: tokens.hf_token, - civitai_key: tokens.civitai_key - }) - }); - - if (!response.ok) { - throw new Error(`Download failed: ${response.status}`); - } - - const data = await response.json(); - if (!data.success) { - throw new Error(data.error || 'Download failed'); - } - - // Track and poll - const downloadId = data.download_id; - this.activeDownloads[downloadId] = { missing, progressDiv, downloadBtn: btn }; - - // Update the Download All button state - this.updateDownloadAllButtonState(); - - // Attach cancel handler to pending button (before polling replaces it) - const pendingCancelBtn = progressDiv?.querySelector('.cancel-download-btn-pending'); - if (pendingCancelBtn) { - pendingCancelBtn.addEventListener('click', () => this.cancelDownload(downloadId)); - } - - this.pollDownloadProgress(downloadId); - - } catch (error) { - console.error('Model Linker: Download error:', error); - if (progressDiv) { - progressDiv.innerHTML = this.renderStatusMessage(error.message, 'error'); - } - btn.disabled = false; - btn.classList.remove('ml-is-success-action', 'ml-btn-primary'); - btn.textContent = 'Retry'; - this.showNotification('Download failed: ' + error.message, 'error'); - } - } - - /** - * Format bytes to human readable string - */ - formatBytes(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; - } - - /** - * Extract model page URL from a download URL - * HuggingFace file: https://huggingface.co/Owner/Repo/resolve/main/file.safetensors -> https://huggingface.co/Owner/Repo/blob/main/file.safetensors - * CivitAI: https://civitai.com/api/download/models/123?type=Model -> https://civitai.com/models/123 - */ - getModelCardUrl(downloadUrl) { - if (!downloadUrl) return null; - - try { - // HuggingFace URLs - if (downloadUrl.includes('huggingface.co')) { - const fileMatch = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)\/(?:resolve|blob)\/([^\/]+)\/(.+)$/); - if (fileMatch) { - const repo = fileMatch[1]; - const revision = fileMatch[2]; - const filePath = fileMatch[3].split(/[?#]/)[0]; - return `https://huggingface.co/${repo}/blob/${revision}/${filePath}`; - } - - const match = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)/); - if (match) { - return `https://huggingface.co/${match[1]}`; - } - } - - // CivitAI URLs - if (downloadUrl.includes('civitai.com')) { - // Format: /api/download/models/123456 or /models/123456/... - const modelIdMatch = downloadUrl.match(/models\/(\d+)/); - if (modelIdMatch) { - return `https://civitai.com/models/${modelIdMatch[1]}`; - } - } - } catch (e) { - console.error('Error parsing model card URL:', e); - } - - return null; - } - - /** - * Update workflow in ComfyUI's UI/memory - * Updates the current workflow in place instead of creating a new tab - */ - async updateWorkflowInComfyUI(workflow) { - if (!app || !app.graph) { - console.warn('Model Linker: Could not update workflow - app or app.graph not available'); - return; - } - - try { - // Method 1: Try to directly update the current graph using configure - // This is the most direct way to update in place - if (app.graph && typeof app.graph.configure === 'function') { - app.graph.configure(workflow); - return; - } - - // Method 2: Try deserialize to update the graph in place - if (app.graph && typeof app.graph.deserialize === 'function') { - app.graph.deserialize(workflow); - return; - } - - // Method 3: Use loadGraphData with explicit parameters to update current tab - // The key is to NOT create a new workflow - pass null or undefined for the workflow parameter - // clean=false means don't clear the graph first - // restore_view=false means don't restore the viewport - // workflow=null means update current workflow instead of creating new one - if (app.loadGraphData) { - // Try with null as 4th parameter first - await app.loadGraphData(workflow, false, false, null); - return; - } - - console.warn('Model Linker: No method available to update workflow'); - } catch (error) { - console.error('Model Linker: Error updating workflow in ComfyUI:', error); - // Don't throw - allow the workflow update to continue even if UI update fails - // The backend has already updated the workflow data - } - } -} - -// Main extension class -class ModelLinker { - constructor() { - this.linkerButton = null; - this.buttonGroup = null; - this.buttonId = "model-linker-button"; - this.sidebarTabId = "comfyui-model-linker"; - this.sidebarRegistered = false; - this.openTooltip = "Open Model Linker to find or download missing workflow models. Shortcut: Ctrl+Shift+L."; - this.dialog = null; - this.isCheckingMissing = false; // Prevent multiple simultaneous checks - this.lastCheckedWorkflow = null; // Track to avoid duplicate checks - } - - setup = async () => { - loadStylesWhenNeeded(); - - // Remove any existing button - this.removeExistingButton(); - - // Create dialog instance - if (!this.dialog) { - this.dialog = new LinkerManagerDialog(); - window.modelLinkerDialog = this.dialog; - } - - // Register keyboard shortcut (Ctrl+Shift+L) - document.addEventListener('keydown', (e) => { - if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'l') { - e.preventDefault(); - this.openLinkerManager(); - } - }); - - // Listen for workflow load events to auto-check for missing models - this.setupAutoOpenOnMissingModels(); - this.setupActiveWorkflowChangeListeners(); - - const sidebarRegistered = this.registerSidebarButton(); - if (!sidebarRegistered) { - await this.registerTopbarButton(); - } else { - this.attachSidebarButtonToggleHandler(); - } - } - - registerSidebarButton() { - const registerSidebarTab = app.extensionManager?.registerSidebarTab; - if (typeof registerSidebarTab !== "function") { - return false; - } - - if (this.sidebarRegistered || window.__modelLinkerSidebarRegistered) { - this.sidebarRegistered = true; - return true; - } - - try { - registerSidebarTab.call(app.extensionManager, { - id: this.sidebarTabId, - icon: "mdi mdi-link-variant", - title: "Model Linker", - tooltip: this.openTooltip, - type: "custom", - render: (element) => this.renderSidebarPanel(element), - }); - this.sidebarRegistered = true; - window.__modelLinkerSidebarRegistered = true; - return true; - } catch (error) { - console.warn("Model Linker: Sidebar tab registration failed, falling back to top menu button.", error); - return false; - } - } - - attachSidebarButtonToggleHandler() { - window.__modelLinkerSidebarToggleOwner = this; - if (window.__modelLinkerSidebarToggleHandlerAttached) return; - - window.__modelLinkerSidebarToggleHandlerAttached = true; - window.__modelLinkerSidebarToggleHandler = (event) => { - window.__modelLinkerSidebarToggleOwner?.handleSidebarButtonClick(event); - }; - document.addEventListener('click', window.__modelLinkerSidebarToggleHandler, true); - } - - handleSidebarButtonClick(event) { - const target = event.target instanceof Element ? event.target : null; - const button = target?.closest(`.${this.sidebarTabId}-tab-button`); - if (!(button instanceof HTMLElement)) return; - if (!this.dialog?.isVisible()) return; - - const wasDocked = this.dialog.docked; - this.dialog.close({ collapseSidebar: false }); - - if (!wasDocked) { - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation?.(); - - requestAnimationFrame(() => { - if (this.isSidebarButtonActive(button)) { - button.click(); - } - }); - } - } - - isSidebarButtonActive(button) { - return button.matches([ - '[aria-pressed="true"]', - '[aria-selected="true"]', - '[data-active="true"]', - '[data-selected="true"]', - '.active', - '.is-active', - '.selected', - '.p-highlight' - ].join(',')); - } - - renderSidebarPanel(element) { - element.style.height = "100%"; - element.classList.add("ml-sidebar-dock-panel"); - - if (!this.dialog) { - this.dialog = new LinkerManagerDialog(); - window.modelLinkerDialog = this.dialog; - } - - if (this.dialog.shouldOpenFromSidebarFloating()) { - if (this.dialog.isVisible() && !this.dialog.docked) { - this.dialog.close(); - this.dialog.closeComfySidebar(element); - return; - } - - this.openLinkerManager({ - forceFloating: true, - closeSidebarContainer: element, - }); - return; - } - - element.replaceChildren(); - this.openLinkerManager({ dockContainer: element }); - } - - async registerTopbarButton() { - // Try to use new ComfyUI button system (like ComfyUI Manager does) - try { - // Dynamic imports for ComfyUI's button components - const { ComfyButtonGroup } = await import("../../../scripts/ui/components/buttonGroup.js"); - const { ComfyButton } = await import("../../../scripts/ui/components/button.js"); - - // Create button group with Model Linker button - const modelLinkerButton = new ComfyButton({ - icon: "link-variant", - action: () => this.openLinkerManager(), - content: "Model Linker", - classList: "comfyui-button comfyui-menu-mobile-collapse" - }).element; - modelLinkerButton.id = this.buttonId; - this.dialog.setTooltip(modelLinkerButton, this.openTooltip); - this.buttonGroup = new ComfyButtonGroup( - modelLinkerButton - ); - - // Insert before settings group in the menu - app.menu?.settingsGroup.element.before(this.buttonGroup.element); - } catch (e) { - // Fallback for older ComfyUI versions without the new button system - console.log('Model Linker: New button system not available, using floating button fallback.'); - this.createFloatingButton(); - } - } - - /** - * Setup auto-open functionality when workflow is loaded with missing models - */ - setupAutoOpenOnMissingModels() { - // Watch for ComfyUI's Missing Models popup and inject our button - this.setupMissingModelsPopupObserver(); - - console.log('Model Linker: Missing models popup button injection enabled'); - } - - setupActiveWorkflowChangeListeners() { - if (window.__modelLinkerWorkflowChangeHandlers) { - for (const { target, event, handler } of window.__modelLinkerWorkflowChangeHandlers) { - target?.removeEventListener?.(event, handler); - } - } - - if (!window.__modelLinkerHistoryPatched) { - const originalPushState = history.pushState; - const originalReplaceState = history.replaceState; - history.pushState = function(...args) { - const result = originalPushState.apply(this, args); - window.dispatchEvent(new Event('model-linker-locationchange')); - return result; - }; - history.replaceState = function(...args) { - const result = originalReplaceState.apply(this, args); - window.dispatchEvent(new Event('model-linker-locationchange')); - return result; - }; - window.__modelLinkerHistoryPatched = true; - } - - window.__modelLinkerWorkflowChangeOwner = this; - - const routeHandler = () => { - window.__modelLinkerWorkflowChangeOwner?.handleActiveWorkflowRouteChange('route-change'); - }; - const focusHandler = () => { - window.__modelLinkerWorkflowChangeOwner?.handleActiveWorkflowRouteChange('window-focus'); - }; - const visibilityHandler = () => { - if (document.visibilityState === 'visible') { - window.__modelLinkerWorkflowChangeOwner?.handleActiveWorkflowRouteChange('visibility-change'); - } - }; - - window.addEventListener('hashchange', routeHandler); - window.addEventListener('popstate', routeHandler); - window.addEventListener('model-linker-locationchange', routeHandler); - window.addEventListener('focus', focusHandler); - document.addEventListener('visibilitychange', visibilityHandler); - - window.__modelLinkerWorkflowChangeHandlers = [ - { target: window, event: 'hashchange', handler: routeHandler }, - { target: window, event: 'popstate', handler: routeHandler }, - { target: window, event: 'model-linker-locationchange', handler: routeHandler }, - { target: window, event: 'focus', handler: focusHandler }, - { target: document, event: 'visibilitychange', handler: visibilityHandler } - ]; - } - - handleActiveWorkflowRouteChange(reason = 'workflow-change') { - if (!this.dialog?.isVisible()) return; - this.dialog.scheduleActiveWorkflowRefresh(reason); - } - - /** - * Setup MutationObserver to detect ComfyUI's Missing Models popup and inject our button - */ - setupMissingModelsPopupObserver() { - const observer = new MutationObserver((mutations) => { - for (const mutation of mutations) { - for (const node of mutation.addedNodes) { - if (node.nodeType === Node.ELEMENT_NODE) { - this.checkAndInjectButton(node); - } - } - } - }); - - // Observe the entire document for added nodes - observer.observe(document.body, { - childList: true, - subtree: true - }); - } - - /** - * Check if a node is the Missing Models popup and inject our buttons - */ - checkAndInjectButton(node) { - // Look for the Missing Models popup by finding elements with "Missing Models" text - const findMissingModelsDialog = (element) => { - // Check if this element or its children contain "Missing Models" heading - const headings = element.querySelectorAll ? element.querySelectorAll('h2, h3, [class*="title"], [class*="header"]') : []; - for (const heading of headings) { - if (heading.textContent?.includes('Missing Models')) { - return element; - } - } - // Also check text content directly - if (element.textContent?.includes('Missing Models') && - element.textContent?.includes('following models were not found')) { - return element; - } - return null; - }; - - const dialog = findMissingModelsDialog(node); - if (!dialog) return; - - // Check if we already injected buttons - if (dialog.querySelector('#model-linker-btn-container')) return; - - // Find a suitable place to inject the button - const injectButtons = () => { - // Auto-resolve button (green) - const autoResolveBtn = document.createElement('button'); - autoResolveBtn.id = 'model-linker-btn-container'; // Use this ID to prevent duplicate injection - autoResolveBtn.className = 'ml-popup-auto-resolve-btn'; - autoResolveBtn.textContent = '🔗 Auto-resolve 100%'; - this.dialog?.setTooltip(autoResolveBtn, 'Link every missing model that already has an exact local match, then open Model Linker for the rest.'); - autoResolveBtn.addEventListener('click', async () => { - await this.handleAutoResolveInPopup(dialog, autoResolveBtn); - }); - - // Find the "Don't show this again" checkbox row and add button next to it - const checkbox = dialog.querySelector('input[type="checkbox"]'); - if (checkbox) { - const checkboxRow = checkbox.closest('label') || checkbox.parentElement; - if (checkboxRow && checkboxRow.parentElement) { - // Make the parent a flex container to align checkbox and button - checkboxRow.parentElement.classList.add('ml-popup-inline-actions'); - // Insert button at the beginning (left side) - checkboxRow.parentElement.insertBefore(autoResolveBtn, checkboxRow); - return; - } - } - - // Fallback: Find the list of models and insert before it - const modelList = dialog.querySelector('[style*="overflow"]') || - dialog.querySelector('[class*="list"]') || - dialog.querySelector('[class*="content"]'); - - if (modelList) { - // Create a wrapper and insert before the model list - const wrapper = document.createElement('div'); - wrapper.className = 'ml-popup-actions-wrap'; - wrapper.appendChild(autoResolveBtn); - modelList.parentElement?.insertBefore(wrapper, modelList); - } else { - // Find after the description text - const allElements = dialog.querySelectorAll('*'); - for (const el of allElements) { - if (el.textContent?.includes('following models were not found') && - el.children.length === 0) { - el.parentElement?.insertBefore(btnContainer, el.nextSibling); - break; - } - } - } - - console.log('Model Linker: Injected buttons into Missing Models popup'); - }; - - // Small delay to ensure popup is fully rendered - setTimeout(injectButtons, 100); - } - - /** - * Handle auto-resolve in the popup - resolve 100% matches and open Model Linker for remaining - */ - async handleAutoResolveInPopup(dialog, button) { - button.textContent = '⏳ Resolving...'; - button.disabled = true; - - // Close the popup first - const closeBtn = dialog.querySelector('button[class*="close"]') || - dialog.querySelector('svg')?.closest('button') || - Array.from(dialog.querySelectorAll('button')).find(b => - b.textContent === '×' || b.innerHTML.includes('×') || b.innerHTML.includes('close')); - - if (closeBtn) { - closeBtn.click(); - } - - // Small delay to let popup close - await new Promise(r => setTimeout(r, 200)); - -// Create dialog if needed - if (!this.dialog) { - this.dialog = new LinkerManagerDialog(); - window.modelLinkerDialog = this.dialog; - } - - // Run auto-resolve for 100% matches - returns the updated workflow - const updatedWorkflow = await this.dialog.autoResolve100Percent(); - - // Always open Model Linker to show remaining unresolved models - // Pass the updated workflow if available to avoid race condition - this.dialog.show(updatedWorkflow || null); - } - - /** - * Mark resolved model items in the popup as linked (green) and hide download buttons - */ - removeResolvedFromPopup(dialog, resolvedFilenames) { - console.log('Model Linker: Looking for resolved filenames:', resolvedFilenames); - - // Strategy: For each filename, find text nodes containing it, - // then find the nearest Download button and mark that row - for (const filename of resolvedFilenames) { - // Get all text in the dialog and find elements containing our filename - const walker = document.createTreeWalker( - dialog, - NodeFilter.SHOW_TEXT, - null, - false - ); - - let node; - while (node = walker.nextNode()) { - if (node.textContent?.toLowerCase().includes(filename)) { - // Found text containing filename - now find parent with Download button - let parent = node.parentElement; - let attempts = 0; - - while (parent && parent !== dialog && attempts < 10) { - // Look for Download button at this level - const downloadBtn = Array.from(parent.querySelectorAll('button')) - .find(btn => btn.textContent?.includes('Download') && - !btn.id?.includes('model-linker')); - - if (downloadBtn) { - console.log('Model Linker: Found entry for', filename); - this.markEntryAsResolved(parent, downloadBtn); - break; - } - - parent = parent.parentElement; - attempts++; - } - - // Only process first match for this filename - break; - } - } - } - } - - /** - * Mark a model entry as resolved with visual feedback - */ - markEntryAsResolved(container, downloadBtn) { - // Already marked? - if (container.dataset.resolved === 'true') return; - container.dataset.resolved = 'true'; - - console.log('Model Linker: Marking entry as resolved', container); - - // Add green background/styling to the container - container.classList.add('ml-resolved-entry'); - - // Hide the Download button and replace with badge - if (downloadBtn) { - // Create badge - const badge = document.createElement('span'); - badge.className = 'ml-resolved-badge'; - badge.textContent = '✓ Linked'; - - // Replace download button with badge - downloadBtn.style.display = 'none'; - downloadBtn.parentElement?.insertBefore(badge, downloadBtn); - } - - // Find and hide Copy URL button - const allBtns = container.querySelectorAll('button'); - for (const btn of allBtns) { - if (btn.textContent?.includes('Copy URL')) { - btn.style.display = 'none'; - } - } - } - - /** - * Count remaining model items in the popup - */ - countRemainingItems(dialog) { - // Count elements that look like model entries (have Download buttons) - const downloadButtons = dialog.querySelectorAll('button'); - let count = 0; - for (const btn of downloadButtons) { - if (btn.textContent?.includes('Download') && !btn.id?.includes('model-linker')) { - count++; - } - } - return count; - } - - /** - * Update nodes directly in the graph without triggering a full workflow reload - * This prevents the Missing Models popup from closing - */ - updateNodesDirectly(resolutions) { - if (!app?.graph) { - console.warn('Model Linker: Cannot update nodes - graph not available'); - return; - } - - for (const resolution of resolutions) { - const nodeId = resolution.node_id; - const widgetIndex = resolution.widget_index; - const resolvedPath = resolution.resolved_path; - - // Find the node in the graph - const node = app.graph.getNodeById(nodeId); - if (!node) { - console.warn(`Model Linker: Node ${nodeId} not found in graph`); - continue; - } - - // Update the widget value - if (node.widgets && node.widgets[widgetIndex]) { - const widget = node.widgets[widgetIndex]; - widget.value = resolvedPath; - - // Trigger widget callback if it exists - if (widget.callback) { - widget.callback(resolvedPath, app.graph, node, null, null); - } - - console.log(`Model Linker: Updated node ${nodeId} widget ${widgetIndex} to ${resolvedPath}`); - } else if (node.widgets_values) { - // Fallback: update widgets_values array directly - node.widgets_values[widgetIndex] = resolvedPath; - console.log(`Model Linker: Updated node ${nodeId} widgets_values[${widgetIndex}] to ${resolvedPath}`); - } - - // Mark node as dirty to trigger redraw - if (node.setDirtyCanvas) { - node.setDirtyCanvas(true, true); - } - } - - // Trigger canvas redraw - if (app.graph.setDirtyCanvas) { - app.graph.setDirtyCanvas(true, true); - } - } - - /** - * Check if auto-open is enabled in user settings - */ - isAutoOpenEnabled() { - return localStorage.getItem('modelLinker.autoOpenOnMissing') !== 'false'; - } - - /** - * Set auto-open preference - */ - setAutoOpenEnabled(enabled) { - localStorage.setItem('modelLinker.autoOpenOnMissing', enabled ? 'true' : 'false'); - } - - /** - * Check for missing models and auto-open dialog if any are found - */ - async checkAndOpenForMissingModels() { - // Check if auto-open is enabled - if (!this.isAutoOpenEnabled()) { - return; - } - - // Prevent multiple simultaneous checks - if (this.isCheckingMissing) { - return; - } - - this.isCheckingMissing = true; - - try { - // Small delay to let workflow fully load - await new Promise(r => setTimeout(r, 500)); - - // Get current workflow - const workflow = app?.graph?.serialize(); - if (!workflow) { - return; - } - - // Create a simple hash to detect if workflow changed - const workflowHash = JSON.stringify(workflow.nodes?.map(n => n.type + ':' + JSON.stringify(n.widgets_values || []))); - - // Skip if we already checked this exact workflow - if (this.lastCheckedWorkflow === workflowHash) { - return; - } - this.lastCheckedWorkflow = workflowHash; - - // Call analyze endpoint to check for missing models - const response = await api.fetchApi('/model_linker/analyze', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workflow }) - }); - - if (!response.ok) { - console.warn('Model Linker: Failed to analyze workflow for missing models'); - return; - } - - const data = await response.json(); - - // Auto-open dialog if there are missing models - if (data.total_missing > 0) { - console.log(`Model Linker: Found ${data.total_missing} missing model(s), opening dialog...`); - this.openLinkerManager(); - } - - } catch (error) { - console.error('Model Linker: Error checking for missing models:', error); - } finally { - this.isCheckingMissing = false; - } - } - - removeExistingButton() { - // Remove any existing button by ID - const existingButton = document.getElementById(this.buttonId); - if (existingButton) { - existingButton.remove(); - } - - document.querySelectorAll('.comfyui-button-group button.comfyui-button').forEach((button) => { - const label = button.querySelector('span')?.textContent?.trim() || button.textContent?.trim(); - if (label === 'Model Linker') { - button.closest('.comfyui-button-group')?.remove(); - } - }); - - // Remove button group if it exists - if (this.buttonGroup?.element?.parentNode) { - this.buttonGroup.element.remove(); - this.buttonGroup = null; - } - - // Also remove the stored reference if it exists - if (this.linkerButton && this.linkerButton.parentNode) { - this.linkerButton.remove(); - this.linkerButton = null; - } - } - - createFloatingButton() { - // Create a floating button as fallback for legacy ComfyUI versions - this.linkerButton = $el("button", { - id: this.buttonId, - textContent: "🔗 Model Linker", - onclick: () => { - this.openLinkerManager(); - }, - className: "model-linker-floating-button" - }); - - document.body.appendChild(this.linkerButton); - this.dialog?.setTooltip(this.linkerButton, "Open Model Linker to find or download missing workflow models. Shortcut: Ctrl+Shift+L."); - } - - async openLinkerManager(options = {}) { - try { - if (!this.dialog) { - this.dialog = new LinkerManagerDialog(); - window.modelLinkerDialog = this.dialog; - } - if (options.dockContainer && !options.forceFloating) { - await this.dialog.showDocked(options.dockContainer, options.workflow || null); - return; - } - - const wasDocked = this.dialog.docked; - const showPromise = this.dialog.show(options.workflow || null); - - if (options.closeSidebarContainer && !wasDocked) { - this.dialog.closeComfySidebar(options.closeSidebarContainer); - } - - await showPromise; - } catch (error) { - console.error("🔗 Model Linker: Error creating/showing dialog:", error); - alert("Error opening Model Linker: " + error.message); - } - } - - static switchFilter(filter) { - document.querySelectorAll('.ml-btn-filter').forEach(b => b.classList.remove('active')); - document.getElementById('filter-' + filter).classList.add('active'); - - document.querySelectorAll('.ml-model-section').forEach(s => { - const hasActive = s.dataset.mlActive === 'true'; - const hasInactive = s.dataset.mlInactive === 'true'; - - if (filter === 'all') { - s.style.display = 'block'; - } else if (filter === 'active') { - s.style.display = hasActive ? 'block' : 'none'; - } else if (filter === 'inactive') { - s.style.display = hasInactive ? 'block' : 'none'; - } - }); - - const copySection = document.querySelector('[id^="ml-copy-"]'); - if (copySection) { - const codeEl = copySection.querySelector('code'); - const labelEl = copySection.querySelector('div'); - - if (filter === 'all') { - codeEl.textContent = copySection.dataset.mlAll; - labelEl.textContent = 'Copy all:'; - } else if (filter === 'active') { - codeEl.textContent = copySection.dataset.mlActive; - labelEl.textContent = 'Copy active:'; - } else if (filter === 'inactive') { - codeEl.textContent = copySection.dataset.mlInactive; - labelEl.textContent = 'Copy inactive:'; - } - } - } - - static copyToClipboard(text, btn) { - navigator.clipboard.writeText(text).then(() => { - const orig = btn.textContent; - btn.textContent = '✓'; - setTimeout(() => btn.textContent = orig, 1500); - }); - } - - static copyFromCode(sectionId, btn) { - const section = document.getElementById(sectionId); - const codeEl = section.querySelector('code'); - navigator.clipboard.writeText(codeEl.textContent).then(() => { - const orig = btn.textContent; - btn.textContent = '✓ Copied!'; - setTimeout(() => btn.textContent = orig, 1500); - }); - } -} +registerGlobalHelpers(); const modelLinker = new ModelLinker(); -// Register the extension app.registerExtension({ name: "Model Linker", setup: modelLinker.setup }); - -// Global helper functions for inline onclick handlers -window.MLToggleHidden = function(id, trigger, collapsedText, expandedText) { - const element = document.getElementById(id); - if (!element) return; - - const isHidden = element.classList.toggle('ml-hidden'); - if (trigger) { - trigger.setAttribute('aria-expanded', String(!isHidden)); - const label = trigger.querySelector?.('.ml-local-alternatives-label'); - const state = trigger.querySelector?.('.ml-local-alternatives-state'); - if (label || state) { - if (label) label.textContent = isHidden ? collapsedText : expandedText; - if (state) state.textContent = isHidden ? 'Show' : 'Hide'; - } else { - trigger.textContent = isHidden ? collapsedText : expandedText; - } - } -}; - -window.MLSetCopyMode = function(sectionId, mode) { - const section = document.getElementById(sectionId); - if (!section) return; - - const normalizedMode = ['all', 'active', 'inactive'].includes(mode) ? mode : 'all'; - const codeEl = section.querySelector('.ml-copy-code'); - const labelEl = section.querySelector('.ml-copy-label'); - const metaEl = section.querySelector('.ml-copy-meta'); - const dataKey = normalizedMode === 'all' ? 'mlAll' : normalizedMode === 'active' ? 'mlActive' : 'mlInactive'; - const countKey = normalizedMode === 'all' ? 'mlAllCount' : normalizedMode === 'active' ? 'mlActiveCount' : 'mlInactiveCount'; - const labelMap = { - all: 'Copy all', - active: 'Copy active', - inactive: 'Copy inactive' - }; - - if (codeEl) codeEl.textContent = section.dataset[dataKey] || ''; - if (labelEl) labelEl.textContent = labelMap[normalizedMode]; - if (metaEl) { - const count = Number(section.dataset[countKey] || 0); - metaEl.textContent = `${count} token${count === 1 ? '' : 's'}`; - } - - section.querySelectorAll('.ml-copy-mode').forEach(btn => { - btn.classList.toggle('active', btn.dataset.mlCopyMode === normalizedMode); - }); -}; - -window.MLSetCopyButtonFeedback = function(btn, text) { - if (!btn) return; - const originalHtml = btn.dataset.mlOriginalHtml || btn.innerHTML; - btn.dataset.mlOriginalHtml = originalHtml; - btn.textContent = text; - setTimeout(() => { - if (btn.isConnected) btn.innerHTML = originalHtml; - }, 1500); -}; - -window.MLFilterSwitch = function(filter) { - const filterBtn = document.getElementById('filter-' + filter); - if (!filterBtn) return; - - // Update button states - document.querySelectorAll('.ml-btn-filter').forEach(b => b.classList.remove('active')); - filterBtn.classList.add('active'); - - // Filter model sections - document.querySelectorAll('.ml-model-section').forEach(s => { - const hasActive = s.getAttribute('data-ml-active') === 'true'; - const hasInactive = s.getAttribute('data-ml-inactive') === 'true'; - - const activeSection = s.querySelector('.ml-model-group-active'); - const inactiveSection = s.querySelector('.ml-model-group-inactive'); - - if (filter === 'all') { - s.style.display = 'block'; - if (activeSection) activeSection.style.display = 'block'; - if (inactiveSection) inactiveSection.style.display = 'block'; - } else if (filter === 'active') { - s.style.display = hasActive ? 'block' : 'none'; - if (activeSection) activeSection.style.display = hasActive ? 'block' : 'none'; - if (inactiveSection) inactiveSection.style.display = 'none'; - } else if (filter === 'inactive') { - s.style.display = hasInactive ? 'block' : 'none'; - if (activeSection) activeSection.style.display = 'none'; - if (inactiveSection) inactiveSection.style.display = hasInactive ? 'block' : 'none'; - } - }); - - const copySection = document.querySelector('[id^="ml-copy-"]'); - if (copySection) { - window.MLSetCopyMode(copySection.id, filter); - } -}; - -window.MLCopy = function(text, btn) { - navigator.clipboard.writeText(text).then(() => { - window.MLSetCopyButtonFeedback(btn, 'Copied'); - }); -}; - -window.MLCopyCode = function(sectionId, btn) { - const section = document.getElementById(sectionId); - const codeEl = section.querySelector('code'); - navigator.clipboard.writeText(codeEl.textContent).then(() => { - window.MLSetCopyButtonFeedback(btn, 'Copied'); - }); -}; - -window.MLOpenContextMenu = function(event, element) { - event.preventDefault(); - event.stopPropagation(); - - try { - const modelData = element.getAttribute('data-model'); - if (!modelData) return; - - const model = JSON.parse(decodeURIComponent(modelData)); - - // Get dialog instance - const dialog = window.modelLinkerDialog; - if (dialog && dialog.showContextMenu) { - dialog.showContextMenu(event.clientX, event.clientY, model); - } - } catch (e) { - console.error('Model Linker: Error opening context menu:', e); - } -}; - - diff --git a/web/linker/dialog_shell_methods.js b/web/linker/dialog_shell_methods.js new file mode 100644 index 0000000..6a161e1 --- /dev/null +++ b/web/linker/dialog_shell_methods.js @@ -0,0 +1,625 @@ +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; +import { $el } from "../../../../scripts/ui.js"; +import { getSvgIcon } from "../utils/icon_utils.js"; +export const dialogShellMethods = { + createHeader() { + // Create tabs + this.missingTab = $el("button.ml-tab.ml-tab-active", { + onclick: () => this.switchTab('missing') + }, [$el("span.ml-tab-label", { textContent: "Missing Models" })]); + + this.loadedTab = $el("button.ml-tab", { + onclick: () => this.switchTab('loaded') + }, [$el("span.ml-tab-label", { textContent: "Loaded Models" })]); + + this.optionsTab = $el("button.ml-tab", { + onclick: () => this.switchTab('options') + }, [$el("span.ml-tab-label", { textContent: "Options" })]); + if (this.activeTab === 'missing') { + this.updateTabButtonStates(); + } + + const dragHandle = $el("div", { + id: "model-linker-drag-handle", + ondragstart: (e) => e.preventDefault() + }, [ + $el("span", { textContent: "⠿" }) + ]); + this.setTooltip(dragHandle, "Drag window"); + + const fullscreenButton = $el("button", { + id: "model-linker-fullscreen-toggle", + className: "ml-window-btn ml-window-btn--fullscreen", + innerHTML: getSvgIcon('windowMaximize', 'currentColor', 'ml-window-btn-icon'), + ariaLabel: "Toggle full screen", + onclick: () => this.toggleFullScreen() + }); + this.setTooltip(fullscreenButton, "Toggle full screen"); + + this.dockButton = $el("button", { + id: "model-linker-dock-toggle", + className: "ml-window-btn ml-window-btn--dock", + innerHTML: getSvgIcon('internalLink', 'currentColor', 'ml-window-btn-icon'), + ariaLabel: "Dock Model Linker to sidebar", + onclick: () => this.dockToSidebar() + }); + this.setTooltip(this.dockButton, "Dock to sidebar"); + + this.undockButton = $el("button", { + id: "model-linker-undock-toggle", + className: "ml-window-btn ml-window-btn--undock", + innerHTML: getSvgIcon('externalLink', 'currentColor', 'ml-window-btn-icon'), + ariaLabel: "Undock Model Linker", + onclick: () => this.undockToFloating() + }); + this.setTooltip(this.undockButton, "Undock to floating window"); + + return $el("div.ml-dialog-shell", {}, [ + $el("div.ml-dialog-topbar", {}, [ + $el("div.ml-dialog-brand", {}, [ + dragHandle, + $el("div.ml-tabs", {}, [ + this.missingTab, + this.loadedTab, + this.optionsTab + ]) + ]), + $el("div.ml-dialog-controls", {}, [ + this.dockButton, + this.undockButton, + fullscreenButton, + $el("button", { + className: "ml-window-btn ml-window-btn--close", + innerHTML: getSvgIcon('x', 'currentColor', 'ml-window-btn-icon'), + ariaLabel: "Close Model Linker", + onclick: () => this.close() + }) + ]) + ]) + ]); + }, + + captureFloatingRect() { + if (!this.element || this.docked || getComputedStyle(this.element).display === 'none') return; + + const rect = this.element.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + + this._floatingRectBeforeDock = { + top: Math.round(rect.top), + left: Math.round(rect.left), + width: Math.round(rect.width), + height: Math.round(rect.height) + }; + }, + + clearModalPlacementStyles() { + if (!this.element) return; + + [ + 'position', + 'top', + 'left', + 'right', + 'bottom', + 'inset', + 'transform', + 'width', + 'height', + 'maxWidth', + 'maxHeight', + 'minWidth', + 'minHeight', + 'borderRadius', + 'resize' + ].forEach((property) => { + this.element.style[property] = ''; + }); + }, + + restoreFloatingGeometry() { + const el = this.element; + if (!el) return; + + el.style.width = '1100px'; + el.style.height = '700px'; + el.style.maxWidth = '100vw'; + el.style.maxHeight = '100vh'; + el.style.minWidth = '640px'; + el.style.minHeight = '420px'; + el.style.resize = 'both'; + el.style.borderRadius = '7px'; + + const rect = this._floatingRectBeforeDock; + if (rect?.width && rect?.height) { + el.style.width = `${rect.width}px`; + el.style.height = `${rect.height}px`; + el.style.top = `${rect.top}px`; + el.style.left = `${rect.left}px`; + el.style.transform = 'none'; + return; + } + + try { + const wh = JSON.parse(localStorage.getItem('model_linker_modal_size_before_fs') || 'null'); + if (wh?.w && wh?.h) { + el.style.width = `${wh.w}px`; + el.style.height = `${wh.h}px`; + } + + const pos = JSON.parse(localStorage.getItem('model_linker_modal_pos') || 'null'); + if (pos && Number.isFinite(pos.top) && Number.isFinite(pos.left)) { + el.style.top = `${pos.top}px`; + el.style.left = `${pos.left}px`; + el.style.transform = 'none'; + return; + } + } catch (e) { + // Fall back to centered floating geometry below. + } + + el.style.top = '50%'; + el.style.left = '50%'; + el.style.transform = 'translate(-50%, -50%)'; + }, + + rememberSidebarOpenMode(mode) { + try { + localStorage.setItem(this.sidebarOpenModeStorageKey, mode === 'floating' ? 'floating' : 'docked'); + } catch (e) {} + }, + + shouldOpenFromSidebarFloating() { + try { + return localStorage.getItem(this.sidebarOpenModeStorageKey) === 'floating'; + } catch (e) { + return false; + } + }, + + isVisible() { + if (!this.element?.isConnected) return false; + return getComputedStyle(this.element).display !== 'none'; + }, + + dockTo(container) { + if (!container || !this.element) return; + + if (!this.docked) { + this.captureFloatingRect(); + } + + if (this.fullscreen) { + this.setFullScreen(false); + } + + this.docked = true; + this.dockContainer = container; + this.lastDockContainer = container; + this.pendingDockToSidebar = false; + this.rememberSidebarOpenMode('docked'); + this.backdrop.style.display = 'none'; + container.classList.add('ml-sidebar-dock-panel'); + this.element.classList.add('ml-is-docked'); + this.clearModalPlacementStyles(); + this.element.style.display = 'flex'; + + if (this.element.parentNode !== container) { + container.appendChild(this.element); + } + }, + + dockToSidebar() { + if (this.docked) return; + + this.rememberSidebarOpenMode('docked'); + this.pendingDockToSidebar = true; + + if (this.isUsableDockContainer(this.lastDockContainer)) { + this.dockTo(this.lastDockContainer); + return; + } + + this.tryOpenComfySidebarState(); + + requestAnimationFrame(() => { + if (!this.pendingDockToSidebar || this.docked) return; + + const button = document.querySelector(`.${this.sidebarTabId}-tab-button`); + if (button instanceof HTMLElement) { + const isActive = button.matches([ + '[aria-pressed="true"]', + '[aria-selected="true"]', + '[data-active="true"]', + '[data-selected="true"]', + '.active', + '.is-active', + '.selected', + '.p-highlight' + ].join(',')); + + if (!isActive) { + button.click(); + } + } + }); + }, + + isUsableDockContainer(container) { + if (!(container instanceof HTMLElement) || !container.isConnected) return false; + + const style = getComputedStyle(container); + if (style.display === 'none' || style.visibility === 'hidden') return false; + + const rect = container.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }, + + trySetSidebarStateProperty(target, property, value) { + try { + if (!(property in target)) return false; + return Reflect.set(target, property, value); + } catch (error) { + return false; + } + }, + + tryOpenComfySidebarState() { + const extensionManager = app.extensionManager; + let opened = false; + const candidates = [ + extensionManager?.sidebarTab, + extensionManager?.sidebarTabs, + extensionManager?.sidebar, + extensionManager + ].filter(Boolean); + + for (const candidate of candidates) { + try { + if (typeof candidate.openSidebar === 'function') { + candidate.openSidebar(this.sidebarTabId); + opened = true; + } + if (typeof candidate.openSidebarTab === 'function') { + candidate.openSidebarTab(this.sidebarTabId); + opened = true; + } + if (typeof candidate.setActiveSidebarTab === 'function') { + candidate.setActiveSidebarTab(this.sidebarTabId); + opened = true; + } + if (typeof candidate.setActiveSidebarTabId === 'function') { + candidate.setActiveSidebarTabId(this.sidebarTabId); + opened = true; + } + if (this.trySetSidebarStateProperty(candidate, 'activeSidebarTabId', this.sidebarTabId)) { + opened = true; + } + } catch (error) { + // Sidebar internals differ between ComfyUI versions; unsupported state APIs are ignored. + } + } + + return opened; + }, + + undockToFloating({ persist = true, closeSidebar = true } = {}) { + if (!this.element) return; + + const wasDocked = this.docked; + const dockContainer = this.dockContainer; + this.docked = false; + this.dockContainer = null; + this.element.classList.remove('ml-is-docked'); + this.clearModalPlacementStyles(); + + if (this.element.parentNode !== document.body) { + document.body.appendChild(this.element); + } + + this.element.style.display = 'flex'; + this.restoreFloatingGeometry(); + this.ensureModalHandleInViewport({ persist }); + + if (wasDocked && closeSidebar) { + this.closeComfySidebar(dockContainer); + } + }, + + closeComfySidebar(dockContainer = null) { + const closedByState = this.tryCloseComfySidebarState(); + + requestAnimationFrame(() => { + if (dockContainer && !dockContainer.isConnected) return; + + const button = document.querySelector(`.${this.sidebarTabId}-tab-button`); + if (!(button instanceof HTMLElement)) return; + + const isActive = button.matches([ + '[aria-pressed="true"]', + '[aria-selected="true"]', + '[data-active="true"]', + '[data-selected="true"]', + '.active', + '.is-active', + '.selected', + '.p-highlight' + ].join(',')); + + if (!closedByState || isActive) { + button.click(); + } + }); + }, + + tryCloseComfySidebarState() { + const extensionManager = app.extensionManager; + let closed = false; + const candidates = [ + extensionManager?.sidebarTab, + extensionManager?.sidebarTabs, + extensionManager?.sidebar, + extensionManager + ].filter(Boolean); + + for (const candidate of candidates) { + try { + if (typeof candidate.closeSidebar === 'function') { + candidate.closeSidebar(); + closed = true; + } + if (typeof candidate.closeSidebarTab === 'function') { + candidate.closeSidebarTab(this.sidebarTabId); + closed = true; + } + if (typeof candidate.setActiveSidebarTab === 'function') { + candidate.setActiveSidebarTab(null); + closed = true; + } + if (typeof candidate.setActiveSidebarTabId === 'function') { + candidate.setActiveSidebarTabId(null); + closed = true; + } + if ( + this.trySetSidebarStateProperty(candidate, 'activeSidebarTabId', null) || + this.trySetSidebarStateProperty(candidate, 'activeSidebarTab', null) + ) { + closed = true; + } + } catch (error) { + // Sidebar internals differ between ComfyUI versions; unsupported state APIs are ignored. + } + } + + return closed; + }, + + // Toggle full screen mode for the dialog + toggleFullScreen() { + this.setFullScreen(!this.fullscreen); + }, + + setFullScreen(enable) { + const shouldReturnToDocked = !enable && this.returnToDockedAfterFullscreen; + if (enable && this.docked) { + this.returnToDockedAfterFullscreen = true; + this.undockToFloating({ persist: false }); + } else if (enable) { + this.returnToDockedAfterFullscreen = false; + } + + this.fullscreen = !!enable; + const el = this.element; + if (!el) return; + const btn = document.getElementById('model-linker-fullscreen-toggle'); + if (enable) { + // Save current size + try { + const rect = el.getBoundingClientRect(); + localStorage.setItem('model_linker_modal_size_before_fs', JSON.stringify({ w: Math.round(rect.width), h: Math.round(rect.height) })); + } catch (e) {} + el.style.top = '0'; + el.style.left = '0'; + el.style.transform = 'none'; + el.style.width = '100vw'; + el.style.height = '100vh'; + el.style.maxWidth = '100vw'; + el.style.maxHeight = '100vh'; + el.style.borderRadius = '0'; + el.style.resize = 'none'; + if (btn) { + btn.innerHTML = getSvgIcon('windowRestore', 'currentColor', 'ml-window-btn-icon'); + btn.setAttribute('aria-label', 'Exit full screen'); + this.setTooltip(btn, 'Exit full screen'); + } + try { localStorage.setItem('model_linker_modal_fullscreen', '1'); } catch (e) {} + } else { + this.returnToDockedAfterFullscreen = false; + // Restore centered sizing + el.style.maxWidth = '100vw'; + el.style.maxHeight = '100vh'; + el.style.borderRadius = '8px'; + el.style.resize = 'both'; + // Restore saved pre-FS size if available + let wh = null; + try { wh = JSON.parse(localStorage.getItem('model_linker_modal_size_before_fs') || 'null'); } catch (e) {} + if (wh && wh.w && wh.h) { + el.style.width = `${wh.w}px`; + el.style.height = `${wh.h}px`; + } else { + el.style.width = '1100px'; + el.style.height = '700px'; + } + // Restore last known position if available, else center + try { + const pos = JSON.parse(localStorage.getItem('model_linker_modal_pos') || 'null'); + if (pos && Number.isFinite(pos.top) && Number.isFinite(pos.left)) { + el.style.top = `${pos.top}px`; + el.style.left = `${pos.left}px`; + el.style.transform = 'none'; + } else { + el.style.top = '50%'; + el.style.left = '50%'; + el.style.transform = 'translate(-50%, -50%)'; + } + } catch (e) { + el.style.top = '50%'; + el.style.left = '50%'; + el.style.transform = 'translate(-50%, -50%)'; + } + if (btn) { + btn.innerHTML = getSvgIcon('windowMaximize', 'currentColor', 'ml-window-btn-icon'); + btn.setAttribute('aria-label', 'Enter full screen'); + this.setTooltip(btn, 'Enter full screen'); + } + this.ensureModalHandleInViewport({ persist: true }); + try { localStorage.setItem('model_linker_modal_fullscreen', '0'); } catch (e) {} + + if (shouldReturnToDocked) { + this.dockToSidebar(); + } + } + }, + + getViewportClampedModalPosition(top, left) { + const el = this.element; + if (!el) return { top, left }; + + const vw = window.innerWidth || document.documentElement.clientWidth || 0; + const vh = window.innerHeight || document.documentElement.clientHeight || 0; + const pad = 4; + const handle = document.getElementById('model-linker-drag-handle'); + + if (handle) { + const elRect = el.getBoundingClientRect(); + const handleRect = handle.getBoundingClientRect(); + const handleOffsetLeft = handleRect.left - elRect.left; + const handleOffsetTop = handleRect.top - elRect.top; + const handleWidth = handleRect.width || handle.offsetWidth; + const handleHeight = handleRect.height || handle.offsetHeight; + const minLeft = pad - handleOffsetLeft; + const maxLeft = vw - pad - handleOffsetLeft - handleWidth; + const minTop = pad - handleOffsetTop; + const maxTop = vh - pad - handleOffsetTop - handleHeight; + + left = Math.max(minLeft, Math.min(maxLeft, left)); + top = Math.max(minTop, Math.min(maxTop, top)); + } else { + const w = el.offsetWidth; + const h = el.offsetHeight; + left = Math.max(-w + pad, Math.min(vw - pad, left)); + top = Math.max(-h + pad, Math.min(vh - pad, top)); + } + + return { top, left }; + }, + + saveModalPosition() { + if (this.docked) return; + + try { + const el = this.element; + if (!el) return; + const rect = el.getBoundingClientRect(); + localStorage.setItem('model_linker_modal_pos', JSON.stringify({ top: Math.round(rect.top), left: Math.round(rect.left) })); + } catch (e) { /* ignore */ } + }, + + ensureModalHandleInViewport({ persist = false } = {}) { + if (this.docked) return; + if (this.fullscreen) return; + const el = this.element; + if (!el || getComputedStyle(el).display === 'none') return; + + const rect = el.getBoundingClientRect(); + const { top, left } = this.getViewportClampedModalPosition(rect.top, rect.left); + const nextTop = Math.round(top); + const nextLeft = Math.round(left); + + if (Math.round(rect.top) === nextTop && Math.round(rect.left) === nextLeft) return; + + el.style.top = `${nextTop}px`; + el.style.left = `${nextLeft}px`; + el.style.transform = 'none'; + + if (persist) this.saveModalPosition(); + }, + + scheduleModalViewportClamp(persist = false) { + if (this._viewportClampFrame) { + cancelAnimationFrame(this._viewportClampFrame); + } + + this._viewportClampFrame = requestAnimationFrame(() => { + this._viewportClampFrame = null; + this.ensureModalHandleInViewport({ persist }); + }); + }, + + // Begin window drag + startDrag(e) { + if (this.docked) return; + + try { + const el = this.element; + if (!el) return; + const rect = el.getBoundingClientRect(); + // Switch to absolute top/left (no transform) before dragging + el.style.top = `${rect.top}px`; + el.style.left = `${rect.left}px`; + el.style.transform = 'none'; + this._dragging = true; + this._dragStart = { + x: e.clientX, + y: e.clientY, + top: rect.top, + left: rect.left + }; + // Prevent text selection while dragging + this._prevUserSelect = document.body.style.userSelect; + document.body.style.userSelect = 'none'; + // Attach listeners + this._onMouseMove = (ev) => this.onDrag(ev); + this._onMouseUp = () => this.endDrag(); + document.addEventListener('mousemove', this._onMouseMove); + document.addEventListener('mouseup', this._onMouseUp, { once: true }); + } catch (err) { /* ignore */ } + }, + + onDrag(e) { + if (!this._dragging || !this._dragStart) return; + const el = this.element; + if (!el) return; + const dx = e.clientX - this._dragStart.x; + const dy = e.clientY - this._dragStart.y; + let top = this._dragStart.top + dy; + let left = this._dragStart.left + dx; + ({ top, left } = this.getViewportClampedModalPosition(top, left)); + el.style.top = `${Math.round(top)}px`; + el.style.left = `${Math.round(left)}px`; + }, + + endDrag() { + if (!this._dragging) return; + this._dragging = false; + document.removeEventListener('mousemove', this._onMouseMove); + // Persist position + this.saveModalPosition(); + // Restore selection + try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) {} + }, + + /** + * Simple debounce helper + */ + debounce(callback, wait = 250) { + let t = null; + return (...args) => { + if (t) clearTimeout(t); + t = setTimeout(() => { + callback.apply(this, args); + }, wait); + }; + } +}; diff --git a/web/linker/download_target_methods.js b/web/linker/download_target_methods.js new file mode 100644 index 0000000..2e78f85 --- /dev/null +++ b/web/linker/download_target_methods.js @@ -0,0 +1,508 @@ +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; +import { $el } from "../../../../scripts/ui.js"; +import { getSvgIcon } from "../utils/icon_utils.js"; +export const downloadTargetMethods = { + /** + * Ensure all models are loaded for the dropdown. + */ + async ensureAllModelsLoaded() { + if (this.allModels && this.allModels.length) return; + try { + const resp = await api.fetchApi('/model_linker/models'); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const models = await resp.json(); + const list = Array.isArray(models) ? models : []; + // Build labels and sort alphabetically + this.allModels = list.map((m) => ({ + ...m, + __label: `${m.category ? m.category + ': ' : ''}${m.relative_path || m.filename || ''}` + })).sort((a, b) => (a.__label || '').localeCompare(b.__label || '')); + } catch (e) { + console.warn('Model Linker: could not load all models', e); + this.allModels = []; + } + }, + + async ensureDownloadDirectoriesLoaded() { + if (this.downloadDirectories) return; + try { + const resp = await api.fetchApi('/model_linker/directories'); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const directories = await resp.json(); + this.downloadDirectories = directories && typeof directories === 'object' ? directories : {}; + } catch (e) { + console.warn('Model Linker: could not load download directories', e); + this.downloadDirectories = {}; + } + }, + + async ensureCapabilitiesLoaded() { + if (this.capabilities) return; + try { + const resp = await api.fetchApi('/model_linker/capabilities'); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + this.capabilities = data && typeof data === 'object' ? data : { sources: {} }; + } catch (e) { + console.warn('Model Linker: could not load capabilities', e); + this.capabilities = { sources: {} }; + } + }, + + isSourceAvailable(source) { + if (!source || ['all', 'local', 'huggingface', 'civitai', 'civarchive'].includes(source)) { + return true; + } + return Boolean(this.capabilities?.sources?.[source]); + }, + + getCategoryDisplayName(category = '') { + const displayNames = { + 'checkpoints': 'checkpoint', + 'loras': 'lora', + 'vae': 'vae', + 'controlnet': 'controlnet', + 'embeddings': 'embedding', + 'upscale_models': 'upscale_model', + 'latent_upscale_models': 'latent_upscale_model', + 'diffusion_models': 'unet', + 'text_encoders': 'text encoders', + 'clip': 'clip', + 'clip_vision': 'clip_vision', + 'hypernetworks': 'hypernetwork' + }; + return displayNames[category] || category || 'unknown'; + }, + + getModelTypeColorClass(value = '') { + const token = String(value || '') + .trim() + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toLowerCase() + .replace(/[\s./\\-]+/g, '_') + .replace(/[^a-z0-9_]/g, '') + .replace(/_+/g, '_') + .replace(/^_|_$/g, ''); + const colorNames = { + checkpoints: 'model', + checkpoint: 'model', + ckpt: 'model', + model: 'model', + diffusion_models: 'model', + diffusion_model: 'model', + unet: 'model', + loras: 'lora', + lora: 'lora', + locon: 'lora', + lycoris: 'lora', + dora: 'lora', + hypernetworks: 'lora', + hypernetwork: 'lora', + style_models: 'style-model', + style_model: 'style-model', + vae: 'vae', + vaes: 'vae', + vae_approx: 'taesd', + taesd: 'taesd', + controlnet: 'controlnet', + control_net: 'controlnet', + controlnets: 'controlnet', + control_nets: 'controlnet', + t2i_adapter: 'controlnet', + t2i_adapters: 'controlnet', + embeddings: 'conditioning', + embedding: 'conditioning', + textualinversion: 'conditioning', + textual_inversion: 'conditioning', + aesthetic_gradient: 'conditioning', + text_encoders: 'clip', + text_encoder: 'clip', + clip: 'clip', + clips: 'clip', + clip_vision: 'clip-vision', + clipvision: 'clip-vision', + clip_vision_output: 'clip-vision-output', + upscale_models: 'image', + upscale_model: 'image', + upscaler: 'image', + upscalers: 'image', + latent_upscale_models: 'latent', + latent_upscale_model: 'latent', + latent: 'latent', + image: 'image', + images: 'image', + mask: 'mask', + masks: 'mask', + noise: 'noise', + sampler: 'sampler', + samplers: 'sampler', + sigmas: 'sigmas', + guider: 'guider', + guiders: 'guider' + }; + return `ml-type-chip ml-type-chip--${colorNames[token] || 'generic'}`; + }, + + getCategoryTokenName(category = '') { + const tokenNames = { + 'checkpoints': 'checkpoint', + 'loras': 'lora', + 'vae': 'vae', + 'controlnet': 'controlnet', + 'embeddings': 'embedding', + 'upscale_models': 'upscale_model', + 'latent_upscale_models': 'latent_upscale_model', + 'diffusion_models': 'unet', + 'text_encoders': 'text_encoders', + 'clip': 'clip', + 'clip_vision': 'clip_vision', + 'hypernetworks': 'hypernetwork' + }; + return tokenNames[category] || category || 'unknown'; + }, + + getDownloadCategoryOptions(defaultCategory = 'checkpoints') { + const directories = this.downloadDirectories || {}; + const keys = Object.keys(directories); + const preferred = defaultCategory || 'checkpoints'; + const ordered = [ + preferred, + ...keys.filter(key => key !== preferred) + ].filter((value, index, arr) => value && arr.indexOf(value) === index); + + return ordered.length > 0 ? ordered : [preferred]; + }, + + getSearchSourceOptions() { + const sources = ['all', ...this.getEnabledSearchSources()]; + return sources.map(source => ({ + value: source, + label: this.getSearchSourceLabel(source) + })); + }, + + getAvailableSubfolders(category = '') { + return this.downloadSubfolders.get((category || '').toLowerCase()) || []; + }, + + normalizeFolderToken(value = '') { + return String(value || '') + .toLowerCase() + .replace(/[\/\\]+/g, ' ') + .replace(/[^a-z0-9]+/g, ''); + }, + + getSuggestedCivitaiSubfolder(missing, category, folders = []) { + if ((category || '').toLowerCase() !== 'loras' || !folders.length) { + return ''; + } + + const civitaiData = { + ...(missing?.civitai_info || {}), + ...(missing?.civitai_search_result || {}), + ...(missing?.download_source || {}) + }; + const baseModel = civitaiData.base_model || ''; + const tags = Array.isArray(civitaiData.tags) ? civitaiData.tags.filter(Boolean) : []; + if (!baseModel) return ''; + + const priorityTags = [ + 'concept', + 'style', + 'character', + 'clothing', + 'pose', + 'object', + 'vehicle', + 'artist', + 'celebrity' + ]; + const normalizedBase = this.normalizeFolderToken(baseModel); + if (!normalizedBase) return ''; + + const folderEntries = folders.map(folder => { + const segments = String(folder || '').split(/[\/\\]/).filter(Boolean); + return { + value: folder, + segments, + normalizedSegments: segments.map(segment => this.normalizeFolderToken(segment)) + }; + }); + + const baseMatches = folderEntries.filter(entry => entry.normalizedSegments[0] === normalizedBase); + if (!baseMatches.length) return ''; + + const exactBase = baseMatches.find(entry => entry.segments.length === 1); + const orderedTags = [ + ...priorityTags.filter(tag => tags.some(value => this.normalizeFolderToken(value) === this.normalizeFolderToken(tag))), + ...tags + ].filter((value, index, arr) => value && arr.findIndex(other => this.normalizeFolderToken(other) === this.normalizeFolderToken(value)) === index); + + for (const tag of orderedTags) { + const normalizedTag = this.normalizeFolderToken(tag); + if (!normalizedTag) continue; + const match = baseMatches.find(entry => entry.normalizedSegments[1] === normalizedTag); + if (match) { + return match.value; + } + } + + return exactBase?.value || ''; + }, + + async applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl) { + if (!categoryEl || !subfolderEl || subfolderEl.value.trim()) return; + + const category = this.getDropdownValue(categoryEl); + await this.ensureDownloadSubfoldersLoaded(category); + const folders = this.getAvailableSubfolders(category); + const suggestion = this.getSuggestedCivitaiSubfolder(missing, category, folders); + if (suggestion) { + subfolderEl.value = suggestion; + } + }, + + applySearchResultSuggestion(missing) { + const categoryEl = this.contentElement?.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`); + const subfolderEl = this.contentElement?.querySelector(`#download-subfolder-${missing.node_id}-${missing.widget_index}`); + if (!categoryEl || !subfolderEl) return; + this.applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl); + }, + + async ensureDownloadSubfoldersLoaded(category = '') { + const key = (category || '').trim().toLowerCase(); + if (!key) return []; + if (key === 'unknown') { + this.downloadSubfolders.set(key, []); + return []; + } + if (this.downloadSubfolders.has(key)) { + return this.downloadSubfolders.get(key) || []; + } + + try { + const resp = await api.fetchApi(`/model_linker/subfolders/${encodeURIComponent(key)}`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const subfolders = await resp.json(); + const list = Array.isArray(subfolders) ? subfolders : []; + this.downloadSubfolders.set(key, list); + return list; + } catch (e) { + console.warn(`Model Linker: could not load subfolders for ${key}`, e); + this.downloadSubfolders.set(key, []); + return []; + } + }, + + renderDownloadTargetControls(missing, defaultCategory = 'checkpoints') { + const selectId = `download-category-${missing.node_id}-${missing.widget_index}`; + const subfolderId = `download-subfolder-${missing.node_id}-${missing.widget_index}`; + const categoryListId = `download-category-list-${missing.node_id}-${missing.widget_index}`; + const subfolderListId = `download-subfolder-list-${missing.node_id}-${missing.widget_index}`; + const selectedCategory = defaultCategory || 'checkpoints'; + + let html = `
`; + html += `
`; + html += ``; + 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; + const search_source_enabled = this.getSearchSourceEnabledMap(); + + return { + civitai_key: localStorage.getItem('modelLinker.civitaiApiKey') || '', + civitai_session_token: localStorage.getItem('modelLinker.civitaiSessionToken') || '', + hf_token: localStorage.getItem('modelLinker.huggingFaceToken') || '', + brave_search_api_key: localStorage.getItem('modelLinker.braveSearchApiKey') || '', + civitai_use_trpc_search: localStorage.getItem('modelLinker.civitaiUseTrpcSearch') !== 'false', + civitai_use_html_fallback: localStorage.getItem('modelLinker.civitaiUseHtmlFallback') !== 'false', + hf_use_api_search: localStorage.getItem('modelLinker.hfUseApiSearch') !== 'false', + hf_use_comfy_org_fallback: localStorage.getItem('modelLinker.hfUseComfyOrgFallback') !== 'false', + hf_use_brave_fallback: localStorage.getItem('modelLinker.hfUseBraveFallback') !== 'false', + civitai_candidate_limit, + search_source_enabled + }; + }, + + async clearSearchCaches() { + for (const state of this.searchResultCache.values()) { + state.activeSearchRunId = null; + } + this.clearAllSearchProgressTimers(); + this.searchResultCache.clear(); + const key = this.getWorkflowScopedQueueKey(); + if (key) { + this.workflowSearchResultCaches.delete(key); + } + try { + const response = await api.fetchApi('/model_linker/clear-search-cache', { + method: 'POST' + }); + if (!response.ok) { + throw new Error('Failed to clear backend search cache'); + } + } catch (error) { + console.error('Model Linker: Clear search cache error:', error); + } + } +}; diff --git a/web/linker/globals.js b/web/linker/globals.js new file mode 100644 index 0000000..235e185 --- /dev/null +++ b/web/linker/globals.js @@ -0,0 +1,129 @@ +export function registerGlobalHelpers() { + // Global helper functions for inline onclick handlers + window.MLToggleHidden = function(id, trigger, collapsedText, expandedText) { + const element = document.getElementById(id); + if (!element) return; + + const isHidden = element.classList.toggle('ml-hidden'); + if (trigger) { + trigger.setAttribute('aria-expanded', String(!isHidden)); + const label = trigger.querySelector?.('.ml-local-alternatives-label'); + const state = trigger.querySelector?.('.ml-local-alternatives-state'); + if (label || state) { + if (label) label.textContent = isHidden ? collapsedText : expandedText; + if (state) state.textContent = isHidden ? 'Show' : 'Hide'; + } else { + trigger.textContent = isHidden ? collapsedText : expandedText; + } + } + }; + + window.MLSetCopyMode = function(sectionId, mode) { + const section = document.getElementById(sectionId); + if (!section) return; + + const normalizedMode = ['all', 'active', 'inactive'].includes(mode) ? mode : 'all'; + const codeEl = section.querySelector('.ml-copy-code'); + const labelEl = section.querySelector('.ml-copy-label'); + const metaEl = section.querySelector('.ml-copy-meta'); + const dataKey = normalizedMode === 'all' ? 'mlAll' : normalizedMode === 'active' ? 'mlActive' : 'mlInactive'; + const countKey = normalizedMode === 'all' ? 'mlAllCount' : normalizedMode === 'active' ? 'mlActiveCount' : 'mlInactiveCount'; + const labelMap = { + all: 'Copy all', + active: 'Copy active', + inactive: 'Copy inactive' + }; + + if (codeEl) codeEl.textContent = section.dataset[dataKey] || ''; + if (labelEl) labelEl.textContent = labelMap[normalizedMode]; + if (metaEl) { + const count = Number(section.dataset[countKey] || 0); + metaEl.textContent = `${count} token${count === 1 ? '' : 's'}`; + } + + section.querySelectorAll('.ml-copy-mode').forEach(btn => { + btn.classList.toggle('active', btn.dataset.mlCopyMode === normalizedMode); + }); + }; + + window.MLSetCopyButtonFeedback = function(btn, text) { + if (!btn) return; + const originalHtml = btn.dataset.mlOriginalHtml || btn.innerHTML; + btn.dataset.mlOriginalHtml = originalHtml; + btn.textContent = text; + setTimeout(() => { + if (btn.isConnected) btn.innerHTML = originalHtml; + }, 1500); + }; + + window.MLFilterSwitch = function(filter) { + const filterBtn = document.getElementById('filter-' + filter); + if (!filterBtn) return; + + // Update button states + document.querySelectorAll('.ml-btn-filter').forEach(b => b.classList.remove('active')); + filterBtn.classList.add('active'); + + // Filter model sections + document.querySelectorAll('.ml-model-section').forEach(s => { + const hasActive = s.getAttribute('data-ml-active') === 'true'; + const hasInactive = s.getAttribute('data-ml-inactive') === 'true'; + + const activeSection = s.querySelector('.ml-model-group-active'); + const inactiveSection = s.querySelector('.ml-model-group-inactive'); + + if (filter === 'all') { + s.style.display = 'block'; + if (activeSection) activeSection.style.display = 'block'; + if (inactiveSection) inactiveSection.style.display = 'block'; + } else if (filter === 'active') { + s.style.display = hasActive ? 'block' : 'none'; + if (activeSection) activeSection.style.display = hasActive ? 'block' : 'none'; + if (inactiveSection) inactiveSection.style.display = 'none'; + } else if (filter === 'inactive') { + s.style.display = hasInactive ? 'block' : 'none'; + if (activeSection) activeSection.style.display = 'none'; + if (inactiveSection) inactiveSection.style.display = hasInactive ? 'block' : 'none'; + } + }); + + const copySection = document.querySelector('[id^="ml-copy-"]'); + if (copySection) { + window.MLSetCopyMode(copySection.id, filter); + } + }; + + window.MLCopy = function(text, btn) { + navigator.clipboard.writeText(text).then(() => { + window.MLSetCopyButtonFeedback(btn, 'Copied'); + }); + }; + + window.MLCopyCode = function(sectionId, btn) { + const section = document.getElementById(sectionId); + const codeEl = section.querySelector('code'); + navigator.clipboard.writeText(codeEl.textContent).then(() => { + window.MLSetCopyButtonFeedback(btn, 'Copied'); + }); + }; + + window.MLOpenContextMenu = function(event, element) { + event.preventDefault(); + event.stopPropagation(); + + try { + const modelData = element.getAttribute('data-model'); + if (!modelData) return; + + const model = JSON.parse(decodeURIComponent(modelData)); + + // Get dialog instance + const dialog = window.modelLinkerDialog; + if (dialog && dialog.showContextMenu) { + dialog.showContextMenu(event.clientX, event.clientY, model); + } + } catch (e) { + console.error('Model Linker: Error opening context menu:', e); + } + }; +} diff --git a/web/linker/lifecycle_graph_methods.js b/web/linker/lifecycle_graph_methods.js new file mode 100644 index 0000000..1552ee7 --- /dev/null +++ b/web/linker/lifecycle_graph_methods.js @@ -0,0 +1,453 @@ +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; +import { $el } from "../../../../scripts/ui.js"; +import { getSvgIcon } from "../utils/icon_utils.js"; +export const lifecycleGraphMethods = { + async show(workflow = null) { + this.undockToFloating({ persist: false }); + await this.showContent(workflow, { restoreFullscreen: true }); + }, + + async showDocked(container, workflow = null) { + this.dockTo(container); + await this.showContent(workflow, { restoreFullscreen: false }); + }, + + async showContent(workflow = null, { restoreFullscreen = true } = {}) { + this.backdrop.style.display = "none"; + this.element.style.display = "flex"; + if (!this.docked) { + this.scheduleModalViewportClamp(); + } + + // Update button state in case there are active downloads + this.updateDownloadAllButtonState(); + + // Ensure all models are loaded for dropdown + await this.ensureCapabilitiesLoaded(); + await this.ensureAllModelsLoaded(); + await this.ensureDownloadDirectoriesLoaded(); + + // Restore fullscreen state if enabled + try { + const fs = localStorage.getItem('model_linker_modal_fullscreen'); + if (!this.docked && restoreFullscreen && fs === '1') this.setFullScreen(true); + } catch (e) { } + + // Attach drag handle event listener (only once) + this.attachDragHandleIfNeeded(); + + this.activeTab = this.restoreActiveTab(); + this.updateTabButtonStates(); + this.updateQueueVisibility(); + this.syncWorkflowScopedQueue(workflow || this.getCurrentWorkflow()); + + if (this.activeTab === 'missing') { + await this.loadWorkflowData(workflow); + } else if (this.activeTab === 'loaded') { + this.switchTab('loaded', { force: true }); + } else { + this.switchTab('options', { force: true }); + } + }, + + // Attach topbar drag listeners while preserving the handle-based viewport clamp. + attachDragHandleIfNeeded() { + if (this._dragHandleAttached) return; + + const handle = document.getElementById('model-linker-drag-handle'); + const topbar = this.element?.querySelector('.ml-dialog-topbar'); + if (!handle || !topbar) return; + + const onMouseDown = (e) => { + if (this.fullscreen || this.docked) return; // no drag in fullscreen or docked mode + if (e.button !== 0 || this.isTopbarDragExcluded(e.target)) return; + topbar.classList.add('ml-is-dragging'); + this.startDrag(e); + }; + const onMouseUp = () => { topbar.classList.remove('ml-is-dragging'); }; + + topbar.addEventListener('mousedown', onMouseDown); + document.addEventListener('mouseup', onMouseUp); + + this._dragHandleAttached = true; + }, + + isTopbarDragExcluded(target) { + if (!(target instanceof Element)) return false; + return !!target.closest([ + '.ml-tabs', + '.ml-dialog-controls', + 'button', + 'a', + 'input', + 'select', + 'textarea', + '[role="button"]', + '[data-ml-no-drag]' + ].join(',')); + }, + + close({ collapseSidebar = true } = {}) { + const wasDocked = this.docked; + const dockContainer = this.dockContainer; + this.rememberSidebarOpenMode(this.docked ? 'docked' : 'floating'); + this._hidePreview?.(); + this.hideTooltip(); + this.backdrop.style.display = "none"; + this.element.style.display = "none"; + + if (wasDocked && collapseSidebar) { + this.closeComfySidebar(dockContainer); + } + }, + + /** + * Load workflow data and display missing models + */ + async loadWorkflowData(workflow = null) { + if (!this.contentElement) return; + + // Show loading state + try { + // Use provided workflow, or get current workflow from ComfyUI + if (!workflow) { + workflow = this.getCurrentWorkflow(); + } + + if (!workflow) { + this._analysisProgressToken = null; + this.contentElement.innerHTML = '

No workflow loaded. Please load a workflow first.

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

Error: ${error.message}

`; + } + } + }, + + /** + * Get current workflow from ComfyUI + */ + getCurrentWorkflow() { + // Try to get workflow from app + if (app?.graph) { + try { + // Use ComfyUI's workflow serialization + const workflow = app.graph.serialize(); + return workflow; + } catch (e) { + console.warn('Model Linker: Could not serialize workflow from graph:', e); + } + } + return null; + }, + + /** + * Locate and focus a node in the ComfyUI canvas + */ + locateNodeInGraph(nodeId) { + try { + if (!app?.graph) { + this.showNotification('Cannot locate node - graph not available', 'error'); + return; + } + + // Find the node in the graph + const node = app.graph.getNodeById(nodeId); + if (!node) { + this.showNotification(`Node #${nodeId} not found in graph`, 'error'); + return; + } + + let locatedWithAnimation = false; + + // Focus on the node in the canvas with animated panning when possible + if (app.canvas?.ds && app.canvas?.canvas) { + locatedWithAnimation = this.animateCanvasToNode(node); + } + + if (!locatedWithAnimation && app.graph._nodes && app.graph._nodes.get(nodeId)) { + // Alternative method for older versions + const canvasNode = app.graph._nodes.get(nodeId); + if (canvasNode && canvasNode.setSelected && canvasNode.graph) { + canvasNode.setSelected(true); + // Scroll to node + app.canvas.scrollToNode(canvasNode); + } + } else if (!locatedWithAnimation && app.ui && app.ui.nodeGraph && typeof app.ui.nodeGraph.scrollToNode === 'function') { + // Alternative for other versions + app.ui.nodeGraph.scrollToNode(node); + } else if (!locatedWithAnimation && app.canvas && typeof app.canvas.centerOnNode === 'function') { + // Final fallback for versions where animation path is unavailable + app.canvas.centerOnNode(node); + } + + // Also try to flash/select the node + // Deselect all nodes first + if (app.graph && app.graph.nodes) { + app.graph.nodes.forEach(n => { + if (n.selected) n.selected = false; + }); + } + + // Select and highlight our node + node.selected = true; + + this.showNotification(`Focused on Node #${nodeId} (${node.type})`, 'info'); + } catch (e) { + console.error('Model Linker: Error locating node:', e); + this.showNotification('Error locating node: ' + e.message, 'error'); + } + }, + + animateCanvasToNode(node) { + const canvas = app?.canvas; + const ds = canvas?.ds; + const htmlCanvas = canvas?.canvas; + if (!canvas || !ds || !htmlCanvas) return false; + + const rect = node.boundingRect || [node.pos?.[0] || 0, node.pos?.[1] || 0, node.size?.[0] || 0, node.size?.[1] || 0]; + const centerX = rect[0] + (rect[2] || 0) / 2; + const centerY = rect[1] + (rect[3] || 0) / 2; + const startScale = Number.isFinite(ds.scale) && ds.scale > 0 ? ds.scale : 1; + const locateViewport = this.getLocateViewport(htmlCanvas); + const viewportWidth = locateViewport.width; + const viewportHeight = locateViewport.height; + + if (!viewportWidth || !viewportHeight) return false; + + const nodeWidth = Math.max(rect[2] || 0, 1); + const nodeHeight = Math.max(rect[3] || 0, 1); + const paddingX = 36; + const paddingY = 32; + const maxLocateScale = 2.5; + const fitScaleX = viewportWidth / (nodeWidth + (paddingX * 2)); + const fitScaleY = viewportHeight / (nodeHeight + (paddingY * 2)); + const targetScale = Math.max(0.15, Math.min(maxLocateScale, fitScaleX, fitScaleY)); + + const getCenteredOffset = (scale) => { + return { + x: -centerX + (locateViewport.centerX / scale), + y: -centerY + (locateViewport.centerY / scale) + }; + }; + + const startOffset = { + x: Number.isFinite(ds.offset?.[0]) ? ds.offset[0] : getCenteredOffset(startScale).x, + y: Number.isFinite(ds.offset?.[1]) ? ds.offset[1] : getCenteredOffset(startScale).y + }; + const targetOffset = getCenteredOffset(targetScale); + + if (this._locateAnimationFrame) { + cancelAnimationFrame(this._locateAnimationFrame); + this._locateAnimationFrame = null; + } + + const durationMs = 380; + const startTime = performance.now(); + const easeInOutCubic = (t) => t < 0.5 + ? 4 * t * t * t + : 1 - Math.pow(-2 * t + 2, 3) / 2; + + const tick = (now) => { + const progress = Math.min(1, (now - startTime) / durationMs); + const eased = easeInOutCubic(progress); + + ds.scale = startScale + ((targetScale - startScale) * eased); + ds.offset[0] = startOffset.x + ((targetOffset.x - startOffset.x) * eased); + ds.offset[1] = startOffset.y + ((targetOffset.y - startOffset.y) * eased); + canvas.setDirty?.(true, true); + app.graph?.setDirtyCanvas?.(true, true); + + if (progress < 1) { + this._locateAnimationFrame = requestAnimationFrame(tick); + } else { + this._locateAnimationFrame = null; + } + }; + + this._locateAnimationFrame = requestAnimationFrame(tick); + return true; + }, + + getLocateViewport(htmlCanvas) { + const canvasRect = htmlCanvas.getBoundingClientRect?.(); + const dpr = window.devicePixelRatio || 1; + const canvasWidth = canvasRect?.width || htmlCanvas.clientWidth || ((htmlCanvas.width || 0) / dpr); + const canvasHeight = canvasRect?.height || htmlCanvas.clientHeight || ((htmlCanvas.height || 0) / dpr); + const viewport = { + left: 0, + top: 0, + right: canvasWidth, + bottom: canvasHeight + }; + + if (this.docked && canvasRect?.width && canvasRect?.height) { + const obstructionElement = this.dockContainer instanceof HTMLElement + ? this.dockContainer + : this.element; + const obstructionRect = obstructionElement?.getBoundingClientRect?.(); + if (obstructionRect?.width && obstructionRect?.height) { + this.subtractDockedObstructionFromLocateViewport(viewport, canvasRect, obstructionRect); + } + } + + const width = Math.max(1, viewport.right - viewport.left); + const height = Math.max(1, viewport.bottom - viewport.top); + return { + width, + height, + centerX: viewport.left + (width / 2), + centerY: viewport.top + (height / 2) + }; + }, + + subtractDockedObstructionFromLocateViewport(viewport, canvasRect, obstructionRect) { + const obstruction = { + left: Math.max(0, obstructionRect.left - canvasRect.left), + top: Math.max(0, obstructionRect.top - canvasRect.top), + right: Math.min(canvasRect.width, obstructionRect.right - canvasRect.left), + bottom: Math.min(canvasRect.height, obstructionRect.bottom - canvasRect.top) + }; + const overlapWidth = obstruction.right - obstruction.left; + const overlapHeight = obstruction.bottom - obstruction.top; + if (overlapWidth <= 0 || overlapHeight <= 0) return; + + const coversMostHeight = overlapHeight >= canvasRect.height * 0.5; + const coversMostWidth = overlapWidth >= canvasRect.width * 0.5; + const edgeTolerance = 2; + + if (coversMostHeight) { + if (obstruction.left <= edgeTolerance) { + viewport.left = Math.min(viewport.right, Math.max(viewport.left, obstruction.right)); + return; + } + if (obstruction.right >= canvasRect.width - edgeTolerance) { + viewport.right = Math.max(viewport.left, Math.min(viewport.right, obstruction.left)); + return; + } + + const leftSpace = obstruction.left - viewport.left; + const rightSpace = viewport.right - obstruction.right; + if (rightSpace >= leftSpace) { + viewport.left = Math.min(viewport.right, Math.max(viewport.left, obstruction.right)); + } else { + viewport.right = Math.max(viewport.left, Math.min(viewport.right, obstruction.left)); + } + return; + } + + if (coversMostWidth) { + if (obstruction.top <= edgeTolerance) { + viewport.top = Math.min(viewport.bottom, Math.max(viewport.top, obstruction.bottom)); + return; + } + if (obstruction.bottom >= canvasRect.height - edgeTolerance) { + viewport.bottom = Math.max(viewport.top, Math.min(viewport.bottom, obstruction.top)); + } + } + }, + + /** + * Reconnect active downloads to their new progress div elements after UI refresh + */ + reconnectActiveDownloads() { + if (!this.contentElement) return; + + for (const [downloadId, info] of Object.entries(this.activeDownloads)) { + const { missing } = info; + if (!missing) continue; + + // Find the new progress div by ID + const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`; + const newProgressDiv = this.contentElement.querySelector(`#${progressId}`); + const newDownloadBtn = this.contentElement.querySelector(`#download-${missing.node_id}-${missing.widget_index}`); + + if (newProgressDiv) { + // Update the reference + info.progressDiv = newProgressDiv; + info.downloadBtn = newDownloadBtn; + + // Show that download is in progress + newProgressDiv.classList.remove('ml-is-hidden'); + newProgressDiv.classList.add('ml-is-visible'); + newProgressDiv.innerHTML = this.renderProgressWithAction({ + percent: 0, + leftText: 'Downloading...', + rightText: '', + actionClass: 'cancel-download-btn ml-btn ml-btn-danger ml-btn-sm', + actionText: 'Cancel', + actionDataAttr: `data-download-id="${downloadId}"` + }); + + // Attach cancel handler + const cancelBtn = newProgressDiv.querySelector('.cancel-download-btn'); + if (cancelBtn) { + cancelBtn.addEventListener('click', () => this.cancelDownload(downloadId)); + } + + // Update download button if exists + if (newDownloadBtn) { + newDownloadBtn.disabled = true; + newDownloadBtn.textContent = 'Downloading...'; + } + } + } + }, + + getMissingModelKey(missing = {}) { + const nodeId = missing.node_id ?? ''; + const widgetIndex = missing.widget_index ?? ''; + const subgraphId = missing.subgraph_id || ''; + const scope = missing.is_top_level !== false ? 'T' : 'F'; + return `${nodeId}:${widgetIndex}:${subgraphId}:${scope}`; + } +}; diff --git a/web/linker/linker_dialog.js b/web/linker/linker_dialog.js new file mode 100644 index 0000000..5eab3d7 --- /dev/null +++ b/web/linker/linker_dialog.js @@ -0,0 +1,163 @@ +import { $el, ComfyDialog } from "../../../../scripts/ui.js"; +import { searchPanelMethods } from "./search_panel.js"; +import { modelInfoMethods } from "./model_info_methods.js"; +import { renderFormatMethods } from "./render_format_methods.js"; +import { workflowStateMethods } from "./workflow_state_methods.js"; +import { selectionMethods } from "./selection_methods.js"; +import { dialogShellMethods } from "./dialog_shell_methods.js"; +import { downloadTargetMethods } from "./download_target_methods.js"; +import { optionsMethods } from "./options_methods.js"; +import { tabsLoadedMethods } from "./tabs_loaded_methods.js"; +import { queueMethods } from "./queue_methods.js"; +import { lifecycleGraphMethods } from "./lifecycle_graph_methods.js"; +import { missingBrowserMethods } from "./missing_browser_methods.js"; +import { resolveDownloadMethods } from "./resolve_download_methods.js"; +import { workflowUpdateMethods } from "./workflow_update_methods.js"; +export class LinkerManagerDialog extends ComfyDialog { + constructor() { + super(); + this.currentWorkflow = null; + this.missingModels = []; + this.allModels = null; // list of all available models for dropdown + this.downloadDirectories = null; + this.capabilities = null; + this.downloadSubfolders = new Map(); + this.pendingResolutions = []; + this.pendingIndex = new Map(); // key -> index in pendingResolutions + this.workflowPendingSelections = new Map(); // workflow key -> queued selections + this.workflowSearchResultCaches = new Map(); // workflow key -> search results by missing model + this.activeDownloads = {}; // Track active downloads + this.searchResultCache = new Map(); + this.searchProgressTimers = new Map(); + this.urnResolvePromises = new Map(); + this.urnLocalMatchPromises = new Map(); + this.cachedAnalysisData = null; + this.cachedWorkflowSignature = null; + this.selectedMissingModelKey = null; + this.batchSelectedMissingKeys = new Set(); + this.lastBatchSelectedMissingKey = null; + this.activeFooterMenu = null; + this.batchSearchRunning = false; + this.boundHandleOutsideClick = this.handleOutsideClick.bind(this); + this.activeTabStorageKey = 'model_linker_active_tab'; + this.activeTab = this.restoreActiveTab(); // Default tab + this.fullscreen = false; + this.returnToDockedAfterFullscreen = false; + this.docked = false; + this.dockContainer = null; + this.lastDockContainer = null; + this.pendingDockToSidebar = false; + this.sidebarTabId = "comfyui-model-linker"; + this.sidebarOpenModeStorageKey = "model_linker_sidebar_open_mode"; + this.missingBrowserSplitStorageKey = "model_linker_missing_browser_detail_w"; + this.dockButton = null; + this.undockButton = null; + this._floatingRectBeforeDock = null; + this._dragging = false; + this._dragStart = null; + this._analysisProgressToken = null; + this._locateAnimationFrame = null; + this._viewportClampFrame = null; + this.activeWorkflowRouteKey = this.getActiveWorkflowRouteKey(); + this.activeWorkflowSignature = null; + this._workflowRefreshTimer = null; + this._workflowRefreshExpectedRoute = null; + this._workflowRefreshPreviousSignature = null; + this._boundHandleViewportResize = () => this.scheduleModalViewportClamp(true); + + // Create backdrop overlay for click-outside-to-close + this.backdrop = $el("div.model-linker-backdrop", { + parent: document.body + }); + + // Create context menu for model chips + this.contextMenu = $el("div.ml-context-menu", { + parent: document.body + }, [ + $el("div.ml-context-menu-item", { + onclick: () => this.handleContextMenuAction('showInfo') + }, [ + $el("span.ml-context-menu-item-icon", { textContent: "ℹ" }), + $el("span", { textContent: "Show Info" }) + ]), + $el("div.ml-context-menu-divider"), + $el("div.ml-context-menu-item", { + onclick: () => this.handleContextMenuAction('civitai') + }, [ + $el("span.ml-context-menu-item-icon", { textContent: "🌐" }), + $el("span", { textContent: "Open in CivitAI" }) + ]), + $el("div.ml-context-menu-divider"), + $el("div.ml-context-menu-item", { + onclick: () => this.handleContextMenuAction('openFolder') + }, [ + $el("span.ml-context-menu-item-icon", { textContent: "📁" }), + $el("span", { textContent: "Open Containing Folder" }) + ]) + ]); + + this.tooltipElement = $el("div.ml-global-tooltip", { parent: document.body }); + + // Selected model for context menu + this._contextMenuModel = null; + + // Create dialog element using $el + this.element = $el("div.comfy-modal.model-linker-modal", { + id: "model-linker-modal", + parent: document.body + }, [ + this.createHeader(), + this.createContent(), + this.createFooter() + ]); + + this.tooltipObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType === Node.ELEMENT_NODE) { + this.bindTooltips(node); + } + } + } + }); + this.tooltipObserver.observe(this.element, { + childList: true, + subtree: true + }); + this.bindTooltips(this.element); + + // Add click listener to hide context menu when clicking outside + this.boundHandleContextMenuClick = (e) => this.handleContextMenuOutsideClick(e); + document.addEventListener('click', this.boundHandleContextMenuClick); + this.boundHandleFooterMenuClick = (e) => this.handleFooterMenuOutsideClick(e); + document.addEventListener('click', this.boundHandleFooterMenuClick); + window.addEventListener('resize', this._boundHandleViewportResize); + } +} + +function applyDialogMethods(...sources) { + for (const source of sources) { + for (const key of Reflect.ownKeys(source)) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + descriptor.enumerable = false; + Object.defineProperty(LinkerManagerDialog.prototype, key, descriptor); + } + } +} + +applyDialogMethods( + searchPanelMethods, + modelInfoMethods, + renderFormatMethods, + workflowStateMethods, + selectionMethods, + dialogShellMethods, + downloadTargetMethods, + optionsMethods, + tabsLoadedMethods, + queueMethods, + lifecycleGraphMethods, + missingBrowserMethods, + resolveDownloadMethods, + workflowUpdateMethods +); diff --git a/web/linker/missing_browser_methods.js b/web/linker/missing_browser_methods.js new file mode 100644 index 0000000..3fca6dc --- /dev/null +++ b/web/linker/missing_browser_methods.js @@ -0,0 +1,948 @@ +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; +import { $el } from "../../../../scripts/ui.js"; +import { getSvgIcon } from "../utils/icon_utils.js"; +export const missingBrowserMethods = { + getMissingFilename(missing = {}) { + return missing.original_path?.split('/').pop()?.split('\\').pop() || missing.name || 'Missing model'; + }, + + getMissingNodeDisplay(missing = {}) { + const isSubgraphNode = missing.node_type && missing.node_type.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + let nodeLabel; + if (missing.subgraph_name) { + nodeLabel = missing.subgraph_name; + } else if (isSubgraphNode) { + nodeLabel = 'Subgraph'; + } else { + nodeLabel = missing.node_type || 'Node'; + } + + const nodeId = missing.node_id ?? ''; + const customNodeTitle = String(missing.node_title || '').trim(); + const hasCustomNodeTitle = customNodeTitle && customNodeTitle !== nodeLabel; + const text = hasCustomNodeTitle + ? `${nodeLabel} #${nodeId} · ${customNodeTitle}` + : `${nodeLabel} #${nodeId}`; + + return { + label: nodeLabel, + text, + canLocate: missing.is_top_level !== false && nodeId !== '' + }; + }, + + getBestLocalMatch(missing = {}, minConfidence = 0) { + const matches = Array.isArray(missing.matches) ? missing.matches : []; + return matches + .filter(match => Number(match.confidence) >= minConfidence) + .sort((a, b) => Number(b.confidence || 0) - Number(a.confidence || 0))[0] || null; + }, + + getMissingModelSummaryStats(missingModels = []) { + return missingModels.reduce((stats, missing) => { + const best = this.getBestLocalMatch(missing, 70); + if (best?.confidence === 100) { + stats.exact += 1; + } else if (best) { + stats.partial += 1; + } else { + stats.none += 1; + } + return stats; + }, { exact: 0, partial: 0, none: 0 }); + }, + + getMissingSourceStatus(missing = {}, source = '') { + const state = this.searchResultCache.get(this.getMissingSearchKey(missing)); + const progress = state?.sourceProgress?.[source]; + const resultStatus = this.getMissingSourceResultStatus(missing, source, state); + if (progress?.status === 'found') return resultStatus || 'found'; + if (progress?.status) return progress.status; + + if (resultStatus) return resultStatus; + + return 'idle'; + }, + + getMissingSourceResultStatus(missing = {}, source = '', state = null) { + const results = state?.results || {}; + const candidates = []; + + if (source === 'local') { + candidates.push(results.model_list, results.popular); + } else if (source === 'huggingface') { + candidates.push(results.huggingface); + } else if (source === 'civitai') { + candidates.push(results.civitai); + } else if (source === 'civarchive') { + candidates.push(results.civarchive); + } else if (source === 'lora_manager_archive') { + candidates.push(results.lora_manager_archive); + } + + const downloadSource = missing.download_source || {}; + const mappedDownloadSource = { + model_list: 'local', + popular: 'local', + workflow: 'civitai' + }[downloadSource.source] || downloadSource.source; + if (mappedDownloadSource === source && downloadSource.url) { + candidates.push(downloadSource); + } + + return this.getSearchResultStatusLevel(candidates); + }, + + getSearchResultStatusLevel(resultOrResults) { + const results = Array.isArray(resultOrResults) + ? resultOrResults.flatMap(result => Array.isArray(result) ? result : [result]) + : [resultOrResults]; + let hasPartial = false; + let hasUnknownFound = false; + + for (const result of results) { + if (!result) continue; + + const confidence = Number(result.confidence); + const hasConfidence = Number.isFinite(confidence); + const matchType = String(result.match_type || '').toLowerCase(); + + if (hasConfidence) { + if (confidence >= 100) return 'exact'; + if (confidence > 0) { + hasPartial = true; + continue; + } + } + + if (matchType === 'exact' || result.source === 'popular') { + return 'exact'; + } + if (matchType === 'partial' || matchType === 'fuzzy' || matchType === 'similar') { + hasPartial = true; + continue; + } + if (result.url || result.download_url) { + hasUnknownFound = true; + } + } + + if (hasPartial) return 'partial'; + if (hasUnknownFound) return 'found'; + return ''; + }, + + renderMissingSourcesSummary(missing = {}) { + const sourceItems = this.getEnabledSearchSources().map(source => ({ source })); + + return sourceItems.map(item => { + const status = this.getMissingSourceStatus(missing, item.source); + const statusClass = String(status || 'idle').replace(/[^a-z0-9_-]/gi, ''); + const label = this.getSearchSourceLabel(item.source); + const statusLabels = { + pending: 'Queued', + running: 'Searching', + exact: 'Exact match', + partial: 'Partial match', + found: 'Found', + none: 'No match', + error: 'Error', + idle: 'Not searched' + }; + const title = `${label}: ${statusLabels[status] || status}`; + const iconName = this.getSearchSourceIconName(item.source); + const iconHtml = getSvgIcon(iconName, 'currentColor', 'ml-missing-source-icon'); + return `${iconHtml}`; + }).join(''); + }, + + hasRenderableSearchState(state = {}) { + return Boolean( + (state.lastAttemptSources || []).length + || Object.keys(state.sourceProgress || {}).length + || this.hasSearchResults(state.results || {}) + || state.lastAttemptError + ); + }, + + getMissingModelsListLayout(missingModels = []) { + const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); + const textWidth = (value, charPx) => Math.ceil(String(value || '').length * charPx); + + let modelPx = 180; + let typePx = 66; + for (const missing of missingModels) { + const filename = this.getMissingFilename(missing); + const nodeLabel = this.getMissingNodeDisplay(missing).text; + const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown'; + modelPx = Math.max( + modelPx, + textWidth(filename, 7.2) + 12, + textWidth(nodeLabel, 5.5) + 34 + ); + typePx = Math.max(typePx, textWidth(typeLabel, 5.8) + 18); + } + + return { + modelPx: clamp(modelPx, 180, 430), + typePx: clamp(typePx, 66, 120) + }; + }, + + renderMissingModelsBrowser(missingModels, selectedKey, totalMissing, activeCount, hasAny100Match) { + const stats = this.getMissingModelSummaryStats(missingModels); + const detailIndex = missingModels.findIndex(missing => this.getMissingModelKey(missing) === selectedKey); + const detailMissing = detailIndex >= 0 ? missingModels[detailIndex] : null; + const activeHint = activeCount > 0 + ? `${activeCount} downloading` + : (hasAny100Match ? 'Auto-link ready for exact matches' : 'Review matches or search online'); + const listLayout = this.getMissingModelsListLayout(missingModels); + let savedDetailWidth = null; + try { + savedDetailWidth = parseInt(localStorage.getItem(this.missingBrowserSplitStorageKey) || '', 10); + } catch (e) {} + const splitStyle = Number.isFinite(savedDetailWidth) && savedDetailWidth > 0 + ? `--ml-missing-detail-track:${savedDetailWidth}px;` + : ''; + const listStyle = `--ml-missing-model-col:${listLayout.modelPx}px;--ml-missing-type-col:${listLayout.typePx}px;`; + + let html = ` +
+
+
+
+
${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 isBatchSelected = this.batchSelectedMissingKeys?.has(key); + const filename = this.getMissingFilename(missing); + const formattedFilename = this.formatFilename(filename, 46); + const bestMatch = this.getBestLocalMatch(missing, 70); + const confidence = bestMatch ? Number(bestMatch.confidence || 0) : 0; + const matchName = bestMatch?.model?.relative_path || bestMatch?.filename || bestMatch?.path || ''; + const matchDisplay = matchName || 'No local match'; + const matchClass = confidence === 100 ? 'exact' : (bestMatch ? 'partial' : 'none'); + const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown'; + const typeColorClass = this.getModelTypeColorClass(missing.category || typeLabel); + const nodeDisplay = this.getMissingNodeDisplay(missing); + const nodeId = missing.node_id ?? ''; + const rowNodeHtml = nodeDisplay.canLocate + ? `` + : `${this.escapeHtml(nodeDisplay.text)}`; + + html += ` +
+ + + + ${index + 1} + + ${this.escapeHtml(formattedFilename.display)} + ${rowNodeHtml} + + ${this.escapeHtml(typeLabel)} + + ${bestMatch ? this.escapeHtml(matchDisplay) : '-- No local match'} + + + ${bestMatch ? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%` : '--'} + + ${this.renderMissingSourcesSummary(missing)} +
+ `; + }); + + html += ` +
+
+ +
+ ${detailMissing ? this.renderMissingModel(detailMissing, detailIndex) : this.renderStatusMessage('Select a missing model to inspect details.', 'info')} +
+
+ `; + return html; + }, + + wireMissingModelsBrowser(container, data, sortedMissingModels) { + this.wireMissingBrowserSplitter(container); + + const selectRow = (row) => { + const key = row.dataset.missingKey; + if (!key || key === this.selectedMissingModelKey) return; + this.selectedMissingModelKey = key; + this.displayMissingModels(container, data); + }; + + const selectAllCheckbox = container.querySelector('.ml-missing-select-all-check'); + if (selectAllCheckbox) { + this.updateBatchSelectAllCheckbox(); + selectAllCheckbox.addEventListener('click', (event) => { + event.stopPropagation(); + }); + selectAllCheckbox.addEventListener('change', () => { + const shouldSelectAll = selectAllCheckbox.checked; + this.batchSelectedMissingKeys = shouldSelectAll + ? new Set((sortedMissingModels || []).map(missing => this.getMissingModelKey(missing))) + : new Set(); + this.lastBatchSelectedMissingKey = null; + this.refreshBatchSelectionUi(); + this.updateBatchFooterButtons(); + }); + } + + container.querySelectorAll('.ml-missing-list-row').forEach(row => { + const checkbox = row.querySelector('.ml-missing-row-check'); + if (checkbox) { + checkbox.addEventListener('click', (event) => { + event.stopPropagation(); + checkbox.dataset.shiftClick = event.shiftKey ? '1' : '0'; + }); + checkbox.addEventListener('change', (event) => { + const key = row.dataset.missingKey; + if (!key) return; + const selected = checkbox.checked; + const isShiftRange = event.shiftKey || checkbox.dataset.shiftClick === '1'; + + if (isShiftRange && this.lastBatchSelectedMissingKey) { + this.applyBatchSelectionRange( + sortedMissingModels, + this.lastBatchSelectedMissingKey, + key, + selected + ); + } else { + this.setBatchSelectionForKey(key, selected); + } + + this.lastBatchSelectedMissingKey = key; + this.refreshBatchSelectionUi(); + this.updateBatchFooterButtons(); + }); + } + + row.addEventListener('click', (event) => { + const clickedLocate = event.target instanceof Element && event.target.closest('.ml-missing-row-locate'); + if (clickedLocate) return; + selectRow(row); + }); + + row.addEventListener('keydown', (event) => { + if (event.target !== row || (event.key !== 'Enter' && event.key !== ' ')) return; + event.preventDefault(); + selectRow(row); + }); + }); + + container.querySelectorAll('.ml-missing-row-locate').forEach(button => { + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const rawNodeId = button.dataset.nodeId; + const numericNodeId = Number(rawNodeId); + this.locateNodeInGraph(Number.isNaN(numericNodeId) ? rawNodeId : numericNodeId); + }); + }); + + const selectedMissing = sortedMissingModels.find(missing => this.getMissingModelKey(missing) === this.selectedMissingModelKey); + if (!selectedMissing) return; + + const selectedIndex = sortedMissingModels.indexOf(selectedMissing); + this.wireMissingModelDetail(container, selectedMissing, selectedIndex); + }, + + wireMissingBrowserSplitter(container) { + const browser = container.querySelector('.ml-missing-browser'); + const splitter = browser?.querySelector('.ml-missing-browser-splitter'); + if (!(browser instanceof HTMLElement) || !(splitter instanceof HTMLElement)) return; + + this.restoreMissingBrowserSplitWidth(browser); + + splitter.addEventListener('mousedown', (event) => { + if (event.button !== 0) return; + this.startMissingBrowserSplitDrag(event, browser); + }); + + splitter.addEventListener('dblclick', () => { + browser.querySelector('.ml-missing-detail-pane')?.style.removeProperty('--ml-missing-detail-track'); + try { + localStorage.removeItem(this.missingBrowserSplitStorageKey); + } catch (e) {} + }); + + splitter.addEventListener('keydown', (event) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + this.resizeMissingBrowserDetailBy(browser, event.key === 'ArrowLeft' ? 32 : -32); + }); + + this._missingBrowserResizeObserver?.disconnect?.(); + if (typeof ResizeObserver === 'function') { + this._missingBrowserResizeObserver = new ResizeObserver(() => { + if (!this._missingBrowserSplitDragging) { + this.restoreMissingBrowserSplitWidth(browser); + } + }); + this._missingBrowserResizeObserver.observe(browser); + } + }, + + restoreMissingBrowserSplitWidth(browser) { + try { + const savedWidth = parseInt(localStorage.getItem(this.missingBrowserSplitStorageKey) || '', 10); + if (Number.isFinite(savedWidth) && savedWidth > 0) { + this.setMissingBrowserDetailWidth(browser, savedWidth, { persist: false }); + } + } catch (e) {} + }, + + startMissingBrowserSplitDrag(event, browser) { + if (!(browser instanceof HTMLElement)) return; + + const detailPane = browser.querySelector('.ml-missing-detail-pane'); + if (!(detailPane instanceof HTMLElement)) return; + + event.preventDefault(); + this._missingBrowserSplitDragging = true; + this._missingBrowserSplitStart = { + x: event.clientX, + width: detailPane.getBoundingClientRect().width, + bounds: this.getMissingBrowserSplitBounds(browser) + }; + this._lastMissingBrowserSplitApply = 0; + this._missingBrowserSplitBrowser = browser; + this._missingBrowserSplitDetailPane = detailPane; + this._missingBrowserPrevUserSelect = document.body.style.userSelect; + this._missingBrowserPrevCursor = document.body.style.cursor; + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + browser.classList.add('is-resizing'); + + this._onMissingBrowserSplitMove = (moveEvent) => this.onMissingBrowserSplitDrag(moveEvent); + this._onMissingBrowserSplitUp = () => this.endMissingBrowserSplitDrag(); + document.addEventListener('mousemove', this._onMissingBrowserSplitMove); + document.addEventListener('mouseup', this._onMissingBrowserSplitUp, { once: true }); + }, + + onMissingBrowserSplitDrag(event) { + if (!this._missingBrowserSplitDragging || !this._missingBrowserSplitStart || !this._missingBrowserSplitBrowser) return; + + const nextWidth = this._missingBrowserSplitStart.width - (event.clientX - this._missingBrowserSplitStart.x); + const bounds = this._missingBrowserSplitStart.bounds; + this._pendingMissingBrowserSplitWidth = Math.round(Math.max(bounds.min, Math.min(bounds.max, nextWidth))); + const now = performance.now(); + if (this._lastMissingBrowserSplitApply && now - this._lastMissingBrowserSplitApply < 33) return; + this._lastMissingBrowserSplitApply = now; + if (this._missingBrowserSplitFrame) return; + this._missingBrowserSplitFrame = requestAnimationFrame(() => { + this._missingBrowserSplitFrame = null; + if (!this._missingBrowserSplitDragging || !this._missingBrowserSplitDetailPane || !this._pendingMissingBrowserSplitWidth) return; + this._missingBrowserSplitDetailPane.style.setProperty('--ml-missing-detail-track', `${this._pendingMissingBrowserSplitWidth}px`); + }); + }, + + endMissingBrowserSplitDrag() { + if (!this._missingBrowserSplitDragging) return; + + this._missingBrowserSplitDragging = false; + document.removeEventListener('mousemove', this._onMissingBrowserSplitMove); + if (this._missingBrowserSplitFrame) { + cancelAnimationFrame(this._missingBrowserSplitFrame); + this._missingBrowserSplitFrame = null; + } + if (this._missingBrowserSplitDetailPane && this._pendingMissingBrowserSplitWidth) { + this._missingBrowserSplitDetailPane.style.setProperty('--ml-missing-detail-track', `${this._pendingMissingBrowserSplitWidth}px`); + try { + localStorage.setItem(this.missingBrowserSplitStorageKey, String(this._pendingMissingBrowserSplitWidth)); + } catch (e) {} + } + this._missingBrowserSplitBrowser?.classList.remove('is-resizing'); + this._missingBrowserSplitBrowser = null; + this._missingBrowserSplitDetailPane = null; + this._missingBrowserSplitStart = null; + this._pendingMissingBrowserSplitWidth = null; + this._lastMissingBrowserSplitApply = 0; + try { + document.body.style.userSelect = this._missingBrowserPrevUserSelect || ''; + document.body.style.cursor = this._missingBrowserPrevCursor || ''; + } catch (e) {} + }, + + resizeMissingBrowserDetailBy(browser, delta) { + const detailPane = browser.querySelector('.ml-missing-detail-pane'); + if (!(detailPane instanceof HTMLElement)) return; + + this.setMissingBrowserDetailWidth(browser, detailPane.getBoundingClientRect().width + delta); + }, + + setMissingBrowserDetailWidth(browser, width, { persist = true } = {}) { + const bounds = this.getMissingBrowserSplitBounds(browser); + const nextWidth = Math.round(Math.max(bounds.min, Math.min(bounds.max, width))); + const detailPane = browser.querySelector('.ml-missing-detail-pane'); + const target = detailPane instanceof HTMLElement ? detailPane : browser; + target.style.setProperty('--ml-missing-detail-track', `${nextWidth}px`); + + if (persist) { + try { + localStorage.setItem(this.missingBrowserSplitStorageKey, String(nextWidth)); + } catch (e) {} + } + }, + + getMissingBrowserSplitBounds(browser) { + const browserRect = browser.getBoundingClientRect(); + const splitter = browser.querySelector('.ml-missing-browser-splitter'); + const splitterWidth = splitter instanceof HTMLElement + ? splitter.getBoundingClientRect().width + : 10; + const available = Math.max(1, browserRect.width - splitterWidth); + const minListWidth = Math.min(360, Math.max(240, Math.floor(available * 0.28))); + const max = Math.max(1, available - minListWidth); + const min = Math.min(max, Math.min(420, Math.max(300, Math.floor(available * 0.3)))); + + return { min, max }; + }, + + wireMissingModelDetail(container, missing, missingIndex) { + this.wireLocalMatchButtons(container, missing, missingIndex); + this.wireDownloadSearchPanel(container, missing); + this.updateSelectedBarForMissing(missing); + + const locateId = `locate-${missing.node_id}-${missing.widget_index}`; + const locateBtn = container.querySelector(`#${locateId}`); + if (locateBtn && missing.is_top_level !== false) { + locateBtn.addEventListener('click', () => { + this.locateNodeInGraph(missing.node_id); + }); + } + + const comboId = `combo-${missing.node_id}-${missing.widget_index}`; + const comboInput = container.querySelector(`#combo-input-${comboId}`); + const comboList = container.querySelector(`#combo-list-${comboId}`); + const comboRefresh = container.querySelector(`#combo-refresh-${comboId}`); + + if (comboList) { + this.enableWheelScrollChaining(comboList); + } + + const allModels = Array.isArray(this.allModels) ? this.allModels : []; + const buildLabel = (m) => `${m.category ? m.category + ': ' : ''}${m.relative_path || m.filename || ''}`; + const getFolder = (m) => m.path || m.base_directory || ''; + + const populateComboOptions = (filterText, highlightIdx = -1) => { + if (!comboList) return; + const f = (filterText || '').toLowerCase(); + const filtered = f + ? allModels.filter(m => buildLabel(m).toLowerCase().includes(f)) + : allModels.slice(); + + let html = ''; + for (let i = 0; i < filtered.length; i++) { + const m = filtered[i]; + const label = buildLabel(m); + const folder = getFolder(m); + const isHighlighted = i === highlightIdx; + const folderDisplay = folder ? folder.replace(/\\/g, '/').replace(/:/, '') : ''; + html += `
`; + html += `
`; + html += `${this.escapeHtml(label)}`; + html += `
`; + if (folderDisplay) { + html += `
Folder ${this.escapeHtml(folderDisplay)}
`; + } + html += `
`; + } + comboList.innerHTML = html; + + comboList.querySelectorAll('div[data-idx]').forEach(el => { + el.addEventListener('click', () => { + const idx = parseInt(el.dataset.idx, 10); + if (!Number.isNaN(idx) && idx >= 0 && idx < allModels.length) { + const chosenModel = allModels[idx]; + if (chosenModel) { + this.queueResolution(missing, chosenModel); + } + } + }); + }); + }; + + if (comboList) { + populateComboOptions(''); + } + + if (comboInput) { + const debouncedFilter = this.debounce(() => { + populateComboOptions(comboInput.value); + }, 200); + comboInput.addEventListener('input', debouncedFilter); + comboInput.addEventListener('focus', () => { + if (comboList) { + comboList.classList.remove('ml-is-hidden'); + comboList.classList.add('ml-is-visible'); + } + populateComboOptions(comboInput.value); + }); + comboInput.addEventListener('blur', () => { + setTimeout(() => { + if (comboList) { + comboList.classList.remove('ml-is-visible'); + comboList.classList.add('ml-is-hidden'); + } + }, 200); + }); + } + + if (comboRefresh) { + comboRefresh.addEventListener('click', async () => { + this.allModels = null; + await this.ensureAllModelsLoaded(); + populateComboOptions(comboInput?.value || ''); + }); + } + + const state = this.searchResultCache.get(this.getMissingSearchKey(missing)); + const searchResultsDiv = container.querySelector(`#search-results-${missing.node_id}-${missing.widget_index}`); + if (state && searchResultsDiv && this.hasRenderableSearchState(state)) { + searchResultsDiv.classList.remove('ml-is-hidden'); + searchResultsDiv.classList.add('ml-is-visible'); + this.displaySearchResults(missing, state, searchResultsDiv); + } + }, + + /** + * Display missing models in the dialog + */ + displayMissingModels(container, data) { + const missingModels = data.missing_models || []; + const totalMissing = data.total_missing || 0; + this.missingModels = missingModels; + this.syncBatchSelectionForMissingModels(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 + this.setMissingFooterControlsVisible(totalMissing > 0 || activeCount > 0); + 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 || activeCount > 0) ? 'inline-flex' : 'none'; + } + + if (totalMissing === 0 && activeCount === 0) { + container.innerHTML = this.renderStatusMessage('All models are available! No missing models found.', 'success'); + return; + } + + // If no missing models but downloads are active, show a waiting message + if (totalMissing === 0 && activeCount > 0) { + container.innerHTML = this.renderStatusMessage( + `${activeCount} download${activeCount > 1 ? 's' : ''} in progress. Models will be auto-linked when complete.`, + 'info' + ); + return; + } + + // Skip rendering if active tab is not "missing" + if (this.activeTab !== 'missing') { + container.innerHTML = ''; + return; + } + + // Sort missing models: those with 100% confidence matches first, then others + const sortedMissingModels = [...missingModels].sort((a, b) => { + const aMatches = a.matches || []; + const bMatches = b.matches || []; + + // Filter to 70%+ confidence + const aFiltered = aMatches.filter(m => m.confidence >= 70); + const bFiltered = bMatches.filter(m => m.confidence >= 70); + + // Check if they have 100% matches + const aHas100 = aFiltered.some(m => m.confidence === 100); + const bHas100 = bFiltered.some(m => m.confidence === 100); + + // If one has 100% and the other doesn't, prioritize the one with 100% + if (aHas100 && !bHas100) return -1; + if (!aHas100 && bHas100) return 1; + + // If both have 100% or neither has 100%, sort by best confidence + const aBestConf = aFiltered.length > 0 ? Math.max(...aFiltered.map(m => m.confidence)) : 0; + const bBestConf = bFiltered.length > 0 ? Math.max(...bFiltered.map(m => m.confidence)) : 0; + + return bBestConf - aBestConf; // Higher confidence first + }); + + for (let mi = 0; mi < sortedMissingModels.length; mi++) { + sortedMissingModels[mi].__displayIndex = mi; + } + + const selectedStillExists = sortedMissingModels.some(missing => ( + this.getMissingModelKey(missing) === this.selectedMissingModelKey + )); + if (!selectedStillExists) { + this.selectedMissingModelKey = sortedMissingModels.length + ? this.getMissingModelKey(sortedMissingModels[0]) + : null; + } + + this.missingModels = sortedMissingModels; + container.innerHTML = this.renderMissingModelsBrowser( + sortedMissingModels, + this.selectedMissingModelKey, + totalMissing, + activeCount, + hasAny100Match + ); + this.wireMissingModelsBrowser(container, data, sortedMissingModels); + this.scheduleInitialUrnLocalMatchRefresh(sortedMissingModels, container, data); + this.updateBatchFooterButtons(); + }, + + 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 (downloadSource && downloadSource.url) { + html += this.renderKnownDownloadPanel(missing, downloadSource); + } else if (perfectMatches.length > 0) { + // Has perfect local match - download not needed, but allow online re-check. + html += `
`; + html += this.renderSearchControls(missing, { buttonText: 'Search Online' }); + html += this.renderDownloadTargetControls(missing, missing.category || 'checkpoints'); + html += `
`; + html += `
`; + } else if (missing.is_urn) { + html += `
`; + html += `
Resolving CivitAI download for this URN...
`; + html += `
`; + } else { + // No known download - offer search + html += `
`; + html += this.renderSearchControls(missing); + html += this.renderDownloadTargetControls(missing, missing.category || 'checkpoints'); + html += `
`; + html += `
`; + } + + // Progress container (for downloads) + html += `
`; + + html += `
`; // End right column + html += `
`; // End columns + + html += `
`; // End card + return html; + }, + + /** + * Show a notification banner (similar to ComfyUI's "Reconnecting" banner) + */ + showNotification(message, type = 'success') { + // Build children array, filtering out nulls + const children = []; + + if (type === 'success') { + children.push($el("span", { + textContent: "✓", + className: "ml-notification-icon" + })); + } else if (type === 'error') { + children.push($el("span", { + textContent: "×", + className: "ml-notification-icon" + })); + } else if (type === 'info') { + children.push($el("span", { + textContent: "ℹ", + className: "ml-notification-icon" + })); + } + + // Create notification banner + const notification = $el("div", { + className: `ml-notification ml-notification--${type}` + }, [ + ...children, + $el("span", { + textContent: message + }), + $el("button", { + className: "ml-notification-close", + textContent: "×", + onclick: () => { + if (notification.parentNode) { + notification.style.opacity = "0"; + notification.style.transform = "translateX(-50%) translateY(-100%)"; + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, 300); + } + } + }) + ]); + + document.body.appendChild(notification); + + // Auto-dismiss after 4 seconds for success, 6 seconds for errors + const dismissTime = type === 'success' ? 4000 : 6000; + setTimeout(() => { + if (notification.parentNode) { + notification.style.opacity = "0"; + notification.style.transform = "translateX(-50%) translateY(-100%)"; + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, 300); + } + }, dismissTime); + } +}; diff --git a/web/linker/model_info_methods.js b/web/linker/model_info_methods.js new file mode 100644 index 0000000..bc3a2c1 --- /dev/null +++ b/web/linker/model_info_methods.js @@ -0,0 +1,823 @@ +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; +import { $el } from "../../../../scripts/ui.js"; +import { getSvgIcon } from "../utils/icon_utils.js"; +export const modelInfoMethods = { + /** + * 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, '''); + }, + + escapeJsString(value) { + return this.escapeHtml(JSON.stringify(String(value ?? ''))); + }, + + truncateText(value, maxLength = 160) { + const text = String(value ?? '').trim(); + if (!text) return ''; + return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; + }, + + sanitizeDescriptionHtml(html) { + const raw = String(html ?? '').trim(); + if (!raw) return ''; + + const parser = new DOMParser(); + const doc = parser.parseFromString(`
${raw}
`, 'text/html'); + const root = doc.body.firstElementChild; + if (!root) return this.escapeHtml(raw); + + const allowedTags = new Set([ + 'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'pre', + 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'a', 'span' + ]); + const allowedStyles = new Set(['color']); + + const sanitizeNode = (node) => { + if (node.nodeType === Node.TEXT_NODE) { + return; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + node.remove(); + return; + } + + const tag = node.tagName.toLowerCase(); + if (!allowedTags.has(tag)) { + const parent = node.parentNode; + if (!parent) { + node.remove(); + return; + } + + while (node.firstChild) { + parent.insertBefore(node.firstChild, node); + } + parent.removeChild(node); + return; + } + + const attrs = Array.from(node.attributes); + for (const attr of attrs) { + const name = attr.name.toLowerCase(); + if (name.startsWith('on') || name === 'id' || name === 'class' || name.startsWith('data-')) { + node.removeAttribute(attr.name); + continue; + } + + if (name === 'href' && tag === 'a') { + const href = node.getAttribute('href') || ''; + if (!/^https?:\/\//i.test(href)) { + node.removeAttribute('href'); + } else { + node.setAttribute('target', '_blank'); + node.setAttribute('rel', 'noopener noreferrer'); + } + continue; + } + + if (name === 'style') { + const safeStyles = []; + const styleValue = node.getAttribute('style') || ''; + for (const part of styleValue.split(';')) { + const [prop, value] = part.split(':').map(v => v?.trim()); + if (!prop || !value) continue; + if (allowedStyles.has(prop.toLowerCase())) { + safeStyles.push(`${prop}: ${value}`); + } + } + if (safeStyles.length) { + node.setAttribute('style', safeStyles.join('; ')); + } else { + node.removeAttribute('style'); + } + continue; + } + + if (!(tag === 'a' && (name === 'target' || name === 'rel')) ) { + node.removeAttribute(attr.name); + } + } + + Array.from(node.childNodes).forEach(child => sanitizeNode(child)); + }; + + Array.from(root.childNodes).forEach(child => sanitizeNode(child)); + return root.innerHTML; + }, + + normalizeTrainedWords(words) { + if (Array.isArray(words)) { + return [...new Set(words.map(word => String(word || '').trim()).filter(Boolean))]; + } + + if (typeof words === 'string') { + return [...new Set( + words + .split(/[\n,|;]/) + .map(word => word.trim()) + .filter(Boolean) + )]; + } + + return []; + }, + + updateSelectedTrainedWordsSummary(dialog) { + if (!dialog) return; + + const countEl = dialog.querySelector('.ml-info-trained-words-count'); + const copyBtn = dialog.querySelector('.ml-info-copy-trained-words'); + const selected = dialog._selectedTrainedWords instanceof Set + ? Array.from(dialog._selectedTrainedWords) + : []; + + if (countEl) { + countEl.textContent = `${selected.length} selected`; + } + if (copyBtn) { + copyBtn.disabled = selected.length === 0; + copyBtn.textContent = 'Copy'; + } + }, + + bindInfoDialogInteractions(dialog) { + if (!dialog || dialog.dataset.mlInfoBound === 'true') return; + dialog.dataset.mlInfoBound = 'true'; + + dialog.addEventListener('click', async (event) => { + const wordBtn = event.target.closest('.ml-info-trained-word'); + if (wordBtn && dialog.contains(wordBtn)) { + const word = wordBtn.dataset.word || ''; + if (word) { + if (!(dialog._selectedTrainedWords instanceof Set)) { + dialog._selectedTrainedWords = new Set(); + } + + if (dialog._selectedTrainedWords.has(word)) { + dialog._selectedTrainedWords.delete(word); + wordBtn.classList.remove('is-selected'); + wordBtn.setAttribute('aria-pressed', 'false'); + } else { + dialog._selectedTrainedWords.add(word); + wordBtn.classList.add('is-selected'); + wordBtn.setAttribute('aria-pressed', 'true'); + } + + this.updateSelectedTrainedWordsSummary(dialog); + } + return; + } + + const copyBtn = event.target.closest('.ml-info-copy-trained-words'); + if (copyBtn && dialog.contains(copyBtn)) { + const words = dialog._selectedTrainedWords instanceof Set + ? Array.from(dialog._selectedTrainedWords) + : []; + + if (!words.length) return; + + try { + await navigator.clipboard.writeText(words.join(', ')); + copyBtn.textContent = 'Copied'; + } catch (error) { + console.error('Model Linker: Failed to copy trained words:', error); + copyBtn.textContent = 'Failed'; + } + + setTimeout(() => { + if (copyBtn.isConnected) { + copyBtn.textContent = 'Copy'; + } + }, 1200); + return; + } + + const descToggleBtn = event.target.closest('.ml-info-description-toggle'); + if (descToggleBtn && dialog.contains(descToggleBtn)) { + const descEl = dialog.querySelector('.ml-info-description'); + if (!descEl) return; + + const isExpanded = descEl.classList.toggle('is-expanded'); + descToggleBtn.textContent = isExpanded ? 'Show less' : 'Show more'; + } + }); + }, + + getInfoDialogElement(dialog) { + return dialog?.querySelector?.('.ml-info-dialog') || null; + }, + + restoreInfoDialogSize(dialog) { + const panel = this.getInfoDialogElement(dialog); + if (!panel) return; + + try { + const saved = JSON.parse(localStorage.getItem('model_linker_info_dialog_size') || 'null'); + if (!saved || typeof saved !== 'object') return; + + const width = Number(saved.w); + const height = Number(saved.h); + if (!Number.isFinite(width) || !Number.isFinite(height)) return; + + const maxWidth = Math.floor(window.innerWidth * 0.9); + const maxHeight = Math.floor(window.innerHeight * 0.8); + const clampedWidth = Math.max(420, Math.min(width, maxWidth)); + const clampedHeight = Math.max(320, Math.min(height, maxHeight)); + + panel.style.width = `${clampedWidth}px`; + panel.style.height = `${clampedHeight}px`; + } catch (error) { + console.warn('Model Linker: Failed to restore info dialog size:', error); + } + }, + + saveInfoDialogSize(dialog) { + const panel = this.getInfoDialogElement(dialog); + if (!panel) return; + + const rect = panel.getBoundingClientRect(); + const width = Math.round(rect.width); + const height = Math.round(rect.height); + if (!width || !height) return; + + try { + localStorage.setItem('model_linker_info_dialog_size', JSON.stringify({ w: width, h: height })); + } catch (error) { + console.warn('Model Linker: Failed to save info dialog size:', error); + } + }, + + bindInfoDialogResizePersistence(dialog) { + const panel = this.getInfoDialogElement(dialog); + if (!panel || typeof ResizeObserver === 'undefined') return; + if (dialog._infoDialogResizeObserver) return; + + let resizeSaveTimer = null; + const observer = new ResizeObserver(() => { + clearTimeout(resizeSaveTimer); + resizeSaveTimer = setTimeout(() => this.saveInfoDialogSize(dialog), 180); + dialog._infoDialogResizeSaveTimer = resizeSaveTimer; + }); + + observer.observe(panel); + dialog._infoDialogResizeObserver = observer; + dialog._infoDialogResizeSaveTimer = resizeSaveTimer; + }, + + /** + * Fetch model info and update the dialog + */ + async fetchModelInfoForDialog(loraName, modelData, dialog) { + try { + const response = await api.fetchApi('/model_linker/civitai-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filename: loraName, + category: modelData?.category || '', + resolved_path: modelData?.resolved_path || '' + }) + }); + + if (response.ok) { + const data = await response.json(); + this.updateInfoDialogWithData(dialog, data); + } else { + this.updateInfoDialogError(dialog, 'Model not found on CivitAI'); + } + } catch (e) { + console.error('Model Linker: Error fetching model info:', e); + this.updateInfoDialogError(dialog, 'Error fetching info'); + } + }, + + /** + * Update the info dialog with data + */ + updateInfoDialogWithData(dialog, data) { + const loadingDiv = dialog.querySelector('.ml-info-dialog-loading'); + const bodyDiv = dialog.querySelector('.ml-info-dialog-body'); + this.bindInfoDialogInteractions(dialog); + + if (loadingDiv) loadingDiv.style.display = 'none'; + if (bodyDiv) bodyDiv.style.display = 'block'; + + if (!data) { + this.updateInfoDialogError(dialog, 'No data received'); + return; + } + + // Update title + const titleEl = dialog.querySelector('.ml-info-dialog-title'); + if (titleEl) { + const modelName = data.model_name || data.modelName || 'Unknown Model'; + const versionName = data.version_name || data.versionName || ''; + titleEl.innerHTML = this.renderVersionedModelNameHtml(modelName, versionName) || this.escapeHtml(modelName); + } + + // Update type tag + const typeTag = dialog.querySelector('.ml-info-type'); + if (typeTag) { + const modelType = data.model_type || data.modelType || ''; + typeTag.textContent = modelType.toUpperCase(); + typeTag.className = `ml-info-tag ml-info-type ${this.getModelTypeColorClass(modelType)}`; + } + + // Update base model tag + const baseModelTag = dialog.querySelector('.ml-info-basemodel'); + if (baseModelTag) { + const baseModel = data.base_model || data.baseModel || ''; + baseModelTag.textContent = baseModel || ''; + if (baseModel) { + baseModelTag.style.display = ''; + baseModelTag.className = `ml-info-tag ml-info-basemodel -basemodel-${baseModel.toLowerCase().replace(/\s+/g, '-')}`; + } else { + baseModelTag.style.display = 'none'; + } + } + + // Update file + const fileEl = dialog.querySelector('.ml-info-file'); + if (fileEl && data.filename) { + fileEl.textContent = data.filename; + } + + // Update hash + const hashEl = dialog.querySelector('.ml-info-hash'); + if (hashEl) { + hashEl.textContent = data.sha256 || data.hash || ''; + } + + // Update CivitAI link + const civitaiLinkEl = dialog.querySelector('.ml-info-civitai-link'); + if (civitaiLinkEl) { + if (data.url || data.version_url) { + const url = data.version_url || data.url; + civitaiLinkEl.innerHTML = ` + + ${getSvgIcon('civitai', 'currentColor', 'ml-info-civitai-logo')} + View on Civitai + + `; + } else { + const searchName = data.model_name || data.modelName || 'Unknown'; + civitaiLinkEl.innerHTML = ` + Model not found + + ${this.getSearchIconHtml()} Search on CivitAI + + `; + } + } + + // Update name + const nameEl = dialog.querySelector('.ml-info-name'); + if (nameEl) { + nameEl.textContent = data.model_name || data.modelName || ''; + } + + // Update base model row + const baseModelRowEl = dialog.querySelector('.ml-info-base-model'); + if (baseModelRowEl) { + const baseModel = data.base_model || data.baseModel || ''; + baseModelRowEl.textContent = baseModel; + const row = baseModelRowEl.closest('tr'); + if (row && baseModel) { + row.style.display = ''; + } else if (row) { + row.style.display = 'none'; + } + } + + // Update trained words + const trainedWordsEl = dialog.querySelector('.ml-info-trained-words'); + if (trainedWordsEl) { + const words = this.normalizeTrainedWords(data.trained_words || data.trainedWords || []); + if (words.length > 0) { + dialog._selectedTrainedWords = new Set(); + trainedWordsEl.innerHTML = `
${words.map(word => ` + + `).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); + } + } +}; diff --git a/web/linker/model_linker.js b/web/linker/model_linker.js new file mode 100644 index 0000000..5290093 --- /dev/null +++ b/web/linker/model_linker.js @@ -0,0 +1,730 @@ +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; +import { $el } from "../../../../scripts/ui.js"; +import { loadStylesWhenNeeded } from "../utils/css_loader.js"; +import { LinkerManagerDialog } from "./linker_dialog.js"; + +// Main extension class +export class ModelLinker { + constructor() { + this.linkerButton = null; + this.buttonGroup = null; + this.buttonId = "model-linker-button"; + this.sidebarTabId = "comfyui-model-linker"; + this.sidebarRegistered = false; + this.openTooltip = "Open Model Linker to find or download missing workflow models. Shortcut: Ctrl+Shift+L."; + this.dialog = null; + this.isCheckingMissing = false; // Prevent multiple simultaneous checks + this.lastCheckedWorkflow = null; // Track to avoid duplicate checks + } + + setup = async () => { + loadStylesWhenNeeded(); + + // Remove any existing button + this.removeExistingButton(); + + // Create dialog instance + if (!this.dialog) { + this.dialog = new LinkerManagerDialog(); + window.modelLinkerDialog = this.dialog; + } + + // Register keyboard shortcut (Ctrl+Shift+L) + document.addEventListener('keydown', (e) => { + if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'l') { + e.preventDefault(); + this.openLinkerManager(); + } + }); + + // Listen for workflow load events to auto-check for missing models + this.setupAutoOpenOnMissingModels(); + this.setupActiveWorkflowChangeListeners(); + + const sidebarRegistered = this.registerSidebarButton(); + if (!sidebarRegistered) { + await this.registerTopbarButton(); + } else { + this.attachSidebarButtonToggleHandler(); + } + } + + registerSidebarButton() { + const registerSidebarTab = app.extensionManager?.registerSidebarTab; + if (typeof registerSidebarTab !== "function") { + return false; + } + + if (this.sidebarRegistered || window.__modelLinkerSidebarRegistered) { + this.sidebarRegistered = true; + return true; + } + + try { + registerSidebarTab.call(app.extensionManager, { + id: this.sidebarTabId, + icon: "mdi mdi-link-variant", + title: "Model Linker", + tooltip: this.openTooltip, + type: "custom", + render: (element) => this.renderSidebarPanel(element), + }); + this.sidebarRegistered = true; + window.__modelLinkerSidebarRegistered = true; + return true; + } catch (error) { + console.warn("Model Linker: Sidebar tab registration failed, falling back to top menu button.", error); + return false; + } + } + + attachSidebarButtonToggleHandler() { + window.__modelLinkerSidebarToggleOwner = this; + if (window.__modelLinkerSidebarToggleHandlerAttached) return; + + window.__modelLinkerSidebarToggleHandlerAttached = true; + window.__modelLinkerSidebarToggleHandler = (event) => { + window.__modelLinkerSidebarToggleOwner?.handleSidebarButtonClick(event); + }; + document.addEventListener('click', window.__modelLinkerSidebarToggleHandler, true); + } + + handleSidebarButtonClick(event) { + const target = event.target instanceof Element ? event.target : null; + const button = target?.closest(`.${this.sidebarTabId}-tab-button`); + if (!(button instanceof HTMLElement)) return; + if (!this.dialog?.isVisible()) return; + + const wasDocked = this.dialog.docked; + this.dialog.close({ collapseSidebar: false }); + + if (!wasDocked) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation?.(); + + requestAnimationFrame(() => { + if (this.isSidebarButtonActive(button)) { + button.click(); + } + }); + } + } + + isSidebarButtonActive(button) { + return button.matches([ + '[aria-pressed="true"]', + '[aria-selected="true"]', + '[data-active="true"]', + '[data-selected="true"]', + '.active', + '.is-active', + '.selected', + '.p-highlight' + ].join(',')); + } + + renderSidebarPanel(element) { + element.style.height = "100%"; + element.classList.add("ml-sidebar-dock-panel"); + + if (!this.dialog) { + this.dialog = new LinkerManagerDialog(); + window.modelLinkerDialog = this.dialog; + } + + if (this.dialog.shouldOpenFromSidebarFloating()) { + if (this.dialog.isVisible() && !this.dialog.docked) { + this.dialog.close(); + this.dialog.closeComfySidebar(element); + return; + } + + this.openLinkerManager({ + forceFloating: true, + closeSidebarContainer: element, + }); + return; + } + + element.replaceChildren(); + this.openLinkerManager({ dockContainer: element }); + } + + async registerTopbarButton() { + // Try to use new ComfyUI button system (like ComfyUI Manager does) + try { + // Dynamic imports for ComfyUI's button components + const { ComfyButtonGroup } = await import("../../../../scripts/ui/components/buttonGroup.js"); + const { ComfyButton } = await import("../../../../scripts/ui/components/button.js"); + + // Create button group with Model Linker button + const modelLinkerButton = new ComfyButton({ + icon: "link-variant", + action: () => this.openLinkerManager(), + content: "Model Linker", + classList: "comfyui-button comfyui-menu-mobile-collapse" + }).element; + modelLinkerButton.id = this.buttonId; + this.dialog.setTooltip(modelLinkerButton, this.openTooltip); + this.buttonGroup = new ComfyButtonGroup( + modelLinkerButton + ); + + // Insert before settings group in the menu + app.menu?.settingsGroup.element.before(this.buttonGroup.element); + } catch (e) { + // Fallback for older ComfyUI versions without the new button system + console.log('Model Linker: New button system not available, using floating button fallback.'); + this.createFloatingButton(); + } + } + + /** + * Setup auto-open functionality when workflow is loaded with missing models + */ + setupAutoOpenOnMissingModels() { + // Watch for ComfyUI's Missing Models popup and inject our button + this.setupMissingModelsPopupObserver(); + + console.log('Model Linker: Missing models popup button injection enabled'); + } + + setupActiveWorkflowChangeListeners() { + if (window.__modelLinkerWorkflowChangeHandlers) { + for (const { target, event, handler } of window.__modelLinkerWorkflowChangeHandlers) { + target?.removeEventListener?.(event, handler); + } + } + + if (!window.__modelLinkerHistoryPatched) { + const originalPushState = history.pushState; + const originalReplaceState = history.replaceState; + history.pushState = function(...args) { + const result = originalPushState.apply(this, args); + window.dispatchEvent(new Event('model-linker-locationchange')); + return result; + }; + history.replaceState = function(...args) { + const result = originalReplaceState.apply(this, args); + window.dispatchEvent(new Event('model-linker-locationchange')); + return result; + }; + window.__modelLinkerHistoryPatched = true; + } + + window.__modelLinkerWorkflowChangeOwner = this; + + const routeHandler = () => { + window.__modelLinkerWorkflowChangeOwner?.handleActiveWorkflowRouteChange('route-change'); + }; + const focusHandler = () => { + window.__modelLinkerWorkflowChangeOwner?.handleActiveWorkflowRouteChange('window-focus'); + }; + const visibilityHandler = () => { + if (document.visibilityState === 'visible') { + window.__modelLinkerWorkflowChangeOwner?.handleActiveWorkflowRouteChange('visibility-change'); + } + }; + + window.addEventListener('hashchange', routeHandler); + window.addEventListener('popstate', routeHandler); + window.addEventListener('model-linker-locationchange', routeHandler); + window.addEventListener('focus', focusHandler); + document.addEventListener('visibilitychange', visibilityHandler); + + window.__modelLinkerWorkflowChangeHandlers = [ + { target: window, event: 'hashchange', handler: routeHandler }, + { target: window, event: 'popstate', handler: routeHandler }, + { target: window, event: 'model-linker-locationchange', handler: routeHandler }, + { target: window, event: 'focus', handler: focusHandler }, + { target: document, event: 'visibilitychange', handler: visibilityHandler } + ]; + } + + handleActiveWorkflowRouteChange(reason = 'workflow-change') { + if (!this.dialog?.isVisible()) return; + this.dialog.scheduleActiveWorkflowRefresh(reason); + } + + /** + * Setup MutationObserver to detect ComfyUI's Missing Models popup and inject our button + */ + setupMissingModelsPopupObserver() { + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType === Node.ELEMENT_NODE) { + this.checkAndInjectButton(node); + } + } + } + }); + + // Observe the entire document for added nodes + observer.observe(document.body, { + childList: true, + subtree: true + }); + } + + /** + * Check if a node is the Missing Models popup and inject our buttons + */ + checkAndInjectButton(node) { + // Look for the Missing Models popup by finding elements with "Missing Models" text + const findMissingModelsDialog = (element) => { + // Check if this element or its children contain "Missing Models" heading + const headings = element.querySelectorAll ? element.querySelectorAll('h2, h3, [class*="title"], [class*="header"]') : []; + for (const heading of headings) { + if (heading.textContent?.includes('Missing Models')) { + return element; + } + } + // Also check text content directly + if (element.textContent?.includes('Missing Models') && + element.textContent?.includes('following models were not found')) { + return element; + } + return null; + }; + + const dialog = findMissingModelsDialog(node); + if (!dialog) return; + + // Check if we already injected buttons + if (dialog.querySelector('#model-linker-btn-container')) return; + + // Find a suitable place to inject the button + const injectButtons = () => { + // Auto-resolve button (green) + const autoResolveBtn = document.createElement('button'); + autoResolveBtn.id = 'model-linker-btn-container'; // Use this ID to prevent duplicate injection + autoResolveBtn.className = 'ml-popup-auto-resolve-btn'; + autoResolveBtn.textContent = '🔗 Auto-resolve 100%'; + this.dialog?.setTooltip(autoResolveBtn, 'Link every missing model that already has an exact local match, then open Model Linker for the rest.'); + autoResolveBtn.addEventListener('click', async () => { + await this.handleAutoResolveInPopup(dialog, autoResolveBtn); + }); + + // Find the "Don't show this again" checkbox row and add button next to it + const checkbox = dialog.querySelector('input[type="checkbox"]'); + if (checkbox) { + const checkboxRow = checkbox.closest('label') || checkbox.parentElement; + if (checkboxRow && checkboxRow.parentElement) { + // Make the parent a flex container to align checkbox and button + checkboxRow.parentElement.classList.add('ml-popup-inline-actions'); + // Insert button at the beginning (left side) + checkboxRow.parentElement.insertBefore(autoResolveBtn, checkboxRow); + return; + } + } + + // Fallback: Find the list of models and insert before it + const modelList = dialog.querySelector('[style*="overflow"]') || + dialog.querySelector('[class*="list"]') || + dialog.querySelector('[class*="content"]'); + + if (modelList) { + // Create a wrapper and insert before the model list + const wrapper = document.createElement('div'); + wrapper.className = 'ml-popup-actions-wrap'; + wrapper.appendChild(autoResolveBtn); + modelList.parentElement?.insertBefore(wrapper, modelList); + } else { + // Find after the description text + const allElements = dialog.querySelectorAll('*'); + for (const el of allElements) { + if (el.textContent?.includes('following models were not found') && + el.children.length === 0) { + el.parentElement?.insertBefore(btnContainer, el.nextSibling); + break; + } + } + } + + console.log('Model Linker: Injected buttons into Missing Models popup'); + }; + + // Small delay to ensure popup is fully rendered + setTimeout(injectButtons, 100); + } + + /** + * Handle auto-resolve in the popup - resolve 100% matches and open Model Linker for remaining + */ + async handleAutoResolveInPopup(dialog, button) { + button.textContent = '⏳ Resolving...'; + button.disabled = true; + + // Close the popup first + const closeBtn = dialog.querySelector('button[class*="close"]') || + dialog.querySelector('svg')?.closest('button') || + Array.from(dialog.querySelectorAll('button')).find(b => + b.textContent === '×' || b.innerHTML.includes('×') || b.innerHTML.includes('close')); + + if (closeBtn) { + closeBtn.click(); + } + + // Small delay to let popup close + await new Promise(r => setTimeout(r, 200)); + +// Create dialog if needed + if (!this.dialog) { + this.dialog = new LinkerManagerDialog(); + window.modelLinkerDialog = this.dialog; + } + + // Run auto-resolve for 100% matches - returns the updated workflow + const updatedWorkflow = await this.dialog.autoResolve100Percent(); + + // Always open Model Linker to show remaining unresolved models + // Pass the updated workflow if available to avoid race condition + this.dialog.show(updatedWorkflow || null); + } + + /** + * Mark resolved model items in the popup as linked (green) and hide download buttons + */ + removeResolvedFromPopup(dialog, resolvedFilenames) { + console.log('Model Linker: Looking for resolved filenames:', resolvedFilenames); + + // Strategy: For each filename, find text nodes containing it, + // then find the nearest Download button and mark that row + for (const filename of resolvedFilenames) { + // Get all text in the dialog and find elements containing our filename + const walker = document.createTreeWalker( + dialog, + NodeFilter.SHOW_TEXT, + null, + false + ); + + let node; + while (node = walker.nextNode()) { + if (node.textContent?.toLowerCase().includes(filename)) { + // Found text containing filename - now find parent with Download button + let parent = node.parentElement; + let attempts = 0; + + while (parent && parent !== dialog && attempts < 10) { + // Look for Download button at this level + const downloadBtn = Array.from(parent.querySelectorAll('button')) + .find(btn => btn.textContent?.includes('Download') && + !btn.id?.includes('model-linker')); + + if (downloadBtn) { + console.log('Model Linker: Found entry for', filename); + this.markEntryAsResolved(parent, downloadBtn); + break; + } + + parent = parent.parentElement; + attempts++; + } + + // Only process first match for this filename + break; + } + } + } + } + + /** + * Mark a model entry as resolved with visual feedback + */ + markEntryAsResolved(container, downloadBtn) { + // Already marked? + if (container.dataset.resolved === 'true') return; + container.dataset.resolved = 'true'; + + console.log('Model Linker: Marking entry as resolved', container); + + // Add green background/styling to the container + container.classList.add('ml-resolved-entry'); + + // Hide the Download button and replace with badge + if (downloadBtn) { + // Create badge + const badge = document.createElement('span'); + badge.className = 'ml-resolved-badge'; + badge.textContent = '✓ Linked'; + + // Replace download button with badge + downloadBtn.style.display = 'none'; + downloadBtn.parentElement?.insertBefore(badge, downloadBtn); + } + + // Find and hide Copy URL button + const allBtns = container.querySelectorAll('button'); + for (const btn of allBtns) { + if (btn.textContent?.includes('Copy URL')) { + btn.style.display = 'none'; + } + } + } + + /** + * Count remaining model items in the popup + */ + countRemainingItems(dialog) { + // Count elements that look like model entries (have Download buttons) + const downloadButtons = dialog.querySelectorAll('button'); + let count = 0; + for (const btn of downloadButtons) { + if (btn.textContent?.includes('Download') && !btn.id?.includes('model-linker')) { + count++; + } + } + return count; + } + + /** + * Update nodes directly in the graph without triggering a full workflow reload + * This prevents the Missing Models popup from closing + */ + updateNodesDirectly(resolutions) { + if (!app?.graph) { + console.warn('Model Linker: Cannot update nodes - graph not available'); + return; + } + + for (const resolution of resolutions) { + const nodeId = resolution.node_id; + const widgetIndex = resolution.widget_index; + const resolvedPath = resolution.resolved_path; + + // Find the node in the graph + const node = app.graph.getNodeById(nodeId); + if (!node) { + console.warn(`Model Linker: Node ${nodeId} not found in graph`); + continue; + } + + // Update the widget value + if (node.widgets && node.widgets[widgetIndex]) { + const widget = node.widgets[widgetIndex]; + widget.value = resolvedPath; + + // Trigger widget callback if it exists + if (widget.callback) { + widget.callback(resolvedPath, app.graph, node, null, null); + } + + console.log(`Model Linker: Updated node ${nodeId} widget ${widgetIndex} to ${resolvedPath}`); + } else if (node.widgets_values) { + // Fallback: update widgets_values array directly + node.widgets_values[widgetIndex] = resolvedPath; + console.log(`Model Linker: Updated node ${nodeId} widgets_values[${widgetIndex}] to ${resolvedPath}`); + } + + // Mark node as dirty to trigger redraw + if (node.setDirtyCanvas) { + node.setDirtyCanvas(true, true); + } + } + + // Trigger canvas redraw + if (app.graph.setDirtyCanvas) { + app.graph.setDirtyCanvas(true, true); + } + } + + /** + * Check if auto-open is enabled in user settings + */ + isAutoOpenEnabled() { + return localStorage.getItem('modelLinker.autoOpenOnMissing') !== 'false'; + } + + /** + * Set auto-open preference + */ + setAutoOpenEnabled(enabled) { + localStorage.setItem('modelLinker.autoOpenOnMissing', enabled ? 'true' : 'false'); + } + + /** + * Check for missing models and auto-open dialog if any are found + */ + async checkAndOpenForMissingModels() { + // Check if auto-open is enabled + if (!this.isAutoOpenEnabled()) { + return; + } + + // Prevent multiple simultaneous checks + if (this.isCheckingMissing) { + return; + } + + this.isCheckingMissing = true; + + try { + // Small delay to let workflow fully load + await new Promise(r => setTimeout(r, 500)); + + // Get current workflow + const workflow = app?.graph?.serialize(); + if (!workflow) { + return; + } + + // Create a simple hash to detect if workflow changed + const workflowHash = JSON.stringify(workflow.nodes?.map(n => n.type + ':' + JSON.stringify(n.widgets_values || []))); + + // Skip if we already checked this exact workflow + if (this.lastCheckedWorkflow === workflowHash) { + return; + } + this.lastCheckedWorkflow = workflowHash; + + // Call analyze endpoint to check for missing models + const response = await api.fetchApi('/model_linker/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workflow }) + }); + + if (!response.ok) { + console.warn('Model Linker: Failed to analyze workflow for missing models'); + return; + } + + const data = await response.json(); + + // Auto-open dialog if there are missing models + if (data.total_missing > 0) { + console.log(`Model Linker: Found ${data.total_missing} missing model(s), opening dialog...`); + this.openLinkerManager(); + } + + } catch (error) { + console.error('Model Linker: Error checking for missing models:', error); + } finally { + this.isCheckingMissing = false; + } + } + + removeExistingButton() { + // Remove any existing button by ID + const existingButton = document.getElementById(this.buttonId); + if (existingButton) { + existingButton.remove(); + } + + document.querySelectorAll('.comfyui-button-group button.comfyui-button').forEach((button) => { + const label = button.querySelector('span')?.textContent?.trim() || button.textContent?.trim(); + if (label === 'Model Linker') { + button.closest('.comfyui-button-group')?.remove(); + } + }); + + // Remove button group if it exists + if (this.buttonGroup?.element?.parentNode) { + this.buttonGroup.element.remove(); + this.buttonGroup = null; + } + + // Also remove the stored reference if it exists + if (this.linkerButton && this.linkerButton.parentNode) { + this.linkerButton.remove(); + this.linkerButton = null; + } + } + + createFloatingButton() { + // Create a floating button as fallback for legacy ComfyUI versions + this.linkerButton = $el("button", { + id: this.buttonId, + textContent: "🔗 Model Linker", + onclick: () => { + this.openLinkerManager(); + }, + className: "model-linker-floating-button" + }); + + document.body.appendChild(this.linkerButton); + this.dialog?.setTooltip(this.linkerButton, "Open Model Linker to find or download missing workflow models. Shortcut: Ctrl+Shift+L."); + } + + async openLinkerManager(options = {}) { + try { + if (!this.dialog) { + this.dialog = new LinkerManagerDialog(); + window.modelLinkerDialog = this.dialog; + } + if (options.dockContainer && !options.forceFloating) { + await this.dialog.showDocked(options.dockContainer, options.workflow || null); + return; + } + + const wasDocked = this.dialog.docked; + const showPromise = this.dialog.show(options.workflow || null); + + if (options.closeSidebarContainer && !wasDocked) { + this.dialog.closeComfySidebar(options.closeSidebarContainer); + } + + await showPromise; + } catch (error) { + console.error("🔗 Model Linker: Error creating/showing dialog:", error); + alert("Error opening Model Linker: " + error.message); + } + } + + static switchFilter(filter) { + document.querySelectorAll('.ml-btn-filter').forEach(b => b.classList.remove('active')); + document.getElementById('filter-' + filter).classList.add('active'); + + document.querySelectorAll('.ml-model-section').forEach(s => { + const hasActive = s.dataset.mlActive === 'true'; + const hasInactive = s.dataset.mlInactive === 'true'; + + if (filter === 'all') { + s.style.display = 'block'; + } else if (filter === 'active') { + s.style.display = hasActive ? 'block' : 'none'; + } else if (filter === 'inactive') { + s.style.display = hasInactive ? 'block' : 'none'; + } + }); + + const copySection = document.querySelector('[id^="ml-copy-"]'); + if (copySection) { + const codeEl = copySection.querySelector('code'); + const labelEl = copySection.querySelector('div'); + + if (filter === 'all') { + codeEl.textContent = copySection.dataset.mlAll; + labelEl.textContent = 'Copy all:'; + } else if (filter === 'active') { + codeEl.textContent = copySection.dataset.mlActive; + labelEl.textContent = 'Copy active:'; + } else if (filter === 'inactive') { + codeEl.textContent = copySection.dataset.mlInactive; + labelEl.textContent = 'Copy inactive:'; + } + } + } + + static copyToClipboard(text, btn) { + navigator.clipboard.writeText(text).then(() => { + const orig = btn.textContent; + btn.textContent = '✓'; + setTimeout(() => btn.textContent = orig, 1500); + }); + } + + static copyFromCode(sectionId, btn) { + const section = document.getElementById(sectionId); + const codeEl = section.querySelector('code'); + navigator.clipboard.writeText(codeEl.textContent).then(() => { + const orig = btn.textContent; + btn.textContent = '✓ Copied!'; + setTimeout(() => btn.textContent = orig, 1500); + }); + } +} diff --git a/web/linker/options_methods.js b/web/linker/options_methods.js new file mode 100644 index 0000000..a32eabd --- /dev/null +++ b/web/linker/options_methods.js @@ -0,0 +1,752 @@ +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; +import { $el } from "../../../../scripts/ui.js"; +import { getSvgIcon } from "../utils/icon_utils.js"; +export const optionsMethods = { + displayOptions() { + if (!this.contentElement) return; + this.contentElement.style.overflowY = 'hidden'; + + const tokens = this.getStoredTokens(); + const sourceDefaultsRows = this.getSearchSourceDefinitions() + .filter(def => this.isSourceAvailable(def.source)) + .map(def => { + const iconName = this.getSearchSourceIconName(def.source); + const checked = tokens.search_source_enabled?.[def.source] !== false; + const inputId = `ml-options-source-${def.source.replace(/_/g, '-')}`; + return ` + + `; + }) + .join(''); + this.contentElement.innerHTML = ` +
+
+ +
+
+
+

Download Sources

+
+
+
+
+ ${sourceDefaultsRows} +
+
+
+
+ +
+
+

CivitAI

+
+
+
+
+ +
+
+
+ + + +
+
+ + Not checked +
+
+
+
+ + + +
+
+ How to find the session token +
    +
  1. Sign in on civitai.com in your browser.
  2. +
  3. Open DevTools, then Application or Storage.
  4. +
  5. Open Cookies for civitai.com and copy __Secure-civitai-token.
  6. +
+

Treat it like a password. It is only needed when session-aware CivitAI search is required.

+
+
+ + Not checked +
+
+
+ +
+
+
+ CivitAI Models To Inspect ? +
+ +
+
+
+
+
+
+
+
+

HuggingFace

+
+
+
+
+ +
+
+
+ + + +
+
+ + Not checked +
+
+
+ +
+
+
+ Comfy-Org files + Loading... +
+
+ Last refresh + Loading... +
+
+ Status + Checking local index... +
+
+
Comfy-Org fallback uses a cached HuggingFace file index.
+
+ +
+
+ +
+
+
+ + + +
+
+ + Not checked +
+
+
+
+
+
+
+
+
+
+ `; + + 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 civitaiKeyCheckBtn = this.contentElement.querySelector('#ml-options-civitai-key-check'); + const civitaiKeyCheckStatus = this.contentElement.querySelector('#ml-options-civitai-key-check-status'); + const civitaiSessionCheckBtn = this.contentElement.querySelector('#ml-options-civitai-session-check'); + const civitaiSessionCheckStatus = this.contentElement.querySelector('#ml-options-civitai-session-check-status'); + 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 hfTokenCheckBtn = this.contentElement.querySelector('#ml-options-hf-token-check'); + const hfTokenCheckStatus = this.contentElement.querySelector('#ml-options-hf-token-check-status'); + const braveKeyCheckBtn = this.contentElement.querySelector('#ml-options-brave-key-check'); + const braveKeyCheckStatus = this.contentElement.querySelector('#ml-options-brave-key-check-status'); + const sourceEnabledInputs = Array.from(this.contentElement.querySelectorAll('.ml-options-source-enabled')); + const status = this.contentElement.querySelector('#ml-options-status'); + const saveBtn = this.contentElement.querySelector('#ml-options-save'); + const modelListCountEl = this.contentElement.querySelector('#ml-options-model-list-count'); + const modelListUpdatedEl = this.contentElement.querySelector('#ml-options-model-list-updated'); + const modelListStateEl = this.contentElement.querySelector('#ml-options-model-list-state'); + const modelListMessageEl = this.contentElement.querySelector('#ml-options-model-list-message'); + const modelListCheckBtn = this.contentElement.querySelector('#ml-options-model-list-check'); + const modelListUpdateBtn = this.contentElement.querySelector('#ml-options-model-list-update'); + const hfIndexCountEl = this.contentElement.querySelector('#ml-options-hf-index-count'); + const hfIndexUpdatedEl = this.contentElement.querySelector('#ml-options-hf-index-updated'); + const hfIndexStateEl = this.contentElement.querySelector('#ml-options-hf-index-state'); + const hfIndexMessageEl = this.contentElement.querySelector('#ml-options-hf-index-message'); + const hfIndexRefreshBtn = this.contentElement.querySelector('#ml-options-hf-index-refresh'); + const navButtons = Array.from(this.contentElement.querySelectorAll('.ml-options-nav-btn')); + const optionSections = Array.from(this.contentElement.querySelectorAll('.ml-options-section')); + const trackedInputs = [ + civitaiInput, + civitaiSessionInput, + hfInput, + braveInput, + civitaiLimitInput, + civitaiUseTrpcSearchInput, + civitaiUseHtmlFallbackInput, + hfUseApiSearchInput, + hfUseComfyOrgFallbackInput, + hfUseBraveFallbackInput, + ...sourceEnabledInputs, + ].filter(Boolean); + + const setStatus = (text, mode = '') => { + if (!status) return; + status.textContent = text; + status.classList.remove('is-dirty', 'is-saved'); + if (mode) status.classList.add(mode); + }; + + const formatOptionDate = (value) => { + if (!value) return 'Bundled'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); + }; + + const shortSha = (value) => value ? String(value).slice(0, 8) : ''; + + const setModelListBusy = (busy) => { + if (modelListCheckBtn) modelListCheckBtn.disabled = busy; + if (modelListUpdateBtn) modelListUpdateBtn.disabled = busy; + }; + + const setHfIndexBusy = (busy) => { + if (hfIndexRefreshBtn) hfIndexRefreshBtn.disabled = busy; + }; + + const setTokenCheckStatus = (statusEl, text, mode = '') => { + if (!statusEl) return; + statusEl.textContent = text; + statusEl.classList.remove('is-valid', 'is-invalid', 'is-pending'); + if (mode) statusEl.classList.add(mode); + }; + + const checkCredential = async ({ input, button, statusEl, endpoint, payloadKey, missingText }) => { + const value = (input?.value || '').trim(); + if (!value) { + setTokenCheckStatus(statusEl, missingText, 'is-invalid'); + return; + } + + try { + if (button) button.disabled = true; + setTokenCheckStatus(statusEl, 'Checking...', 'is-pending'); + const response = await api.fetchApi(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [payloadKey]: value }) + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || `Check failed: ${response.status}`); + } + setTokenCheckStatus( + statusEl, + data.message || (data.valid ? 'Valid' : 'Invalid'), + data.valid ? 'is-valid' : 'is-invalid' + ); + } catch (error) { + console.error('Model Linker: Credential check error:', error); + setTokenCheckStatus(statusEl, error.message || 'Check failed', 'is-invalid'); + } finally { + if (button) button.disabled = false; + } + }; + + const renderModelListStatus = (data = {}, checkedRemote = false) => { + if (modelListCountEl) { + modelListCountEl.textContent = Number.isFinite(Number(data.local_count)) + ? Number(data.local_count).toLocaleString() + : '-'; + } + if (modelListUpdatedEl) { + modelListUpdatedEl.textContent = formatOptionDate(data.local_updated_at); + } + if (modelListStateEl) { + if (checkedRemote && data.can_compare && data.update_available) { + modelListStateEl.textContent = 'Update available'; + } else if (checkedRemote && data.can_compare) { + modelListStateEl.textContent = 'Up to date'; + } else if (checkedRemote && !data.can_compare) { + modelListStateEl.textContent = 'Remote checked'; + } else { + modelListStateEl.textContent = data.local_sha ? 'Tracked' : 'Bundled'; + } + } + if (modelListMessageEl) { + const localSha = shortSha(data.local_sha); + const remoteSha = shortSha(data.remote_sha); + if (checkedRemote && data.update_available) { + modelListMessageEl.textContent = `A newer ComfyUI-Manager model-list.json is available (${localSha || 'local'} -> ${remoteSha}).`; + } else if (checkedRemote && data.can_compare) { + modelListMessageEl.textContent = `Local Database is current (${localSha}).`; + } else if (checkedRemote) { + modelListMessageEl.textContent = `Remote file was checked. Local SHA is not recorded yet; run update once to track future changes.`; + } else { + modelListMessageEl.textContent = `Local Database uses ComfyUI-Manager model-list.json.`; + } + } + }; + + const loadModelListStatus = async (checkRemote = false) => { + try { + setModelListBusy(true); + if (modelListStateEl) { + modelListStateEl.textContent = checkRemote ? 'Checking GitHub...' : 'Loading...'; + } + const response = await api.fetchApi(`/model_linker/model-list/status${checkRemote ? '?check_remote=1' : ''}`); + if (!response.ok) { + throw new Error(`Status failed: ${response.status}`); + } + const data = await response.json(); + renderModelListStatus(data, checkRemote); + return data; + } catch (error) { + console.error('Model Linker: model-list status error:', error); + if (modelListStateEl) modelListStateEl.textContent = 'Error'; + if (modelListMessageEl) modelListMessageEl.textContent = error.message || 'Failed to read Local Database status.'; + return null; + } finally { + setModelListBusy(false); + } + }; + + const updateModelList = async () => { + try { + setModelListBusy(true); + if (modelListStateEl) modelListStateEl.textContent = 'Updating...'; + if (modelListMessageEl) modelListMessageEl.textContent = 'Downloading latest ComfyUI-Manager model-list.json...'; + const response = await api.fetchApi('/model_linker/model-list/update', { + method: 'POST' + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || `Update failed: ${response.status}`); + } + await this.clearSearchCaches(); + renderModelListStatus(data, false); + if (modelListStateEl) modelListStateEl.textContent = 'Updated'; + if (modelListMessageEl) modelListMessageEl.textContent = `Local Database updated. ${Number(data.local_count || 0).toLocaleString()} models loaded.`; + this.showNotification('Local Database updated', 'success'); + } catch (error) { + console.error('Model Linker: model-list update error:', error); + if (modelListStateEl) modelListStateEl.textContent = 'Error'; + if (modelListMessageEl) modelListMessageEl.textContent = error.message || 'Failed to update Local Database.'; + this.showNotification('Local Database update failed', 'error'); + } finally { + setModelListBusy(false); + } + }; + + const renderHfIndexStatus = (data = {}) => { + if (hfIndexCountEl) { + hfIndexCountEl.textContent = Number.isFinite(Number(data.file_count)) + ? Number(data.file_count).toLocaleString() + : '-'; + } + if (hfIndexUpdatedEl) { + hfIndexUpdatedEl.textContent = data.updated_at + ? formatOptionDate(Number(data.updated_at) * 1000) + : 'Never'; + } + if (hfIndexStateEl) { + if (!data.exists) { + hfIndexStateEl.textContent = 'Not cached'; + } else if (data.stale) { + hfIndexStateEl.textContent = 'Refresh due'; + } else { + hfIndexStateEl.textContent = 'Cached'; + } + } + if (hfIndexMessageEl) { + if (!data.exists) { + hfIndexMessageEl.textContent = 'The first Comfy-Org fallback search will build the local HuggingFace file index.'; + } else if (data.stale) { + hfIndexMessageEl.textContent = `Index has ${Number(data.repo_count || 0).toLocaleString()} repos and should be refreshed.`; + } else { + hfIndexMessageEl.textContent = `Index has ${Number(data.repo_count || 0).toLocaleString()} repos and is used before per-repo fallback checks.`; + } + } + }; + + const loadHfIndexStatus = async () => { + try { + setHfIndexBusy(true); + if (hfIndexStateEl) hfIndexStateEl.textContent = 'Loading...'; + const response = await api.fetchApi('/model_linker/huggingface/author-index/status'); + if (!response.ok) { + throw new Error(`Status failed: ${response.status}`); + } + const data = await response.json(); + renderHfIndexStatus(data); + return data; + } catch (error) { + console.error('Model Linker: HuggingFace index status error:', error); + if (hfIndexStateEl) hfIndexStateEl.textContent = 'Error'; + if (hfIndexMessageEl) hfIndexMessageEl.textContent = error.message || 'Failed to read HuggingFace index status.'; + return null; + } finally { + setHfIndexBusy(false); + } + }; + + const refreshHfIndex = async () => { + try { + setHfIndexBusy(true); + if (hfIndexStateEl) hfIndexStateEl.textContent = 'Refreshing...'; + if (hfIndexMessageEl) hfIndexMessageEl.textContent = 'Downloading Comfy-Org file index from HuggingFace...'; + const response = await api.fetchApi('/model_linker/huggingface/author-index/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hf_token: hfInput?.value || '' }) + }); + const data = await response.json(); + if (!response.ok || data.success === false) { + throw new Error(data.error || `Refresh failed: ${response.status}`); + } + await this.clearSearchCaches(); + renderHfIndexStatus(data); + if (hfIndexStateEl) hfIndexStateEl.textContent = 'Refreshed'; + this.showNotification('HuggingFace Comfy-Org index refreshed', 'success'); + } catch (error) { + console.error('Model Linker: HuggingFace index refresh error:', error); + if (hfIndexStateEl) hfIndexStateEl.textContent = 'Error'; + if (hfIndexMessageEl) hfIndexMessageEl.textContent = error.message || 'Failed to refresh HuggingFace index.'; + this.showNotification('HuggingFace index refresh failed', 'error'); + } finally { + setHfIndexBusy(false); + } + }; + + const setActiveNav = (targetId) => { + navButtons.forEach((btn) => { + btn.classList.toggle('is-active', btn.dataset.target === targetId); + }); + }; + + const setVisibleSection = (targetId) => { + optionSections.forEach((section) => { + section.classList.toggle('is-hidden', section.id !== targetId); + }); + setActiveNav(targetId); + }; + + const getVisibilityIcon = (visible) => visible + ? getSvgIcon('eye') + : getSvgIcon('eyeOff'); + + const syncVisibilityToggle = (input, button) => { + if (!input || !button) return; + const visible = input.type === 'text'; + button.innerHTML = getVisibilityIcon(visible); + button.classList.toggle('ml-is-active-text', visible); + button.classList.toggle('ml-is-muted', !visible); + button.setAttribute('aria-pressed', visible ? 'true' : 'false'); + this.setTooltip(button, visible ? 'Hide saved value' : 'Show saved value'); + }; + + const bindVisibilityToggle = (input, button) => { + if (!input || !button) return; + syncVisibilityToggle(input, button); + button.addEventListener('click', () => { + input.type = input.type === 'password' ? 'text' : 'password'; + syncVisibilityToggle(input, button); + }); + }; + + bindVisibilityToggle(civitaiInput, civitaiToggle); + bindVisibilityToggle(civitaiSessionInput, civitaiSessionToggle); + bindVisibilityToggle(hfInput, hfToggle); + bindVisibilityToggle(braveInput, braveToggle); + setStatus('Saved only on this machine.'); + setVisibleSection('ml-options-section-sources'); + + trackedInputs.forEach((input) => { + const eventName = input.type === 'checkbox' ? 'change' : 'input'; + input.addEventListener(eventName, () => { + setStatus('You have unsaved changes.', 'is-dirty'); + }); + }); + + this.bindTooltips(this.contentElement); + + navButtons.forEach((btn) => { + btn.addEventListener('click', () => { + const targetId = btn.dataset.target; + if (!targetId) return; + setVisibleSection(targetId); + if (targetId === 'ml-options-section-local-db') { + loadModelListStatus(false); + } else if (targetId === 'ml-options-section-hf') { + loadHfIndexStatus(); + } + }); + }); + + if (modelListCheckBtn) { + modelListCheckBtn.addEventListener('click', () => { + loadModelListStatus(true); + }); + } + + if (modelListUpdateBtn) { + modelListUpdateBtn.addEventListener('click', () => { + updateModelList(); + }); + } + + if (hfIndexRefreshBtn) { + hfIndexRefreshBtn.addEventListener('click', () => { + refreshHfIndex(); + }); + } + + if (civitaiSessionCheckBtn) { + civitaiSessionCheckBtn.addEventListener('click', () => { + checkCredential({ + input: civitaiSessionInput, + button: civitaiSessionCheckBtn, + statusEl: civitaiSessionCheckStatus, + endpoint: '/model_linker/civitai/session-token/check', + payloadKey: 'civitai_session_token', + missingText: 'Paste token first' + }); + }); + } + + if (civitaiKeyCheckBtn) { + civitaiKeyCheckBtn.addEventListener('click', () => { + checkCredential({ + input: civitaiInput, + button: civitaiKeyCheckBtn, + statusEl: civitaiKeyCheckStatus, + endpoint: '/model_linker/civitai/api-key/check', + payloadKey: 'civitai_key', + missingText: 'Paste API key first' + }); + }); + } + + if (hfTokenCheckBtn) { + hfTokenCheckBtn.addEventListener('click', () => { + checkCredential({ + input: hfInput, + button: hfTokenCheckBtn, + statusEl: hfTokenCheckStatus, + endpoint: '/model_linker/huggingface/token/check', + payloadKey: 'hf_token', + missingText: 'Paste token first' + }); + }); + } + + if (braveKeyCheckBtn) { + braveKeyCheckBtn.addEventListener('click', () => { + checkCredential({ + input: braveInput, + button: braveKeyCheckBtn, + statusEl: braveKeyCheckStatus, + endpoint: '/model_linker/brave/api-key/check', + payloadKey: 'brave_search_api_key', + missingText: 'Paste API key first' + }); + }); + } + + loadModelListStatus(false); + loadHfIndexStatus(); + + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + const civitaiCandidateLimitRaw = parseInt(civitaiLimitInput?.value || `${tokens.civitai_candidate_limit}`, 10); + const civitaiCandidateLimit = Number.isFinite(civitaiCandidateLimitRaw) + ? Math.min(20, Math.max(1, civitaiCandidateLimitRaw)) + : 5; + localStorage.setItem('modelLinker.civitaiApiKey', civitaiInput?.value || ''); + localStorage.setItem('modelLinker.civitaiSessionToken', civitaiSessionInput?.value || ''); + localStorage.setItem('modelLinker.civitaiUseTrpcSearch', civitaiUseTrpcSearchInput?.checked ? 'true' : 'false'); + localStorage.setItem('modelLinker.civitaiUseHtmlFallback', civitaiUseHtmlFallbackInput?.checked ? 'true' : 'false'); + localStorage.setItem('modelLinker.huggingFaceToken', hfInput?.value || ''); + localStorage.setItem('modelLinker.braveSearchApiKey', braveInput?.value || ''); + localStorage.setItem('modelLinker.hfUseApiSearch', hfUseApiSearchInput?.checked ? 'true' : 'false'); + localStorage.setItem('modelLinker.hfUseComfyOrgFallback', hfUseComfyOrgFallbackInput?.checked ? 'true' : 'false'); + localStorage.setItem('modelLinker.hfUseBraveFallback', hfUseBraveFallbackInput?.checked ? 'true' : 'false'); + localStorage.setItem('modelLinker.civitaiCandidateLimit', `${civitaiCandidateLimit}`); + if (sourceEnabledInputs.length && !sourceEnabledInputs.some(input => input.checked)) { + const localInput = sourceEnabledInputs.find(input => input.dataset.source === 'local'); + if (localInput) { + localInput.checked = true; + } + } + sourceEnabledInputs.forEach((input) => { + const key = input.dataset.storageKey; + if (key) { + localStorage.setItem(key, input.checked ? 'true' : 'false'); + } + }); + if (civitaiLimitInput) { + civitaiLimitInput.value = `${civitaiCandidateLimit}`; + } + await this.clearSearchCaches(); + setStatus('Options saved locally.', 'is-saved'); + this.showNotification('Options saved and search cache cleared', 'success'); + }); + } + + } +}; diff --git a/web/linker/queue_methods.js b/web/linker/queue_methods.js new file mode 100644 index 0000000..1b1c3ea --- /dev/null +++ b/web/linker/queue_methods.js @@ -0,0 +1,829 @@ +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; +import { $el } from "../../../../scripts/ui.js"; +import { getSvgIcon } from "../utils/icon_utils.js"; +export const queueMethods = { + createContent() { + // Wrap the body in a two-column layout: left = items, right = queued panel + const body = $el("div", { + id: "model-linker-body" + }); + + this.contentElement = $el("div.ml-scrollable", { + id: "model-linker-content" + }); + + this.queueElement = $el("div", { + id: "model-linker-queue" + }, [ + this.createQueuePanel() + ]); + + // Splitter between content and queue + this.splitterElement = $el("div", { + id: "model-linker-splitter", + ondragstart: (e) => e.preventDefault() + }); + + body.appendChild(this.contentElement); + body.appendChild(this.splitterElement); + body.appendChild(this.queueElement); + + // Restore saved queue width and wire splitter + try { + const savedSplit = localStorage.getItem('model_linker_split_w'); + if (savedSplit) { + const w = parseInt(savedSplit, 10); + if (!isNaN(w) && w > 0) { + this.queueElement.style.width = `${w}px`; + } + } + } catch (e) { } + + try { + const onSplitMouseDown = (e) => this.startSplitDrag(e); + this.splitterElement.addEventListener('mousedown', onSplitMouseDown); + this._splitterMouseDown = onSplitMouseDown; + } catch (e) { } + + // Toggle icon always visible + try { + this.queueToggleIcon = $el("button", { + id: "queue-toggle-icon", + type: "button", + "aria-label": "Toggle queued selections", + onclick: (event) => this.onQueueEdgeClick(event), + onmousedown: (event) => this.startQueueEdgeDrag(event) + }); + body.appendChild(this.queueToggleIcon); + this.updateQueueToggleIcon(); + } catch (e) { } + + // Restore queue collapsed state + try { + const col = localStorage.getItem('model_linker_queue_collapsed'); + if (col === '1') this.setQueueCollapsed(true); + } catch (e) { } + + return body; + }, + + createQueuePanel() { + // Header row with title and clear button + this.queueHeader = $el("div.ml-queue-header", {}, [ + $el("div#queue-title.ml-queue-title", { textContent: "Queued Selections (0)" }), + $el("div.ml-queue-actions", {}, [ + $el("button", { + id: "queue-toggle", + className: "ml-btn ml-btn-secondary ml-btn-sm", + textContent: "Collapse", + onclick: () => this.toggleQueueCollapsed() + }), + $el("button", { + id: "queue-clear", + className: "ml-btn ml-btn-secondary ml-btn-sm", + textContent: "Clear All", + onclick: () => this.clearAllQueued() + }) + ]) + ]); + + // Scrollable list + this.queueList = $el("div#queue-list.ml-queue-list"); + + const panel = $el("div.ml-queue-panel", {}, [ + $el("div.ml-queue-stack", {}, [this.queueHeader, this.queueList]) + ]); + return panel; + }, + + updateQueuePanel() { + if (!this.queueList || !this.queueHeader) return; + const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : []; + // Update title count + const title = this.queueHeader.querySelector('#queue-title'); + if (title) title.textContent = `Queued Selections (${list.length})`; + const toggleBtn = this.queueHeader.querySelector('#queue-toggle'); + if (toggleBtn) toggleBtn.textContent = this.queueCollapsed ? 'Expand' : 'Collapse'; + + if (!list.length) { + this.queueList.innerHTML = '
No selections queued.
'; + return; + } + + let html = '
'; + for (let i = 0; i < list.length; i++) { + const r = list[i]; + const label = (r.resolved_model?.relative_path || r.resolved_model?.filename || r.resolved_path || '').toString(); + const nodeLabel = r.node_label || r.node_type || (r.subgraph_id ? 'Subgraph' : 'Node'); + const orig = (r.original_path || '').toString(); + const rmId = `queue-remove-${i}`; + html += `
`; + html += `
${this.escapeHtml(nodeLabel)} #${this.escapeHtml(String(r.node_id))}
`; + html += `
Original${this.escapeHtml(orig)}
`; + html += `
Selected${this.escapeHtml(label)}
`; + html += `
`; + html += `
`; + } + html += '
'; + this.queueList.innerHTML = html; + + // Wire remove buttons + for (let i = 0; i < list.length; i++) { + const rmId = `queue-remove-${i}`; + const btn = this.queueList.querySelector(`#${rmId}`); + if (btn) { + btn.addEventListener('click', () => this.removeQueuedByIndex(i)); + } + } + }, + + // Remove queued by index + removeQueuedByIndex(i) { + const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : []; + if (i < 0 || i >= list.length) return; + const r = list[i]; + // Remove + this.pendingResolutions.splice(i, 1); + this.rebuildPendingIndex(); + this.savePendingQueueForActiveWorkflow(); + // Update per-item selected bar + const m = { node_id: r.node_id, widget_index: r.widget_index, subgraph_id: r.subgraph_id, is_top_level: r.is_top_level }; + this.updateSelectedBarForMissing?.(m); + this.updateApplyPendingButton?.(); + this.updateQueuePanel(); + }, + + // Clear all queued selections + clearAllQueued() { + this.pendingResolutions = []; + this.pendingIndex = new Map(); + this.savePendingQueueForActiveWorkflow(); + this.updateApplyPendingButton?.(); + this.updateQueuePanel(); + try { + document.querySelectorAll('.model-linker-selected').forEach(el => { el.style.display = 'none'; el.innerHTML = ''; }); + } catch (e) { /* ignore */ } + }, + + // Update selected bar for a specific missing model slot + updateSelectedBarForMissing(missing) { + if (!missing) return; + const nodeId = missing.node_id; + const widgetIndex = missing.widget_index; + const subgraphId = missing.subgraph_id || ''; + const isTopLevel = missing.is_top_level !== false; + const key = `${nodeId}:${widgetIndex}:${subgraphId}:${isTopLevel ? 'T' : 'F'}`; + + const selectedBar = document.getElementById(`selected-bar-${nodeId}-${widgetIndex}`); + if (!selectedBar) return; + + // Find selection for this slot + let selection = null; + let selectionIdx = -1; + if (this.pendingIndex.has(key)) { + const idx = this.pendingIndex.get(key); + if (idx >= 0 && idx < this.pendingResolutions.length) { + selection = this.pendingResolutions[idx]; + selectionIdx = idx; + } + } + + if (!selection) { + selectedBar.style.display = 'none'; + selectedBar.innerHTML = ''; + return; + } + + // Build selected bar content + const label = selection.resolved_model?.relative_path || selection.resolved_model?.filename || selection.resolved_path || ''; + const resolveBtnId = `selected-remove-${nodeId}-${widgetIndex}`; + + selectedBar.innerHTML = `
`; + selectedBar.innerHTML += `✓ Selected:`; + selectedBar.innerHTML += `${label}`; + selectedBar.innerHTML += ``; + selectedBar.innerHTML += `
`; + selectedBar.style.display = 'block'; + + // Wire remove button - use key-based removal + const removeBtn = selectedBar.querySelector(`#${resolveBtnId}`); + if (removeBtn) { + removeBtn.addEventListener('click', () => { + this.removeQueuedByKey(key); + }); + } + }, + + // Remove queued by key (more reliable than index) + removeQueuedByKey(key) { + if (!key || !this.pendingIndex.has(key)) return; + + // Find and remove the item with this key + const idx = this.pendingIndex.get(key); + if (idx >= 0 && idx < this.pendingResolutions.length) { + const r = this.pendingResolutions[idx]; + // Update the selected bar before removing + const m = { node_id: r.node_id, widget_index: r.widget_index, subgraph_id: r.subgraph_id, is_top_level: r.is_top_level }; + + // Remove from array + this.pendingResolutions.splice(idx, 1); + this.rebuildPendingIndex(); + this.savePendingQueueForActiveWorkflow(); + this.updateSelectedBarForMissing(m); + this.updateApplyPendingButton?.(); + this.updateQueuePanel(); + } + }, + + // Rebuild pending index after modification + rebuildPendingIndex() { + this.pendingIndex = new Map(); + for (let i = 0; i < this.pendingResolutions.length; i++) { + const r = this.pendingResolutions[i]; + const key = `${r.node_id}:${r.widget_index}:${r.subgraph_id || ''}:${r.is_top_level ? 'T' : 'F'}`; + this.pendingIndex.set(key, i); + } + }, + + // Collapse/expand queue panel + toggleQueueCollapsed() { + this.setQueueCollapsed(!this.queueCollapsed); + }, + + setQueueCollapsed(collapsed) { + this.queueCollapsed = !!collapsed; + if (this.queueCollapsed) { + try { localStorage.setItem('model_linker_queue_collapsed', '1'); } catch (e) { } + } else { + try { localStorage.setItem('model_linker_queue_collapsed', '0'); } catch (e) { } + } + this.updateQueueVisibility(); + this.updateQueuePanel(); + }, + + updateQueueVisibility() { + const isMissingTab = this.activeTab === 'missing'; + const showQueuePanel = isMissingTab && !this.queueCollapsed; + + if (this.queueElement) { + this.queueElement.style.display = showQueuePanel ? '' : 'none'; + } + if (this.splitterElement) { + this.splitterElement.style.display = showQueuePanel ? '' : 'none'; + } + if (this.queueToggleIcon) { + this.queueToggleIcon.style.display = isMissingTab ? '' : 'none'; + } + + this.updateQueueToggleIcon(); + }, + + updateQueueToggleIcon() { + if (!this.queueToggleIcon) return; + this.queueToggleIcon.style.display = this.activeTab === 'missing' ? '' : 'none'; + this.queueToggleIcon.textContent = ''; + this.queueToggleIcon.removeAttribute('data-tooltip'); + this.queueToggleIcon.removeAttribute('title'); + if (this.queueCollapsed) { + this.queueToggleIcon.classList.add('is-collapsed'); + this.queueToggleIcon.setAttribute('aria-label', 'Show queued selections'); + } else { + this.queueToggleIcon.classList.remove('is-collapsed'); + this.queueToggleIcon.setAttribute('aria-label', 'Hide queued selections'); + } + }, + + onQueueEdgeClick(event) { + if (this._suppressQueueEdgeClick) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + this._suppressQueueEdgeClick = false; + return; + } + this.toggleQueueCollapsed(); + }, + + startQueueEdgeDrag(event) { + if (event.button !== 0 || !this.queueCollapsed || !this.queueElement) return; + + const startX = event.clientX; + const previousCursor = document.body.style.cursor; + event.preventDefault(); + document.body.style.cursor = 'col-resize'; + this._suppressQueueEdgeClick = true; + this.splitterElement.style.display = ''; + document.getElementById('model-linker-body')?.classList.add('ml-queue-preview-collapsed'); + this.queueElement.style.display = 'none'; + this.queueElement.style.width = '0px'; + this.startSplitDrag( + { clientX: startX, preventDefault: () => {} }, + { startWidth: 0, edgeOpen: true, previousCursor, startCollapsedPreview: true } + ); + }, + + // Begin split drag for resizable panels + startSplitDrag(e, { startWidth = null, edgeOpen = false, previousCursor = null, startCollapsedPreview = false } = {}) { + try { + e?.preventDefault?.(); + if (!this.queueElement) return; + const rect = this.queueElement.getBoundingClientRect(); + const body = document.getElementById('model-linker-body'); + const bodyRect = body ? body.getBoundingClientRect() : { width: window.innerWidth }; + this._splitDragging = true; + body?.classList.add('ml-is-resizing-queue'); + this._splitStart = { + x: e.clientX, + startWidth: Number.isFinite(startWidth) ? startWidth : rect.width, + containerWidth: bodyRect.width + }; + this._lastSplitDragApply = 0; + this._splitPreviewCollapsed = !!startCollapsedPreview; + this._splitEdgeOpen = !!edgeOpen; + this._splitEdgeOpenedPastThreshold = false; + this._splitPreviousCursor = previousCursor; + this._prevUserSelect = document.body.style.userSelect; + document.body.style.userSelect = 'none'; + this._onSplitMove = (ev) => this.onSplitDrag(ev); + this._onSplitUp = () => this.endSplitDrag(); + document.addEventListener('mousemove', this._onSplitMove); + document.addEventListener('mouseup', this._onSplitUp, { once: true }); + } catch (err) { /* ignore */ } + }, + + onSplitDrag(e) { + if (!this._splitDragging || !this._splitStart || !this.queueElement) return; + const dx = e.clientX - this._splitStart.x; + let newW = this._splitStart.startWidth - dx; + const minW = this._splitEdgeOpen ? 0 : 56; + const maxW = Math.max(minW, Math.floor(this._splitStart.containerWidth - 360)); + if (newW < minW) newW = minW; + if (newW > maxW) newW = maxW; + this._pendingSplitWidth = Math.round(newW); + if (this._splitEdgeOpen && this._pendingSplitWidth > 140) { + this._splitEdgeOpenedPastThreshold = true; + if (this.queueCollapsed) { + this.queueCollapsed = false; + try { localStorage.setItem('model_linker_queue_collapsed', '0'); } catch (error) { } + this.updateQueueToggleIcon(); + this.updateQueuePanel(); + } + } + const previewCollapsed = this._splitEdgeOpen + ? (!this._splitEdgeOpenedPastThreshold || this._pendingSplitWidth <= 140) + : this._pendingSplitWidth <= 140; + if (previewCollapsed !== this._splitPreviewCollapsed) { + this._splitPreviewCollapsed = previewCollapsed; + const body = document.getElementById('model-linker-body'); + body?.classList.toggle('ml-queue-preview-collapsed', previewCollapsed); + this.queueElement.style.display = previewCollapsed ? 'none' : ''; + } + const now = performance.now(); + if (this._lastSplitDragApply && now - this._lastSplitDragApply < 33) return; + this._lastSplitDragApply = now; + if (this._splitDragFrame) return; + this._splitDragFrame = requestAnimationFrame(() => { + this._splitDragFrame = null; + if (!this._splitDragging || !this.queueElement || !this._pendingSplitWidth || this._splitPreviewCollapsed) return; + this.queueElement.style.width = `${this._pendingSplitWidth}px`; + }); + }, + + endSplitDrag() { + if (!this._splitDragging) return; + this._splitDragging = false; + if (this._splitDragFrame) { + cancelAnimationFrame(this._splitDragFrame); + this._splitDragFrame = null; + } + if (this.queueElement && this._pendingSplitWidth && !this._splitPreviewCollapsed) { + this.queueElement.style.width = `${this._pendingSplitWidth}px`; + } + const body = document.getElementById('model-linker-body'); + body?.classList.remove('ml-is-resizing-queue'); + body?.classList.remove('ml-queue-preview-collapsed'); + document.removeEventListener('mousemove', this._onSplitMove); + if (this._splitEdgeOpen && !this._pendingSplitWidth) { + try { + const restoreWidth = Math.max(240, Math.round(this._splitStart?.startWidth || 320)); + this.queueElement.style.width = `${restoreWidth}px`; + this.queueElement.style.display = ''; + if (this.splitterElement) this.splitterElement.style.display = ''; + localStorage.setItem('model_linker_split_w', String(restoreWidth)); + } catch (e) { } + this.queueCollapsed = false; + try { localStorage.setItem('model_linker_queue_collapsed', '0'); } catch (e) { } + this.updateQueueVisibility(); + this.updateQueuePanel(); + this._splitEdgeOpen = false; + this._pendingSplitWidth = null; + this._lastSplitDragApply = 0; + this._splitPreviewCollapsed = false; + try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { } + try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { } + this._splitPreviousCursor = null; + this._splitEdgeOpenedPastThreshold = false; + setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0); + return; + } + const shouldCollapse = !!this._splitPreviewCollapsed; + if (shouldCollapse) { + try { + const restoreWidth = Math.max(240, Math.round(this._splitStart?.startWidth || 320)); + this.queueElement.style.width = `${restoreWidth}px`; + this.queueElement.style.display = ''; + localStorage.setItem('model_linker_split_w', String(restoreWidth)); + } catch (e) { } + this._pendingSplitWidth = null; + this._lastSplitDragApply = 0; + this._splitPreviewCollapsed = false; + this._splitEdgeOpen = false; + this._splitEdgeOpenedPastThreshold = false; + try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { } + try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { } + this._splitPreviousCursor = null; + this.setQueueCollapsed(true); + setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0); + return; + } + if (this.queueElement) this.queueElement.style.display = ''; + try { + const rect = this.queueElement.getBoundingClientRect(); + localStorage.setItem('model_linker_split_w', String(Math.round(rect.width))); + } catch (e) { } + this._pendingSplitWidth = null; + this._lastSplitDragApply = 0; + this._splitPreviewCollapsed = false; + this._splitEdgeOpen = false; + this._splitEdgeOpenedPastThreshold = false; + try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { } + if (this._splitPreviousCursor !== null) { + try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { } + this._splitPreviousCursor = null; + } + setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0); + }, + + /** + * Queue a resolution for later batch apply + */ + queueResolution(missing, resolvedModel) { + if (!resolvedModel) { + this.showNotification('No model selected', 'error'); + return; + } + + const resolution = { + node_id: missing.node_id, + widget_index: missing.widget_index, + resolved_path: resolvedModel.path, + category: missing.category, + resolved_model: resolvedModel, + original_path: missing.original_path, + subgraph_id: missing.subgraph_id, + is_top_level: missing.is_top_level, + node_type: missing.node_type, + node_label: missing.subgraph_name || missing.node_type + }; + + const key = `${resolution.node_id}:${resolution.widget_index}:${resolution.subgraph_id || ''}:${resolution.is_top_level ? 'T' : 'F'}`; + if (this.pendingIndex.has(key)) { + // replace existing selection for this slot + const idx = this.pendingIndex.get(key); + this.pendingResolutions[idx] = resolution; + } else { + this.pendingIndex.set(key, this.pendingResolutions.length); + this.pendingResolutions.push(resolution); + } + this.savePendingQueueForActiveWorkflow(); + + // Update selected bar UI + this.updateSelectedBarForMissing?.(missing); + this.updateQueuePanel(); + this.updateApplyPendingButton(); + }, + + /** + * Apply all pending resolutions in batch + */ + async applyPendingResolutions() { + const list = this.pendingResolutions || []; + if (!list.length) { + this.showNotification('No selections queued', 'error'); + return; + } + + try { + const workflow = this.getCurrentWorkflow(); + if (!workflow) { + this.showNotification('No workflow loaded', 'error'); + return; + } + + const response = await api.fetchApi('/model_linker/resolve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workflow, resolutions: list }) + }); + + if (!response.ok) throw new Error(`API error: ${response.status}`); + + const data = await response.json(); + if (data.success) { + await this.updateWorkflowInComfyUI(data.workflow); + this.showNotification(`✓ Linked ${list.length} selection${list.length>1?'s':''}`, 'success'); + // Clear queue and refresh analysis + this.pendingResolutions = []; + this.pendingIndex = new Map(); + this.savePendingQueueForActiveWorkflow(); + this.updateApplyPendingButton(); + this.updateQueuePanel(); + await this.loadWorkflowData(data.workflow); + } else { + this.showNotification('Failed to apply selections: ' + (data.error || 'Unknown error'), 'error'); + } + } catch (e) { + console.error('Model Linker: applyPendingResolutions error', e); + this.showNotification('Error applying selections: ' + e.message, 'error'); + } + }, + + updateApplyPendingButton() { + if (!this.applyPendingBtn) return; + const count = this.pendingResolutions?.length || 0; + const isEmpty = count === 0; + const tooltip = count > 0 + ? `Apply ${count} queued model link${count > 1 ? 's' : ''} to the current workflow.` + : 'Apply the model links you selected from local matches or search results.'; + this.applyPendingBtn.textContent = `Apply Selected (${count})`; + this.setTooltip(this.applyPendingBtn, tooltip); + this.applyPendingBtn.setAttribute('aria-disabled', String(isEmpty)); + this.applyPendingBtn.classList.toggle('ml-btn-is-disabled', isEmpty); + this.updateBatchFooterButtons(); + }, + + updateBatchFooterButtons() { + const missingModels = this.missingModels || []; + const selectedCount = this.getSelectedMissingModels().length; + const totalCount = missingModels.length; + const pendingCount = this.pendingResolutions?.length || 0; + const activeCount = Object.keys(this.activeDownloads || {}).length; + const downloadableSelected = this.getMissingWithDownloadSources(this.getSelectedMissingModels()).length; + const downloadableAll = this.getMissingWithDownloadSources(missingModels).length; + + if (this.selectMenuButton) { + this.selectMenuButton.textContent = `Select (${selectedCount}/${totalCount})`; + this.selectMenuButton.classList.toggle('ml-btn-is-disabled', totalCount === 0); + } + if (this.searchMenuButton) { + this.searchMenuButton.textContent = this.batchSearchRunning ? 'Searching...' : 'Search'; + this.searchMenuButton.classList.toggle('ml-btn-is-disabled', totalCount === 0 || this.batchSearchRunning); + } + if (this.downloadMenuButton) { + const label = activeCount > 0 ? `Cancel Downloads (${activeCount})` : 'Download'; + this.downloadMenuButton.textContent = label; + this.downloadMenuButton.classList.toggle('ml-btn-danger', activeCount > 0); + this.downloadMenuButton.classList.toggle('ml-btn-download', activeCount === 0); + this.downloadMenuButton.classList.toggle('ml-btn-is-disabled', activeCount === 0 && downloadableAll === 0); + this.setTooltip( + this.downloadMenuButton, + activeCount > 0 + ? `Cancel ${activeCount} active download${activeCount > 1 ? 's' : ''}.` + : `${downloadableSelected || downloadableAll} missing model${(downloadableSelected || downloadableAll) === 1 ? '' : 's'} currently have download sources.` + ); + } + if (this.applyPendingBtn) { + this.applyPendingBtn.textContent = `Apply Selected (${pendingCount})`; + } + }, + + createFooterMenu(name, label, items = [], buttonClass = 'ml-btn-secondary') { + const button = $el(`button.ml-btn.${buttonClass}.ml-footer-btn.ml-footer-menu-button`, { + type: 'button', + 'aria-haspopup': 'menu', + 'aria-expanded': 'false', + onclick: (event) => { + event.stopPropagation(); + if (button.classList.contains('ml-btn-is-disabled')) { + if (name === 'search' && this.batchSearchRunning) { + this.showNotification('Batch search is already running.', 'info'); + } else if (name === 'download') { + this.showNotification('No missing models have downloadable sources yet.', 'info'); + } else { + this.showNotification('No missing models available.', 'info'); + } + return; + } + this.toggleFooterMenu(name); + } + }, [$el("span", { textContent: label })]); + + const menu = $el("div.ml-footer-menu", { role: 'menu' }, items.map(item => ( + item === 'divider' + ? $el("div.ml-footer-menu-divider", {}) + : $el("button.ml-footer-menu-item", { + type: 'button', + role: 'menuitem', + onclick: (event) => { + event.stopPropagation(); + item.action(); + } + }, [$el("span", { textContent: item.label })]) + ))); + + this.footerMenuButtons.set(name, button); + this.footerMenus.set(name, menu); + + return $el("div.ml-footer-menu-wrap", {}, [button, menu]); + }, + + createFooter() { + this.footerMenus = new Map(); + this.footerMenuButtons = new Map(); + + const selectMenu = this.createFooterMenu('select', 'Select (0/0)', [ + { label: 'Select All', action: () => this.selectBatchMissingModels('all') }, + { label: 'Select None', action: () => this.selectBatchMissingModels('none') }, + { label: 'Invert Selection', action: () => this.selectBatchMissingModels('invert') }, + 'divider', + { label: 'Select Exact Local', action: () => this.selectBatchMissingModels('exact') }, + { label: 'Select No Exact Local', action: () => this.selectBatchMissingModels('no_exact') }, + { label: 'Select Partial Local', action: () => this.selectBatchMissingModels('partial') }, + { label: 'Select No Local Match', action: () => this.selectBatchMissingModels('no_local') }, + 'divider', + { label: 'Select With Download Source', action: () => this.selectBatchMissingModels('downloadable') }, + { label: 'Select Without Download Source', action: () => this.selectBatchMissingModels('no_download') }, + 'divider', + { label: 'Select Searched', action: () => this.selectBatchMissingModels('searched') }, + { label: 'Select Unsearched', action: () => this.selectBatchMissingModels('unsearched') } + ]); + this.selectMenuButton = this.footerMenuButtons.get('select'); + this.selectMenuWrap = selectMenu; + + const searchMenu = this.createFooterMenu('search', 'Search', [ + { label: 'Search Selected', action: () => this.searchMissingBatch('selected', 'all') }, + { label: 'Search All Missing', action: () => this.searchMissingBatch('all', 'all') }, + { label: 'Search Unsearched', action: () => this.searchMissingBatch('unsearched', 'all') }, + 'divider', + { label: 'Selected: CivitAI', action: () => this.searchMissingBatch('selected', 'civitai') }, + { label: 'Selected: HuggingFace', action: () => this.searchMissingBatch('selected', 'huggingface') } + ]); + this.searchMenuButton = this.footerMenuButtons.get('search'); + this.searchMenuWrap = searchMenu; + + // Store reference to download all button so we can update its text + this.downloadAllButton = $el("button.ml-btn.ml-btn-download.ml-footer-btn", { + "data-tooltip": "Download every missing model that has a known download source.", + "aria-label": "Download all missing models", + onclick: () => this.handleDownloadAllClick() + }, [ + $el("span.ml-btn-icon", { textContent: "☁" }), + $el("span", { textContent: " Download All Missing" }) + ]); + + // Auto-resolve button (secondary style) + this.autoResolveButton = $el("button.ml-btn.ml-btn-secondary.ml-footer-btn", { + "data-tooltip": "Automatically link all missing models with a 100% local match.", + "aria-label": "Auto-link all 100 percent local matches", + onclick: () => this.autoResolve100Percent() + }, [ + $el("span.ml-btn-icon", { textContent: "🔗" }), + $el("span", { textContent: " Auto-Link 100%" }) + ]); + this.queueExactButton = $el("button.ml-btn.ml-btn-secondary.ml-footer-btn", { + type: 'button', + onclick: () => this.queueExactLocalMatchesBatch('selected') + }, [ + $el("span", { textContent: "Queue Exact" }) + ]); + + // Apply pending resolutions button + this.applyPendingBtn = $el("button.ml-btn.ml-btn-primary.ml-footer-btn", { + id: "apply-pending-resolutions", + "data-tooltip": "Apply the model links you selected from local matches or search results.", + "aria-label": "Apply selected model links", + "aria-disabled": "true", + textContent: "Apply Selected (0)", + onclick: () => { + if ((this.pendingResolutions?.length || 0) === 0) return; + this.applyPendingResolutions(); + } + }); + this.applyPendingBtn.classList.add('ml-btn-is-disabled'); + this.downloadAllButton.setAttribute('aria-label', 'Download all missing models'); + this.autoResolveButton.setAttribute('aria-label', 'Auto-link all 100 percent local matches'); + this.queueExactButton.setAttribute('aria-label', 'Queue exact local matches'); + this.applyPendingBtn.setAttribute('aria-label', 'Apply selected model links'); + this.applyPendingBtn.setAttribute('aria-disabled', 'true'); + + this.setTooltip(this.downloadAllButton, 'Download every missing model that has a known download source.'); + this.setTooltip(this.autoResolveButton, 'Automatically link all missing models with a 100% local match.'); + this.setTooltip(this.queueExactButton, 'Queue exact local matches for the selected rows, or all exact matches if nothing is selected.'); + this.setTooltip(this.applyPendingBtn, 'Apply the model links you selected from local matches or search results.'); + + const downloadMenu = this.createFooterMenu('download', 'Download', [ + { label: 'Download Selected', action: () => this.downloadMissingBatch('selected') }, + { label: 'Download All With Sources', action: () => this.downloadMissingBatch('all') }, + 'divider', + { label: 'Cancel Downloads', action: () => { this.closeFooterMenus(); this.cancelAllDownloads(); } } + ], 'ml-btn-download'); + this.downloadMenuButton = this.footerMenuButtons.get('download'); + this.downloadMenuWrap = downloadMenu; + this.downloadAllButton = this.downloadMenuButton; + this.downloadAllButton.setAttribute('aria-label', 'Download missing models'); + this.setTooltip(this.downloadAllButton, 'Download selected models or all missing models with known download sources.'); + + return $el("div.ml-footer", {}, [ + selectMenu, + searchMenu, + this.queueExactButton, + this.applyPendingBtn, + this.autoResolveButton, + downloadMenu + ]); + }, + + 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; + if (this.downloadMenuButton && this.downloadAllButton === this.downloadMenuButton) { + this.updateBatchFooterButtons(); + return; + } + + const activeCount = Object.keys(this.activeDownloads).length; + if (activeCount > 0) { + this.downloadAllButton.innerHTML = `✕ Cancel All (${activeCount})`; + this.downloadAllButton.setAttribute( + 'data-tooltip', + `Cancel ${activeCount} active download${activeCount > 1 ? 's' : ''}.` + ); + this.downloadAllButton.setAttribute('aria-label', 'Cancel all active downloads'); + this.downloadAllButton.classList.remove('ml-btn-download'); + this.downloadAllButton.classList.add('ml-btn-danger'); + } else { + this.downloadAllButton.innerHTML = `☁ Download All Missing`; + this.downloadAllButton.setAttribute( + 'data-tooltip', + 'Download every missing model that has a known download source.' + ); + this.downloadAllButton.setAttribute('aria-label', 'Download all missing models'); + this.downloadAllButton.classList.remove('ml-btn-danger'); + this.downloadAllButton.classList.add('ml-btn-download'); + } + } +}; diff --git a/web/linker/render_format_methods.js b/web/linker/render_format_methods.js new file mode 100644 index 0000000..c4205b4 --- /dev/null +++ b/web/linker/render_format_methods.js @@ -0,0 +1,179 @@ +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; +import { $el } from "../../../../scripts/ui.js"; +import { getSvgIcon } from "../utils/icon_utils.js"; +export const renderFormatMethods = { + /** + * 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)); + } + } +}; diff --git a/web/linker/resolve_download_methods.js b/web/linker/resolve_download_methods.js new file mode 100644 index 0000000..d4f392f --- /dev/null +++ b/web/linker/resolve_download_methods.js @@ -0,0 +1,1450 @@ +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; +import { $el } from "../../../../scripts/ui.js"; +import { getSvgIcon } from "../utils/icon_utils.js"; +export const resolveDownloadMethods = { + /** + * 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'); + } + }, + + getBestDownloadSourceForMissing(missing) { + if (!missing) return null; + + if (missing.download_source?.url) { + return { + ...missing.download_source, + directory: missing.download_source.directory || missing.download_source.category || missing.category || 'checkpoints' + }; + } + + const state = this.searchResultCache.get(this.getMissingSearchKey(missing)); + const results = state?.results || {}; + const filename = missing.original_path?.split('/').pop()?.split('\\').pop() || ''; + const first = (value) => Array.isArray(value) ? value[0] : value; + const candidates = [ + { + source: 'popular', + result: first(results.popular), + urlKey: 'url', + filenameKey: 'filename', + categoryKey: 'directory' + }, + { + source: 'model_list', + result: first(results.model_list), + urlKey: 'url', + filenameKey: 'filename', + categoryKey: 'directory' + }, + { + source: 'huggingface', + result: first(results.huggingface), + urlKey: 'url', + filenameKey: 'filename' + }, + { + source: 'civarchive', + result: first(results.civarchive), + urlKey: 'download_url', + filenameKey: 'filename' + }, + { + source: 'lora_manager_archive', + result: first(results.lora_manager_archive), + urlKey: 'download_url', + filenameKey: 'filename' + }, + { + source: 'civitai', + result: first(results.civitai), + urlKey: 'download_url', + filenameKey: 'filename' + } + ]; + + for (const candidate of candidates) { + const result = candidate.result; + const url = result?.[candidate.urlKey]; + if (!url) continue; + + return { + source: candidate.source, + url, + filename: result[candidate.filenameKey] || missing.civitai_info?.expected_filename || filename, + directory: result[candidate.categoryKey] || result.directory || result.category || missing.category || 'checkpoints', + model_url: result.url || result.model_url || this.getModelCardUrl(url), + name: result.name || result.repo_id || result.repo || result.filename || filename, + size: result.size, + type: result.type || missing.category + }; + } + + return null; + }, + + getBatchSearchTargets(mode) { + const missingModels = this.missingModels || []; + if (mode === 'selected') { + return this.getSelectedMissingModels(); + } + if (mode === 'unsearched') { + return missingModels.filter(missing => !this.hasRenderableSearchState(this.getSearchState(missing))); + } + return missingModels; + }, + + async searchMissingBatch(mode = 'selected', source = 'all') { + if (this.batchSearchRunning) { + this.showNotification('Batch search is already running.', 'info'); + return; + } + + const targets = this.getBatchSearchTargets(mode); + if (!targets.length) { + this.showNotification(mode === 'selected' ? 'No missing models selected.' : 'No missing models to search.', 'info'); + this.closeFooterMenus(); + return; + } + + this.batchSearchRunning = true; + this.updateBatchFooterButtons(); + this.closeFooterMenus(); + this.showNotification(`Searching ${targets.length} missing model${targets.length > 1 ? 's' : ''}...`, 'info'); + + let completed = 0; + let failed = 0; + try { + for (const missing of targets) { + const state = this.getSearchState(missing); + state.selectedSource = source || 'all'; + try { + await this.searchOnline(missing); + } catch (error) { + failed += 1; + console.error('Model Linker: batch search item failed:', error); + } + completed += 1; + this.updateBatchFooterButtons(); + } + } finally { + this.batchSearchRunning = false; + this.persistSearchStateForActiveWorkflow(); + this.updateBatchFooterButtons(); + } + + const suffix = failed ? `, ${failed} failed` : ''; + this.showNotification(`Finished search for ${completed} model${completed === 1 ? '' : 's'}${suffix}.`, failed ? 'error' : 'success'); + }, + + async downloadMissingBatch(mode = 'selected') { + const targets = mode === 'selected' + ? this.getSelectedMissingModels() + : (this.missingModels || []); + + if (!targets.length) { + this.showNotification(mode === 'selected' ? 'No missing models selected.' : 'No missing models to download.', 'info'); + this.closeFooterMenus(); + return; + } + + const toDownload = []; + for (const missing of targets) { + if (this.getBestLocalMatch(missing, 100)) continue; + const source = this.getBestDownloadSourceForMissing(missing); + if (!source?.url) continue; + missing.download_source = source; + toDownload.push(missing); + } + + if (!toDownload.length) { + this.showNotification('No selected models have downloadable sources.', 'info'); + this.closeFooterMenus(); + return; + } + + this.closeFooterMenus(); + this.showNotification(`Starting ${toDownload.length} download${toDownload.length > 1 ? 's' : ''}...`, 'info'); + for (const missing of toDownload) { + await this.downloadModel(missing); + } + this.updateDownloadAllButtonState(); + this.updateBatchFooterButtons(); + }, + + queueExactLocalMatchesBatch(mode = 'selected') { + const selected = this.getSelectedMissingModels(); + const targets = mode === 'selected' && selected.length ? selected : (this.missingModels || []); + let queued = 0; + + for (const missing of targets) { + const match = this.getBestLocalMatch(missing, 100); + if (!match?.model) continue; + this.queueResolution(missing, match.model); + queued += 1; + } + + this.closeFooterMenus(); + if (!queued) { + this.showNotification('No exact local matches found to queue.', 'info'); + return; + } + this.showNotification(`Queued ${queued} exact local match${queued === 1 ? '' : 'es'}.`, 'success'); + this.updateBatchFooterButtons(); + }, + + /** + * Auto-resolve a model after download completes + * Reloads the workflow analysis and resolves if the downloaded model is found + */ + async autoResolveAfterDownload(missing, downloadedFilename) { + try { + const workflow = this.getCurrentWorkflow(); + if (!workflow) { + // Just reload the UI to show updated state + await this.loadWorkflowData(); + return; + } + + // Re-analyze workflow to find the newly downloaded model + const analyzeResponse = await api.fetchApi('/model_linker/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workflow }) + }); + + if (!analyzeResponse.ok) { + // Just reload UI + await this.loadWorkflowData(); + return; + } + + const analyzeData = await analyzeResponse.json(); + const missingModels = analyzeData.missing_models || []; + + // Find the missing model entry that matches our download by filename + const targetMissing = missingModels.find(m => { + const missingFilename = m.original_path?.split('/').pop()?.split('\\').pop() || ''; + return missingFilename.toLowerCase() === downloadedFilename.toLowerCase(); + }); + + if (!targetMissing) { + // Model no longer missing - already resolved or workflow changed + await this.loadWorkflowData(); + return; + } + + // Look for a 100% match with the downloaded filename + const matches = targetMissing.matches || []; + const perfectMatch = matches.find(m => { + const matchFilename = m.filename || m.model?.filename || ''; + // Check for exact match or 100% confidence + return m.confidence === 100 || + matchFilename.toLowerCase() === downloadedFilename.toLowerCase(); + }); + + if (perfectMatch && perfectMatch.model) { + // Auto-resolve ALL nodes that need this model + // all_node_refs contains all nodes referencing this model (deduplicated) + const nodeRefs = targetMissing.all_node_refs || [targetMissing]; + const resolutions = nodeRefs.map(ref => ({ + node_id: ref.node_id, + widget_index: ref.widget_index, + resolved_path: perfectMatch.model.path, + category: ref.category, + resolved_model: perfectMatch.model, + subgraph_id: ref.subgraph_id, + is_top_level: ref.is_top_level, + is_lora_v2: ref.is_lora_v2, + original_lora_name: ref.name || ref.original_path + })); + + const resolveResponse = await api.fetchApi('/model_linker/resolve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workflow, + resolutions: resolutions + }) + }); + + if (resolveResponse.ok) { + const resolveData = await resolveResponse.json(); + if (resolveData.success) { + await this.updateWorkflowInComfyUI(resolveData.workflow); + const count = resolutions.length; + this.showNotification(`✓ Auto-resolved: ${downloadedFilename} (${count} reference${count > 1 ? 's' : ''})`, 'success'); + await this.loadWorkflowData(resolveData.workflow); + return; + } + } + } + + // If we couldn't auto-resolve, just reload the UI + await this.loadWorkflowData(); + + } catch (error) { + console.error('Model Linker: Error auto-resolving after download:', error); + // Still reload UI even on error + await this.loadWorkflowData(); + } + }, + + /** + * Download a model from a known source + */ + async downloadModel(missing) { + const source = missing.download_source; + if (!source || !source.url) { + this.showNotification('No download URL available', 'error'); + return; + } + + // Use filename from download source if available (may be different from original) + const originalFilename = missing.original_path?.split('/').pop()?.split('\\').pop() || 'model.safetensors'; + const filename = source.filename || originalFilename; + const targetSelection = this.getDownloadTargetSelection(missing, source.directory || missing.category || 'checkpoints'); + const category = targetSelection.category; + const subfolder = targetSelection.subfolder; + const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`; + const progressDiv = this.contentElement?.querySelector(`#${progressId}`); + const downloadBtn = this.contentElement?.querySelector(`#download-${missing.node_id}-${missing.widget_index}`); + const tokens = this.getStoredTokens(); + + try { + // Disable button and show progress with cancel button immediately + if (downloadBtn) { + downloadBtn.disabled = true; + downloadBtn.classList.remove('ml-is-success-action', 'ml-btn-primary'); + downloadBtn.textContent = 'Starting...'; + } + if (progressDiv) { + progressDiv.classList.remove('ml-is-hidden'); + progressDiv.classList.add('ml-is-visible'); + // Show progress bar with cancel button immediately + progressDiv.innerHTML = this.renderProgressWithAction({ + percent: 0, + leftText: 'Connecting...', + rightText: '', + actionClass: 'cancel-download-btn-pending ml-btn ml-btn-danger ml-btn-sm', + actionText: 'Cancel' + }); + } + + // Start download + const response = await api.fetchApi('/model_linker/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: source.url, + filename: filename, + category: category, + subfolder: subfolder, + hf_token: tokens.hf_token, + civitai_key: tokens.civitai_key + }) + }); + + if (!response.ok) { + throw new Error(`Download failed: ${response.status}`); + } + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Download failed'); + } + + // Track download and poll for progress + const downloadId = data.download_id; + this.activeDownloads[downloadId] = { missing, progressDiv, downloadBtn }; + + // Update the Download All button state + this.updateDownloadAllButtonState(); + + // Attach cancel handler to pending button (before polling replaces it) + const pendingCancelBtn = progressDiv?.querySelector('.cancel-download-btn-pending'); + if (pendingCancelBtn) { + pendingCancelBtn.addEventListener('click', () => this.cancelDownload(downloadId)); + } + + this.pollDownloadProgress(downloadId); + + } catch (error) { + console.error('Model Linker: Download error:', error); + if (progressDiv) { + progressDiv.innerHTML = this.renderStatusMessage(error.message, 'error'); + } + if (downloadBtn) { + downloadBtn.disabled = false; + downloadBtn.classList.remove('ml-is-success-action', 'ml-btn-primary'); + downloadBtn.innerHTML = '☁ Retry'; + } + this.showNotification('Download failed: ' + error.message, 'error'); + } + }, + + /** + * Poll download progress + */ + async pollDownloadProgress(downloadId) { + const info = this.activeDownloads[downloadId]; + if (!info) return; + + try { + const response = await api.fetchApi(`/model_linker/progress/${downloadId}`); + if (!response.ok) { + throw new Error('Failed to get progress'); + } + + const progress = await response.json(); + const { progressDiv, downloadBtn, missing } = info; + + if (progress.status === 'downloading' || progress.status === 'starting') { + const percent = progress.progress || 0; + const downloaded = this.formatBytes(progress.downloaded || 0); + const total = this.formatBytes(progress.total_size || 0); + const speed = progress.speed ? this.formatBytes(progress.speed) + '/s' : ''; + + if (progressDiv) { + progressDiv.innerHTML = this.renderProgressWithAction({ + percent, + leftText: `${downloaded} / ${total} (${percent}%)`, + rightText: speed, + actionClass: 'cancel-download-btn ml-btn ml-btn-danger ml-btn-sm', + actionText: 'Cancel', + actionDataAttr: `data-download-id="${downloadId}"` + }); + // Attach cancel handler + const cancelBtn = progressDiv.querySelector('.cancel-download-btn'); + if (cancelBtn && !cancelBtn._hasListener) { + cancelBtn._hasListener = true; + cancelBtn.addEventListener('click', () => this.cancelDownload(downloadId)); + } + } + if (downloadBtn) { + downloadBtn.textContent = `${percent}%`; + } + + // Continue polling + setTimeout(() => this.pollDownloadProgress(downloadId), 1000); + + } else if (progress.status === 'completed') { + if (progressDiv) { + progressDiv.innerHTML = this.renderStatusMessage('Download complete! Auto-linking...', 'success'); + } + if (downloadBtn) { + downloadBtn.textContent = '✓ Done'; + downloadBtn.classList.remove('ml-is-success-action'); + downloadBtn.classList.add('ml-btn-primary'); + } + delete this.activeDownloads[downloadId]; + this.updateDownloadAllButtonState(); + this.showNotification(`Downloaded: ${progress.filename}`, 'success'); + + // Auto-resolve: Reload workflow data and try to resolve the downloaded model + // Small delay to ensure file system is updated + setTimeout(async () => { + await this.autoResolveAfterDownload(missing, progress.filename); + }, 500); + + } else if (progress.status === 'error') { + if (progressDiv) { + progressDiv.innerHTML = this.renderStatusMessage(progress.error || 'Download failed', 'error'); + } + if (downloadBtn) { + downloadBtn.disabled = false; + downloadBtn.classList.remove('ml-is-success-action', 'ml-btn-primary'); + downloadBtn.textContent = 'Retry'; + } + delete this.activeDownloads[downloadId]; + this.updateDownloadAllButtonState(); + + } else if (progress.status === 'cancelled') { + if (progressDiv) { + progressDiv.innerHTML = this.renderStatusMessage('Download cancelled - incomplete file removed', 'warning'); + } + if (downloadBtn) { + downloadBtn.disabled = false; + downloadBtn.classList.remove('ml-is-success-action', 'ml-btn-primary'); + downloadBtn.innerHTML = '☁ Download'; + } + delete this.activeDownloads[downloadId]; + this.updateDownloadAllButtonState(); + this.showNotification('Download cancelled', 'info'); + + } else { + // Unknown status, keep polling + setTimeout(() => this.pollDownloadProgress(downloadId), 500); + } + + } catch (error) { + console.error('Model Linker: Progress poll error:', error); + const info = this.activeDownloads[downloadId]; + // Update UI to show error state instead of just disappearing + if (info) { + const { progressDiv, downloadBtn } = info; + if (progressDiv) { + progressDiv.innerHTML = this.renderStatusMessage('Connection lost - download may have failed', 'error'); + } + if (downloadBtn) { + downloadBtn.disabled = false; + downloadBtn.textContent = 'Retry'; + downloadBtn.classList.remove('ml-btn-primary'); + downloadBtn.classList.add('ml-is-success-action'); + } + } + delete this.activeDownloads[downloadId]; + this.updateDownloadAllButtonState(); + } + }, + + /** + * Cancel an active download + */ + async cancelDownload(downloadId) { + try { + const response = await api.fetchApi(`/model_linker/cancel/${downloadId}`, { + method: 'POST' + }); + + if (!response.ok) { + throw new Error('Failed to cancel download'); + } + + const info = this.activeDownloads[downloadId]; + if (info?.progressDiv) { + info.progressDiv.innerHTML = this.renderStatusMessage('Cancelling download...', 'info'); + } + + } catch (error) { + console.error('Model Linker: Cancel error:', error); + this.showNotification('Failed to cancel download', 'error'); + } + }, + + /** + * Search online for a model + */ + async searchOnline(missing) { + let filename = missing.original_path?.split('/').pop()?.split('\\').pop() || ''; + let category = missing.category || ''; + const state = this.getSearchState(missing); + let selectedSource = state.selectedSource || 'all'; + if (selectedSource !== 'all' && !this.isSearchSourceUsable(selectedSource)) { + selectedSource = 'all'; + state.selectedSource = 'all'; + this.persistSearchStateForActiveWorkflow(); + } + const selectedSourceLabel = this.getSearchSourceLabel(selectedSource); + const sourceIds = this.getSearchSourcesForSelection(selectedSource, missing); + + // For URNs, use the resolved file/model name for searching instead of the URN itself + // and pass the URN type as category (CivitAI expects specific type names) + if (missing.is_urn) { + const urnSearchName = missing.civitai_info?.expected_filename + || missing.download_source?.filename + || missing.civitai_info?.model_name; + if (urnSearchName) { + filename = urnSearchName; + } + // Pass URN type directly - CivitAPI expects types like 'Upscaler', 'Checkpoint' + const urnType = missing.urn_type || ''; + if (urnType) { + // Map URN types to CivitAI type names + const typeMap = { + 'checkpoint': 'Checkpoint', + 'lora': 'LORA', + 'vae': 'VAE', + 'upscaler': 'Upscaler', + 'upscale_model': 'Upscaler', + 'embedding': 'TextualInversion', + 'controlnet': 'Controlnet' + }; + const civitaiType = typeMap[urnType.toLowerCase()]; + if (civitaiType) { + category = civitaiType; + } + } + } + + const isUrn = missing.is_urn || false; + const resultsId = `search-results-${missing.node_id}-${missing.widget_index}`; + const resultsDiv = this.contentElement?.querySelector(`#${resultsId}`); + const searchBtn = this.contentElement?.querySelector(`#search-${missing.node_id}-${missing.widget_index}`); + let searchRunId = null; + + try { + if (!sourceIds.length) { + throw new Error(`${selectedSourceLabel} is not available in this install`); + } + + this.clearSearchProgressTimers(state.activeSearchRunId); + searchRunId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + state.activeSearchRunId = searchRunId; + state.lastAttemptSources = sourceIds; + state.lastAttemptFound = null; + state.lastAttemptError = null; + state.sourceProgress = {}; + for (const source of sourceIds) { + this.setSourceProgress(state, source, { + status: 'running', + percent: 6, + startedAt: Date.now(), + estimateMs: this.getSearchSourceEstimateMs(source, isUrn) + }, missing); + } + this.persistSearchStateForActiveWorkflow(); + + if (searchBtn) { + searchBtn.disabled = true; + searchBtn.innerHTML = `${this.getSearchIconHtml()} Searching ${selectedSourceLabel}...`; + } + if (resultsDiv) { + resultsDiv.classList.remove('ml-is-hidden'); + resultsDiv.classList.add('ml-is-visible'); + this.displaySearchResults(missing, state, resultsDiv); + } + for (const source of sourceIds) { + this.startEstimatedSearchProgress( + state, + missing, + resultsDiv, + source, + searchRunId + ); + } + + // For URNs, include model_id and version_id for direct download + const tokens = this.getStoredTokens(); + const baseSearchData = { + filename, + category, + is_urn: isUrn, + civitai_session_token: tokens.civitai_session_token, + civitai_candidate_limit: tokens.civitai_candidate_limit, + civitai_use_trpc_search: tokens.civitai_use_trpc_search, + civitai_use_html_fallback: tokens.civitai_use_html_fallback, + hf_token: tokens.hf_token, + brave_search_api_key: tokens.brave_search_api_key, + hf_use_api_search: tokens.hf_use_api_search, + hf_use_comfy_org_fallback: tokens.hf_use_comfy_org_fallback, + hf_use_brave_fallback: tokens.hf_use_brave_fallback + }; + if (isUrn && missing.urn) { + baseSearchData.model_id = missing.urn.model_id; + baseSearchData.version_id = missing.urn.version_id; + } + + const attemptedSources = new Set(); + let anyFound = false; + let hadError = false; + + const searchPromises = sourceIds.map(async (source) => { + const sourceIsUrn = isUrn && source !== 'lora_manager_archive'; + const searchData = { + ...baseSearchData, + is_urn: sourceIsUrn, + sources: [source] + }; + + try { + console.log('Model Linker: Search request:', JSON.stringify(searchData)); + + const response = await api.fetchApi('/model_linker/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(searchData) + }); + + if (!response.ok) { + throw new Error(`Search failed: ${response.status}`); + } + + const data = await response.json(); + console.log('Model Linker: Search response:', JSON.stringify(data)); + + if (state.activeSearchRunId !== searchRunId) { + return { source, stale: true }; + } + + const responseSources = Array.isArray(data.searched_sources) && data.searched_sources.length + ? data.searched_sources + : [source]; + responseSources.forEach(responseSource => attemptedSources.add(responseSource)); + + const found = this.hasSearchResults(data); + anyFound = anyFound || found; + state.results = this.mergeSearchResults(state.results, data); + state.lastAttemptSources = Array.from(attemptedSources); + state.lastAttemptFound = anyFound; + this.clearSearchProgressTimer(searchRunId, source); + this.setSourceProgress(state, source, { + status: found ? 'found' : 'none', + percent: 100, + message: found ? 'Found' : 'No match' + }, missing); + + if (data.civitai) { + missing.civitai_search_result = { + base_model: data.civitai.base_model, + tags: data.civitai.tags || [], + trained_words: data.civitai.trained_words || [], + filename: data.civitai.filename, + name: data.civitai.name, + type: data.civitai.type + }; + } + if (data.civarchive) { + missing.civitai_search_result = { + base_model: data.civarchive.base_model, + tags: data.civarchive.tags || [], + trained_words: data.civarchive.trained_words || [], + filename: data.civarchive.filename, + name: data.civarchive.name, + type: data.civarchive.type + }; + } + + this.displaySearchResults(missing, state, resultsDiv); + this.persistSearchStateForActiveWorkflow(); + this.applySearchResultSuggestion(missing); + return { source, data }; + } catch (error) { + console.error(`Model Linker: Search error for ${source}:`, error); + if (state.activeSearchRunId !== searchRunId) { + return { source, stale: true, error }; + } + + hadError = true; + attemptedSources.add(source); + state.lastAttemptSources = Array.from(attemptedSources); + state.lastAttemptFound = anyFound; + this.clearSearchProgressTimer(searchRunId, source); + this.setSourceProgress(state, source, { + status: 'error', + percent: 100, + message: error.message || 'Error' + }, missing); + this.displaySearchResults(missing, state, resultsDiv); + this.persistSearchStateForActiveWorkflow(); + return { source, error }; + } + }); + + await Promise.all(searchPromises); + this.clearSearchProgressTimers(searchRunId); + + if (state.activeSearchRunId === searchRunId) { + state.activeSearchRunId = null; + state.lastAttemptSources = attemptedSources.size ? Array.from(attemptedSources) : sourceIds; + state.lastAttemptFound = anyFound; + state.lastAttemptError = hadError && !anyFound + ? 'Search finished with errors. Check source statuses above.' + : null; + this.displaySearchResults(missing, state, resultsDiv); + this.persistSearchStateForActiveWorkflow(); + } + + } catch (error) { + console.error('Model Linker: Search error:', error); + this.clearSearchProgressTimers(searchRunId); + state.lastAttemptError = error.message; + this.persistSearchStateForActiveWorkflow(); + if (resultsDiv) { + resultsDiv.innerHTML = this.renderStatusMessage(`Search failed: ${error.message}`, 'error'); + } + } finally { + if (!searchRunId || state.activeSearchRunId === searchRunId) { + this.clearSearchProgressTimers(searchRunId); + state.activeSearchRunId = null; + } + if (searchBtn) { + searchBtn.disabled = false; + searchBtn.innerHTML = `${this.getSearchIconHtml()} Search Again`; + } + this.persistSearchStateForActiveWorkflow(); + } + }, + + /** + * Resolve URN asynchronously - fetch CivitAI info and update UI + */ + async resolveUrnAsync(modelId, versionId, loadingElementId, modelUrl) { + console.log('resolveUrnAsync called:', modelId, versionId); + if (!modelId || !versionId) { + console.log('resolveUrnAsync: missing modelId or versionId'); + return; + } + + try { + const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-'); + const missing = this.missingModels.find(m => + `urn-download-${m.node_id}-${m.widget_index}` === downloadContainerId + ); + + let data = null; + if (missing) { + data = await this.resolveUrnDataForMissing(missing); + } else { + const tokens = this.getStoredTokens(); + const payload = { + filename: `${modelId}_${versionId}`, + category: '', + is_urn: true, + sources: ['civitai'], + model_id: modelId, + version_id: versionId, + civitai_candidate_limit: tokens.civitai_candidate_limit + }; + console.log('resolveUrnAsync payload:', JSON.stringify(payload)); + + const response = await api.fetchApi('/model_linker/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + console.log('resolveUrnAsync response status:', response.status); + if (!response.ok) { + throw new Error(`URN resolve failed: ${response.status}`); + } + data = await response.json(); + } + + if (data) { + const loadingEl = document.getElementById(loadingElementId); + if (loadingEl && data.civitai) { + loadingEl.classList.remove('ml-is-muted', 'ml-is-error'); + const civitai = data.civitai; + const labelHtml = this.renderVersionedModelNameHtml(civitai.name, civitai.version_name) + || this.escapeHtml(civitai.filename || 'Model'); + const url = modelUrl || `https://civitai.com/models/${modelId}?modelVersionId=${versionId}`; + loadingEl.innerHTML = `${labelHtml}`; + } else if (loadingEl) { + loadingEl.textContent = 'Not found'; + loadingEl.classList.remove('ml-is-error'); + loadingEl.classList.add('ml-is-muted'); + } + + const downloadEl = document.getElementById(downloadContainerId); + if (downloadEl && data.civitai?.download_url) { + if (missing) { + this.applyCivitaiUrnResult(missing, data.civitai); + const downloadParent = downloadEl.parentElement; + downloadEl.outerHTML = this.renderKnownDownloadPanel(missing, missing.download_source); + this.wireDownloadSearchPanel(downloadParent || this.contentElement, missing); + this.refreshUrnLocalMatches(missing); + } + } else if (downloadEl) { + downloadEl.innerHTML = `
Unable to resolve direct download for this URN.
`; + } + } else { + const loadingEl = document.getElementById(loadingElementId); + if (loadingEl) { + loadingEl.textContent = 'Error'; + loadingEl.classList.remove('ml-is-muted'); + loadingEl.classList.add('ml-is-error'); + } + const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-'); + const downloadEl = document.getElementById(downloadContainerId); + if (downloadEl) { + downloadEl.innerHTML = `
Failed to resolve URN download.
`; + } + } + } catch (error) { + console.error('Model Linker: URN resolve error:', error); + const loadingEl = document.getElementById(loadingElementId); + if (loadingEl) { + loadingEl.textContent = 'Error'; + loadingEl.classList.remove('ml-is-muted'); + loadingEl.classList.add('ml-is-error'); + } + const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-'); + const downloadEl = document.getElementById(downloadContainerId); + if (downloadEl) { + downloadEl.innerHTML = `
Failed to resolve URN download.
`; + } + } + }, + + wireSearchDownloadButtons(container, missing) { + if (!container) return; + + const downloadBtns = container.querySelectorAll('.search-download-btn'); + downloadBtns.forEach(btn => { + btn.addEventListener('click', () => { + const url = btn.dataset.url; + const filename = btn.dataset.filename; + const category = btn.dataset.category; + this.downloadFromSearch(missing, url, filename, category, btn); + }); + }); + + const openPageBtns = container.querySelectorAll('.search-open-page-btn'); + openPageBtns.forEach(btn => { + btn.addEventListener('click', () => { + const url = btn.dataset.url; + if (!url) return; + + const opened = window.open(url, '_blank', 'noopener,noreferrer'); + if (opened) { + opened.opener = null; + } + }); + }); + }, + + wireDownloadSearchPanel(container, missing) { + if (!container) return; + + const searchResultsId = `search-results-${missing.node_id}-${missing.widget_index}`; + const searchResultsDiv = container.querySelector(`#${searchResultsId}`); + if (searchResultsDiv) { + this.wireSearchDownloadButtons(searchResultsDiv, missing); + } + + const searchBtn = container.querySelector(`#search-${missing.node_id}-${missing.widget_index}`); + if (searchBtn && searchBtn.dataset.mlSearchBound !== 'true') { + searchBtn.dataset.mlSearchBound = 'true'; + searchBtn.addEventListener('click', () => { + this.searchOnline(missing); + }); + } + + const sourceSelect = container.querySelector(`#search-source-select-${missing.node_id}-${missing.widget_index}`); + const sourceList = container.querySelector(`#search-source-list-${missing.node_id}-${missing.widget_index}`); + if (sourceSelect && sourceList && sourceSelect.dataset.mlSearchSourceBound !== 'true') { + sourceSelect.dataset.mlSearchSourceBound = 'true'; + + const renderSourceOptions = () => { + const options = this.getSearchSourceOptions(); + sourceList.innerHTML = options + .map(option => `
${this.escapeHtml(option.label)}
`) + .join(''); + sourceList.style.display = 'block'; + sourceList.querySelectorAll('.ml-download-target-option').forEach(optionEl => { + optionEl.addEventListener('mousedown', (event) => { + event.preventDefault(); + const value = decodeURIComponent(optionEl.dataset.value || ''); + const label = decodeURIComponent(optionEl.dataset.label || optionEl.dataset.value || ''); + this.setDropdownValue(sourceSelect, value, label); + this.setSearchSource(missing, value, container); + sourceList.style.display = 'none'; + }); + }); + }; + + const hideSourceList = () => { + setTimeout(() => { + sourceList.style.display = 'none'; + }, 150); + }; + + this.enableWheelScrollChaining(sourceList); + sourceSelect.addEventListener('focus', () => renderSourceOptions()); + sourceSelect.addEventListener('click', () => renderSourceOptions()); + sourceSelect.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') { + event.preventDefault(); + renderSourceOptions(); + } + }); + sourceSelect.addEventListener('blur', hideSourceList); + this.syncSearchSourceUi(missing, container); + } + + this.wireDownloadTargetAutocomplete(container, missing); + }, + + /** + * Display search results + */ + displaySearchResults(missing, state, container) { + if (!container) return; + + const results = state?.results || {}; + const popular = results.popular; + const modelListResult = results.model_list; + const hfResult = results.huggingface ? (Array.isArray(results.huggingface) ? results.huggingface[0] : results.huggingface) : null; + const civitaiResult = results.civitai ? (Array.isArray(results.civitai) ? results.civitai[0] : results.civitai) : null; + const civarchiveResult = results.civarchive ? (Array.isArray(results.civarchive) ? results.civarchive[0] : results.civarchive) : null; + const loraManagerArchiveResult = results.lora_manager_archive ? (Array.isArray(results.lora_manager_archive) ? results.lora_manager_archive[0] : results.lora_manager_archive) : null; + const knownDownloadRow = this.getDownloadSourceTableRow(missing, missing.download_source); + const hasResults = knownDownloadRow || popular || modelListResult || hfResult || civitaiResult || civarchiveResult || loraManagerArchiveResult; + const progressHtml = this.renderSearchProgress(state); + const hasActiveProgress = this.hasActiveSearchProgress(state); + + if (!hasResults) { + if (hasActiveProgress) { + container.innerHTML = progressHtml; + return; + } + + if (state?.lastAttemptError) { + container.innerHTML = `${progressHtml}${this.renderStatusMessage(state.lastAttemptError, 'error')}`; + return; + } + + container.innerHTML = progressHtml || this.renderStatusMessage( + 'No matches found online for this model.', + 'warning' + ); + return; + } + + const rows = []; + const rowKeys = new Set(); + const addRow = (row) => { + if (!row) return; + const rowKey = row.downloadUrl || row.openUrl || `${row.sourceKey}:${row.model}:${row.filename}`; + if (rowKeys.has(rowKey)) return; + rowKeys.add(rowKey); + rows.push(row); + }; + + let statusHtml = ''; + if (!hasActiveProgress && state?.lastAttemptError) { + statusHtml += this.renderStatusMessage(state.lastAttemptError, 'error'); + } + + addRow(knownDownloadRow); + + if (popular) { + const popularFilename = popular.filename || missing.original_path?.split('/').pop()?.split('\\').pop() || ''; + const popularSize = popular.size + || ( + modelListResult + && modelListResult.filename + && modelListResult.filename.toLowerCase() === popularFilename.toLowerCase() + ? modelListResult.size + : '' + ); + addRow({ + sourceKey: 'popular', + sourceLabel: 'Popular', + model: popular.name || popularFilename, + filename: popularFilename, + secondary: popular.name && popular.name !== popularFilename ? popularFilename : '', + match: this.getSearchResultMatchDisplay(popular, 'Known', 'strong'), + size: this.formatSearchResultSize({ ...popular, size: popularSize }), + downloadUrl: popular.url, + downloadFilename: popularFilename, + category: popular.directory || missing.category, + openUrl: this.getModelCardUrl(popular.url) + }); + } + + if (modelListResult && modelListResult.url) { + addRow({ + sourceKey: 'model-list', + sourceLabel: 'Local Database', + model: modelListResult.name || modelListResult.filename, + filename: modelListResult.filename, + secondary: modelListResult.name && modelListResult.name !== modelListResult.filename ? modelListResult.filename : '', + match: this.getSearchResultMatchDisplay(modelListResult), + size: this.formatSearchResultSize(modelListResult), + downloadUrl: modelListResult.url, + downloadFilename: modelListResult.filename, + category: modelListResult.directory || missing.category, + openUrl: this.getModelCardUrl(modelListResult.url) + }); + } + + if (hfResult && hfResult.url) { + const hfRepo = hfResult.repo_id || hfResult.repo || ''; + const hfModelUrl = hfRepo ? `https://huggingface.co/${hfRepo}` : this.getModelCardUrl(hfResult.url); + addRow({ + sourceKey: 'huggingface', + sourceLabel: 'HuggingFace', + model: hfRepo || hfResult.filename, + filename: hfResult.filename, + secondary: hfResult.path && hfResult.path !== hfResult.filename ? hfResult.path : '', + match: this.getSearchResultMatchDisplay(hfResult), + size: this.formatSearchResultSize(hfResult), + downloadUrl: hfResult.url, + downloadFilename: hfResult.filename, + category: missing.category, + openUrl: hfModelUrl + }); + } + + if (civarchiveResult && civarchiveResult.download_url) { + const archiveFilename = civarchiveResult.filename || missing.original_path?.split('/').pop()?.split('\\').pop() || ''; + const archiveName = civarchiveResult.name || archiveFilename || 'Model'; + addRow({ + sourceKey: 'civarchive', + sourceLabel: 'CivArchive', + model: archiveName, + version: civarchiveResult.version_name || '', + filename: archiveFilename, + secondary: archiveName && archiveName !== archiveFilename ? archiveFilename : (civarchiveResult.platform || civarchiveResult.base_model || ''), + match: this.getSearchResultMatchDisplay(civarchiveResult), + size: this.formatSearchResultSize(civarchiveResult), + downloadUrl: civarchiveResult.download_url, + downloadFilename: archiveFilename, + category: missing.category, + openUrl: civarchiveResult.url + }); + } + + if (loraManagerArchiveResult && loraManagerArchiveResult.url) { + const archiveFilename = loraManagerArchiveResult.filename || missing.original_path?.split('/').pop()?.split('\\').pop() || ''; + const archiveName = loraManagerArchiveResult.name || archiveFilename; + addRow({ + sourceKey: 'lora-archive', + sourceLabel: 'LoRA Archive', + model: archiveName, + version: loraManagerArchiveResult.version_name || '', + filename: archiveFilename, + secondary: archiveName && archiveName !== archiveFilename ? archiveFilename : '', + match: this.getSearchResultMatchDisplay(loraManagerArchiveResult), + size: this.formatSearchResultSize(loraManagerArchiveResult), + downloadUrl: loraManagerArchiveResult.download_url || '', + downloadFilename: archiveFilename, + category: missing.category, + openUrl: loraManagerArchiveResult.url + }); + } + + if (civitaiResult && civitaiResult.download_url) { + const modelUrl = civitaiResult.url || (civitaiResult.model_id ? `https://civitai.com/models/${civitaiResult.model_id}${civitaiResult.version_id ? `?modelVersionId=${civitaiResult.version_id}` : ''}` : ''); + const downloadFilename = missing.civitai_info?.expected_filename || civitaiResult.filename || civitaiResult.name; + const modelName = missing.civitai_info?.model_name || civitaiResult.name || downloadFilename || 'Model'; + addRow({ + sourceKey: 'civitai', + sourceLabel: 'CivitAI', + model: modelName, + version: missing.civitai_info?.version_name || civitaiResult.version_name || '', + filename: downloadFilename, + secondary: civitaiResult.type || civitaiResult.base_model || '', + match: this.getSearchResultMatchDisplay(civitaiResult), + size: this.formatSearchResultSize(civitaiResult), + downloadUrl: civitaiResult.download_url, + downloadFilename, + category: missing.category, + openUrl: modelUrl + }); + } + + const html = `${progressHtml}${statusHtml}${this.renderSearchResultsTable(rows)}`; + container.innerHTML = html; + + this.wireSearchDownloadButtons(container, missing); + }, + + /** + * Download from search results + */ + async downloadFromSearch(missing, url, filename, category, btn) { + const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`; + const progressDiv = this.contentElement?.querySelector(`#${progressId}`); + const tokens = this.getStoredTokens(); + const targetSelection = this.getDownloadTargetSelection(missing, category || missing.category || 'checkpoints'); + + try { + btn.disabled = true; + btn.classList.remove('ml-is-success-action', 'ml-btn-primary'); + btn.textContent = 'Starting...'; + + if (progressDiv) { + progressDiv.classList.remove('ml-is-hidden'); + progressDiv.classList.add('ml-is-visible'); + // Show progress bar with cancel button immediately + progressDiv.innerHTML = this.renderProgressWithAction({ + percent: 0, + leftText: 'Connecting...', + rightText: '', + actionClass: 'cancel-download-btn-pending ml-btn ml-btn-danger ml-btn-sm', + actionText: 'Cancel' + }); + } + + const response = await api.fetchApi('/model_linker/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url, + filename, + category: targetSelection.category, + subfolder: targetSelection.subfolder, + hf_token: tokens.hf_token, + civitai_key: tokens.civitai_key + }) + }); + + if (!response.ok) { + throw new Error(`Download failed: ${response.status}`); + } + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Download failed'); + } + + // Track and poll + const downloadId = data.download_id; + this.activeDownloads[downloadId] = { missing, progressDiv, downloadBtn: btn }; + + // Update the Download All button state + this.updateDownloadAllButtonState(); + + // Attach cancel handler to pending button (before polling replaces it) + const pendingCancelBtn = progressDiv?.querySelector('.cancel-download-btn-pending'); + if (pendingCancelBtn) { + pendingCancelBtn.addEventListener('click', () => this.cancelDownload(downloadId)); + } + + this.pollDownloadProgress(downloadId); + + } catch (error) { + console.error('Model Linker: Download error:', error); + if (progressDiv) { + progressDiv.innerHTML = this.renderStatusMessage(error.message, 'error'); + } + btn.disabled = false; + btn.classList.remove('ml-is-success-action', 'ml-btn-primary'); + btn.textContent = 'Retry'; + this.showNotification('Download failed: ' + error.message, 'error'); + } + }, + + /** + * Format bytes to human readable string + */ + formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + } +}; diff --git a/web/linker/search_panel.js b/web/linker/search_panel.js new file mode 100644 index 0000000..a14e7a0 --- /dev/null +++ b/web/linker/search_panel.js @@ -0,0 +1,1195 @@ +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; +import { $el } from "../../../../scripts/ui.js"; +import { getSvgIcon } from "../utils/icon_utils.js"; +export const searchPanelMethods = { + /** + * Build stable cache key for a missing model entry + */ + getMissingSearchKey(missing) { + const nodeId = missing?.node_id ?? ''; + const widgetIndex = missing?.widget_index ?? ''; + const subgraphId = missing?.subgraph_id ?? ''; + const category = missing?.category ?? ''; + const modelPath = missing?.original_path || missing?.name || missing?.filename || missing?.urn_string || ''; + return [nodeId, widgetIndex, subgraphId, category, modelPath].map(value => String(value)).join(':'); + }, + + /** + * Get or initialize search state for a missing model entry + */ + getSearchState(missing) { + const key = this.getMissingSearchKey(missing); + if (!this.searchResultCache.has(key)) { + this.searchResultCache.set(key, { + selectedSource: 'all', + results: { + popular: null, + model_list: null, + huggingface: null, + civitai: null, + civarchive: null, + lora_manager_archive: null + }, + lastAttemptSources: [], + lastAttemptFound: null, + lastAttemptError: null, + sourceProgress: {}, + activeSearchRunId: null + }); + } + return this.searchResultCache.get(key); + }, + + /** + * Merge new search results into cached per-source results. + * Empty responses do not delete previously found results. + */ + mergeSearchResults(existingResults = {}, newResults = {}) { + return { + popular: newResults.popular || existingResults.popular || null, + model_list: newResults.model_list || existingResults.model_list || null, + huggingface: newResults.huggingface || existingResults.huggingface || null, + civitai: newResults.civitai || existingResults.civitai || null, + civarchive: newResults.civarchive || existingResults.civarchive || null, + lora_manager_archive: newResults.lora_manager_archive || existingResults.lora_manager_archive || null + }; + }, + + getSearchIconHtml() { + return ``; + }, + + 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.civarchive || data.lora_manager_archive); + }, + + /** + * Convert source ids to readable labels + */ + getSearchSourceLabel(source) { + const labels = { + all: 'Everything', + local: 'Local Database', + huggingface: 'HuggingFace', + civitai: 'CivitAI', + civarchive: 'CivArchive', + lora_manager_archive: 'LoRA Manager Archive' + }; + return labels[source] || source; + }, + + getSearchSourceDefinitions() { + return [ + { + source: 'local', + storageKey: 'modelLinker.searchSource.localEnabled', + tooltip: 'Searches bundled known-model data before online providers.' + }, + { + source: 'huggingface', + storageKey: 'modelLinker.searchSource.huggingFaceEnabled', + tooltip: 'Searches Hugging Face when Everything is selected.' + }, + { + source: 'civitai', + storageKey: 'modelLinker.searchSource.civitaiEnabled', + tooltip: 'Searches CivitAI when Everything is selected.' + }, + { + source: 'civarchive', + storageKey: 'modelLinker.searchSource.civArchiveEnabled', + tooltip: 'Searches CivArchive when Everything is selected.' + }, + { + source: 'lora_manager_archive', + storageKey: 'modelLinker.searchSource.loraManagerArchiveEnabled', + tooltip: 'Searches the local LoRA Manager archive when Everything is selected.' + } + ]; + }, + + getSearchSourceDefinition(source) { + return this.getSearchSourceDefinitions().find(def => def.source === source) || null; + }, + + isSearchSourceEnabled(source) { + if (!source || source === 'all') return true; + const definition = this.getSearchSourceDefinition(source); + if (!definition) return true; + return localStorage.getItem(definition.storageKey) !== 'false'; + }, + + isSearchSourceUsable(source) { + return this.isSourceAvailable(source) && this.isSearchSourceEnabled(source); + }, + + getEnabledSearchSources() { + const sources = this.getSearchSourceDefinitions() + .filter(def => this.isSearchSourceUsable(def.source)) + .map(def => def.source); + return sources.length ? sources : ['local']; + }, + + getSearchSourceEnabledMap() { + return this.getSearchSourceDefinitions().reduce((enabled, def) => { + enabled[def.source] = this.isSearchSourceEnabled(def.source); + return enabled; + }, {}); + }, + + getSearchSourcesForSelection(selectedSource, missing = {}) { + if (selectedSource !== 'all') { + return this.isSearchSourceUsable(selectedSource) ? [selectedSource] : []; + } + + return this.getEnabledSearchSources(); + }, + + setSourceProgress(state, source, patch = {}, missing = null) { + state.sourceProgress = { + ...(state.sourceProgress || {}), + [source]: { + ...(state.sourceProgress?.[source] || {}), + ...patch + } + }; + if (missing) { + this.refreshMissingSourcesSummary(missing); + } + }, + + refreshMissingSourcesSummary(missing = {}) { + if (!this.contentElement || !missing) return; + + const key = this.getMissingModelKey(missing); + const rows = this.contentElement.querySelectorAll('.ml-missing-list-row'); + for (const row of rows) { + if (row.getAttribute('data-missing-key') !== key) continue; + + const sourcesEl = row.querySelector('.ml-missing-row-sources'); + if (sourcesEl) { + sourcesEl.innerHTML = this.renderMissingSourcesSummary(missing); + } + } + }, + + getSearchSourceEstimateMs(source, isUrn = false) { + if (isUrn && source === 'civitai') return 5000; + if (isUrn && source === 'civarchive') return 6000; + + const estimates = { + local: 1400, + lora_manager_archive: 3200, + civarchive: 10000, + huggingface: 18000, + civitai: 22000 + }; + return estimates[source] || 8000; + }, + + getEstimatedSearchProgressPercent(elapsedMs, estimateMs) { + const safeEstimate = Math.max(1000, Number(estimateMs) || 8000); + const elapsed = Math.max(0, Number(elapsedMs) || 0); + + if (elapsed <= safeEstimate) { + const normalized = Math.min(1, elapsed / safeEstimate); + const eased = 1 - Math.pow(1 - normalized, 2.1); + return 6 + eased * 78; + } + + const overtime = elapsed - safeEstimate; + const slowTail = 1 - Math.exp(-overtime / (safeEstimate * 1.6)); + return Math.min(94, 84 + slowTail * 10); + }, + + getSearchProgressTimerKey(runId, source) { + return `${runId || 'search'}:${source}`; + }, + + clearSearchProgressTimer(runId, source) { + const key = this.getSearchProgressTimerKey(runId, source); + const timer = this.searchProgressTimers.get(key); + if (timer) { + clearInterval(timer); + this.searchProgressTimers.delete(key); + } + }, + + clearSearchProgressTimers(runId) { + if (!runId) return; + const prefix = `${runId}:`; + for (const [key, timer] of this.searchProgressTimers.entries()) { + if (key.startsWith(prefix)) { + clearInterval(timer); + this.searchProgressTimers.delete(key); + } + } + }, + + startEstimatedSearchProgress(state, missing, container, source, runId) { + this.clearSearchProgressTimer(runId, source); + + const tick = () => { + if (state.activeSearchRunId !== runId) { + this.clearSearchProgressTimer(runId, source); + return; + } + + const progress = state.sourceProgress?.[source]; + if (!progress || progress.status !== 'running') { + this.clearSearchProgressTimer(runId, source); + return; + } + + const elapsedMs = Date.now() - (progress.startedAt || Date.now()); + const percent = this.getEstimatedSearchProgressPercent( + elapsedMs, + progress.estimateMs + ); + + if (percent > (Number(progress.percent) || 0) + 0.2) { + this.setSourceProgress(state, source, { percent }); + this.displaySearchResults(missing, state, container); + } + }; + + tick(); + const timer = setInterval(tick, 450); + this.searchProgressTimers.set( + this.getSearchProgressTimerKey(runId, source), + timer + ); + }, + + hasActiveSearchProgress(state = {}) { + return Object.values(state.sourceProgress || {}).some(progress => ( + progress?.status === 'pending' || progress?.status === 'running' + )); + }, + + renderSearchProgress(state = {}) { + const progressEntries = Object.entries(state.sourceProgress || {}); + if (!progressEntries.length) return ''; + const isCompact = !this.hasActiveSearchProgress(state); + + const statusLabels = { + pending: 'Queued', + running: 'Searching...', + found: 'Found', + none: 'No match', + error: 'Error' + }; + + if (isCompact) { + let html = '
'; + for (const [source, progress] of progressEntries) { + const status = progress?.status || 'pending'; + const statusClass = String(status).replace(/[^a-z0-9_-]/gi, ''); + const label = this.getSearchSourceLabel(source); + const statusLabel = progress?.message || statusLabels[status] || status; + const title = `${label}: ${statusLabel}`; + html += ` +
+ ${this.escapeHtml(label)} + ${this.escapeHtml(statusLabel)} +
+ `; + } + html += '
'; + return html; + } + + let html = '
'; + for (const [source, progress] of progressEntries) { + const status = progress?.status || 'pending'; + const statusClass = String(status).replace(/[^a-z0-9_-]/gi, ''); + const label = this.getSearchSourceLabel(source); + const percent = status === 'pending' + ? 0 + : (status === 'running' ? progress?.percent : 100); + const safePercent = Math.max(0, Math.min(100, Number(percent) || 0)); + const statusLabel = status === 'running' + ? `Searching... ${Math.round(safePercent)}%` + : (progress?.message || statusLabels[status] || status); + html += ` +
+
+ ${this.escapeHtml(label)} + ${this.escapeHtml(statusLabel)} +
+
+
+
+
+ `; + } + html += '
'; + return html; + }, + + /** + * Update source selector buttons and helper text for one card + */ + syncSearchSourceUi(missing, container) { + if (!container) return; + + const state = this.getSearchState(missing); + const selectEl = container.querySelector(`#search-source-select-${missing.node_id}-${missing.widget_index}`); + if (selectEl) { + const options = this.getSearchSourceOptions(); + if (!options.some(option => option.value === state.selectedSource)) { + state.selectedSource = 'all'; + } + this.setDropdownValue(selectEl, state.selectedSource, this.getSearchSourceLabel(state.selectedSource)); + } + }, + + /** + * Set current search source for one card + */ + setSearchSource(missing, source, container) { + const state = this.getSearchState(missing); + state.selectedSource = source || 'all'; + this.persistSearchStateForActiveWorkflow(); + this.syncSearchSourceUi(missing, container); + }, + + getDropdownValue(el) { + return el?.dataset?.value || el?.value || ''; + }, + + setDropdownValue(el, value, label = value) { + if (!el) return; + el.dataset.value = value || ''; + el.value = label || value || ''; + }, + + normalizeVersionName(versionName) { + return String(versionName || '').trim().replace(/^v{2,}(?=\d)/i, 'v'); + }, + + getModelVersionParts(modelName, versionName) { + const name = String(modelName || '').trim(); + const version = this.normalizeVersionName(versionName); + if (!version || version === name) { + return { name: name || version, version: '' }; + } + if (name && name.toLowerCase().includes(version.toLowerCase())) { + return { name, version: '' }; + } + return { name, version }; + }, + + getVersionedModelName(modelName, versionName) { + const parts = this.getModelVersionParts(modelName, versionName); + if (!parts.version) return parts.name; + return parts.name ? `${parts.name} ${parts.version}` : parts.version; + }, + + renderVersionedModelNameHtml(modelName, versionName) { + const parts = this.getModelVersionParts(modelName, versionName); + const nameHtml = parts.name ? this.escapeHtml(parts.name) : ''; + const versionHtml = parts.version + ? `${this.escapeHtml(parts.version)}` + : ''; + return [nameHtml, versionHtml].filter(Boolean).join(' '); + }, + + getSearchSourceIconName(sourceKey) { + const icons = { + popular: 'star', + 'model-list': 'comfyui', + huggingface: 'huggingface', + civitai: 'civitai', + civarchive: 'civarchive', + 'lora-archive': 'loraManager', + lora_manager_archive: 'loraManager', + 'lora-manager-archive': 'loraManager', + local: 'comfyui', + 'workflow-url': 'link', + workflow: 'link', + online: 'globe' + }; + return icons[sourceKey] || 'globe'; + }, + + renderSearchSourcePill(sourceKey, sourceLabel) { + const iconName = this.getSearchSourceIconName(sourceKey); + const iconHtml = getSvgIcon(iconName, 'currentColor', 'ml-search-source-icon'); + return ` + + ${iconHtml} + ${this.escapeHtml(sourceLabel)} + + `; + }, + + getSearchResultsTableLayout(rows = []) { + const textWidth = (value, charPx = 6, minPx = 40, maxPx = 180) => { + const length = String(value || '').length; + return Math.max(minPx, Math.min(maxPx, Math.ceil(length * charPx))); + }; + + const maxSourceLabel = rows.reduce( + (max, row) => Math.max(max, String(row.sourceLabel || row.sourceKey || '').length), + 'Source'.length + ); + const maxMatchLabel = rows.reduce( + (max, row) => Math.max(max, String(row.match?.label || '').length), + 'Match'.length + ); + const maxSizeLabel = rows.reduce( + (max, row) => Math.max(max, String(row.size || '-').length), + 'Size'.length + ); + const maxActions = rows.reduce((max, row) => { + const count = (row.downloadUrl ? 1 : 0) + (row.openUrl ? 1 : 0); + return Math.max(max, count); + }, 1); + + const sourcePx = textWidth('x'.repeat(maxSourceLabel), 6, 34, 100) + 52; + const matchPx = textWidth('x'.repeat(maxMatchLabel), 7, 34, 76) + 22; + const sizePx = textWidth('x'.repeat(maxSizeLabel), 6.5, 28, 72) + 22; + const actionsPx = Math.max(60, (maxActions * 24) + (Math.max(0, maxActions - 1) * 6) + 22); + const modelMinPx = 210; + const tableMinPx = Math.ceil(sourcePx + matchPx + sizePx + actionsPx + modelMinPx); + + return { sourcePx, matchPx, sizePx, actionsPx, tableMinPx }; + }, + + formatSearchResultSize(result = {}) { + if (result.size === 0) return '0 B'; + if (!result.size) return ''; + return typeof result.size === 'number' ? this.formatBytes(result.size) : String(result.size); + }, + + getSearchResultMatchDisplay(result = {}, fallbackLabel = 'Match', fallbackClass = 'neutral') { + const matchType = String(result.match_type || '').toLowerCase(); + if (matchType === 'exact') { + return { label: 'Exact', className: 'strong' }; + } + + const confidence = Number(result.confidence); + if (Number.isFinite(confidence) && confidence > 0) { + const className = confidence >= 95 ? 'strong' : (confidence >= 70 ? 'medium' : 'weak'); + return { label: `${Math.round(confidence)}%`, className }; + } + + if (matchType === 'partial') { + return { label: 'Partial', className: 'medium' }; + } + if (matchType === 'fuzzy' || matchType === 'similar') { + return { label: matchType === 'fuzzy' ? 'Fuzzy' : 'Similar', className: 'medium' }; + } + + return { label: fallbackLabel, className: fallbackClass }; + }, + + getDownloadSourceTableRow(missing, downloadSource = {}) { + if (!downloadSource?.url) return null; + + const originalFilename = missing.original_path?.split('/').pop()?.split('\\').pop() || ''; + const downloadFilename = downloadSource.filename || originalFilename || 'model'; + const isFromWorkflow = downloadSource.url_source === 'workflow'; + const source = downloadSource.source || (isFromWorkflow ? 'workflow' : 'online'); + const sourceLabels = { + popular: 'Popular', + model_list: 'Local Database', + huggingface: 'HuggingFace', + civitai: 'CivitAI', + civarchive: 'CivArchive', + lora_manager_archive: 'LoRA Archive', + workflow: 'Workflow', + online: 'Online' + }; + const sourceLabel = isFromWorkflow ? 'Workflow URL' : (sourceLabels[source] || source); + const sourceKey = isFromWorkflow + ? 'workflow-url' + : String(source).replace(/_/g, '-'); + const sourceSecondary = isFromWorkflow && sourceLabels[source] && source !== 'workflow' + ? sourceLabels[source] + : ''; + const modelUrl = downloadSource.model_url + || downloadSource.workflow_model_url + || this.getModelCardUrl(downloadSource.url); + const versionName = downloadSource.version_name || missing.civitai_info?.version_name || ''; + const modelParts = this.getModelVersionParts( + downloadSource.name || missing.civitai_info?.model_name || '', + versionName + ); + const modelName = modelParts.name || downloadFilename; + const fullModelName = this.getVersionedModelName(modelName, modelParts.version); + + return { + sourceKey, + sourceLabel, + model: modelName, + version: modelParts.version, + filename: downloadFilename, + secondary: sourceSecondary || (fullModelName && fullModelName !== downloadFilename ? downloadFilename : ''), + match: isFromWorkflow + ? { label: 'Provided', className: 'strong' } + : this.getSearchResultMatchDisplay(downloadSource, 'Known', 'strong'), + size: this.formatSearchResultSize(downloadSource), + downloadUrl: downloadSource.url, + downloadFilename, + category: downloadSource.directory || downloadSource.category || missing.category || 'checkpoints', + openUrl: modelUrl + }; + }, + + renderKnownDownloadPanel(missing, downloadSource) { + let html = `
`; + html += this.renderSearchControls(missing, { buttonText: 'Search Again' }); + html += this.renderDownloadTargetControls( + missing, + downloadSource.directory || downloadSource.category || missing.category || 'checkpoints' + ); + html += `
`; + + const downloadSourceRow = this.getDownloadSourceTableRow(missing, downloadSource); + html += `
`; + html += this.renderSearchResultsTable(downloadSourceRow ? [downloadSourceRow] : []); + html += `
`; + return html; + }, + + renderSearchResultsTable(rows = []) { + if (!rows.length) return ''; + const layout = this.getSearchResultsTableLayout(rows); + const tableStyle = [ + `--ml-source-col:${layout.sourcePx}px`, + `--ml-match-col:${layout.matchPx}px`, + `--ml-size-col:${layout.sizePx}px`, + `--ml-actions-col:${layout.actionsPx}px`, + `--ml-table-min:${layout.tableMinPx}px` + ].join(';'); + + let html = ` +
+ + + + + + + + + + + + + + + + + + + `; + + 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 += ` + +
SourceModelMatchSizeActions
${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.model?.path || match.path || match.filename || ''; + const isBestMatch = matchIndex === 0 && match.confidence >= 95; + const contextModel = this.buildContextMenuModelData(match.model || {}, match.filename || ''); + const modelData = encodeURIComponent(JSON.stringify(contextModel)); + + html += `
`; + html += this.getConfidenceBadge(match.confidence); + html += `${this.escapeHtml(matchPath)}`; + html += `${match.confidence === 100 ? 'Exact' : 'Partial'}`; + html += ``; + html += `
`; + } + + 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)); + const matchPath = match.model?.relative_path || match.model?.path || match.path || match.filename || ''; + html += `
`; + html += this.getConfidenceBadge(match.confidence); + html += `${this.escapeHtml(matchPath)}`; + html += `Partial`; + html += ``; + html += `
`; + } + html += `
`; + } + } else if (missing.is_urn && !missing.civitai_info) { + html += `
Waiting for CivitAI filename to search local models...
`; + } else if (allMatches.length > 0 && filteredMatches.length === 0) { + html += `
No matches above 70% confidence
`; + } else { + html += `
No local matches found
`; + } + + return html; + }, + + wireLocalMatchButtons(container, missing, missingIndex = 0) { + const allMatches = missing.matches || []; + const filteredMatches = allMatches.filter(m => m.confidence >= 70); + const perfectMatches = filteredMatches.filter(m => m.confidence === 100); + const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70); + const matchesToShow = perfectMatches.length > 0 + ? perfectMatches + : otherMatches.sort((a, b) => b.confidence - a.confidence).slice(0, 5); + + const sortedMatches = matchesToShow.sort((a, b) => { + if (a.confidence === 100 && b.confidence !== 100) return -1; + if (a.confidence !== 100 && b.confidence === 100) return 1; + return b.confidence - a.confidence; + }); + + sortedMatches.forEach((match, matchIndex) => { + const buttonId = `resolve-${missingIndex}-${missing.node_id}-${missing.widget_index}-${matchIndex}`; + const resolveButton = container.querySelector(`#${buttonId}`); + if (resolveButton) { + resolveButton.onclick = null; + resolveButton.addEventListener('click', () => { + this.queueResolution(missing, match.model); + }); + } + }); + + if (otherMatches && otherMatches.length > 0) { + for (let mIdx = 0; mIdx < otherMatches.length; mIdx++) { + const match = otherMatches[mIdx]; + const altBtnId = `resolve-alt-${missingIndex}-${missing.node_id}-${missing.widget_index}-${mIdx}`; + const altBtn = container.querySelector(`#${altBtnId}`); + if (altBtn) { + altBtn.addEventListener('click', () => { + this.queueResolution(missing, match.model); + }); + } + } + } + }, + + getUrnIds(missing = {}) { + return { + modelId: missing.urn_model_id || missing.urn?.model_id, + versionId: missing.urn_version_id || missing.urn?.version_id + }; + }, + + getUrnResolveKey(missing = {}) { + const ids = this.getUrnIds(missing); + return `${ids.modelId || ''}:${ids.versionId || ''}:${this.getMissingModelKey(missing)}`; + }, + + getCivitaiResultFromMissing(missing = {}) { + if (!missing.civitai_info?.expected_filename && !missing.download_source?.url) return null; + const ids = this.getUrnIds(missing); + return { + name: missing.civitai_info?.model_name || missing.download_source?.name, + version_name: missing.civitai_info?.version_name || missing.download_source?.version_name, + filename: missing.civitai_info?.expected_filename || missing.download_source?.filename, + download_url: missing.download_source?.url, + url: missing.download_source?.model_url, + type: missing.download_source?.type || missing.category, + size: missing.download_source?.size, + model_id: missing.download_source?.model_id || ids.modelId, + version_id: missing.download_source?.version_id || ids.versionId, + base_model: missing.civitai_info?.base_model || missing.download_source?.base_model, + tags: missing.civitai_info?.tags || missing.download_source?.tags || [] + }; + }, + + applyCivitaiUrnResult(missing, civitai = {}) { + if (!missing || !civitai) return; + const ids = this.getUrnIds(missing); + missing.civitai_info = { + model_name: civitai.name, + version_name: civitai.version_name, + expected_filename: civitai.filename, + base_model: civitai.base_model, + tags: civitai.tags || [] + }; + missing.download_source = { + source: 'civitai', + url: civitai.download_url, + filename: civitai.filename, + name: civitai.name, + version_name: civitai.version_name, + type: civitai.type, + directory: missing.category || 'checkpoints', + match_type: 'exact', + size: civitai.size, + model_id: civitai.model_id || ids.modelId, + version_id: civitai.version_id || ids.versionId, + model_url: civitai.url || `https://civitai.com/models/${ids.modelId}?modelVersionId=${ids.versionId}`, + base_model: civitai.base_model, + tags: civitai.tags || [] + }; + }, + + async resolveUrnDataForMissing(missing) { + if (!missing?.is_urn) return null; + + const existing = this.getCivitaiResultFromMissing(missing); + if (existing?.filename) { + return { civitai: existing }; + } + + const ids = this.getUrnIds(missing); + if (!ids.modelId || !ids.versionId) return null; + + const key = this.getUrnResolveKey(missing); + if (this.urnResolvePromises.has(key)) { + return this.urnResolvePromises.get(key); + } + + const promise = (async () => { + const tokens = this.getStoredTokens(); + const payload = { + filename: `${ids.modelId}_${ids.versionId}`, + category: '', + is_urn: true, + sources: ['civitai'], + model_id: ids.modelId, + version_id: ids.versionId, + civitai_candidate_limit: tokens.civitai_candidate_limit + }; + + const response = await api.fetchApi('/model_linker/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + throw new Error(`URN resolve failed: ${response.status}`); + } + + const data = await response.json(); + if (data.civitai) { + this.applyCivitaiUrnResult(missing, data.civitai); + } + return data; + })().finally(() => { + this.urnResolvePromises.delete(key); + }); + + this.urnResolvePromises.set(key, promise); + return promise; + }, + + refreshMissingListStats() { + if (!this.contentElement) return; + const statsEl = this.contentElement.querySelector('.ml-missing-list-stats'); + if (!statsEl) return; + + const stats = this.getMissingModelSummaryStats(this.missingModels || []); + statsEl.innerHTML = ` + ${stats.exact} exact + ${stats.partial} partial + ${stats.none} no match + `; + }, + + refreshMissingListRowLocalMatch(missing = {}) { + if (!this.contentElement) return; + + const key = this.getMissingModelKey(missing); + const rows = this.contentElement.querySelectorAll('.ml-missing-list-row'); + for (const row of rows) { + if (row.getAttribute('data-missing-key') !== key) continue; + + const bestMatch = this.getBestLocalMatch(missing, 70); + const confidence = bestMatch ? Number(bestMatch.confidence || 0) : 0; + const matchName = bestMatch?.model?.relative_path || bestMatch?.filename || bestMatch?.path || ''; + const matchDisplay = matchName || 'No local match'; + const matchClass = confidence === 100 ? 'exact' : (bestMatch ? 'partial' : 'none'); + + const bestEl = row.querySelector('.ml-missing-row-best'); + if (bestEl) { + bestEl.setAttribute('data-tooltip', matchDisplay); + bestEl.innerHTML = bestMatch + ? this.escapeHtml(matchDisplay) + : '-- No local match'; + } + + const matchEl = row.querySelector('.ml-missing-row-match'); + if (matchEl) { + matchEl.classList.remove( + 'ml-missing-row-match-exact', + 'ml-missing-row-match-partial', + 'ml-missing-row-match-none' + ); + matchEl.classList.add(`ml-missing-row-match-${matchClass}`); + const valueEl = matchEl.querySelector('strong'); + if (valueEl) { + valueEl.textContent = bestMatch + ? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%` + : '--'; + } + } + } + + this.refreshMissingListStats(); + }, + + async fetchUrnLocalMatches(missing) { + if (!missing?.civitai_info?.expected_filename) return []; + + const filename = missing.civitai_info.expected_filename; + if (missing.__urnLocalMatchesFilename === filename && Array.isArray(missing.matches)) { + return missing.matches; + } + + const key = this.getUrnResolveKey(missing); + if (this.urnLocalMatchPromises.has(key)) { + return this.urnLocalMatchPromises.get(key); + } + + const promise = (async () => { + const response = await api.fetchApi('/model_linker/local-matches', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filename, + category: missing.category || '' + }) + }); + + if (!response.ok) { + throw new Error(`Local match search failed: ${response.status}`); + } + + const data = await response.json(); + missing.matches = Array.isArray(data.matches) ? data.matches : []; + missing.__urnLocalMatchesFilename = filename; + return missing.matches; + })().finally(() => { + this.urnLocalMatchPromises.delete(key); + }); + + this.urnLocalMatchPromises.set(key, promise); + return promise; + }, + + async refreshUrnLocalMatches(missing) { + if (!missing?.civitai_info?.expected_filename || !this.contentElement) return; + + const bodyId = `local-matches-body-${missing.node_id}-${missing.widget_index}`; + const container = this.contentElement.querySelector(`#${bodyId}`); + if (container) { + container.innerHTML = `
Searching local matches for "${missing.civitai_info.expected_filename}"...
`; + } + + try { + await this.fetchUrnLocalMatches(missing); + if (container) { + container.innerHTML = this.renderLocalMatchesContent(missing, missing.__displayIndex || 0); + this.wireLocalMatchButtons(this.contentElement, missing, missing.__displayIndex || 0); + } + this.refreshMissingListRowLocalMatch(missing); + } catch (error) { + console.error('Model Linker: URN local match refresh error:', error); + if (container) { + container.innerHTML = `
Failed to refresh local matches.
`; + } + } + }, + + scheduleInitialUrnLocalMatchRefresh(missingModels = [], container = null, data = null) { + const refreshTasks = []; + + missingModels.forEach((missing) => { + if (!missing?.is_urn || missing.__urnLocalRefreshQueued) return; + if (this.getBestLocalMatch(missing, 70)) return; + + missing.__urnLocalRefreshQueued = true; + const task = (async () => { + try { + await this.resolveUrnDataForMissing(missing); + await this.refreshUrnLocalMatches(missing); + } catch (error) { + missing.__urnLocalRefreshFailed = true; + console.error('Model Linker: initial URN local match refresh error:', error); + } + })(); + refreshTasks.push(task); + }); + + if (!refreshTasks.length || !container || !data) return; + + Promise.allSettled(refreshTasks).then(() => { + if (!container.isConnected || this.activeTab !== 'missing') return; + this.displayMissingModels(container, data); + }); + } +}; diff --git a/web/linker/selection_methods.js b/web/linker/selection_methods.js new file mode 100644 index 0000000..6d19df6 --- /dev/null +++ b/web/linker/selection_methods.js @@ -0,0 +1,220 @@ +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; +import { $el } from "../../../../scripts/ui.js"; +import { getSvgIcon } from "../utils/icon_utils.js"; +export const selectionMethods = { + /** + * 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(); + } + }, + + handleFooterMenuOutsideClick(e) { + if (!this.activeFooterMenu) return; + if (e.target?.closest?.('.ml-footer-menu-wrap')) return; + this.closeFooterMenus(); + }, + + closeFooterMenus() { + this.activeFooterMenu = null; + this.footerMenus?.forEach?.((menu, name) => { + menu.classList.remove('is-open'); + const button = this.footerMenuButtons?.get?.(name); + button?.setAttribute('aria-expanded', 'false'); + }); + }, + + setMissingFooterControlsVisible(visible) { + const display = visible ? 'inline-flex' : 'none'; + [ + this.selectMenuWrap, + this.searchMenuWrap, + this.queueExactButton, + this.applyPendingBtn, + this.autoResolveButton, + this.downloadMenuWrap + ].forEach(element => { + if (element) element.style.display = display; + }); + if (!visible) { + this.closeFooterMenus(); + } + }, + + toggleFooterMenu(name) { + const menu = this.footerMenus?.get?.(name); + const button = this.footerMenuButtons?.get?.(name); + if (!menu || !button) return; + + const shouldOpen = this.activeFooterMenu !== name; + this.closeFooterMenus(); + if (shouldOpen) { + this.activeFooterMenu = name; + menu.classList.add('is-open'); + button.setAttribute('aria-expanded', 'true'); + } + }, + + syncBatchSelectionForMissingModels(missingModels = this.missingModels || []) { + if (!(this.batchSelectedMissingKeys instanceof Set)) { + this.batchSelectedMissingKeys = new Set(); + } + + const validKeys = new Set(missingModels.map(missing => this.getMissingModelKey(missing))); + for (const key of Array.from(this.batchSelectedMissingKeys)) { + if (!validKeys.has(key)) { + this.batchSelectedMissingKeys.delete(key); + } + } + if (this.lastBatchSelectedMissingKey && !validKeys.has(this.lastBatchSelectedMissingKey)) { + this.lastBatchSelectedMissingKey = null; + } + }, + + getSelectedMissingModels() { + const selectedKeys = this.batchSelectedMissingKeys || new Set(); + return (this.missingModels || []).filter(missing => selectedKeys.has(this.getMissingModelKey(missing))); + }, + + getMissingWithExactLocalMatches(missingModels = this.missingModels || []) { + return missingModels.filter(missing => this.getBestLocalMatch(missing, 100)); + }, + + getMissingWithoutExactLocalMatches(missingModels = this.missingModels || []) { + return missingModels.filter(missing => !this.getBestLocalMatch(missing, 100)); + }, + + getMissingWithPartialLocalMatches(missingModels = this.missingModels || []) { + return missingModels.filter(missing => { + const bestMatch = this.getBestLocalMatch(missing, 70); + return bestMatch && Number(bestMatch.confidence || 0) < 100; + }); + }, + + getMissingWithoutLocalMatches(missingModels = this.missingModels || []) { + return missingModels.filter(missing => !this.getBestLocalMatch(missing, 70)); + }, + + getMissingWithDownloadSources(missingModels = this.missingModels || []) { + return missingModels.filter(missing => this.getBestDownloadSourceForMissing(missing)); + }, + + getMissingWithoutDownloadSources(missingModels = this.missingModels || []) { + return missingModels.filter(missing => !this.getBestDownloadSourceForMissing(missing)); + }, + + getSearchedMissingModels(missingModels = this.missingModels || []) { + return missingModels.filter(missing => this.hasRenderableSearchState(this.getSearchState(missing))); + }, + + getUnsearchedMissingModels(missingModels = this.missingModels || []) { + return missingModels.filter(missing => !this.hasRenderableSearchState(this.getSearchState(missing))); + }, + + selectBatchMissingModels(mode) { + const missingModels = this.missingModels || []; + if (!missingModels.length) { + this.showNotification('No missing models to select.', 'info'); + return; + } + + if (!(this.batchSelectedMissingKeys instanceof Set)) { + this.batchSelectedMissingKeys = new Set(); + } + + if (mode === 'none') { + this.batchSelectedMissingKeys.clear(); + } else if (mode === 'invert') { + const nextSelection = new Set(); + for (const missing of missingModels) { + const key = this.getMissingModelKey(missing); + if (!this.batchSelectedMissingKeys.has(key)) { + nextSelection.add(key); + } + } + this.batchSelectedMissingKeys = nextSelection; + } else { + let models = missingModels; + if (mode === 'exact') { + models = this.getMissingWithExactLocalMatches(missingModels); + } else if (mode === 'no_exact') { + models = this.getMissingWithoutExactLocalMatches(missingModels); + } else if (mode === 'partial') { + models = this.getMissingWithPartialLocalMatches(missingModels); + } else if (mode === 'no_local') { + models = this.getMissingWithoutLocalMatches(missingModels); + } else if (mode === 'downloadable') { + models = this.getMissingWithDownloadSources(missingModels); + } else if (mode === 'no_download') { + models = this.getMissingWithoutDownloadSources(missingModels); + } else if (mode === 'searched') { + models = this.getSearchedMissingModels(missingModels); + } else if (mode === 'unsearched') { + models = this.getUnsearchedMissingModels(missingModels); + } + this.batchSelectedMissingKeys = new Set(models.map(missing => this.getMissingModelKey(missing))); + } + + this.refreshBatchSelectionUi(); + this.updateBatchFooterButtons(); + this.closeFooterMenus(); + }, + + refreshBatchSelectionUi() { + const selectedKeys = this.batchSelectedMissingKeys || new Set(); + this.contentElement?.querySelectorAll?.('.ml-missing-list-row')?.forEach(row => { + const key = row.getAttribute('data-missing-key'); + const selected = selectedKeys.has(key); + row.classList.toggle('is-batch-selected', selected); + const checkbox = row.querySelector('.ml-missing-row-check'); + if (checkbox) { + checkbox.checked = selected; + } + }); + this.updateBatchSelectAllCheckbox(); + }, + + updateBatchSelectAllCheckbox() { + const checkbox = this.contentElement?.querySelector?.('.ml-missing-select-all-check'); + if (!checkbox) return; + + const totalCount = (this.missingModels || []).length; + const selectedCount = this.getSelectedMissingModels().length; + checkbox.checked = totalCount > 0 && selectedCount === totalCount; + checkbox.indeterminate = selectedCount > 0 && selectedCount < totalCount; + checkbox.disabled = totalCount === 0; + }, + + setBatchSelectionForKey(key, selected) { + if (!key) return; + if (!(this.batchSelectedMissingKeys instanceof Set)) { + this.batchSelectedMissingKeys = new Set(); + } + if (selected) { + this.batchSelectedMissingKeys.add(key); + } else { + this.batchSelectedMissingKeys.delete(key); + } + }, + + applyBatchSelectionRange(sortedMissingModels, fromKey, toKey, selected) { + const models = sortedMissingModels || this.missingModels || []; + const fromIndex = models.findIndex(missing => this.getMissingModelKey(missing) === fromKey); + const toIndex = models.findIndex(missing => this.getMissingModelKey(missing) === toKey); + + if (fromIndex < 0 || toIndex < 0) { + this.setBatchSelectionForKey(toKey, selected); + return; + } + + const start = Math.min(fromIndex, toIndex); + const end = Math.max(fromIndex, toIndex); + for (let index = start; index <= end; index += 1) { + this.setBatchSelectionForKey(this.getMissingModelKey(models[index]), selected); + } + } +}; diff --git a/web/linker/tabs_loaded_methods.js b/web/linker/tabs_loaded_methods.js new file mode 100644 index 0000000..5d7d40e --- /dev/null +++ b/web/linker/tabs_loaded_methods.js @@ -0,0 +1,283 @@ +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; +import { $el } from "../../../../scripts/ui.js"; +import { getSvgIcon } from "../utils/icon_utils.js"; +export const tabsLoadedMethods = { + getTabButton(tab) { + return { + missing: this.missingTab, + loaded: this.loadedTab, + options: this.optionsTab + }[tab] || null; + }, + + updateTabButtonStates() { + ['missing', 'loaded', 'options'].forEach((tab) => { + const button = this.getTabButton(tab); + if (!button) return; + const isActive = tab === this.activeTab; + button.classList.toggle('ml-tab-active', isActive); + button.setAttribute('aria-selected', isActive ? 'true' : 'false'); + button.setAttribute('aria-disabled', isActive ? 'true' : 'false'); + if (isActive) { + button.setAttribute('aria-current', 'page'); + } else { + button.removeAttribute('aria-current'); + } + }); + }, + + switchTab(tab, { force = false } = {}) { + const nextTab = this.getValidTab(tab); + const nextTabButton = this.getTabButton(nextTab); + if (!force && nextTab === this.activeTab && nextTabButton?.classList.contains('ml-tab-active')) { + this.hideTooltip(); + this.updateQueueVisibility(); + return; + } + + this.activeTab = nextTab; + this.persistActiveTab(this.activeTab); + this.hideTooltip(); + this.animateTabContentTransition(); + this.updateTabButtonStates(); + this.updateQueueVisibility(); + + if (this.activeTab === 'missing') { + if (this.contentElement) { + this.contentElement.style.overflowY = 'auto'; + } + this.setMissingFooterControlsVisible(true); + this.loadWorkflowData(); + } else if (this.activeTab === 'loaded') { + if (this.contentElement) { + this.contentElement.style.overflowY = 'auto'; + } + this.setMissingFooterControlsVisible(false); + this.loadLoadedModels(); + } else { + if (this.contentElement) { + this.contentElement.style.overflowY = 'hidden'; + } + this.setMissingFooterControlsVisible(false); + this.displayOptions(); + } + }, + + async loadLoadedModels(workflow = null) { + if (!this.contentElement) return; + + this.contentElement.innerHTML = '

Loading loaded models...

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

No workflow loaded. Please load a workflow first.

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

Error: ${error.message}

`; + } + } + }, + + displayLoadedModels(container, data) { + const loadedModels = data.loaded_models || []; + const total = data.total || 0; + + if (total === 0) { + container.innerHTML = this.renderStatusMessage('No models found in workflow.', 'info'); + return; + } + + const byCategory = {}; + + for (const model of loadedModels) { + const cat = model.category || 'unknown'; + if (!byCategory[cat]) { + byCategory[cat] = { active: [], inactive: [] }; + } + + // Determine if model is active or inactive + // For LoraLoaderV2/LoraManager: check model.active field + // For other nodes: check model.connected field (false means not connected or bypassed) + let isActive = true; + if (model.is_lora_v2) { + // For text-based lora loaders, check both active flag AND connected status + isActive = model.active !== false && model.connected !== false; + } else { + // For regular nodes, check connected status + isActive = model.connected !== false; + } + + if (isActive) { + byCategory[cat].active.push(model); + } else { + byCategory[cat].inactive.push(model); + } + } + + const activeCount = Object.values(byCategory).reduce((sum, cat) => sum + cat.active.length, 0); + const inactiveCount = Object.values(byCategory).reduce((sum, cat) => sum + cat.inactive.length, 0); + + const buildCategoryStrings = (filter) => { + const result = {}; + for (const [category, modelsObj] of Object.entries(byCategory)) { + const displayCat = this.getCategoryTokenName(category); + const models = filter === 'active' ? modelsObj.active : filter === 'inactive' ? modelsObj.inactive : [...modelsObj.active, ...modelsObj.inactive]; + const parts = models.map(model => { + const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown'; + let name = fullName; + if (fullName.match(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i)) { + name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); + } + const strength = model.strength !== null && model.strength !== undefined + ? model.strength.toFixed(2) + : '1.00'; + return `<${displayCat}:${name}:${strength}>`; + }); + result[category] = parts.join(' '); + } + return Object.values(result).join(' '); + }; + + const activeString = buildCategoryStrings('active'); + const inactiveString = buildCategoryStrings('inactive'); + const allString = buildCategoryStrings('all'); + + const copyIcon = getSvgIcon('copy', 'currentColor', 'ml-copy-btn-icon'); + const stripModelExtension = (value) => String(value || 'Unknown').replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); + const modelToken = (model, category) => { + const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown'; + const name = stripModelExtension(fullName); + const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : '1.00'; + return `<${this.getCategoryTokenName(category)}:${name}:${strength}>`; + }; + const copyButtonHtml = (text, extraClass = '') => ` + `; + const countText = (count, label) => `${count} ${label}${count === 1 ? '' : 's'}`; + + let html = ` +
+
+

Loaded Models ${total}

+

${countText(activeCount, 'active')} / ${countText(inactiveCount, '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; + const sectionTotal = modelsObj.active.length + modelsObj.inactive.length; + + html += `
`; + + html += `
+
+ ${this.escapeHtml(displayName.toUpperCase())} + ${countText(sectionTotal, 'model')} +
+
+ ${hasActive ? `${countText(modelsObj.active.length, 'active')}` : ''} + ${hasInactive ? `${countText(modelsObj.inactive.length, 'inactive')}` : ''} +
+
`; + + if (hasActive) { + const activeStr = modelsObj.active.map(m => modelToken(m, category)).join(' '); + + html += `
+
+ Active ${modelsObj.active.length} + ${copyButtonHtml(activeStr)} +
+
`; + + for (const model of modelsObj.active) { + const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown'; + const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); + const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null; + const modelData = encodeURIComponent(JSON.stringify(model)); + html += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; + } + html += `
`; + } + + if (hasInactive) { + const inactiveStr = modelsObj.inactive.map(m => modelToken(m, category)).join(' '); + + html += `
+
+ Inactive ${modelsObj.inactive.length} + ${copyButtonHtml(inactiveStr, 'is-muted')} +
+
`; + + for (const model of modelsObj.inactive) { + const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown'; + const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); + const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null; + const modelData = encodeURIComponent(JSON.stringify(model)); + html += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; + } + html += `
`; + } + + html += `
`; + } + + const copySectionId = 'ml-copy-' + Date.now(); + html += ` +
+
+
+
+
Copy all
+
${countText(activeCount + inactiveCount, 'token')}
+
+
+ + + + +
+
+ ${this.escapeHtml(allString)} +
+ `; + + container.innerHTML = html; + + // Store data on container for filter function + container.dataset.mlActiveString = activeString; + container.dataset.mlInactiveString = inactiveString; + container.dataset.mlAllString = allString; + } +}; diff --git a/web/linker/workflow_state_methods.js b/web/linker/workflow_state_methods.js new file mode 100644 index 0000000..dcbd8e3 --- /dev/null +++ b/web/linker/workflow_state_methods.js @@ -0,0 +1,256 @@ +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; +import { $el } from "../../../../scripts/ui.js"; +import { getSvgIcon } from "../utils/icon_utils.js"; +export const workflowStateMethods = { + getWorkflowSignature(workflow) { + if (!workflow) return null; + try { + return JSON.stringify(workflow); + } catch (error) { + console.warn('Model Linker: workflow signature generation failed', error); + return null; + } + }, + + getActiveWorkflowRouteKey() { + try { + return window.location?.hash || ''; + } catch (error) { + return ''; + } + }, + + rememberActiveWorkflow(workflow = null) { + const currentWorkflow = workflow || this.getCurrentWorkflow(); + this.activeWorkflowRouteKey = this.getActiveWorkflowRouteKey(); + this.activeWorkflowSignature = this.getWorkflowSignature(currentWorkflow); + }, + + getWorkflowScopedQueueKey(route = this.activeWorkflowRouteKey, signature = this.activeWorkflowSignature) { + return route || signature || null; + }, + + clonePendingResolutions(list = this.pendingResolutions) { + try { + return JSON.parse(JSON.stringify(Array.isArray(list) ? list : [])); + } catch (error) { + console.warn('Model Linker: failed to clone queued selections', error); + return Array.isArray(list) ? list.map(item => ({ ...item })) : []; + } + }, + + savePendingQueueForActiveWorkflow() { + const key = this.getWorkflowScopedQueueKey(); + if (!key) return; + + const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : []; + if (list.length) { + this.workflowPendingSelections.set(key, this.clonePendingResolutions(list)); + } else { + this.workflowPendingSelections.delete(key); + } + }, + + restorePendingQueueForActiveWorkflow() { + const key = this.getWorkflowScopedQueueKey(); + const saved = key ? this.workflowPendingSelections.get(key) : null; + this.pendingResolutions = saved ? this.clonePendingResolutions(saved) : []; + this.rebuildPendingIndex(); + this.updateApplyPendingButton?.(); + this.updateQueuePanel?.(); + this.updateQueueVisibility?.(); + }, + + cloneSearchState(state = {}) { + let clone = {}; + try { + clone = JSON.parse(JSON.stringify(state || {})); + } catch (error) { + console.warn('Model Linker: failed to clone search state', error); + clone = { ...state }; + } + + clone.selectedSource = clone.selectedSource || 'all'; + clone.results = this.mergeSearchResults({}, clone.results || {}); + clone.lastAttemptSources = Array.isArray(clone.lastAttemptSources) + ? clone.lastAttemptSources + : []; + clone.lastAttemptFound = clone.lastAttemptFound ?? null; + clone.lastAttemptError = clone.lastAttemptError || null; + clone.sourceProgress = clone.sourceProgress || {}; + clone.activeSearchRunId = null; + + for (const progress of Object.values(clone.sourceProgress)) { + if (!progress || (progress.status !== 'pending' && progress.status !== 'running')) continue; + progress.status = 'error'; + progress.percent = 100; + progress.message = 'Search interrupted'; + } + + return clone; + }, + + cloneSearchResultCache(cache = this.searchResultCache) { + const cloned = new Map(); + for (const [key, state] of cache.entries()) { + if (!this.isPersistableSearchState(state)) { + continue; + } + cloned.set(key, this.cloneSearchState(state)); + } + return cloned; + }, + + isPersistableSearchState(state = {}) { + if ((state?.selectedSource || 'all') !== 'all') return true; + if (state?.lastAttemptError) return true; + if (this.hasSearchResults(state?.results || {})) return true; + + const progressEntries = Object.values(state?.sourceProgress || {}); + return progressEntries.some(progress => ( + progress?.status && progress.status !== 'pending' && progress.status !== 'running' + )); + }, + + saveSearchCacheForActiveWorkflow() { + const key = this.getWorkflowScopedQueueKey(); + if (!key) return; + + const cloned = this.cloneSearchResultCache(); + if (cloned.size) { + this.workflowSearchResultCaches.set(key, cloned); + } else { + this.workflowSearchResultCaches.delete(key); + } + }, + + restoreSearchCacheForActiveWorkflow() { + const key = this.getWorkflowScopedQueueKey(); + const saved = key ? this.workflowSearchResultCaches.get(key) : null; + this.searchResultCache = saved ? this.cloneSearchResultCache(saved) : new Map(); + }, + + persistSearchStateForActiveWorkflow() { + this.saveSearchCacheForActiveWorkflow(); + }, + + clearAllSearchProgressTimers() { + for (const timer of this.searchProgressTimers.values()) { + clearInterval(timer); + } + this.searchProgressTimers.clear(); + }, + + syncWorkflowScopedQueue(workflow = null) { + const currentWorkflow = workflow || this.getCurrentWorkflow(); + const nextRoute = this.getActiveWorkflowRouteKey(); + const nextSignature = this.getWorkflowSignature(currentWorkflow); + const previousKey = this.getWorkflowScopedQueueKey(); + const nextKey = this.getWorkflowScopedQueueKey(nextRoute, nextSignature); + + if (!nextKey || nextKey === previousKey) { + this.activeWorkflowRouteKey = nextRoute; + this.activeWorkflowSignature = nextSignature; + return; + } + + this.savePendingQueueForActiveWorkflow(); + this.saveSearchCacheForActiveWorkflow(); + this.clearWorkflowScopedState(); + this.activeWorkflowRouteKey = nextRoute; + this.activeWorkflowSignature = nextSignature; + this.restorePendingQueueForActiveWorkflow(); + this.restoreSearchCacheForActiveWorkflow(); + }, + + clearWorkflowScopedState() { + this.cachedWorkflowSignature = null; + this.cachedAnalysisData = null; + for (const state of this.searchResultCache.values()) { + state.activeSearchRunId = null; + } + this.clearAllSearchProgressTimers(); + this.searchResultCache.clear(); + this.urnResolvePromises.clear(); + this.urnLocalMatchPromises.clear(); + this.pendingResolutions = []; + this.rebuildPendingIndex(); + this.updateApplyPendingButton?.(); + this.updateQueuePanel?.(); + this.updateQueueVisibility(); + }, + + scheduleActiveWorkflowRefresh(reason = 'workflow-change') { + if (!this.isVisible()) return; + + this._workflowRefreshExpectedRoute = this.getActiveWorkflowRouteKey(); + this._workflowRefreshPreviousSignature = this.activeWorkflowSignature; + + if (this._workflowRefreshTimer) { + clearTimeout(this._workflowRefreshTimer); + } + + this._workflowRefreshTimer = setTimeout(() => { + this._workflowRefreshTimer = null; + this.refreshForActiveWorkflowChange({ + reason, + expectedRoute: this._workflowRefreshExpectedRoute, + previousSignature: this._workflowRefreshPreviousSignature, + attempt: 0 + }); + }, 180); + }, + + async refreshForActiveWorkflowChange({ reason = 'workflow-change', expectedRoute = '', previousSignature = null, attempt = 0 } = {}) { + if (!this.isVisible()) return; + + const currentRoute = this.getActiveWorkflowRouteKey(); + if (expectedRoute && currentRoute !== expectedRoute) { + this.scheduleActiveWorkflowRefresh(reason); + return; + } + + const workflow = this.getCurrentWorkflow(); + const signature = this.getWorkflowSignature(workflow); + const routeChanged = currentRoute !== this.activeWorkflowRouteKey; + const signatureChanged = signature && signature !== this.activeWorkflowSignature; + const graphStillLooksOld = routeChanged && previousSignature && signature === previousSignature; + + if ((!signature || graphStillLooksOld) && attempt < 8) { + setTimeout(() => { + this.refreshForActiveWorkflowChange({ + reason, + expectedRoute, + previousSignature, + attempt: attempt + 1 + }); + }, 180 + (attempt * 120)); + return; + } + + if (!routeChanged && !signatureChanged) return; + + console.log('Model Linker: active workflow changed, refreshing current tab', { + reason, + route: currentRoute, + tab: this.activeTab + }); + + this.savePendingQueueForActiveWorkflow(); + this.saveSearchCacheForActiveWorkflow(); + this.clearWorkflowScopedState(); + this.activeWorkflowRouteKey = currentRoute; + this.activeWorkflowSignature = signature; + this.restorePendingQueueForActiveWorkflow(); + this.restoreSearchCacheForActiveWorkflow(); + + if (this.activeTab === 'missing') { + if (this.contentElement) this.contentElement.style.overflowY = 'auto'; + await this.loadWorkflowData(workflow); + } else if (this.activeTab === 'loaded') { + if (this.contentElement) this.contentElement.style.overflowY = 'auto'; + await this.loadLoadedModels(workflow); + } + } +}; diff --git a/web/linker/workflow_update_methods.js b/web/linker/workflow_update_methods.js new file mode 100644 index 0000000..08cf84f --- /dev/null +++ b/web/linker/workflow_update_methods.js @@ -0,0 +1,88 @@ +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; +import { $el } from "../../../../scripts/ui.js"; +import { getSvgIcon } from "../utils/icon_utils.js"; +export const workflowUpdateMethods = { + /** + * Extract model page URL from a download URL + * HuggingFace file: https://huggingface.co/Owner/Repo/resolve/main/file.safetensors -> https://huggingface.co/Owner/Repo/blob/main/file.safetensors + * CivitAI: https://civitai.com/api/download/models/123?type=Model -> https://civitai.com/models/123 + */ + getModelCardUrl(downloadUrl) { + if (!downloadUrl) return null; + + try { + // HuggingFace URLs + if (downloadUrl.includes('huggingface.co')) { + const fileMatch = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)\/(?:resolve|blob)\/([^\/]+)\/(.+)$/); + if (fileMatch) { + const repo = fileMatch[1]; + const revision = fileMatch[2]; + const filePath = fileMatch[3].split(/[?#]/)[0]; + return `https://huggingface.co/${repo}/blob/${revision}/${filePath}`; + } + + const match = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)/); + if (match) { + return `https://huggingface.co/${match[1]}`; + } + } + + // CivitAI URLs + if (downloadUrl.includes('civitai.com')) { + // Format: /api/download/models/123456 or /models/123456/... + const modelIdMatch = downloadUrl.match(/models\/(\d+)/); + if (modelIdMatch) { + return `https://civitai.com/models/${modelIdMatch[1]}`; + } + } + } catch (e) { + console.error('Error parsing model card URL:', e); + } + + return null; + }, + + /** + * Update workflow in ComfyUI's UI/memory + * Updates the current workflow in place instead of creating a new tab + */ + async updateWorkflowInComfyUI(workflow) { + if (!app || !app.graph) { + console.warn('Model Linker: Could not update workflow - app or app.graph not available'); + return; + } + + try { + // Method 1: Try to directly update the current graph using configure + // This is the most direct way to update in place + if (app.graph && typeof app.graph.configure === 'function') { + app.graph.configure(workflow); + return; + } + + // Method 2: Try deserialize to update the graph in place + if (app.graph && typeof app.graph.deserialize === 'function') { + app.graph.deserialize(workflow); + return; + } + + // Method 3: Use loadGraphData with explicit parameters to update current tab + // The key is to NOT create a new workflow - pass null or undefined for the workflow parameter + // clean=false means don't clear the graph first + // restore_view=false means don't restore the viewport + // workflow=null means update current workflow instead of creating new one + if (app.loadGraphData) { + // Try with null as 4th parameter first + await app.loadGraphData(workflow, false, false, null); + return; + } + + console.warn('Model Linker: No method available to update workflow'); + } catch (error) { + console.error('Model Linker: Error updating workflow in ComfyUI:', error); + // Don't throw - allow the workflow update to continue even if UI update fails + // The backend has already updated the workflow data + } + } +};