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 `
`;
},
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 pollAnalysisProgress(analysisId, token) {
await pollBackgroundTask({
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);
},
intervalMs: 250,
fetchJson: this.fetchJson.bind(this)
});
},
async pollLoadedModelsProgress(loadedId, token) {
await pollBackgroundTask({
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);
},
intervalMs: 250,
fetchJson: this.fetchJson.bind(this)
});
},
/**
* 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);
}