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; const canShowMore = this.canShowSourceDetails(model); const isDownloadTableContext = model?.context_scope === 'download_table'; const isDownloadFolderContext = model?.context_scope === 'download_folder'; const hasLocalPath = Boolean(model?.folder_path || model?.download_directory || model?.directory || model?.path || model?.resolved_path); const showOpenFolder = !isDownloadTableContext && hasLocalPath; this.setContextMenuItemVisible('showInfo', !isDownloadTableContext && !isDownloadFolderContext); this.setContextMenuItemVisible('showMore', canShowMore); this.setContextMenuItemVisible('civitai', !isDownloadTableContext && !isDownloadFolderContext); this.setContextMenuItemVisible('openFolder', showOpenFolder); this.setContextMenuDividerVisible('source', (!isDownloadTableContext && !isDownloadFolderContext) || canShowMore); this.setContextMenuDividerVisible('folder', showOpenFolder && !isDownloadFolderContext); const openFolderLabel = this.contextMenu.querySelector('.mr-context-menu-action-open-folder span:last-child'); if (openFolderLabel) { openFolderLabel.textContent = isDownloadFolderContext ? 'Open Download Folder' : 'Open Containing Folder'; } // 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); } else if (action === 'showMore') { this.showSourceModelDetails(model); } }, setContextMenuItemVisible(action, visible) { if (!this.contextMenu) return; const className = `mr-context-menu-action-${String(action).replace(/[A-Z]/g, match => `-${match.toLowerCase()}`)}`; this.contextMenu.querySelectorAll(`[data-menu-action="${action}"], .${className}`).forEach(item => { item.hidden = !visible; item.classList.toggle('mr-context-menu-hidden', !visible); if (visible) { item.style.removeProperty('display'); } else { item.style.setProperty('display', 'none', 'important'); } }); }, setContextMenuDividerVisible(name, visible) { if (!this.contextMenu) return; this.contextMenu.querySelectorAll(`[data-menu-divider="${name}"], .mr-context-menu-divider-${name}`).forEach(item => { item.hidden = !visible; item.classList.toggle('mr-context-menu-hidden', !visible); if (visible) { item.style.removeProperty('display'); } else { item.style.setProperty('display', 'none', 'important'); } }); }, canShowSourceDetails(model = {}) { const source = String(model.details_source || model.source || '').toLowerCase(); return model?.context_scope === 'download_table' && ['civitai', 'civarchive', 'lora_manager_archive'].includes(source) && Boolean(model.model_id || model.modelId); }, getMissingByKey(key = '') { if (!key) return null; return (this.missingModels || []).find(missing => this.getMissingModelKey?.(missing) === key) || null; }, async openContainingFolder(model) { const path = model?.folder_path || model?.download_directory || model?.directory || model?.path || model?.resolved_path || ''; if (!path) { this.showNotification('No local file path available', 'error'); return; } try { const response = await api.fetchApi('/model_resolver/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 Resolver: Open folder error:', error); this.showNotification('Failed to open containing folder', 'error'); } }, /** * Open model in CivitAI */ async openInCivitAI(model) { if (!model) return; const directUrl = this.getKnownCivitaiModelUrl(model); if (directUrl) { window.open(directUrl, '_blank'); 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_resolver/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 Resolver: 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'); } }, getKnownCivitaiModelUrl(model = {}) { const candidates = [ model.model_url, model.workflow_model_url, model.openUrl, model.url ]; for (const url of candidates) { const value = String(url || ''); if (/^https:\/\/(?:www\.)?civitai\.com\/models\/\d+/i.test(value)) { return value; } } const modelId = model.model_id || model.modelId; const versionId = model.version_id || model.versionId; if (modelId) { return `https://civitai.com/models/${modelId}${versionId ? `?modelVersionId=${versionId}` : ''}`; } return ''; }, /** * 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('.mr-info-dialog-close'); const footerCloseBtn = dialog.querySelector('.mr-info-dialog-close-btn'); const backdrop = dialog.querySelector('.mr-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 = 'mr-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('.mr-info-trained-words-count'); const copyBtn = dialog.querySelector('.mr-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('.mr-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('.mr-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 Resolver: Failed to copy trained words:', error); copyBtn.textContent = 'Failed'; } setTimeout(() => { if (copyBtn.isConnected) { copyBtn.textContent = 'Copy'; } }, 1200); return; } const descToggleBtn = event.target.closest('.mr-info-description-toggle'); if (descToggleBtn && dialog.contains(descToggleBtn)) { const descEl = dialog.querySelector('.mr-info-description'); if (!descEl) return; const isExpanded = descEl.classList.toggle('is-expanded'); descToggleBtn.textContent = isExpanded ? 'Show less' : 'Show more'; } }); }, getInfoDialogElement(dialog) { return dialog?.querySelector?.('.mr-info-dialog') || null; }, restoreInfoDialogSize(dialog) { const panel = this.getInfoDialogElement(dialog); if (!panel) return; try { const saved = JSON.parse(localStorage.getItem('model_resolver_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 Resolver: 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_resolver_info_dialog_size', JSON.stringify({ w: width, h: height })); } catch (error) { console.warn('Model Resolver: 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_resolver/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 Resolver: Error fetching model info:', e); this.updateInfoDialogError(dialog, 'Error fetching info'); } }, /** * Update the info dialog with data */ updateInfoDialogWithData(dialog, data) { const loadingDiv = dialog.querySelector('.mr-info-dialog-loading'); const bodyDiv = dialog.querySelector('.mr-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('.mr-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('.mr-info-type'); if (typeTag) { const modelType = data.model_type || data.modelType || ''; typeTag.textContent = modelType.toUpperCase(); typeTag.className = `mr-info-tag mr-info-type ${this.getModelTypeColorClass(modelType)}`; } // Update base model tag const baseModelTag = dialog.querySelector('.mr-info-basemodel'); if (baseModelTag) { const baseModel = data.base_model || data.baseModel || ''; baseModelTag.textContent = baseModel || ''; if (baseModel) { baseModelTag.style.display = ''; baseModelTag.className = `mr-info-tag mr-info-basemodel -basemodel-${baseModel.toLowerCase().replace(/\s+/g, '-')}`; } else { baseModelTag.style.display = 'none'; } } // Update file const fileEl = dialog.querySelector('.mr-info-file'); if (fileEl && data.filename) { fileEl.textContent = data.filename; } // Update hash const hashEl = dialog.querySelector('.mr-info-hash'); if (hashEl) { hashEl.textContent = data.sha256 || data.hash || ''; } // Update CivitAI link const civitaiLinkEl = dialog.querySelector('.mr-info-civitai-link'); if (civitaiLinkEl) { if (data.url || data.version_url) { const url = data.version_url || data.url; civitaiLinkEl.innerHTML = ` ${getSvgIcon('civitai', 'currentColor', 'mr-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('.mr-info-name'); if (nameEl) { nameEl.textContent = data.model_name || data.modelName || ''; } // Update base model row const baseModelRowEl = dialog.querySelector('.mr-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('.mr-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('.mr-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('.mr-info-description'); if (descEl) { const desc = data.description || data.model_description || data.modelDescription || ''; if (desc) { const actionsEl = dialog.querySelector('.mr-info-description-actions'); const toggleBtn = dialog.querySelector('.mr-info-description-toggle'); let sanitizedHtml = ''; try { sanitizedHtml = this.sanitizeDescriptionHtml(desc); } catch (error) { console.error('Model Resolver: 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('.mr-info-images'); if (!imagesContainer) return; if (!Array.isArray(images) || !images.length) { dialog._infoImages = []; imagesContainer.innerHTML = ''; return; } const visibleImages = images.slice(0, 8).filter(img => img?.url); dialog._infoImages = visibleImages; const renderImageCard = (img, index) => { 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 `
${captionParts.join('')}
`; }; let imagesHtml = '
Example Images
'; imagesHtml += visibleImages.map(renderImageCard).join(''); imagesHtml += '
'; imagesContainer.innerHTML = imagesHtml; imagesContainer.querySelectorAll('.mr-info-image-preview-btn').forEach(button => { button.addEventListener('click', () => { const index = parseInt(button.dataset.imageIndex || '0', 10); this.openInfoImagePreview(visibleImages, Number.isNaN(index) ? 0 : index); }); }); }, getInfoImagePromptTags(image = {}) { const prompt = String(image.positive || '').trim(); const explicitTags = Array.isArray(image.tags) ? image.tags.map(tag => String(tag || '').trim()).filter(Boolean) : []; const promptTags = prompt ? prompt .split(/[,;\n]/) .map(tag => tag.trim()) .filter(tag => tag && tag.length <= 64) : []; return [...new Set([...explicitTags, ...promptTags])].slice(0, 24); }, getInfoImageMetadataRows(image = {}) { const size = image.width && image.height ? `${image.width} x ${image.height}` : ''; const rows = [ ['Seed', image.seed], ['Steps', image.steps], ['CFG', image.cfg], ['Sampler', image.sampler], ['Clip skip', image.clip_skip], ['Size', size], ['Model', image.model] ]; return rows.filter(([, value]) => value !== null && value !== undefined && String(value).trim() !== ''); }, getInfoImageResources(image = {}) { const resources = Array.isArray(image.resources) ? image.resources : []; return resources .filter(resource => resource && typeof resource === 'object') .map(resource => ({ name: resource.name || resource.modelName || resource.model || resource.hash || 'Resource', type: resource.type || resource.modelType || resource.resourceType || '', weight: resource.weight || resource.strength || resource.value || '' })) .filter(resource => resource.name); }, renderInfoImagePreviewPrompt(label, value, copyKey) { const text = String(value || '').trim(); if (!text) return ''; return `

${this.escapeHtml(label)}

${this.escapeHtml(text)}

`; }, renderInfoImagePreviewContent(preview) { const images = Array.isArray(preview?._images) ? preview._images : []; const index = Math.max(0, Math.min(images.length - 1, preview?._imageIndex || 0)); const image = images[index] || {}; const tags = this.getInfoImagePromptTags(image); const metadataRows = this.getInfoImageMetadataRows(image); const resources = this.getInfoImageResources(image); const hasPrevious = images.length > 1; const positionText = images.length > 1 ? `${index + 1} / ${images.length}` : '1 image'; const tagsHtml = tags.length ? `
${tags.map(tag => `${this.escapeHtml(tag)}`).join('')}
` : '
No prompt tags available.
'; const metadataHtml = metadataRows.length ? metadataRows.map(([label, value]) => `
${this.escapeHtml(label)} ${this.escapeHtml(value)}
`).join('') : '
No generation metadata available.
'; const resourcesHtml = resources.length ? resources.map(resource => `
${this.escapeHtml(resource.name)} ${this.escapeHtml([resource.type, resource.weight].filter(Boolean).join(' / '))}
`).join('') : ''; return ` `; }, openInfoImagePreview(images, startIndex = 0) { const imageList = (Array.isArray(images) ? images : []).filter(img => img?.url); if (!imageList.length) return; this.closeInfoImagePreview(); const preview = document.createElement('div'); preview.className = 'mr-image-preview-backdrop'; preview._images = imageList; preview._imageIndex = Math.max(0, Math.min(imageList.length - 1, startIndex)); preview._onKeyDown = (event) => { if (event.key === 'Escape') { this.closeInfoImagePreview(preview); } else if (event.key === 'ArrowLeft') { this.showInfoImagePreviewAt(preview, preview._imageIndex - 1); } else if (event.key === 'ArrowRight') { this.showInfoImagePreviewAt(preview, preview._imageIndex + 1); } }; preview.addEventListener('click', async (event) => { if (event.target === preview) { this.closeInfoImagePreview(preview); return; } const actionEl = event.target.closest('[data-action]'); if (actionEl && preview.contains(actionEl)) { const action = actionEl.dataset.action; if (action === 'close') { this.closeInfoImagePreview(preview); } else if (action === 'previous') { this.showInfoImagePreviewAt(preview, preview._imageIndex - 1); } else if (action === 'next') { this.showInfoImagePreviewAt(preview, preview._imageIndex + 1); } return; } const copyBtn = event.target.closest('.mr-image-preview-copy'); if (copyBtn && preview.contains(copyBtn)) { await this.copyInfoImagePreviewText(preview, copyBtn); } }); document.body.appendChild(preview); document.addEventListener('keydown', preview._onKeyDown); this.updateInfoImagePreview(preview); }, showInfoImagePreviewAt(preview, index) { const images = Array.isArray(preview?._images) ? preview._images : []; if (!preview || !images.length) return; preview._imageIndex = (index + images.length) % images.length; this.updateInfoImagePreview(preview); }, updateInfoImagePreview(preview) { if (!preview) return; preview.innerHTML = this.renderInfoImagePreviewContent(preview); this.bindTooltips(preview); }, async copyInfoImagePreviewText(preview, button) { const images = Array.isArray(preview?._images) ? preview._images : []; const image = images[preview?._imageIndex || 0] || {}; const key = button?.dataset?.copyKey || ''; const text = key === 'positive' ? image.positive : key === 'negative' ? image.negative : [ image.positive ? `Prompt: ${image.positive}` : '', image.negative ? `Negative prompt: ${image.negative}` : '', image.seed ? `Seed: ${image.seed}` : '', image.steps ? `Steps: ${image.steps}` : '', image.cfg ? `CFG: ${image.cfg}` : '', image.sampler ? `Sampler: ${image.sampler}` : '', image.model ? `Model: ${image.model}` : '' ].filter(Boolean).join('\n'); if (!text) return; try { await navigator.clipboard.writeText(String(text)); button.classList.add('is-copied'); button.innerHTML = `${getSvgIcon('copy')} Copied`; } catch (error) { console.error('Model Resolver: Failed to copy image metadata:', error); button.innerHTML = `${getSvgIcon('copy')} Failed`; } setTimeout(() => { if (button.isConnected) { button.classList.remove('is-copied'); button.innerHTML = `${getSvgIcon('copy')} Copy${key === 'all' ? ' all' : ''}`; } }, 1200); }, closeInfoImagePreview(preview = null) { const target = preview || document.querySelector('.mr-image-preview-backdrop'); if (!target) return; if (target._onKeyDown) { document.removeEventListener('keydown', target._onKeyDown); target._onKeyDown = null; } target.remove(); }, async showSourceModelDetails(model = {}) { if (!this.canShowSourceDetails(model)) { this.showNotification?.('No detailed source page is available for this model.', 'info'); return; } this.closeSourceModelDetails(); const details = document.createElement('div'); details.className = 'mr-model-details-backdrop'; details._sourceModel = model; details.innerHTML = this.renderSourceModelDetailsLoading(model); document.body.appendChild(details); this.bindSourceModelDetailsEvents(details); try { const tokens = this.getStoredTokens?.() || {}; const response = await api.fetchApi('/model_resolver/model-details', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source: model.details_source || model.source, model_id: model.model_id || model.modelId, version_id: model.version_id || model.versionId, civitai_key: tokens.civitai_key || '' }) }); if (!response.ok) { throw new Error(`Details request failed: ${response.status}`); } const data = await response.json(); details._detailsData = data; details._selectedVersionId = String(data.selected_version?.id || data.version_id || ''); details.innerHTML = this.renderSourceModelDetails(data, model); this.bindSourceModelDetailsEvents(details); this.bindTooltips(details); } catch (error) { console.error('Model Resolver: model details error:', error); details.innerHTML = this.renderSourceModelDetailsError(error); this.bindSourceModelDetailsEvents(details); } }, closeSourceModelDetails() { document.querySelectorAll('.mr-model-details-backdrop').forEach(element => element.remove()); }, renderSourceModelDetailsLoading(model = {}) { const title = model.name || model.model_name || model.filename || 'Model details'; return `

${this.escapeHtml(title)}

${this.escapeHtml(model.details_source || model.source || '')}
Loading model details...
`; }, renderSourceModelDetailsError(error) { return `

Model details

Error
${this.escapeHtml(error?.message || 'Failed to load model details.')}
`; }, async fetchSourceModelDetailsVersion(details, versionId) { if (!details?._detailsData || !versionId) return; try { const data = details._detailsData; const tokens = this.getStoredTokens?.() || {}; const response = await api.fetchApi('/model_resolver/model-details', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source: data.source || details._sourceModel?.details_source || details._sourceModel?.source, model_id: data.model_id || details._sourceModel?.model_id || details._sourceModel?.modelId, version_id: versionId, civitai_key: tokens.civitai_key || '' }) }); if (!response.ok) { throw new Error(`Version details request failed: ${response.status}`); } const fresh = await response.json(); const freshVersion = fresh.selected_version; if (!freshVersion?.id) return; data.versions = (data.versions || []).map(version => String(version.id) === String(freshVersion.id) ? { ...version, ...freshVersion } : version ); data.selected_version = data.versions.find(version => String(version.id) === String(freshVersion.id)) || freshVersion; if (String(details._selectedVersionId || '') !== String(freshVersion.id)) return; details.innerHTML = this.renderSourceModelDetails(data, details._sourceModel || {}); this.bindTooltips(details); } catch (error) { console.error('Model Resolver: failed to fetch source model version details:', error); this.showNotification?.('Failed to load this model version.', 'error'); } }, renderSourceModelDetails(data = {}, contextModel = {}) { const versions = Array.isArray(data.versions) ? data.versions : []; const selectedVersionId = String(data.selected_version?.id || data.version_id || versions[0]?.id || ''); const selectedVersion = versions.find(version => String(version.id) === selectedVersionId) || data.selected_version || versions[0] || {}; const images = (Array.isArray(selectedVersion.images) && selectedVersion.images.length ? selectedVersion.images : data.images || []).filter(img => img?.url).slice(0, 12); const stats = data.stats || {}; const versionStats = selectedVersion.stats || {}; const tags = Array.isArray(data.tags) ? data.tags.slice(0, 12) : []; const trainedWords = this.normalizeTrainedWords(selectedVersion.trained_words || []); const description = data.description || selectedVersion.description || ''; const sanitizedDescription = description ? this.sanitizeDescriptionHtml(description) : ''; const source = data.source || contextModel.details_source || contextModel.source || ''; const creator = data.creator || {}; const creatorName = creator.username || creator.name || ''; const statItems = [ ['Downloads', stats.downloadCount || stats.downloads || versionStats.downloadCount || versionStats.downloads], ['Thumbs Up', stats.thumbsUpCount || versionStats.thumbsUpCount], ['Rating', stats.rating || versionStats.rating], ['Published', selectedVersion.published_at ? this.formatDetailsDate(selectedVersion.published_at) : ''] ].filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== ''); return `

${this.escapeHtml(data.name || contextModel.name || 'Model')}

${this.escapeHtml([source, data.type, creatorName].filter(Boolean).join(' / '))}
${data.version_url || data.url ? `${getSvgIcon('externalLink')} Open page` : ''}
${versions.map(version => ` `).join('')}

About

${sanitizedDescription ? sanitizedDescription : '

No description available.

'}
`; }, renderSourceModelDetailsFiles(version = {}, data = {}, contextModel = {}) { const files = Array.isArray(version.files) ? version.files.filter(file => file?.download_url) : []; if (!files.length) { return '
No downloadable files available for this version.
'; } return files.map((file, index) => { const fileMeta = this.getSourceModelFileMeta(file); const mirrors = this.getSourceModelMirrors(file); const payload = { source: data.source || contextModel.details_source || contextModel.source, model_id: data.model_id || contextModel.model_id, version_id: version.id || data.version_id || contextModel.version_id, name: data.name || contextModel.name, version_name: version.name || '', type: data.type || contextModel.type, filename: file.name || contextModel.filename || data.name || 'model', download_url: file.download_url, url: version.url || data.version_url || data.url, size: file.size, base_model: version.base_model || contextModel.base_model, tags: data.tags || [], match_type: 'selected', confidence: 100 }; const mirrorList = mirrors.length > 1 ? this.renderSourceModelMirrors(mirrors, payload) : ''; return `
${this.escapeHtml(file.name || `File ${index + 1}`)} ${this.escapeHtml(fileMeta.summary)} ${fileMeta.badges.length ? `
${fileMeta.badges.map(badge => `${this.escapeHtml(badge)}`).join('')}
` : ''}
${mirrorList}
`; }).join(''); }, getSourceModelMirrors(file = {}) { const mirrors = Array.isArray(file.mirrors) ? file.mirrors.filter(mirror => mirror?.url) : []; const downloadUrls = Array.isArray(file.download_urls) ? file.download_urls.filter(Boolean) : []; const seen = new Set(); const normalized = []; for (const mirror of mirrors) { const url = String(mirror.url || ''); if (!url || seen.has(url)) continue; seen.add(url); normalized.push({ ...mirror, url, filename: mirror.filename || file.name, is_dead: Boolean(mirror.is_dead || mirror.isDead || mirror.deleted_at || mirror.deletedAt) }); } for (const url of downloadUrls) { const value = String(url || ''); if (!value || seen.has(value)) continue; seen.add(value); normalized.push({ url: value, filename: file.name, source: this.getSourceModelMirrorHost(value), is_dead: false }); } return normalized; }, renderSourceModelMirrors(mirrors = [], basePayload = {}) { if (!mirrors.length) return ''; const groups = this.groupSourceModelMirrors(mirrors); return `
Mirrors
${groups.map(group => `
${this.getSourceModelMirrorIcon(group.label)} ${this.escapeHtml(group.label)} (${group.items.length} ${group.items.length === 1 ? 'mirror' : 'mirrors'})
${group.items.map((mirror) => { const payload = { ...basePayload, download_url: mirror.url, filename: mirror.filename || basePayload.filename, mirror_source: mirror.source || this.getSourceModelMirrorHost(mirror.url), mirror_index: mirror._index }; const meta = this.getSourceModelMirrorMeta(mirror, mirror._index === 0); const isDead = this.isSourceModelMirrorDead(mirror); const isGated = this.isSourceModelMirrorGated(mirror); const title = isDead ? 'Likely dead link' : (isGated ? 'Gated Download' : 'Download file'); const actionIcon = getSvgIcon(isDead ? 'skull' : (isGated ? 'shield' : 'download')); const rowClass = [ isDead ? 'is-dead' : '', isGated && !isDead ? 'is-gated' : '' ].filter(Boolean).map(value => ` ${value}`).join(''); const payloadData = this.escapeHtml(encodeURIComponent(JSON.stringify(payload))); return ` `; }).join('')}
`).join('')}
`; }, groupSourceModelMirrors(mirrors = []) { const groups = new Map(); mirrors.forEach((mirror, index) => { const label = this.getSourceModelMirrorSourceLabel(mirror, index); if (!groups.has(label)) { groups.set(label, { label, items: [] }); } groups.get(label).items.push({ ...mirror, _index: index }); }); return Array.from(groups.values()); }, getSourceModelMirrorIcon(label = '') { const normalized = String(label || '').toLowerCase(); if (normalized.includes('huggingface')) return getSvgIcon('huggingface'); if (normalized.includes('civitai')) return getSvgIcon('civitai'); return getSvgIcon('globe'); }, isSourceModelMirrorDead(mirror = {}) { return Boolean(mirror.is_dead || mirror.isDead || mirror.deleted_at || mirror.deletedAt); }, isSourceModelMirrorGated(mirror = {}) { return Boolean(mirror.is_gated || mirror.isGated || mirror.gated); }, getSourceModelMirrorHost(url = '') { try { return new URL(url).hostname.replace(/^www\./, ''); } catch (error) { return ''; } }, getSourceModelMirrorSourceLabel(mirror = {}, index = 0) { const source = String(mirror.source || '').trim(); if (source) return source; const host = this.getSourceModelMirrorHost(mirror.url || ''); if (host.includes('huggingface.co')) return 'HuggingFace'; if (host.includes('civitai.com')) return 'CivitAI'; return host || `Mirror ${index + 1}`; }, getSourceModelMirrorMeta(mirror = {}, isDefault = false) { const host = this.getSourceModelMirrorHost(mirror.url || ''); const meta = [ isDefault ? 'Default' : '', this.isSourceModelMirrorDead(mirror) ? 'Likely dead' : '', host, this.isSourceModelMirrorGated(mirror) ? 'Gated' : '', mirror.is_paid ? 'Paid' : '', mirror.sha256 ? `SHA256 ${String(mirror.sha256).slice(0, 10)}` : '' ].filter(Boolean); return meta.join(' / '); }, getSourceModelFileMeta(file = {}) { const metadata = file.metadata && typeof file.metadata === 'object' ? file.metadata : {}; const mirrorCount = Array.isArray(file.mirrors) && file.mirrors.length ? file.mirrors.length : file.mirror_count; const badges = [ file.primary ? 'Primary' : '', file.type || '', metadata.format || '', metadata.size || '', metadata.fp || '', mirrorCount ? `${mirrorCount} mirrors` : '', file.id ? `ID ${file.id}` : '' ].filter(Boolean); const summary = [ this.formatSearchResultSize(file), file.sha256 ? `SHA256 ${String(file.sha256).slice(0, 10)}` : '' ].filter(Boolean).join(' / ') || 'Downloadable file'; return { badges, summary }; }, renderSourceModelImageMeta(image = {}) { const stats = image.stats || {}; const metaItems = [ ['Like', image.likeCount || image.likes || stats.likeCount || stats.likes], ['Love', image.heartCount || image.reactions || stats.heartCount], ['Comments', image.commentCount || stats.commentCount], ['Buzz', image.buzzCount || stats.buzzCount] ].filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== ''); if (!metaItems.length) return ''; return ` ${metaItems.map(([label, value]) => `${this.escapeHtml(label)} ${this.escapeHtml(value)}`).join('')} `; }, getSourceModelImageSizingStyle(image = {}) { const width = Number(image.width || image.metadata?.width || 0); const height = Number(image.height || image.metadata?.height || 0); const ratio = width > 0 && height > 0 ? width / height : 0.72; const clampedRatio = Math.max(0.48, Math.min(1.85, ratio)); const preferredHeight = Math.max( 320, Math.min( 560, Math.round(430 + Math.max(0, 1 - clampedRatio) * 150 - Math.max(0, clampedRatio - 1) * 70) ) ); let preferredWidth = Math.round(preferredHeight * clampedRatio); if (preferredWidth < 220) { preferredWidth = 220; } else if (preferredWidth > 640) { preferredWidth = 640; } const adjustedHeight = Math.round(preferredWidth / clampedRatio); return `--mr-image-ratio:${clampedRatio.toFixed(3)}; --mr-image-width:${preferredWidth}px; --mr-image-height:${adjustedHeight}px;`; }, bindSourceModelDetailsEvents(details) { if (!details) return; if (details.dataset.modelDetailsBound === 'true') return; details.dataset.modelDetailsBound = 'true'; details.addEventListener('click', (event) => { if (event.target === details) { this.closeSourceModelDetails(); return; } const actionEl = event.target.closest('[data-action]'); if (actionEl?.dataset.action === 'close') { this.closeSourceModelDetails(); return; } const versionTab = event.target.closest('.mr-model-details-version-tab'); if (versionTab && details.contains(versionTab)) { details._selectedVersionId = String(versionTab.dataset.versionId || ''); const data = details._detailsData; if (data) { const selected = (data.versions || []).find(version => String(version.id) === details._selectedVersionId) || data.selected_version; data.selected_version = selected; details.innerHTML = this.renderSourceModelDetails(data, details._sourceModel || {}); this.bindTooltips(details); if (selected?.id && (!Array.isArray(selected.files) || !selected.files.length)) { this.fetchSourceModelDetailsVersion(details, selected.id); } } return; } const galleryNav = event.target.closest('.mr-model-details-gallery-nav'); if (galleryNav && details.contains(galleryNav)) { const strip = galleryNav.closest('.mr-model-details-gallery')?.querySelector('.mr-model-details-gallery-strip'); if (!strip) return; const direction = Number(galleryNav.dataset.galleryDirection || 1); strip.scrollBy({ left: direction * Math.max(260, Math.round(strip.clientWidth * 0.82)), behavior: 'smooth' }); return; } const imageBtn = event.target.closest('.mr-model-details-image'); if (imageBtn && details.contains(imageBtn)) { if (details._detailsGalleryDraggedUntil && Date.now() < details._detailsGalleryDraggedUntil) { return; } const data = details._detailsData || {}; const version = data.selected_version || {}; const images = (Array.isArray(version.images) && version.images.length ? version.images : data.images || []).filter(img => img?.url); const index = parseInt(imageBtn.dataset.imageIndex || '0', 10); this.openInfoImagePreview(images, Number.isNaN(index) ? 0 : index); return; } const useBtn = event.target.closest('.mr-model-details-use'); if (useBtn && details.contains(useBtn)) { try { const selection = JSON.parse(decodeURIComponent(useBtn.dataset.selection || '')); this.applySourceModelDetailsSelection(selection, details._sourceModel || {}); this.closeSourceModelDetails(); } catch (error) { console.error('Model Resolver: failed to apply model detail selection:', error); this.showNotification?.('Failed to select this model version.', 'error'); } } }); details.addEventListener('pointerdown', (event) => { const strip = event.target.closest?.('.mr-model-details-gallery-strip'); if (!strip || !details.contains(strip) || event.button > 0) return; const startX = event.clientX; const startScrollLeft = strip.scrollLeft; let moved = false; strip.classList.add('is-dragging'); strip.setPointerCapture?.(event.pointerId); const onPointerMove = (moveEvent) => { const delta = moveEvent.clientX - startX; if (Math.abs(delta) > 4) { moved = true; details._detailsGalleryDraggedUntil = Date.now() + 250; } strip.scrollLeft = startScrollLeft - delta; }; const finishDrag = () => { strip.classList.remove('is-dragging'); strip.removeEventListener('pointermove', onPointerMove); strip.removeEventListener('pointerup', finishDrag); strip.removeEventListener('pointercancel', finishDrag); if (moved) { details._detailsGalleryDraggedUntil = Date.now() + 250; } }; strip.addEventListener('pointermove', onPointerMove); strip.addEventListener('pointerup', finishDrag, { once: true }); strip.addEventListener('pointercancel', finishDrag, { once: true }); }); }, applySourceModelDetailsSelection(selection = {}, contextModel = {}) { const missing = this.getMissingByKey(contextModel.missing_key || contextModel.missingKey || ''); if (!missing || !selection.download_url) { this.showNotification?.('Cannot apply this version to the current missing model.', 'error'); return; } const sourceKey = String(selection.source || contextModel.details_source || contextModel.source || '').toLowerCase(); const selectedBaseModel = selection.base_model || contextModel.base_model || ''; const sourceResult = { source: sourceKey, model_id: selection.model_id, version_id: selection.version_id, name: selection.name, version_name: selection.version_name, type: selection.type, filename: selection.filename, url: selection.url, download_url: selection.download_url, size: selection.size, base_model: selectedBaseModel, tags: selection.tags || [], match_type: selection.match_type || 'selected', confidence: selection.confidence || 100, searchedAt: new Date().toISOString() }; missing.download_source = { ...sourceResult, url: sourceResult.download_url, model_url: sourceResult.url, directory: missing.category || 'checkpoints' }; const state = this.getSearchState?.(missing); if (state?.results && sourceKey) { state.results[sourceKey] = sourceResult; } missing.civitai_info = { ...(missing.civitai_info || {}), model_name: selection.name || missing.civitai_info?.model_name, version_name: selection.version_name || missing.civitai_info?.version_name, expected_filename: selection.filename || missing.civitai_info?.expected_filename, base_model: selectedBaseModel || missing.civitai_info?.base_model, tags: selection.tags || missing.civitai_info?.tags || [] }; if (selectedBaseModel) { const canonicalBaseModel = this.resolveBaseModelAlias?.(selectedBaseModel) || selectedBaseModel; if (state) { state.selectedBaseModel = canonicalBaseModel; state.lastAttemptBaseModelContext = canonicalBaseModel; } } this.refreshSearchUiForMissing?.(missing, state || null); this.refreshSearchBaseModelLabels?.(); this.updateBatchFooterButtons?.(); this.persistSearchStateForActiveWorkflow?.(); this.showNotification?.(`Selected ${selection.filename || selection.name || 'model version'}.`, 'success'); }, formatDetailsDate(value) { if (!value) return ''; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value); return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); }, /** * Update the info dialog with error */ updateInfoDialogError(dialog, message) { const civitaiLink = dialog.querySelector('.mr-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); this.closeInfoImagePreview(); if (dialog && dialog.parentNode) { dialog.parentNode.removeChild(dialog); } } };