import { api } from "../../../../../scripts/api.js"; import { escapeHtml, escapeJsString, getFilenameFromPath, pollBackgroundTask } from "./html_utils.js"; import { syncElementAttributes, setTextIfChanged } from "./dom_patch_utils.js"; export const renderFormatMethods = { escapeHtml, escapeJsString, getFilenameFromPath, encodeContextMenuModel(context = null) { if (!context) return ''; try { return encodeURIComponent(JSON.stringify(context)); } catch (error) { console.warn('Model Resolver: failed to encode context menu model', error); return ''; } }, getContextMenuAttrs(context = null, tooltip = '') { const data = this.encodeContextMenuModel(context); if (!data) return ''; const tooltipAttr = tooltip ? ` data-tooltip="${this.escapeHtml(tooltip)}"` : ''; return ` data-model="${this.escapeHtml(data)}" oncontextmenu="window.MLOpenContextMenu(event, this)"${tooltipAttr}`; }, setContextMenuElement(element, context = null, tooltip = '') { if (!element) return; const data = this.encodeContextMenuModel(context); if (!data) { element.removeAttribute('data-model'); element.removeAttribute('data-tooltip'); element.oncontextmenu = null; return; } element.dataset.model = data; if (tooltip) { element.dataset.tooltip = tooltip; } else { element.removeAttribute('data-tooltip'); } element.oncontextmenu = (event) => { window.MLOpenContextMenu?.(event, event.currentTarget); }; }, getModelPreviewTooltipAttrs(model = {}, label = '') { const tooltipText = String(label || '').trim(); const tooltipAttr = tooltipText ? ` data-tooltip="${this.escapeHtml(tooltipText)}"` : ''; const modelPath = String( model.resolved_path || model.path || model.full_path || '' ).trim(); if (!modelPath) return tooltipAttr; const endpoint = api.apiURL('/model_resolver/model-preview'); const previewUrl = `${endpoint}?path=${encodeURIComponent(modelPath)}`; return `${tooltipAttr} data-tooltip-image="${this.escapeHtml(previewUrl)}"`; }, /** * 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 = 'mr-badge-high'; } else if (confidence >= 70) { badgeClass = 'mr-badge-medium'; } else { badgeClass = 'mr-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 = this.getFilenameFromPath(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 }; }, /** * 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', options = {}) { const icons = { error: '⚠', success: '✓', info: 'ℹ', warning: '⚡' }; const icon = icons[type] || icons.info; const contextMenuModel = options?.contextMenuModel || null; const contextMenuTooltip = options?.contextMenuTooltip || 'Right-click to open download folder'; const contextMenuAttrs = this.getContextMenuAttrs(contextMenuModel, contextMenuTooltip); const className = contextMenuAttrs ? `mr-status mr-status-${type} mr-download-folder-context` : `mr-status mr-status-${type}`; 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}
`; }, renderProgressSection({ statusLabel, messageHtml = '', progressHtml = '', detailHtml = '' } = {}) { return `
${this.getStatusBadge(statusLabel, 'info')} ${messageHtml}
${progressHtml} ${detailHtml}
`; }, 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.renderProgressSection({ statusLabel: 'Analyzing', messageHtml: message, progressHtml: this.renderProgressBar(percent, detail, `${percent}%`), detailHtml: modelName ? `
${modelName}
` : '', }); }, renderLoadedModelsProgress(progress = {}) { const current = Number(progress.current) || 0; const total = Number(progress.total) || 0; const explicitPercent = Number(progress.percent); const percent = Number.isFinite(explicitPercent) ? Math.max(0, Math.min(100, Math.round(explicitPercent))) : (total > 0 ? Math.max(0, Math.min(100, Math.round((current / total) * 100))) : 0); const message = progress.message || 'Loading loaded models...'; const detail = total > 0 ? `${current} / ${total}` : 'Preparing...'; const nodeType = progress.node_type ? this.escapeHtml(String(progress.node_type)) : ''; return this.renderProgressSection({ statusLabel: 'Loading', messageHtml: this.escapeHtml(message), progressHtml: this.renderProgressBar(percent, detail, `${percent}%`), detailHtml: nodeType ? `
${nodeType}
` : '', }); }, patchLoadedModelsProgress(container, renderedHtml = '') { if (!container) return; const documentRef = container.ownerDocument || globalThis.document; if (!documentRef?.createElement) { container.innerHTML = renderedHtml; return; } const template = documentRef.createElement('template'); template.innerHTML = renderedHtml || ''; const nextRoot = template.content.firstElementChild; const currentRoot = container.firstElementChild; if ( !nextRoot || !currentRoot || !currentRoot.classList.contains('mr-download-section') || !nextRoot.classList.contains('mr-download-section') ) { if (container.innerHTML !== renderedHtml) { container.innerHTML = renderedHtml; } return; } const currentInfo = Array.from(currentRoot.querySelectorAll('.mr-download-info')); const nextInfo = Array.from(nextRoot.querySelectorAll('.mr-download-info')); const currentProgressBar = currentRoot.querySelector('.mr-progress-bar'); const nextProgressBar = nextRoot.querySelector('.mr-progress-bar'); const currentProgressFill = currentRoot.querySelector('.mr-progress-fill'); const nextProgressFill = nextRoot.querySelector('.mr-progress-fill'); const currentProgressText = Array.from(currentRoot.querySelectorAll('.mr-progress-text span')); const nextProgressText = Array.from(nextRoot.querySelectorAll('.mr-progress-text span')); if ( currentInfo.length !== nextInfo.length || !currentProgressBar || !nextProgressBar || !currentProgressFill || !nextProgressFill || currentProgressText.length !== nextProgressText.length ) { currentRoot.innerHTML = nextRoot.innerHTML; syncElementAttributes(currentRoot, nextRoot); return; } syncElementAttributes(currentRoot, nextRoot); currentInfo.forEach((element, index) => { setTextIfChanged(element, nextInfo[index].textContent); }); syncElementAttributes(currentProgressBar, nextProgressBar); syncElementAttributes(currentProgressFill, nextProgressFill); currentProgressText.forEach((element, index) => { setTextIfChanged(element, nextProgressText[index].textContent); }); }, patchAnalysisProgress(container, renderedHtml = '') { this.patchLoadedModelsProgress(container, renderedHtml); }, async pollWorkflowProgress({ endpoint, tokenCheck, onProgress, onTerminal, onError, filterIgnoredStatus, }) { await pollBackgroundTask({ endpoint, tokenCheck, onProgress, isTerminal: (progress) => progress.status === 'completed' || progress.status === 'error', onTerminal, onError, intervalMs: 250, fetchJson: this.fetchJson.bind(this), filterIgnoredStatus, }); }, async pollAnalysisProgress(analysisId, token) { await this.pollWorkflowProgress({ endpoint: `/model_resolver/analyze-progress/${analysisId}`, tokenCheck: () => this._analysisProgressToken === token, onProgress: (progress) => { if (this.contentElement && this.activeTab === 'missing') { this.patchAnalysisProgress( this.contentElement, this.renderAnalysisProgress(progress) ); } }, isTerminal: (progress) => progress.status === 'completed' || progress.status === 'error', onTerminal: () => { if (this._analysisProgressToken === token) { this._analysisProgressToken = null; } }, onError: (error) => { console.warn('Model Resolver: analysis progress polling failed', error); }, }); }, async pollLoadedModelsProgress(loadedId, token) { await this.pollWorkflowProgress({ endpoint: `/model_resolver/loaded-progress/${encodeURIComponent(loadedId)}`, tokenCheck: () => this._loadedModelsProgressToken === token, filterIgnoredStatus: (progress) => progress?.status === 'unknown', onProgress: (progress) => { if (this.contentElement && this.activeTab === 'loaded') { this.patchLoadedModelsProgress( this.contentElement, this.renderLoadedModelsProgress(progress) ); } }, isTerminal: (progress) => progress.status === 'completed' || progress.status === 'error', onTerminal: () => { if (this._loadedModelsProgressToken === token) { this._loadedModelsProgressToken = null; } }, onError: (error) => { console.warn('Model Resolver: loaded models progress polling failed', error); }, }); }, /** * Get display information for the best local match of a missing model */ getLocalMatchDisplayInfo(missing) { const bestMatch = this.getBestLocalMatch?.(missing, 70) || null; 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'); return { bestMatch, confidence, matchDisplay, matchClass }; }, /** * Format bytes to human readable string */ formatBytes(bytes) { const value = Number(bytes) || 0; if (!Number.isFinite(value) || value <= 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.min(sizes.length - 1, Math.floor(Math.log(value) / Math.log(k))); return parseFloat((value / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; }, formatDuration(seconds) { const totalSeconds = Math.max(0, Math.ceil(Number(seconds) || 0)); if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return ''; if (totalSeconds < 60) return `${totalSeconds}s`; const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const remainingSeconds = totalSeconds % 60; if (hours > 0) { return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; } return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; }, formatDownloadPercent(value) { const percent = Math.max(0, Math.min(100, Number(value) || 0)); if (percent > 0 && percent < 0.1) return '<0.1'; if (percent > 0 && percent < 10) { return percent.toFixed(1).replace(/\.0$/, ''); } return String(Math.round(percent)); }, getDownloadDisplayProgress(progress = {}) { const logicalDownloaded = Math.max(0, Number(progress.downloaded) || 0); const logicalTotalSize = Math.max(0, Number(progress.total_size) || 0); const logicalPercent = Math.max(0, Math.min(100, Number(progress.progress) || 0)); const backend = String(progress.download_backend || '').toLowerCase(); const transferDownloaded = Math.max(0, Number(progress.transfer_downloaded) || 0); const transferTotalSize = Math.max(0, Number(progress.transfer_total_size) || 0); if (backend === 'huggingface_xet' && logicalTotalSize > 0) { return { percent: Math.max(0, Math.min(100, (transferDownloaded / logicalTotalSize) * 100)), downloaded: transferDownloaded, totalSize: logicalTotalSize, isTransfer: true, isFinalizing: transferTotalSize > 0 && transferDownloaded >= transferTotalSize && logicalTotalSize > 0 && logicalDownloaded < logicalTotalSize }; } return { percent: logicalPercent, downloaded: logicalDownloaded, totalSize: logicalTotalSize, isTransfer: false, isFinalizing: false }; }, getDownloadProgressPresentation(progress = {}, { unknownTotal = '' } = {}) { const displayProgress = this.getDownloadDisplayProgress(progress); const percent = displayProgress.percent; return { ...displayProgress, percentLabel: this.formatDownloadPercent?.(percent) ?? String(Math.round(percent)), downloadedText: this.formatBytes(displayProgress.downloaded), totalText: this.formatBytes(displayProgress.totalSize), progressMeta: this.formatDownloadProgressMeta(progress), logicalDownloadedText: this.formatBytes(progress.downloaded || 0), logicalTotalText: progress.total_size ? this.formatBytes(progress.total_size) : unknownTotal, }; }, getDownloadEtaText(progress = {}) { const backend = String(progress.download_backend || '').toLowerCase(); const totalSize = Number(progress.total_size) || 0; const downloaded = backend === 'huggingface_xet' ? (Number(progress.transfer_downloaded) || 0) : (Number(progress.downloaded) || 0); const speed = Number(progress.speed) || 0; if (totalSize <= 0 || downloaded <= 0 || speed <= 0 || downloaded >= totalSize) return ''; const duration = this.formatDuration((totalSize - downloaded) / speed); return duration ? `ETA ${duration}` : ''; }, formatDownloadProgressMeta(progress = {}) { const parts = []; const speed = Number(progress.speed) || 0; if (speed > 0) { parts.push(`${this.formatBytes(speed)}/s`); } const eta = this.getDownloadEtaText(progress); if (eta) { parts.push(eta); } return parts.join(' | '); }, getBaseDirectoryLabel(baseDirectory = '') { const clean = String(baseDirectory || '').replace(/[\\/]+$/, ''); if (!clean) return 'Default root'; return clean.split(/[\\/]+/).filter(Boolean).pop() || clean; }, estimateTextWidth(value, charPx = 6, minPx = 40, maxPx = 180) { const length = String(value || '').length; return Math.max(minPx, Math.min(maxPx, Math.ceil(length * charPx))); }, normalizePathToForward(value) { return String(value || '').trim().replace(/\\/g, '/'); }, normalizePathToBackward(value) { return String(value || '').trim().replace(/\//g, '\\'); }, stripModelExtension(value) { return stripModelExtension(value); }, hasModelExtension(value) { return hasModelExtension(value); } }; export const MODEL_EXTENSIONS_REGEX = /\.(safetensors|ckpt|pt2?|bin|pth|pkl|sft|onnx|gguf)(?:$|[?#])/i; export function stripModelExtension(value) { if (!value) return ''; return String(value).replace(MODEL_EXTENSIONS_REGEX, ''); } export function hasModelExtension(value) { if (!value) return false; return MODEL_EXTENSIONS_REGEX.test(value); }