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 isDownloadRootContext = model?.context_scope === 'download_root';
const isDownloadQueueContext = model?.context_scope === 'download_queue';
const isDownloadHistoryContext = model?.context_scope === 'download_history';
const isFolderOnlyContext = isDownloadFolderContext || isDownloadRootContext;
const isSourceModelContext = !isDownloadTableContext && !isFolderOnlyContext && !isDownloadQueueContext && !isDownloadHistoryContext;
const hasLocalPath = Boolean(model?.open_path || model?.folder_path || model?.download_directory || model?.directory || model?.path || model?.resolved_path);
const showOpenFolder = !isDownloadTableContext && hasLocalPath;
const showSwitchWorkflow = (isDownloadQueueContext || isDownloadHistoryContext) && Boolean(this.canSwitchToDownloadWorkflow?.(model));
this.setContextMenuItemVisible('showInfo', isSourceModelContext);
this.setContextMenuItemVisible('showMore', canShowMore);
this.setContextMenuItemVisible('civitai', isSourceModelContext);
this.setContextMenuItemVisible('switchWorkflow', showSwitchWorkflow);
this.setContextMenuItemVisible('openFolder', showOpenFolder);
this.setContextMenuDividerVisible('source', isSourceModelContext || canShowMore);
this.setContextMenuDividerVisible('workflow', showSwitchWorkflow && (isSourceModelContext || canShowMore));
this.setContextMenuDividerVisible('folder', showOpenFolder && (isSourceModelContext || canShowMore || showSwitchWorkflow));
const openFolderLabel = this.contextMenu.querySelector('.mr-context-menu-action-open-folder span:last-child');
if (openFolderLabel) {
openFolderLabel.textContent = model?.open_folder_label
|| (isDownloadRootContext ? 'Open Root Folder' : 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 === 'switchWorkflow') {
this.switchToDownloadWorkflow(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?.open_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 {
await this.fetchJson('/model_resolver/open-containing-folder', {
method: 'POST',
body: JSON.stringify({ path })
}, 'Open containing folder');
} catch (error) {
// Already logged and notified inside fetchJson
}
},
/**
* 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 data = await this.fetchJson('/model_resolver/civitai-search', {
method: 'POST',
body: JSON.stringify({
filename: name,
category: model.category,
resolved_path: model.resolved_path || ''
})
}, 'Search CivitAI');
if (data && data.url) {
window.open(data.url, '_blank');
} else {
throw new Error('No URL in search result');
}
} catch (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);
dialog._onInfoDialogKeyDown = (event) => {
if (event.key !== 'Escape') return;
if (document.querySelector('.mr-image-preview-backdrop')) return;
event.preventDefault();
this.closeInfoDialog(dialog);
};
document.addEventListener('keydown', dialog._onInfoDialogKeyDown);
// 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);
}
});
}
dialog._infoDialogLookup = { loraName, modelData };
this.fetchLocalModelInfoForDialog(loraName, modelData, dialog);
},
/**
* Create the info dialog element
*/
renderInfoFieldLabel(iconName, label, tooltip) {
return `
${getSvgIcon(iconName, 'currentColor', 'mr-info-label-icon')}
${this.escapeHtml(label)}
?
`;
},
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 = `
Loading...
${this.renderInfoFieldLabel('file', 'File', 'The model file name found locally or returned by CivitAI.')}
${this.renderInfoFieldLabel('box', 'Base Model', 'Base model this resource was made for, for example SD1.5, SDXL or Flux.')}
${this.renderInfoFieldLabel('hash', 'Hash (sha256)', 'Unique fingerprint of the local file. Model Resolver uses it to confirm the exact CivitAI version.')}
${this.renderInfoFieldLabel('hardDrive', 'Size', 'The local model file size, read from metadata or from the file on disk.')}
${this.renderInfoFieldLabel('locate', 'Location', 'Folder where this local model file is stored.')}
${this.renderInfoFieldLabel('bookType', 'Trained Words', 'Trigger words recommended by the model author. Click the words you want, then copy them into your prompt.')}
0 selected
Copy
Click words to select them.
${this.renderInfoFieldLabel('wrench', 'Clip Skip', 'Recommended Clip Skip value from the model author, if one is provided.')}
${this.renderInfoFieldLabel('fileText', 'Description', 'Model description from CivitAI or local metadata. Long descriptions are shortened until you click Show more.')}
`;
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)
)];
}
if (words && typeof words === 'object') {
return this.normalizeTrainedWords(
words.trained_words
|| words.trainedWords
|| words.words
|| words.values
|| []
);
}
return [];
},
formatInfoDialogLocation(data = {}) {
let location = data.location
|| data.folder_path
|| data.folderPath
|| data.download_directory
|| data.downloadDirectory
|| data.directory
|| '';
if (!location) {
const fullPath = data.file_path || data.filePath || data.resolved_path || data.resolvedPath || data.full_path || data.fullPath || data.path || '';
const normalizedFullPath = String(fullPath || '').replace(/\\/g, '/');
const lastSlash = normalizedFullPath.lastIndexOf('/');
if (lastSlash > 0) {
location = normalizedFullPath.slice(0, lastSlash + 1);
}
}
location = String(location || '').trim().replace(/\\/g, '/');
if (location && !location.endsWith('/')) {
location += '/';
}
return location;
},
getInfoDialogLocationOpenPath(data = {}) {
return data.file_path
|| data.filePath
|| data.resolved_path
|| data.resolvedPath
|| data.full_path
|| data.fullPath
|| data.path
|| data.location
|| data.folder_path
|| data.folderPath
|| data.download_directory
|| data.downloadDirectory
|| data.directory
|| '';
},
normalizeInfoTags(tags) {
if (Array.isArray(tags)) {
const seen = new Set();
const result = [];
for (let tag of tags) {
if (tag && typeof tag === 'object') {
tag = tag.name || tag.tag || tag.text || tag.value || '';
}
const text = String(tag || '').trim();
if (!text) continue;
const key = text.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(text);
}
return result;
}
if (typeof tags === 'string') {
return this.normalizeInfoTags(
tags
.split(/[\n,|;]/)
.map(tag => tag.trim())
.filter(Boolean)
);
}
if (tags && typeof tags === 'object') {
return this.normalizeInfoTags(
tags.tags
|| tags.model_tags
|| tags.modelTags
|| tags.values
|| []
);
}
return [];
},
getInfoDialogTags(data = {}) {
const sources = [
data.tags,
data.model_tags,
data.modelTags,
data.civitai?.tags,
data.civitai?.model?.tags,
data.model?.tags,
data.metadata?.tags,
data.path_metadata?.tags
];
return this.normalizeInfoTags(
sources.flatMap(source => this.normalizeInfoTags(source))
);
},
formatInfoDialogSize(data = {}) {
const rawSize = data.size
?? data.file_size
?? data.fileSize
?? data.size_bytes
?? data.sizeBytes
?? data.file?.size
?? data.file?.sizeBytes
?? null;
if (rawSize === null || rawSize === undefined || rawSize === '') return '';
if (rawSize === 0 || rawSize === '0') return '0 B';
const numericSize = Number(rawSize);
if (Number.isFinite(numericSize)) {
return typeof this.formatBytes === 'function'
? this.formatBytes(numericSize)
: `${numericSize} B`;
}
return String(rawSize);
},
getInfoDialogTrainedWords(data = {}) {
const sources = [
data.trained_words,
data.trainedWords,
data.civitai?.trainedWords,
data.civitai?.trained_words,
data.selected_version?.trainedWords,
data.selected_version?.trained_words,
data.modelVersion?.trainedWords,
data.modelVersion?.trained_words,
data.model_version?.trainedWords,
data.model_version?.trained_words,
data.version?.trainedWords,
data.version?.trained_words,
data.path_metadata?.trainedWords,
data.path_metadata?.trained_words,
data.metadata?.trainedWords,
data.metadata?.trained_words
];
return this.normalizeTrainedWords(
sources.flatMap(source => this.normalizeTrainedWords(source))
);
},
setInfoTableRowVisible(row, visible) {
if (!row) return;
row.classList.toggle('mr-hidden-initial', !visible);
row.style.display = visible ? '' : 'none';
},
setInfoElementVisible(element, visible) {
if (!element) return;
element.classList.toggle('mr-hidden-initial', !visible);
element.style.display = visible ? '' : 'none';
},
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';
}
},
getInfoDialogHash(data = {}) {
return String(data.sha256 || data.hash || data.hashes?.SHA256 || data.hashes?.sha256 || '').trim();
},
getInfoDialogModelFilePath(data = {}) {
return data.file_path
|| data.filePath
|| data.resolved_path
|| data.resolvedPath
|| data.full_path
|| data.fullPath
|| data.path
|| '';
},
updateInfoDialogHashDisplay(dialog, data = {}) {
const hashEl = dialog.querySelector('.mr-info-hash');
const button = dialog.querySelector('.mr-info-hash-calculate');
const progressEl = dialog.querySelector('.mr-info-hash-progress');
const hash = this.getInfoDialogHash(data);
const filePath = this.getInfoDialogModelFilePath(data);
if (hashEl) {
hashEl.textContent = hash;
hashEl.classList.toggle('is-empty', !hash);
}
if (button) {
const canCalculate = Boolean(!hash && filePath);
button.classList.toggle('mr-hidden-initial', !canCalculate);
button.hidden = !canCalculate;
button.disabled = false;
button.textContent = 'Calculate hash';
delete button.dataset.hashCalculating;
delete button.dataset.progressId;
}
if (progressEl && hash) {
this.hideInfoDialogHashProgress(dialog);
}
},
hideInfoDialogHashProgress(dialog) {
const progressEl = dialog.querySelector('.mr-info-hash-progress');
if (!progressEl) return;
const bar = progressEl.querySelector('.mr-info-hash-progress-bar span');
const label = progressEl.querySelector('small');
progressEl.classList.add('mr-hidden-initial');
progressEl.hidden = true;
if (bar) bar.style.width = '0%';
if (label) label.textContent = '0%';
},
updateInfoDialogHashProgress(dialog, percent = 0, message = '') {
const progressEl = dialog.querySelector('.mr-info-hash-progress');
if (!progressEl) return;
const safePercent = Math.max(0, Math.min(100, Number(percent) || 0));
const bar = progressEl.querySelector('.mr-info-hash-progress-bar span');
const label = progressEl.querySelector('small');
progressEl.classList.remove('mr-hidden-initial');
progressEl.hidden = false;
if (bar) bar.style.width = `${safePercent}%`;
if (label) {
label.textContent = message
? `${Math.round(safePercent)}% · ${message}`
: `${Math.round(safePercent)}%`;
}
},
async cancelInfoDialogHashCalculation(dialog, button) {
const state = dialog?._hashCalculation || {};
state.cancelRequested = true;
dialog._hashCalculation = state;
if (button) {
button.disabled = true;
button.textContent = 'Stopping...';
}
if (state.progressId) {
try {
await this.fetchJson(
`/model_resolver/calculate-file-hash/cancel/${encodeURIComponent(state.progressId)}`,
{ method: 'POST', silent: true },
'Cancel model hash calculation'
);
} catch (error) {
// Keep the UI in stopping state; polling will surface the final state if possible.
}
}
},
async calculateInfoDialogHash(dialog, button) {
if (button?.dataset.hashCalculating === 'true') {
await this.cancelInfoDialogHashCalculation(dialog, button);
return null;
}
const data = dialog?._infoDialogData || {};
const filePath = this.getInfoDialogModelFilePath(data);
if (!filePath) {
this.showNotification?.('No local file path available', 'error');
return null;
}
dialog._hashCalculation = { progressId: '', cancelRequested: false };
if (button) {
button.disabled = false;
button.textContent = 'Stop calculate';
button.dataset.hashCalculating = 'true';
delete button.dataset.progressId;
}
this.updateInfoDialogHashProgress(dialog, 0, 'Preparing');
try {
const start = await this.fetchJson('/model_resolver/calculate-file-hash/start', {
method: 'POST',
body: JSON.stringify({
file_path: filePath,
metadata_path: data.metadata_path || data.metadataPath || ''
})
}, 'Start model hash calculation');
const progressId = start?.progress_id;
if (!progressId) {
throw new Error('No progress id returned');
}
if (!dialog._hashCalculation) {
dialog._hashCalculation = { progressId, cancelRequested: false };
}
dialog._hashCalculation.progressId = progressId;
if (button) {
button.dataset.progressId = progressId;
button.textContent = 'Stop calculate';
button.disabled = false;
}
if (dialog._hashCalculation.cancelRequested) {
await this.cancelInfoDialogHashCalculation(dialog, button);
}
let result = null;
for (;;) {
await new Promise(resolve => setTimeout(resolve, 250));
if (!dialog.isConnected) {
await this.cancelInfoDialogHashCalculation(dialog, button);
return null;
}
const progress = await this.fetchJson(
`/model_resolver/calculate-file-hash/progress/${encodeURIComponent(progressId)}`,
{ silent: true },
'Get model hash progress'
);
this.updateInfoDialogHashProgress(
dialog,
progress?.percent || 0,
progress?.message || ''
);
if (progress?.status === 'done') {
result = progress;
break;
}
if (progress?.status === 'cancelled') {
this.hideInfoDialogHashProgress(dialog);
if (button) {
button.disabled = false;
button.textContent = 'Calculate hash';
delete button.dataset.hashCalculating;
delete button.dataset.progressId;
}
dialog._hashCalculation = null;
this.showNotification?.('Hash calculation stopped.', 'info');
return null;
}
if (progress?.status === 'error') {
throw new Error(progress.error || progress.message || 'Hash calculation failed');
}
}
const sha256 = result?.sha256 || result?.hash || '';
if (!sha256) {
throw new Error('No hash returned');
}
const updatedData = {
...data,
sha256,
hash: sha256,
metadata_path: result.metadata_path || data.metadata_path || data.metadataPath || '',
metadata_saved: Boolean(result.metadata_updated || data.metadata_saved)
};
dialog._infoDialogData = updatedData;
dialog._hashCalculation = null;
this.updateInfoDialogHashDisplay(dialog, updatedData);
this.showNotification?.(
result.metadata_updated ? 'Hash calculated and saved to metadata.' : 'Hash calculated.',
'success'
);
return updatedData;
} catch (error) {
dialog._hashCalculation = null;
if (button) {
button.disabled = false;
button.textContent = 'Calculate hash';
delete button.dataset.hashCalculating;
delete button.dataset.progressId;
}
this.hideInfoDialogHashProgress(dialog);
this.showNotification?.(`Hash calculation failed: ${error?.message || error}`, 'error');
return null;
}
},
bindInfoDialogInteractions(dialog) {
if (!dialog || dialog.dataset.mlInfoBound === 'true') return;
dialog.dataset.mlInfoBound = 'true';
dialog.addEventListener('click', async (event) => {
const fetchCivitaiButton = event.target.closest('.mr-info-fetch-civitai');
if (fetchCivitaiButton && dialog.contains(fetchCivitaiButton)) {
await this.fetchCivitaiModelInfoForDialog(dialog, fetchCivitaiButton);
return;
}
const hashButton = event.target.closest('.mr-info-hash-calculate');
if (hashButton && dialog.contains(hashButton)) {
await this.calculateInfoDialogHash(dialog, hashButton);
return;
}
const locationBtn = event.target.closest('.mr-info-location-button');
if (locationBtn && dialog.contains(locationBtn)) {
const path = locationBtn.dataset.path || '';
if (path) {
await this.openContainingFolder({ path, resolved_path: path });
}
return;
}
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.92);
const maxHeight = Math.max(320, window.innerHeight - 16);
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;
},
getModelInfoResolvedPath(modelData = {}) {
return modelData?.resolved_path
|| modelData?.path
|| modelData?.full_path
|| modelData?.folder_path
|| modelData?.download_directory
|| '';
},
buildLocalInfoDialogData(loraName, modelData = {}) {
const filename = loraName || modelData?.filename || modelData?.name || '';
const modelName = String(filename || '').replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
const resolvedPath = this.getModelInfoResolvedPath(modelData);
const categoryTypeMap = {
checkpoints: 'checkpoint',
loras: 'lora',
vae: 'vae',
text_encoders: 'text_encoder',
clip: 'clip',
clip_vision: 'clip_vision',
controlnet: 'controlnet',
upscale_models: 'upscale',
diffusion_models: 'diffusion_model'
};
const rawType = modelData?.model_type || modelData?.modelType || modelData?.type || modelData?.category || '';
const normalizedType = String(rawType || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
return {
filename,
category: modelData?.category || '',
file_path: resolvedPath,
resolved_path: resolvedPath,
location: resolvedPath ? this.formatInfoDialogLocation({ file_path: resolvedPath }) : '',
model_name: modelData?.model_name || modelData?.modelName || modelName,
model_type: categoryTypeMap[normalizedType] || rawType,
sha256: modelData?.sha256 || modelData?.hash || modelData?.hashes?.SHA256 || '',
size: modelData?.size || modelData?.file_size || modelData?.fileSize || '',
base_model: modelData?.base_model || modelData?.baseModel || '',
tags: modelData?.tags || [],
trained_words: modelData?.trained_words || modelData?.trainedWords || [],
local_only: true,
civitai_checked: false
};
},
/**
* Fetch local model info and update the dialog without querying CivitAI.
*/
async fetchLocalModelInfoForDialog(loraName, modelData, dialog) {
const requestId = `${Date.now()}_${Math.random()}`;
dialog._localInfoRequestId = requestId;
const fallbackData = this.buildLocalInfoDialogData(loraName, modelData);
this.updateInfoDialogWithData(dialog, fallbackData);
try {
const data = await this.fetchJson('/model_resolver/civitai-search', {
method: 'POST',
body: JSON.stringify({
filename: loraName,
category: modelData?.category || '',
resolved_path: this.getModelInfoResolvedPath(modelData),
local_only: true
})
}, 'Fetch local model info');
if (
dialog._localInfoRequestId !== requestId
|| dialog._infoDialogCivitaiFetchStarted
|| dialog._infoDialogData?.civitai_checked
|| dialog._infoDialogData?.version_url
|| dialog._infoDialogData?.url
) {
return;
}
this.updateInfoDialogWithData(dialog, { ...fallbackData, ...data, civitai_checked: false });
} catch (e) {
if (dialog._localInfoRequestId !== requestId || dialog._infoDialogCivitaiFetchStarted) {
return;
}
this.updateInfoDialogWithData(dialog, fallbackData);
}
},
async fetchCivitaiModelInfoForDialog(dialog, button = null) {
if (!dialog) return;
if (button?.dataset.fetchingCivitai === 'true') return;
const forceRefresh = button?.dataset.forceRefresh === 'true';
const lookup = dialog._infoDialogLookup || {};
let data = dialog._infoDialogData || {};
const filename = data.filename || lookup.loraName || '';
const category = data.category || lookup.modelData?.category || '';
const resolvedPath = this.getInfoDialogModelFilePath(data)
|| this.getModelInfoResolvedPath(lookup.modelData || {});
if (!filename) {
this.showNotification?.('No model filename available', 'error');
return;
}
dialog._infoDialogCivitaiFetchStarted = true;
const defaultLabel = forceRefresh ? 'Refetch metadata' : 'Fetch metadata';
const setFetchingState = (isFetching, label = defaultLabel) => {
if (!button) return;
button.dataset.fetchingCivitai = isFetching ? 'true' : 'false';
button.disabled = isFetching;
button.innerHTML = isFetching
? `${getSvgIcon('globe', 'currentColor', 'mr-info-external-link-icon')} Fetching...`
: `${getSvgIcon('globe', 'currentColor', 'mr-info-external-link-icon')} ${this.escapeHtml(label)}`;
};
setFetchingState(true);
try {
let hash = this.getInfoDialogHash(data);
if (!hash) {
const hashButton = dialog.querySelector('.mr-info-hash-calculate');
const hashResult = await this.calculateInfoDialogHash(dialog, hashButton);
if (!hashResult) {
setFetchingState(false);
return;
}
data = dialog._infoDialogData || data;
hash = this.getInfoDialogHash(data);
}
if (!hash) {
throw new Error('No SHA256 hash available');
}
const tokens = this.getStoredTokens?.() || {};
const result = await this.fetchJson('/model_resolver/civitai-search', {
method: 'POST',
body: JSON.stringify({
filename,
category,
resolved_path: resolvedPath,
sha256: hash,
force_refresh: forceRefresh,
hf_token: tokens.hf_token || '',
brave_search_api_key: tokens.brave_search_api_key || '',
hf_use_brave_fallback: tokens.hf_use_brave_fallback !== false
})
}, 'Fetch model metadata');
const merged = {
...data,
...result,
filename,
category,
file_path: result.file_path || data.file_path || resolvedPath,
resolved_path: result.resolved_path || data.resolved_path || resolvedPath,
sha256: result.sha256 || hash,
metadata_checked: true,
civitai_checked: true
};
dialog._infoDialogData = merged;
this.updateInfoDialogWithData(dialog, merged);
if (merged.url || merged.version_url) {
this.showNotification?.(
forceRefresh ? 'Metadata refreshed.' : 'Metadata loaded.',
'success'
);
} else {
this.showNotification?.('No exact remote metadata match found.', 'info');
}
} catch (error) {
this.showNotification?.(`Metadata fetch failed: ${error?.message || error}`, 'error');
this.updateInfoDialogWithData(dialog, {
...(dialog._infoDialogData || data),
metadata_checked: true,
civitai_checked: true
});
} finally {
setFetchingState(false);
}
},
getInfoMetadataSourceKey(data = {}) {
const source = String(data.details_source || data.source || '').toLowerCase();
const url = String(data.version_url || data.url || data.platform_url || '').toLowerCase();
if (source.includes('huggingface') || url.includes('huggingface.co')) return 'huggingface';
if (source.includes('civarchive') || url.includes('civarchive.com')) return 'civarchive';
if (source.includes('civitai') || url.includes('civitai.com') || url.includes('civitai.red')) return 'civitai';
return 'metadata';
},
getInfoMetadataSourceLabel(data = {}) {
const key = this.getInfoMetadataSourceKey(data);
if (key === 'huggingface') return 'HuggingFace';
if (key === 'civarchive') return 'CivArchive';
if (key === 'civitai') return 'CivitAI';
return 'metadata source';
},
getInfoMetadataSourceIcon(data = {}) {
const key = this.getInfoMetadataSourceKey(data);
if (key === 'huggingface') return getSvgIcon('huggingface', 'currentColor', 'mr-info-external-link-icon');
if (key === 'civitai') return getSvgIcon('civitai', 'currentColor', 'mr-info-external-link-icon');
return getSvgIcon('externalLink', 'currentColor', 'mr-info-external-link-icon');
},
hasInfoMetadataBeenChecked(data = {}) {
return Boolean(data.metadata_checked || data.metadataChecked || data.civitai_checked || data.civitaiChecked);
},
/**
* 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;
}
dialog._infoDialogData = { ...(dialog._infoDialogData || {}), ...data };
// Update title
const titleEl = dialog.querySelector('.mr-info-dialog-title');
if (titleEl) {
const modelName = data.model_name || data.modelName || data.name || data.filename || '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 top tags
const tagsRowEl = dialog.querySelector('.mr-info-tags-row');
const tagsEl = dialog.querySelector('.mr-info-tags');
if (tagsRowEl && tagsEl) {
const tags = this.getInfoDialogTags(data);
if (tags.length > 0) {
tagsEl.innerHTML = tags.map(tag => `
${this.escapeHtml(tag)}
`).join('');
this.setInfoElementVisible(tagsRowEl, true);
this.bindTooltips(tagsEl);
} else {
tagsEl.innerHTML = '';
this.setInfoElementVisible(tagsRowEl, false);
}
}
// Update file
const fileEl = dialog.querySelector('.mr-info-file');
if (fileEl && data.filename) {
fileEl.textContent = data.filename;
}
// Update location
const locationEl = dialog.querySelector('.mr-info-location');
if (locationEl) {
const location = this.formatInfoDialogLocation(data);
const openPath = this.getInfoDialogLocationOpenPath(data) || location;
locationEl.innerHTML = location ? `
${this.escapeHtml(location)}
${getSvgIcon('folderOpen', 'currentColor', 'mr-info-location-icon')}
` : '';
this.setInfoTableRowVisible(locationEl.closest('tr'), Boolean(location));
if (location) this.bindTooltips(locationEl);
}
// Update hash
this.updateInfoDialogHashDisplay(dialog, data);
// Update CivitAI link
const civitaiLinkEl = dialog.querySelector('.mr-info-civitai-link');
if (civitaiLinkEl) {
const hasLocalFile = Boolean(this.getInfoDialogModelFilePath(data));
if (data.url || data.version_url) {
const url = data.version_url || data.url;
const sourceLabel = this.getInfoMetadataSourceLabel(data);
civitaiLinkEl.innerHTML = `
View on ${this.escapeHtml(sourceLabel)}
${this.getInfoMetadataSourceIcon(data)}
${hasLocalFile ? `
${getSvgIcon('globe', 'currentColor', 'mr-info-external-link-icon')}
Refetch metadata
` : ''}
`;
} else if (this.hasInfoMetadataBeenChecked(data)) {
civitaiLinkEl.innerHTML = `
No exact metadata match
${hasLocalFile ? `
${getSvgIcon('globe', 'currentColor', 'mr-info-external-link-icon')}
Refetch metadata
` : ''}
`;
} else if (hasLocalFile) {
civitaiLinkEl.innerHTML = `
${getSvgIcon('globe', 'currentColor', 'mr-info-external-link-icon')}
Fetch metadata
`;
} else {
civitaiLinkEl.innerHTML = '';
}
}
// 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');
this.setInfoTableRowVisible(row, Boolean(baseModel));
}
// Update file size row
const sizeEl = dialog.querySelector('.mr-info-size');
if (sizeEl) {
const sizeLabel = this.formatInfoDialogSize(data);
sizeEl.textContent = sizeLabel;
this.setInfoTableRowVisible(sizeEl.closest('tr'), Boolean(sizeLabel));
}
// Update trained words
const trainedWordsEl = dialog.querySelector('.mr-info-trained-words');
if (trainedWordsEl) {
const words = this.getInfoDialogTrainedWords(data);
const row = trainedWordsEl.closest('tr');
if (words.length > 0) {
dialog._selectedTrainedWords = new Set();
trainedWordsEl.innerHTML = `${words.map(word => `
${this.escapeHtml(word)}
`).join('')}
`;
this.setInfoTableRowVisible(row, true);
this.bindTooltips(trainedWordsEl);
this.updateSelectedTrainedWordsSummary(dialog);
} else {
this.setInfoTableRowVisible(row, false);
}
}
// Update clip skip
const clipSkipEl = dialog.querySelector('.mr-info-clip-skip');
if (clipSkipEl) {
const clipSkip = data.clip_skip || data.clipSkip;
const row = clipSkipEl.closest('tr');
if (clipSkip && clipSkip !== 'None') {
clipSkipEl.textContent = clipSkip;
this.setInfoTableRowVisible(row, true);
} else {
this.setInfoTableRowVisible(row, false);
}
}
// 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;
this.setInfoElementVisible(actionsEl, shouldCollapse);
if (toggleBtn) {
toggleBtn.textContent = 'Show more';
}
if (!shouldCollapse) {
descEl.classList.add('is-expanded');
}
const row = descEl.closest('tr');
this.setInfoTableRowVisible(row, true);
} else {
const row = descEl.closest('tr');
this.setInfoTableRowVisible(row, false);
}
}
// 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.seed) captionParts.push(`seed ${this.escapeHtml(img.seed)} `);
if (img.steps) captionParts.push(`steps ${this.escapeHtml(img.steps)} `);
if (img.cfg) captionParts.push(`cfg ${this.escapeHtml(img.cfg)} `);
if (img.sampler) captionParts.push(`sampler ${this.escapeHtml(img.sampler)} `);
if (img.model) captionParts.push(`model ${this.escapeHtml(this.truncateText(img.model, 72))} `);
if (img.positive) captionParts.push(`positive ${this.escapeHtml(this.truncateText(img.positive, 180))} `);
if (img.negative) captionParts.push(`negative ${this.escapeHtml(this.truncateText(img.negative, 180))} `);
return `
`;
};
let imagesHtml = '';
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);
});
});
this.bindTooltips(imagesContainer);
},
getInfoImageMetadataRows(image = {}) {
const metadata = image.metadata && typeof image.metadata === 'object' ? image.metadata : {};
const width = image.width || metadata.width || '';
const height = image.height || metadata.height || '';
const cfg = image.cfg || image.cfgScale || metadata.cfg || metadata.cfgScale || '';
const denoise = image.denoise || metadata.denoise || '';
const scheduler = image.scheduler || metadata.scheduler || '';
const rows = [
['Seed', image.seed],
['Steps', image.steps],
['Width', width],
['Height', height],
['CFG Scale', cfg],
['Denoise', denoise],
['Sampler', image.sampler],
['Scheduler', scheduler],
['Clip skip', image.clip_skip],
['Model', image.model]
];
return rows.filter(([, value]) => value !== null && value !== undefined && String(value).trim() !== '');
},
getInfoImageResources(image = {}) {
const metadata = image.metadata && typeof image.metadata === 'object' ? image.metadata : {};
const resources = Array.isArray(image.resources)
? image.resources
: Array.isArray(image.additionalResources)
? image.additionalResources
: Array.isArray(metadata.additionalResources)
? metadata.additionalResources
: Array.isArray(metadata.resources)
? metadata.resources
: [];
return resources
.filter(resource => resource && typeof resource === 'object')
.map(resource => {
const modelId = resource.modelId || resource.model_id;
const versionId = resource.modelVersionId || resource.versionId || resource.model_version_id;
const url = resource.url
|| resource.modelUrl
|| (modelId ? `https://civitai.com/models/${modelId}${versionId ? `?modelVersionId=${versionId}` : ''}` : '');
return {
name: resource.name || resource.modelName || resource.model || resource.hash || 'Resource',
version: resource.versionName || resource.modelVersionName || resource.version || '',
type: resource.type || resource.modelType || resource.resourceType || '',
weight: resource.weight || resource.strength || resource.value || '',
url
};
})
.filter(resource => resource.name);
},
renderInfoImagePreviewPrompt(label, value, copyKey) {
const text = String(value || '').trim();
if (!text) return '';
return `
${this.escapeHtml(label)}
${getSvgIcon('copy')} Copy
${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 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 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 => `
${resource.url
? `
${this.escapeHtml(resource.name)} `
: `
${this.escapeHtml(resource.name)} `
}
${resource.type ? `${this.escapeHtml(resource.type)} ` : ''}
${resource.weight !== '' ? `${this.escapeHtml(resource.weight)} ` : ''}
${resource.version
? (resource.url
? `
${this.escapeHtml(resource.version)} `
: `
${this.escapeHtml(resource.version)}
`
)
: ''}
`).join('')
: '';
return `
${getSvgIcon('x')}
${this.escapeHtml(positionText)}
${hasPrevious ? `‹ ` : ''}
${hasPrevious ? `› ` : ''}
`;
},
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 metadataRows = this.getInfoImageMetadataRows(image);
const resources = this.getInfoImageResources(image);
const text = key === 'positive'
? image.positive
: key === 'negative'
? image.negative
: [
image.positive ? `Prompt: ${image.positive}` : '',
image.negative ? `Negative prompt: ${image.negative}` : '',
...metadataRows.map(([label, value]) => `${label}: ${value}`),
...resources.map(resource => `Resource: ${[
resource.name,
resource.version,
resource.type,
resource.weight !== '' ? `strength ${resource.weight}` : ''
].filter(Boolean).join(' / ')}`)
].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 data = await this.fetchJson('/model_resolver/model-details', {
method: 'POST',
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 || ''
})
}, 'Fetch model details');
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 `
${getSvgIcon('x')}
${this.escapeHtml(title)}
${this.escapeHtml(model.details_source || model.source || '')}
Loading model details...
`;
},
renderSourceModelDetailsError(error) {
return `
${getSvgIcon('x')}
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 fresh = await this.fetchJson('/model_resolver/model-details', {
method: 'POST',
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 || ''
})
}, 'Fetch model version details');
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 pageUrl = data.version_url || data.url || '';
const civitaiUrl = this.getSourceModelCivitaiUrl(data, selectedVersion, contextModel);
const showCivitaiAction = civitaiUrl && civitaiUrl !== pageUrl;
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 `
${getSvgIcon('x')}
${this.escapeHtml(data.name || contextModel.name || 'Model')}
${this.escapeHtml([source, data.type, creatorName].filter(Boolean).join(' / '))}
${versions.map(version => `
${this.escapeHtml(version.name || `Version ${version.id || ''}`)}
${version.base_model ? `${this.escapeHtml(version.base_model)} ` : ''}
`).join('')}
${images.length > 1 ? `
‹ ` : ''}
${images.length ? images.map((image, index) => `
${this.renderSourceModelImageMeta(image)}
`).join('') : '
No example images available.
'}
${images.length > 1 ? `
› ` : ''}
About
${sanitizedDescription ? sanitizedDescription : '
No description available.
'}
Details
Type ${this.escapeHtml(data.type || '-')}
Base Model ${this.escapeHtml(selectedVersion.base_model || '-')}
Version ${this.escapeHtml(selectedVersion.name || selectedVersion.id || '-')}
${statItems.map(([label, value]) => `
${this.escapeHtml(label)} ${this.escapeHtml(value)}
`).join('')}
${tags.length ? `${tags.map(tag => `${this.escapeHtml(tag)} `).join('')}
` : ''}
${trainedWords.length ? `
Trained Words
${trainedWords.map(word => `${this.escapeHtml(word)} `).join('')}
` : ''}
Download Variants
${this.renderSourceModelDetailsFiles(selectedVersion, data, contextModel)}
`;
},
getSourceModelCivitaiUrl(data = {}, selectedVersion = {}, contextModel = {}) {
const modelId = selectedVersion.civitai_model_id
|| selectedVersion.civitaiModelId
|| data.civitai_model_id
|| data.civitaiModelId
|| contextModel.civitai_model_id
|| contextModel.civitaiModelId;
const versionId = selectedVersion.civitai_model_version_id
|| selectedVersion.civitaiModelVersionId
|| data.civitai_model_version_id
|| data.civitaiModelVersionId
|| contextModel.civitai_model_version_id
|| contextModel.civitaiModelVersionId;
if (modelId) {
return `https://civitai.com/models/${encodeURIComponent(modelId)}${versionId ? `?modelVersionId=${encodeURIComponent(versionId)}` : ''}`;
}
const explicitUrl = selectedVersion.platform_url
|| selectedVersion.platformUrl
|| data.platform_url
|| data.platformUrl
|| contextModel.platform_url
|| contextModel.platformUrl;
const value = String(explicitUrl || '').trim();
if (!/^https?:\/\//i.test(value)) return '';
try {
const url = new URL(value);
const host = url.hostname.toLowerCase().replace(/^www\./, '');
if (host !== 'civitai.com' && host !== 'civitai.red') return '';
url.hostname = 'civitai.com';
return url.toString();
} catch (error) {
return /civitai\.(?:com|red)\//i.test(value)
? value.replace('civitai.red', 'civitai.com')
: '';
}
},
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.
';
}
const targetIndex = files.findIndex(file => this.isSourceModelDetailsTargetFile(file, contextModel));
const orderedFiles = targetIndex > 0
? [files[targetIndex], ...files.filter((_, index) => index !== targetIndex)]
: files;
return orderedFiles.map((file, index) => {
const isTarget = this.isSourceModelDetailsTargetFile(file, contextModel);
const fileMeta = this.getSourceModelFileMeta(file);
const mirrors = this.getSourceModelMirrors(file);
const preferredMirror = this.getSourceModelPreferredMirror(file, contextModel, mirrors);
const preferredDownloadUrl = preferredMirror?.url || file.download_url;
const preferredFilename = preferredMirror?.filename || file.name || contextModel.filename || data.name || 'model';
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: preferredFilename,
download_url: preferredDownloadUrl,
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)
: '';
const summaryBadges = [
isTarget ? 'Matched' : '',
file.primary ? 'Primary' : '',
mirrors.length ? `${mirrors.length} mirrors` : ''
].filter(Boolean);
const openAttr = index === 0 ? ' open' : '';
return `
${this.escapeHtml(file.name || `File ${index + 1}`)}
${this.escapeHtml(fileMeta.summary)}
${summaryBadges.length ? `${summaryBadges.map(badge => `${this.escapeHtml(badge)} `).join('')} ` : ''}
${fileMeta.badges.length ? `
${fileMeta.badges.map(badge => `${this.escapeHtml(badge)} `).join('')}
` : ''}
${isTarget ? 'Use matched file' : 'Use this file'}
${mirrorList}
`;
}).join('');
},
isSourceModelDetailsTargetFile(file = {}, contextModel = {}) {
const targetFilename = this.getSourceModelComparableFilename(contextModel.filename || contextModel.original_path || '');
const targetUrl = String(contextModel.download_url || contextModel.url || '').trim();
const targetHash = String(contextModel.sha256 || contextModel.hash || '').trim().toLowerCase();
const fileHash = this.getSourceModelFileHash(file);
if (targetHash && fileHash && targetHash === fileHash) return true;
const urls = this.getSourceModelFileUrls(file);
if (targetUrl && urls.some(url => url === targetUrl)) return true;
if (!targetFilename) return false;
return this.getSourceModelFileNames(file).some(name => (
this.getSourceModelComparableFilename(name) === targetFilename
));
},
getSourceModelPreferredMirror(file = {}, contextModel = {}, mirrors = null) {
const candidates = Array.isArray(mirrors) ? mirrors : this.getSourceModelMirrors(file);
const targetUrl = String(contextModel.download_url || contextModel.url || '').trim();
if (targetUrl) {
const byUrl = candidates.find(mirror => String(mirror.url || '') === targetUrl);
if (byUrl) return byUrl;
}
const targetFilename = this.getSourceModelComparableFilename(contextModel.filename || contextModel.original_path || '');
if (targetFilename) {
const byName = candidates.find(mirror => (
this.getSourceModelComparableFilename(mirror.filename || mirror.name || '') === targetFilename
));
if (byName) return byName;
}
return null;
},
getSourceModelComparableFilename(value = '') {
return String(value || '')
.split(/[\\/]+/)
.pop()
.trim()
.toLowerCase();
},
getSourceModelFileHash(file = {}) {
const hashes = file.hashes && typeof file.hashes === 'object' ? file.hashes : {};
return String(file.sha256 || file.hash || hashes.SHA256 || hashes.sha256 || '').trim().toLowerCase();
},
getSourceModelFileNames(file = {}) {
const names = new Set();
[file.name, file.filename].forEach(name => {
if (name) names.add(String(name));
});
const mirrors = Array.isArray(file.mirrors) ? file.mirrors : [];
mirrors.forEach(mirror => {
if (mirror?.filename) names.add(String(mirror.filename));
if (mirror?.name) names.add(String(mirror.name));
});
return Array.from(names);
},
getSourceModelFileUrls(file = {}) {
const urls = new Set();
[file.download_url, file.downloadUrl, file.url].forEach(url => {
if (url) urls.add(String(url));
});
const downloadUrls = Array.isArray(file.download_urls) ? file.download_urls : [];
downloadUrls.forEach(url => {
if (url) urls.add(String(url));
});
const mirrors = Array.isArray(file.mirrors) ? file.mirrors : [];
mirrors.forEach(mirror => {
if (mirror?.url) urls.add(String(mirror.url));
});
return Array.from(urls);
},
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 `
${this.escapeHtml(mirror.filename || basePayload.filename || 'Download mirror')}
${meta ? `${this.escapeHtml(meta)} ` : ''}
${actionIcon}
`;
}).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 mirrorHash = this.getSourceModelDisplayHash(mirror);
const meta = [
isDefault ? 'Default' : '',
this.isSourceModelMirrorDead(mirror) ? 'Likely dead' : '',
host,
this.isSourceModelMirrorGated(mirror) ? 'Gated' : '',
mirror.is_paid ? 'Paid' : '',
mirrorHash ? `SHA256 ${mirrorHash}` : ''
].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 fileHash = this.getSourceModelDisplayHash(file);
const summary = [
this.formatSearchResultSize(file),
fileHash ? `SHA256 ${fileHash}` : ''
].filter(Boolean).join(' / ') || 'Downloadable file';
return { badges, summary };
},
getSourceModelDisplayHash(value = {}) {
const hashes = value.hashes && typeof value.hashes === 'object' ? value.hashes : {};
return String(value.sha256 || value.hash || hashes.SHA256 || hashes.sha256 || '').trim();
},
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;
}
if (dialog?._onInfoDialogKeyDown) {
document.removeEventListener('keydown', dialog._onInfoDialogKeyDown);
dialog._onInfoDialogKeyDown = null;
}
this.saveInfoDialogSize(dialog);
this.closeInfoImagePreview();
if (dialog && dialog.parentNode) {
dialog.parentNode.removeChild(dialog);
}
}
};