import { api } from "../../../../../scripts/api.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
import { escapeHtml, escapeJsString, getFilenameFromPath, sanitizeDescriptionHtml, pollBackgroundTask, safeStorage, copyTextWithFeedback } from "../utils/html_utils.js";
import { getModelCardUrl, getSourceKeyFromUrl } from "../utils/url_utils.js";
import { extractComfyWorkflow } from "../utils/workflow_metadata.js";
import { getSha256Field, normalizeSha256 } from "../utils/hash_utils.js";
import { parseFiniteNumber } from "../utils/size_utils.js";
import {
getSourceDisplayLabel,
getSourceKeyFromText,
normalizeSourceKey,
} from "../utils/source_labels.js";
import { getCivitaiModelUrl } from "../globals.js";
export const modelInfoMethods = {
escapeHtml,
escapeJsString,
getFilenameFromPath,
/**
* 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.hideTooltip?.();
this._contextMenuModel = model;
const canShowMore = this.canShowSourceDetails(model);
const sourceLink = this.getContextMenuSourceLink(this.getContextMenuSourceLookupModel(model));
const showSourceLink = Boolean(sourceLink?.url);
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 isLoadedModelContext = model?.context_scope === 'loaded_model';
const isWorkflowNodeContext = model?.context_scope === 'workflow_node';
const isLocalModelContext = model?.context_scope === 'local_model' || model?.context_scope === 'local_match';
const isFolderOnlyContext = isDownloadFolderContext || isDownloadRootContext;
const isSourceModelContext = !isDownloadTableContext
&& !isFolderOnlyContext
&& !isDownloadQueueContext
&& !isDownloadHistoryContext
&& !isWorkflowNodeContext;
const showSuggestSubfolder = this.canSuggestDownloadSubfolderFromContextMenu?.(model) || false;
const hasLocalPath = Boolean(model?.open_path || model?.folder_path || model?.download_directory || model?.directory || model?.path || model?.resolved_path);
const showOpenFolder = !isDownloadTableContext && hasLocalPath;
const showCompareHashes = (isLocalModelContext || isDownloadFolderContext)
&& hasLocalPath
&& Boolean(model?.missing_key || model?.missing_search_key);
const showSwitchWorkflow = (isDownloadQueueContext || isDownloadHistoryContext) && Boolean(this.canSwitchToDownloadWorkflow?.(model));
const showLocateNode = (isLoadedModelContext || isWorkflowNodeContext)
&& model?.node_id !== undefined
&& model?.node_id !== null
&& model?.node_id !== '';
this.setContextMenuItemVisible('showInfo', isSourceModelContext);
this.setContextMenuItemVisible('showMore', canShowMore);
this.setContextMenuItemVisible('suggestSubfolder', showSuggestSubfolder);
this.setContextMenuItemVisible('source', showSourceLink);
this.setContextMenuItemVisible('civitai', false);
this.setContextMenuItemVisible('locateNode', showLocateNode);
this.setContextMenuItemVisible('switchWorkflow', showSwitchWorkflow);
this.setContextMenuItemVisible('compareHashes', showCompareHashes);
this.setContextMenuItemVisible('openFolder', showOpenFolder);
this.setContextMenuDividerVisible('source', showSourceLink && (isSourceModelContext || canShowMore || showSuggestSubfolder));
this.setContextMenuDividerVisible(
'workflow',
(showLocateNode || showSwitchWorkflow) && (isSourceModelContext || canShowMore || showSourceLink)
);
this.setContextMenuDividerVisible('folder', (showCompareHashes || showOpenFolder) && (isSourceModelContext || canShowMore || showSourceLink || showSwitchWorkflow));
this.updateContextMenuSourceItem(sourceLink);
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`;
}
this.refreshContextMenuSourceLink(model);
},
/**
* 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 === 'source' || action === 'civitai') {
this.openModelSourceLink(model);
} else if (action === 'openFolder') {
this.openContainingFolder(model);
} else if (action === 'compareHashes') {
this.compareLocalModelHashesWithCurrentFinding(model);
} else if (action === 'switchWorkflow') {
this.switchToDownloadWorkflow(model);
} else if (action === 'locateNode') {
const locateTarget = this.getMissingLocateTarget(model);
this.locateNodeInGraph(locateTarget.nodeId, {
subgraphId: locateTarget.subgraphId || '',
isTopLevel: locateTarget.isTopLevel
});
} else if (action === 'showInfo') {
this.showModelInfo(model);
} else if (action === 'showMore') {
this.showSourceModelDetails(model);
} else if (action === 'suggestSubfolder') {
if (typeof this.suggestDownloadSubfolderFromContextMenu !== 'function') {
console.error('Model Resolver: Suggest Subfolder handler is unavailable.');
this.showNotification?.('Suggest Subfolder is unavailable. Reload ComfyUI and try again.', 'error');
return;
}
console.info('Model Resolver: Suggest Subfolder requested from the download result context menu.', {
source: model.details_source || model.source || '',
model: model.name || model.filename || '',
baseModel: model.base_model || model.baseModel || '',
tags: model.tags || []
});
Promise.resolve(this.suggestDownloadSubfolderFromContextMenu(model)).catch(error => {
console.error('Model Resolver: context-menu subfolder suggestion failed:', error);
this.showNotification?.('Could not apply a subfolder suggestion.', 'error');
});
}
},
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');
}
});
},
clampContextMenuToViewport() {
if (!this.contextMenu || this.contextMenu.style.display === 'none') return;
const rect = this.contextMenu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
this.contextMenu.style.left = `${Math.max(10, window.innerWidth - rect.width - 10)}px`;
}
if (rect.bottom > window.innerHeight) {
this.contextMenu.style.top = `${Math.max(10, window.innerHeight - rect.height - 10)}px`;
}
},
getContextMenuSourceConfig(key = '') {
const normalized = normalizeSourceKey(key).replace(/\s+/g, '_');
const configs = {
civitai: { key: 'civitai', icon: 'civitai' },
civarchive: { key: 'civarchive', icon: 'civarchive' },
huggingface: { key: 'huggingface', icon: 'huggingface' },
lora_manager_archive: { key: 'lora_manager_archive', icon: 'loraManager' }
};
const config = configs[normalized];
const label = getSourceDisplayLabel(normalized, { fallback: 'Source' });
return config
? { ...config, label }
: { key: normalized || 'source', label, icon: 'externalLink' };
},
getContextMenuSourceKey(model = {}) {
return getSourceKeyFromText(model?.details_source)
|| getSourceKeyFromText(model?.source)
|| getSourceKeyFromText(model?.source_name)
|| getSourceKeyFromText(model?.sourceName)
|| getSourceKeyFromText(model?.metadata_source)
|| getSourceKeyFromText(model?.metadataSource)
|| '';
},
getContextMenuSourceUrlCandidates(model = {}) {
const candidates = [];
const seen = new Set();
const add = (value) => {
const url = String(value || '').trim();
if (!url || seen.has(url)) return;
seen.add(url);
candidates.push(url);
};
const addFrom = (value) => {
if (!value || typeof value !== 'object') return;
[
'version_url',
'versionUrl',
'model_url',
'modelUrl',
'workflow_model_url',
'workflowModelUrl',
'platform_url',
'platformUrl',
'source_url',
'sourceUrl',
'page_url',
'pageUrl',
'html_url',
'htmlUrl',
'github_url',
'githubUrl',
'civitai_url',
'civitaiUrl',
'creator_url',
'creatorUrl',
'openUrl',
'url'
].forEach(key => add(value[key]));
};
addFrom(model);
[
model?.metadata,
model?.path_metadata || model?.pathMetadata,
model?.download_metadata || model?.downloadMetadata,
model?.selected_version || model?.selectedVersion,
model?.civitai_details || model?.civitaiDetails,
model?.version,
model?.civitai,
model?.civarchive,
model?.huggingface
].forEach(addFrom);
const repoId = model?.repo_id || model?.repo || model?.repository || '';
if (repoId) {
add(`https://huggingface.co/${repoId}`);
}
return candidates;
},
normalizeContextMenuSourceUrl(value = '', sourceKey = '') {
const url = String(value || '').trim();
if (!url) return '';
if (/^https?:\/\//i.test(url)) return url;
if (!url.startsWith('/')) return '';
if (sourceKey === 'civarchive') return `https://civarchive.com${url}`;
if (sourceKey === 'civitai') return `https://civitai.com${url}`;
if (sourceKey === 'huggingface') return `https://huggingface.co${url}`;
return '';
},
buildContextMenuSourceUrlFromIds(model = {}, sourceKey = '') {
const modelId = model?.model_id || model?.modelId || model?.civitai_model_id || model?.civitaiModelId || model?.id || '';
const versionId = model?.version_id || model?.versionId || model?.modelVersionId || model?.civitai_model_version_id || model?.civitaiModelVersionId || '';
if (sourceKey === 'civitai' && modelId) {
return getCivitaiModelUrl(modelId, versionId);
}
if (sourceKey === 'civarchive' && modelId) {
return `https://civarchive.com/models/${modelId}${versionId ? `?modelVersionId=${versionId}` : ''}`;
}
const repoId = model?.repo_id || model?.repo || model?.repository || '';
if (sourceKey === 'huggingface' && repoId) {
return `https://huggingface.co/${repoId}`;
}
return '';
},
getContextMenuSourceLink(model = {}) {
const fallbackKey = this.getContextMenuSourceKey(model);
const candidates = this.getContextMenuSourceUrlCandidates(model);
const resolveCandidate = (candidate) => {
const candidateKey = getSourceKeyFromUrl(candidate, { fallbackToText: true }) || fallbackKey;
const normalizedUrl = this.normalizeContextMenuSourceUrl(candidate, candidateKey);
if (!normalizedUrl) return null;
const cardUrl = getModelCardUrl(normalizedUrl) || normalizedUrl;
const sourceKey = getSourceKeyFromUrl(cardUrl, { fallbackToText: true })
|| getSourceKeyFromUrl(normalizedUrl, { fallbackToText: true })
|| candidateKey;
const config = this.getContextMenuSourceConfig(sourceKey);
return {
...config,
url: cardUrl
};
};
const preferredCandidates = fallbackKey
? candidates.filter(candidate => {
const candidateKey = getSourceKeyFromUrl(candidate, { fallbackToText: true });
return !candidateKey || candidateKey === fallbackKey;
})
: candidates;
for (const candidate of preferredCandidates) {
const resolved = resolveCandidate(candidate);
if (resolved) return resolved;
}
const builtUrl = this.buildContextMenuSourceUrlFromIds(model, fallbackKey);
if (builtUrl) {
const config = this.getContextMenuSourceConfig(fallbackKey);
return {
...config,
url: builtUrl
};
}
for (const candidate of candidates) {
if (preferredCandidates.includes(candidate)) continue;
const resolved = resolveCandidate(candidate);
if (resolved) return resolved;
}
return null;
},
getContextMenuSourceLookupPath(model = {}) {
return model?.open_path
|| model?.resolved_path
|| model?.file_path
|| model?.full_path
|| model?.path
|| '';
},
getContextMenuSourceLookupKey(model = {}) {
const path = String(this.getContextMenuSourceLookupPath(model) || '').trim();
return path ? this.normalizePathToForward(path).toLowerCase() : '';
},
getContextMenuSourceLookupModel(model = {}) {
const key = this.getContextMenuSourceLookupKey(model);
const cached = key && this._contextMenuSourceLookupCache instanceof Map
? this._contextMenuSourceLookupCache.get(key)
: null;
const data = cached?.data && typeof cached.data === 'object' ? cached.data : null;
if (!data) return model;
const merged = { ...model, metadata: data };
Object.entries(data).forEach(([field, value]) => {
if (value === undefined || value === null || value === '') return;
merged[field] = value;
});
return merged;
},
rememberContextMenuSourceMetadata(data = {}) {
if (!data || typeof data !== 'object') return;
const paths = [
data.open_path,
data.openPath,
data.resolved_path,
data.resolvedPath,
data.file_path,
data.filePath,
data.full_path,
data.fullPath,
data.path
].map(value => String(value || '').trim()).filter(Boolean);
const keys = [...new Set(paths.map(path => this.getContextMenuSourceLookupKey({ path })).filter(Boolean))];
if (!keys.length) return;
if (!(this._contextMenuSourceLookupCache instanceof Map)) {
this._contextMenuSourceLookupCache = new Map();
}
keys.forEach(key => {
const existing = this._contextMenuSourceLookupCache.get(key)?.data || {};
const merged = { ...(existing && typeof existing === 'object' ? existing : {}), ...data };
this._contextMenuSourceLookupCache.set(key, { data: merged });
});
if (this.contextMenu?.style.display !== 'none' && this._contextMenuModel) {
const activeKey = this.getContextMenuSourceLookupKey(this._contextMenuModel);
if (keys.includes(activeKey)) {
const sourceLink = this.getContextMenuSourceLink(this.getContextMenuSourceLookupModel(this._contextMenuModel));
this.applyContextMenuSourceLink(this._contextMenuModel, sourceLink);
}
}
},
getContextMenuSourceLookupFilename(model = {}) {
return model?.filename
|| model?.name
|| this.getFilenameFromPath(model?.original_path)
|| this.getFilenameFromPath(this.getContextMenuSourceLookupPath(model))
|| '';
},
canLookupContextMenuSourceMetadata(model = {}, sourceLink = null) {
if (sourceLink?.url) return false;
if (!this.getContextMenuSourceLookupPath(model)) return false;
const scope = String(model?.context_scope || '').toLowerCase();
return [
'local_model',
'local_match',
'download_folder',
'download_queue',
'download_history',
''
].includes(scope);
},
applyContextMenuSourceLink(model = {}, sourceLink = null) {
if (!this.contextMenu) return;
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 isWorkflowNodeContext = model?.context_scope === 'workflow_node';
const isFolderOnlyContext = isDownloadFolderContext || isDownloadRootContext;
const isSourceModelContext = !isDownloadTableContext
&& !isFolderOnlyContext
&& !isDownloadQueueContext
&& !isDownloadHistoryContext
&& !isWorkflowNodeContext;
const showSuggestSubfolder = this.canSuggestDownloadSubfolderFromContextMenu?.(model) || false;
const hasLocalPath = Boolean(model?.open_path || model?.folder_path || model?.download_directory || model?.directory || model?.path || model?.resolved_path);
const showOpenFolder = !isDownloadTableContext && hasLocalPath;
const showCompareHashes = (model?.context_scope === 'local_model' || model?.context_scope === 'local_match' || isDownloadFolderContext)
&& hasLocalPath
&& Boolean(model?.missing_key || model?.missing_search_key);
const showSwitchWorkflow = (isDownloadQueueContext || isDownloadHistoryContext) && Boolean(this.canSwitchToDownloadWorkflow?.(model));
const showSourceLink = Boolean(sourceLink?.url);
this.setContextMenuItemVisible('suggestSubfolder', showSuggestSubfolder);
this.setContextMenuItemVisible('source', showSourceLink);
this.setContextMenuItemVisible('civitai', false);
this.setContextMenuDividerVisible('source', showSourceLink && (isSourceModelContext || canShowMore || showSuggestSubfolder));
this.setContextMenuDividerVisible('workflow', showSwitchWorkflow && (isSourceModelContext || canShowMore || showSourceLink));
this.setContextMenuDividerVisible('folder', (showCompareHashes || showOpenFolder) && (isSourceModelContext || canShowMore || showSourceLink || showSwitchWorkflow));
this.updateContextMenuSourceItem(sourceLink);
this.clampContextMenuToViewport();
},
async refreshContextMenuSourceLink(model = {}) {
const initialSourceLink = this.getContextMenuSourceLink(this.getContextMenuSourceLookupModel(model));
if (!this.canLookupContextMenuSourceMetadata(model, initialSourceLink)) return;
const key = this.getContextMenuSourceLookupKey(model);
const filename = this.getContextMenuSourceLookupFilename(model);
const path = this.getContextMenuSourceLookupPath(model);
if (!key || !filename || !path) return;
if (!(this._contextMenuSourceLookupCache instanceof Map)) {
this._contextMenuSourceLookupCache = new Map();
}
const token = ++this._contextMenuSourceLookupToken;
let cached = this._contextMenuSourceLookupCache.get(key);
try {
if (!cached?.promise && !cached?.data) {
const promise = this.fetchJson('/model_resolver/civitai-search', {
method: 'POST',
silent: true,
body: JSON.stringify({
filename,
category: model.category || '',
resolved_path: path,
local_only: true
})
}, 'Read local source metadata');
cached = { promise };
this._contextMenuSourceLookupCache.set(key, cached);
const data = await promise;
cached = { data };
this._contextMenuSourceLookupCache.set(key, cached);
} else if (cached.promise) {
const data = await cached.promise;
cached = { data };
this._contextMenuSourceLookupCache.set(key, cached);
}
} catch (error) {
this._contextMenuSourceLookupCache.set(key, { data: null, error });
return;
}
if (token !== this._contextMenuSourceLookupToken) return;
if (this.contextMenu?.style.display === 'none') return;
if (this.getContextMenuSourceLookupKey(this._contextMenuModel || {}) !== key) return;
const refreshedModel = this.getContextMenuSourceLookupModel(model);
const sourceLink = this.getContextMenuSourceLink(refreshedModel);
this.applyContextMenuSourceLink(model, sourceLink);
},
updateContextMenuSourceItem(sourceLink = null) {
if (!this.contextMenu) return;
const sourceItem = this.contextMenu.querySelector('.mr-context-menu-action-source, [data-menu-action="source"]');
if (!sourceItem) return;
const iconEl = sourceItem.querySelector('.mr-context-menu-item-icon');
const labelEl = sourceItem.querySelector('span:last-child');
const config = sourceLink || this.getContextMenuSourceConfig('');
if (iconEl) {
iconEl.innerHTML = getSvgIcon(config.icon || 'externalLink', 'currentColor', 'mr-context-menu-item-svg')
|| getSvgIcon('externalLink', 'currentColor', 'mr-context-menu-item-svg');
}
if (labelEl) {
labelEl.textContent = sourceLink?.label
? `Open in ${sourceLink.label}`
: 'Open Source';
}
},
canShowSourceDetails(model = {}) {
const source = String(model.details_source || model.source || '').toLowerCase();
return model?.context_scope === 'download_table'
&& ['civitai', 'civarchive', 'huggingface', '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;
},
getMissingForHashCompareContext(model = {}) {
const missingKey = String(model?.missing_key || '').trim();
if (missingKey) {
const byMissingKey = this.getMissingByKey(missingKey);
if (byMissingKey) return byMissingKey;
}
const searchKey = String(model?.missing_search_key || '').trim();
if (searchKey && typeof this.getMissingSearchKey === 'function') {
return (this.missingModels || []).find(missing => this.getMissingSearchKey(missing) === searchKey) || null;
}
return null;
},
async fetchLocalHashCandidatesForCompare(model = {}) {
const path = this.getLocalHashComparePath(model);
if (!path) {
return { candidates: [], data: null };
}
const data = await this.fetchJson('/model_resolver/local-model-hashes', {
method: 'POST',
silent: true,
body: JSON.stringify({ path, model })
}, 'Read local model hash metadata');
return {
data,
candidates: this.collectHashCandidatesForCompare(data, 'local metadata', new Set())
};
},
formatHashCompareProgressMessage(filename = '', percent = 0, message = '') {
const safePercent = Math.max(0, Math.min(100, Number(percent) || 0));
const status = message || 'Calculating SHA256...';
return `Calculating hash for ${filename}...\n${Math.round(safePercent)}% - ${status}`;
},
async pollHashCalculation(filePath, metadataPath, {
onStart = () => {},
onProgress = () => {},
checkAbort = () => false,
onAbort = () => {},
silent = true
} = {}) {
const start = await this.fetchJson('/model_resolver/calculate-file-hash/start', {
method: 'POST',
silent,
body: JSON.stringify({
file_path: filePath,
metadata_path: metadataPath
})
}, 'Start model hash calculation');
const progressId = start?.progress_id;
if (!progressId) {
throw new Error('No progress id returned');
}
onStart(progressId);
let finalResult = null;
await pollBackgroundTask({
endpoint: `/model_resolver/calculate-file-hash/progress/${encodeURIComponent(progressId)}`,
tokenCheck: () => {
if (checkAbort()) {
onAbort(progressId);
return false;
}
return true;
},
onProgress: (progress) => {
onProgress(progress?.percent || 0, progress?.message || '');
},
isTerminal: (progress) => {
return ['done', 'cancelled', 'error'].includes(progress?.status);
},
onTerminal: (progress) => {
if (progress.status === 'done') {
finalResult = progress;
} else if (progress.status === 'cancelled') {
throw new Error('Hash calculation cancelled');
} else if (progress.status === 'error') {
throw new Error(progress.error || progress.message || 'Hash calculation failed');
}
},
onError: (err) => {
throw err;
},
fetchJson: this.fetchJson.bind(this)
});
if (checkAbort()) {
throw new Error('Hash calculation cancelled');
}
return finalResult;
},
async calculateLocalHashCandidatesForCompare(model = {}) {
const path = this.getLocalHashComparePath(model);
if (!path) {
throw new Error('No local file path available');
}
const filename = this.getHashCompareFilename(model);
const notification = this.showNotification?.(
this.formatHashCompareProgressMessage(filename, 0, 'Preparing hash calculation...'),
'info',
{ manualProgress: true }
);
try {
const result = await this.pollHashCalculation(
path,
model.metadata_path || model.metadataPath || '',
{
silent: true,
onProgress: (percent, message) => {
notification?.updateProgress?.(
percent,
this.formatHashCompareProgressMessage(filename, percent, message)
);
}
}
);
if (!result) return null;
const sha256 = normalizeSha256(result?.sha256 || result?.hash || '');
if (!sha256) {
throw new Error('No hash returned');
}
this.updateHashCompareModelMetadata(model, result);
notification?.updateProgress?.(
100,
result?.metadata_updated
? `Hash calculated and saved for ${filename}.`
: `Hash calculated for ${filename}.`
);
window.setTimeout(() => notification?.close?.(), 900);
return {
data: result,
candidates: this.collectHashCandidatesForCompare(
{
...result,
sha256,
hash: sha256,
hashes: { SHA256: sha256 }
},
'calculated hash',
new Set()
)
};
} catch (error) {
notification?.close?.();
if (error.message === 'Hash calculation cancelled') {
this.showNotification?.('Hash calculation stopped.', 'info');
return null;
}
throw error;
}
},
showHashCompareSuccessNotification(filename = 'Selected local model', matches = []) {
const uniqueMatches = this.dedupeHashCompareMatches(matches);
if (!uniqueMatches.length) return;
const visibleMatches = uniqueMatches.slice(0, 6);
const lines = visibleMatches.map(match => (
`- ${match.sourceLabel || 'Source'}: ${match.name || 'Model'}`
));
if (uniqueMatches.length > visibleMatches.length) {
lines.push(`- +${uniqueMatches.length - visibleMatches.length} more`);
}
this.showNotification?.(
`Hash match found for ${filename}:\n${lines.join('\n')}`,
'success',
{ duration: 7000 }
);
},
markSearchResultHashBadgesForMissing(missing = {}, sha256 = '', options = {}) {
const hash = normalizeSha256(sha256);
if (!missing || !hash) return [];
const localMatchIdentity = String(options.localMatchIdentity || '').trim();
const markIfMatches = (result, sourceLabel = 'result') => {
if (!result || typeof result !== 'object') return null;
const hashes = this.collectHashCandidatesForCompare(result, sourceLabel, new Set());
if (!hashes.some(candidate => candidate.hash === hash)) return null;
result.hash_verified = true;
result.hash_verified_sha256 = hash;
if (localMatchIdentity) {
const identities = Array.isArray(result.hash_verified_local_match_identities)
? result.hash_verified_local_match_identities
: [];
if (!identities.includes(localMatchIdentity)) {
identities.push(localMatchIdentity);
}
result.hash_verified_local_match_identities = identities;
}
return {
sourceKey: sourceLabel,
sourceLabel: this.getHashCompareSourceLabel(sourceLabel, result),
name: this.getHashCompareResultName(result),
url: this.getHashCompareResultUrl(result),
sha256: hash
};
};
const state = this.getSearchState?.(missing)
|| this.searchResultCache?.get(this.getMissingSearchKey?.(missing));
const results = state?.results || {};
const sources = [
['download_source', missing.download_source],
['popular', results.popular],
['model_list', results.model_list],
['huggingface', results.huggingface],
['civitai', results.civitai],
['civarchive', results.civarchive],
['lora_manager_archive', results.lora_manager_archive]
];
let changed = false;
const matches = [];
sources.forEach(([sourceKey, value]) => {
if (Array.isArray(value)) {
value.forEach(item => {
const match = markIfMatches(item, sourceKey);
if (match) {
matches.push(match);
changed = true;
}
});
return;
}
const match = markIfMatches(value, sourceKey);
if (match) {
matches.push(match);
changed = true;
}
});
if (changed && state) {
const workflowKey = this.getWorkflowScopedQueueKey?.();
this.persistSearchStateForWorkflow?.(workflowKey, missing, state);
this.refreshSearchUiForMissing?.(missing, state, { workflowKey });
}
return matches;
},
async compareLocalModelHashesWithCurrentFinding(model = {}) {
const missing = this.getMissingForHashCompareContext(model);
if (!missing) {
this.showNotification?.('Cannot find the search results for this model.', 'warning');
return;
}
let localHashes = this.getLocalHashCandidatesForCompare(model);
let metadataResult = null;
if (!localHashes.length) {
try {
const lookup = await this.fetchLocalHashCandidatesForCompare(model);
metadataResult = lookup.data;
localHashes = lookup.candidates;
} catch (error) {
this.showNotification?.(`Local hash metadata lookup failed: ${error?.message || error}`, 'error');
return;
}
}
if (!localHashes.length) {
try {
const calculated = await this.calculateLocalHashCandidatesForCompare(model);
if (!calculated) return;
metadataResult = calculated.data || metadataResult;
localHashes = calculated.candidates;
} catch (error) {
this.showNotification?.(`Hash calculation failed: ${error?.message || error}`, 'error');
return;
}
}
const filename = this.getHashCompareFilename(model);
if (!localHashes.length) {
const metadataPath = metadataResult?.metadata_path || model.metadata_path || '';
this.showNotification?.(
metadataPath
? 'Hash calculation finished, but no completed SHA256 hash was found.'
: 'No local SHA256 hash found for this model.',
'warning'
);
return;
}
const hashMatches = [];
const localMatchIdentity = this.getHashCompareLocalMatchIdentity(model);
localHashes.forEach(candidate => {
hashMatches.push(
...this.markSearchResultHashBadgesForMissing(missing, candidate.hash, {
localMatchIdentity
})
);
});
if (hashMatches.length) {
this.showHashCompareSuccessNotification(filename, hashMatches);
return;
}
const localPreview = localHashes
.map(candidate => this.formatSha256Short(candidate.hash))
.join(', ');
this.showNotification?.(
`No search result hash matches ${filename} (${localPreview}).`,
'warning'
);
},
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
}
},
openModelSourceLink(model = {}) {
const sourceLink = this.getContextMenuSourceLink(this.getContextMenuSourceLookupModel(model));
if (!sourceLink?.url) {
this.showNotification?.('No source page URL available for this model.', 'info');
return;
}
window.open(sourceLink.url, '_blank', 'noopener,noreferrer');
},
/**
* Show model info dialog (similar to rgthree's RgthreeLoraInfoDialog)
*/
async showModelInfo(model) {
if (!model) return;
const name = model.name || this.getFilenameFromPath(model.original_path) || '';
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 = this.stripModelExtension(loraName);
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', 'Version notes', 'Description or release notes for the exact model version matched by SHA-256.')}
${this.renderInfoFieldLabel('fileText', 'Description', 'Model description from CivitAI or local metadata. Long descriptions are shortened until you click Show more.')}
`;
return dialog;
},
truncateText(value, maxLength = 160) {
const text = String(value ?? '').trim();
if (!text) return '';
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
},
sanitizeDescriptionHtml(html) {
return sanitizeDescriptionHtml(html);
},
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 = this.normalizePathToForward(fullPath);
const lastSlash = normalizedFullPath.lastIndexOf('/');
if (lastSlash > 0) {
location = normalizedFullPath.slice(0, lastSlash + 1);
}
}
location = this.normalizePathToForward(location);
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 = parseFiniteNumber(rawSize);
if (numericSize !== null) {
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 getSha256Field(data);
},
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 result = await this.pollHashCalculation(
filePath,
data.metadata_path || data.metadataPath || '',
{
silent: false,
onStart: (progressId) => {
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;
}
},
onProgress: (percent, message) => {
this.updateInfoDialogHashProgress(dialog, percent, message);
},
checkAbort: () => {
return !dialog.isConnected || (dialog._hashCalculation && dialog._hashCalculation.cancelRequested);
},
onAbort: async (_progressId) => {
await this.cancelInfoDialogHashCalculation(dialog, button);
}
}
);
if (!result) return null;
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) {
if (error.message === 'Hash calculation 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;
}
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;
await copyTextWithFeedback(words.join(', '), copyBtn, {
successText: 'Copied',
errorText: 'Failed',
duration: 1200,
successClass: ''
});
return;
}
const descToggleBtn = event.target.closest('.mr-info-description-toggle');
if (descToggleBtn && dialog.contains(descToggleBtn)) {
const targetSelector = descToggleBtn.dataset.descriptionTarget || '.mr-info-description';
const descEl = dialog.querySelector(targetSelector);
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(safeStorage.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 {
safeStorage.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);
const resizeHandle = panel?.querySelector('.mr-info-dialog-resize-handle');
if (!panel || !resizeHandle || dialog._infoDialogResizeStartHandler) return;
const resizeStartEventName = typeof PointerEvent === 'function' ? 'pointerdown' : 'mousedown';
const resizeEndEventName = typeof PointerEvent === 'function' ? 'pointerup' : 'mouseup';
const resizeCancelEventName = typeof PointerEvent === 'function' ? 'pointercancel' : null;
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const getCssNumber = (value, fallback = 0) => {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const toCssSize = (outerSize, borderSize, boxSizing) => (
boxSizing === 'border-box'
? outerSize
: Math.max(1, outerSize - borderSize)
);
const getResizeMetrics = () => {
const rect = panel.getBoundingClientRect();
const computed = typeof getComputedStyle === 'function' ? getComputedStyle(panel) : null;
const boxSizing = computed?.boxSizing || 'content-box';
const borderWidth = getCssNumber(computed?.borderLeftWidth) + getCssNumber(computed?.borderRightWidth);
const borderHeight = getCssNumber(computed?.borderTopWidth) + getCssNumber(computed?.borderBottomWidth);
const minWidth = Math.max(
1,
getCssNumber(computed?.minWidth, 420) + (boxSizing === 'border-box' ? 0 : borderWidth)
);
const minHeight = Math.max(
1,
getCssNumber(computed?.minHeight, 320) + (boxSizing === 'border-box' ? 0 : borderHeight)
);
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || rect.right;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || rect.bottom;
const centerX = rect.left + (rect.width / 2);
const centerY = rect.top + (rect.height / 2);
return {
rect,
boxSizing,
borderWidth,
borderHeight,
minWidth,
minHeight,
maxWidth: Math.max(minWidth, 2 * Math.min(centerX, Math.max(0, viewportWidth - centerX))),
maxHeight: Math.max(minHeight, 2 * Math.min(centerY, Math.max(0, viewportHeight - centerY)))
};
};
const updateViewportLimits = () => {
if (!dialog._infoDialogResizeHasCustomLimits) return;
const metrics = getResizeMetrics();
panel.style.maxWidth = `${Math.round(toCssSize(metrics.maxWidth, metrics.borderWidth, metrics.boxSizing))}px`;
panel.style.maxHeight = `${Math.round(toCssSize(metrics.maxHeight, metrics.borderHeight, metrics.boxSizing))}px`;
if (dialog._infoDialogResizeState) {
dialog._infoDialogResizeState.maxWidth = metrics.maxWidth;
dialog._infoDialogResizeState.maxHeight = metrics.maxHeight;
}
};
const applyResize = (state) => {
if (!state || dialog._infoDialogResizeState !== state) return;
panel.style.width = `${Math.round(toCssSize(state.pendingWidth, state.borderWidth, state.boxSizing))}px`;
panel.style.height = `${Math.round(toCssSize(state.pendingHeight, state.borderHeight, state.boxSizing))}px`;
};
const finishResize = ({ flush = false } = {}) => {
const state = dialog._infoDialogResizeState;
if (!state) return false;
if (state.animationFrame) {
cancelAnimationFrame(state.animationFrame);
state.animationFrame = null;
}
if (flush) applyResize(state);
document.removeEventListener(state.moveEventName, state.moveHandler, true);
document.removeEventListener(state.endEventName, state.endHandler, true);
if (state.cancelEventName) {
document.removeEventListener(state.cancelEventName, state.cancelHandler, true);
}
window.removeEventListener('blur', state.blurHandler);
dialog._infoDialogResizeState = null;
panel.classList.remove('is-resizing');
this.saveInfoDialogSize(dialog);
return true;
};
const startResize = (event) => {
if (event.button !== undefined && event.button !== 0) return;
if (event.isPrimary === false) return;
const clientX = Number(event.clientX);
const clientY = Number(event.clientY);
if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return;
finishResize({ flush: true });
event.preventDefault?.();
event.stopPropagation?.();
const metrics = getResizeMetrics();
const {
rect,
boxSizing,
borderWidth,
borderHeight,
minWidth,
minHeight,
maxWidth,
maxHeight
} = metrics;
dialog._infoDialogResizeHasCustomLimits = true;
updateViewportLimits();
const state = {
startX: clientX,
startY: clientY,
startWidth: rect.width,
startHeight: rect.height,
minWidth,
minHeight,
maxWidth,
maxHeight,
borderWidth,
borderHeight,
boxSizing,
pendingWidth: rect.width,
pendingHeight: rect.height,
pointerId: event.pointerId,
moveEventName: typeof PointerEvent === 'function' ? 'pointermove' : 'mousemove',
endEventName: resizeEndEventName,
cancelEventName: resizeCancelEventName,
animationFrame: null,
moveHandler: null,
endHandler: null,
cancelHandler: null,
blurHandler: null
};
panel.classList.add('is-resizing');
state.moveHandler = (moveEvent) => {
if (state.pointerId !== undefined && moveEvent.pointerId !== state.pointerId) return;
const moveX = Number(moveEvent.clientX);
const moveY = Number(moveEvent.clientY);
const deltaX = (Number.isFinite(moveX) ? moveX : state.startX) - state.startX;
const deltaY = (Number.isFinite(moveY) ? moveY : state.startY) - state.startY;
state.pendingWidth = clamp(state.startWidth + (deltaX * 2), state.minWidth, state.maxWidth);
state.pendingHeight = clamp(state.startHeight + (deltaY * 2), state.minHeight, state.maxHeight);
if (state.animationFrame) return;
state.animationFrame = requestAnimationFrame(() => {
state.animationFrame = null;
applyResize(state);
});
};
state.endHandler = () => finishResize({ flush: true });
state.cancelHandler = state.endHandler;
state.blurHandler = state.endHandler;
dialog._infoDialogResizeState = state;
document.addEventListener(state.moveEventName, state.moveHandler, true);
document.addEventListener(state.endEventName, state.endHandler, true);
if (state.cancelEventName) {
document.addEventListener(state.cancelEventName, state.cancelHandler, true);
}
window.addEventListener('blur', state.blurHandler, { once: true });
};
resizeHandle.addEventListener(resizeStartEventName, startResize, true);
dialog._infoDialogResizeStartHandler = startResize;
dialog._infoDialogResizeHandle = resizeHandle;
dialog._infoDialogResizeStartEventName = resizeStartEventName;
dialog._infoDialogResizeFinishHandler = finishResize;
dialog._infoDialogResizeViewportHandler = updateViewportLimits;
window.addEventListener('resize', updateViewportLimits);
if (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 = this.stripModelExtension(filename);
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 || [],
description: modelData?.model_description
|| modelData?.modelDescription
|| modelData?.description
|| '',
model_description: modelData?.model_description
|| modelData?.modelDescription
|| modelData?.description
|| '',
version_description: modelData?.version_description
|| modelData?.versionDescription
|| '',
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;
}
const localData = { ...fallbackData, ...data, civitai_checked: false };
this.rememberContextMenuSourceMetadata(localData);
this.updateInfoDialogWithData(dialog, localData);
} catch (_e) {
if (dialog._localInfoRequestId !== requestId || dialog._infoDialogCivitaiFetchStarted) {
return;
}
this.rememberContextMenuSourceMetadata(fallbackData);
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 modelDescription = result.model_description
|| result.modelDescription
|| result.description
|| data.model_description
|| data.modelDescription
|| data.description
|| '';
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,
description: modelDescription,
model_description: modelDescription,
version_description: result.version_description
|| result.versionDescription
|| data.version_description
|| data.versionDescription
|| '',
metadata_checked: true,
civitai_checked: true
};
dialog._infoDialogData = merged;
this.rememberContextMenuSourceMetadata(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');
const failedData = {
...(dialog._infoDialogData || data),
metadata_checked: true,
civitai_checked: true
};
this.rememberContextMenuSourceMetadata(failedData);
this.updateInfoDialogWithData(dialog, failedData);
} finally {
setFetchingState(false);
}
},
getInfoMetadataSourceKey(data = {}) {
const sourceKey = this.getContextMenuSourceKey(data);
if (sourceKey) return sourceKey;
for (const url of this.getContextMenuSourceUrlCandidates(data)) {
const key = getSourceKeyFromUrl(url, { fallbackToText: true });
if (key) return key;
}
return 'metadata';
},
getInfoMetadataSourceLabel(data = {}) {
const key = this.getInfoMetadataSourceKey(data);
return getSourceDisplayLabel(key, { fallback: 'metadata source' });
},
getInfoMetadataSourceIcon(data = {}) {
const key = this.getInfoMetadataSourceKey(data);
if (key === 'huggingface') return getSvgIcon('huggingface', 'currentColor', 'mr-info-external-link-icon');
if (key === 'civarchive') return getSvgIcon('civarchive', 'currentColor', 'mr-info-external-link-icon');
if (key === 'civitai') return getSvgIcon('civitai', 'currentColor', 'mr-info-external-link-icon');
if (key === 'lora_manager_archive') return getSvgIcon('loraManager', '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);
},
updateInfoDialogDescriptionSection(dialog, {
value = '',
contentSelector,
actionsSelector,
toggleSelector
} = {}) {
const descEl = dialog.querySelector(contentSelector);
if (!descEl) return;
const row = descEl.closest('tr');
if (!value) {
this.setInfoTableRowVisible(row, false);
return;
}
const actionsEl = dialog.querySelector(actionsSelector);
const toggleBtn = dialog.querySelector(toggleSelector);
let sanitizedHtml = '';
try {
sanitizedHtml = this.sanitizeDescriptionHtml(value);
} catch (error) {
console.error('Model Resolver: Failed to sanitize description HTML:', error);
}
const fallbackText = this.escapeHtml(String(value).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');
}
this.setInfoTableRowVisible(row, true);
},
/**
* 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 };
this.rememberContextMenuSourceMetadata(dialog._infoDialogData);
// 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));
const sourceLink = this.getContextMenuSourceLink(data);
if (sourceLink?.url) {
const sourceLabel = sourceLink.label || 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);
}
}
// Keep the model description stable while exposing version-specific
// release notes separately.
const modelDescription = data.model_description
|| data.modelDescription
|| data.description
|| '';
const versionDescription = data.version_description
|| data.versionDescription
|| '';
this.updateInfoDialogDescriptionSection(dialog, {
value: modelDescription,
contentSelector: '.mr-info-model-description',
actionsSelector: '.mr-info-description-actions:not(.mr-info-version-description-actions)',
toggleSelector: '.mr-info-description-toggle:not(.mr-info-version-description-toggle)'
});
this.updateInfoDialogDescriptionSection(dialog, {
value: versionDescription && versionDescription !== modelDescription
? versionDescription
: '',
contentSelector: '.mr-info-version-description',
actionsSelector: '.mr-info-version-description-actions',
toggleSelector: '.mr-info-version-description-toggle'
});
// Update preview media
this.updateInfoDialogImages(
dialog,
this.getInfoDialogMedia(dialog._infoDialogData)
);
},
isInfoPreviewVideo(media = {}) {
const declaredType = String(media.type || media.mediaType || '').trim().toLowerCase();
if (declaredType === 'video') return true;
let url = String(media.url || media.preview_url || media.previewUrl || '');
try {
url = decodeURIComponent(url);
} catch (_error) {
// Keep the original URL when it contains malformed escape sequences.
}
return /\.(?:mp4|webm)(?:$|[?#&])/i.test(url);
},
getInfoDialogMedia(data = {}) {
const sourceMedia = (
Array.isArray(data.images)
? data.images
: Array.isArray(data.modelImages)
? data.modelImages
: []
).filter(media => media?.url);
const previewPath = String(data.preview_url || data.previewUrl || '').trim();
const modelPath = this.getModelInfoResolvedPath(data);
const isRemotePreview = /^(?:https?:|data:|blob:)/i.test(previewPath);
if (previewPath && modelPath && !isRemotePreview) {
const endpoint = api.apiURL('/model_resolver/model-preview');
const localPreview = {
...(sourceMedia[0] || {}),
url: `${endpoint}?path=${encodeURIComponent(modelPath)}`,
type: this.isInfoPreviewVideo({ url: previewPath }) ? 'video' : 'image'
};
return [localPreview, ...sourceMedia.slice(1)];
}
if (!sourceMedia.length && previewPath) {
return [{
url: previewPath,
type: this.isInfoPreviewVideo({ url: previewPath }) ? 'video' : 'image'
}];
}
return sourceMedia;
},
renderInfoPreviewMedia(media = {}, options = {}) {
const url = this.escapeHtml(media.url || '');
const thumbnail = Boolean(options.thumbnail);
if (this.isInfoPreviewVideo(media)) {
return thumbnail
? ` `
: ` `;
}
return thumbnail
? ` `
: ` `;
},
/**
* Update preview media 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
|| getCivitaiModelUrl(modelId, 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 comfyWorkflow = extractComfyWorkflow(image);
const hasPrevious = images.length > 1;
const positionText = images.length > 1 ? `${index + 1} / ${images.length}` : '1 media';
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 ? `‹ ` : ''}
${this.renderInfoPreviewMedia(image)}
${hasPrevious ? `› ` : ''}
Generation data
${(image.positive || image.negative) ? `${getSvgIcon('copy')} Copy all ` : ''}
${metadataHtml}
${resources.length ? `
Resources used
${resourcesHtml}
` : ''}
${comfyWorkflow ? `
Other metadata
comfy:
${this.escapeHtml(comfyWorkflow.nodeCount)} ${comfyWorkflow.nodeCount === 1 ? 'Node' : 'Nodes'}
${getSvgIcon('copy')}
` : ''}
${this.renderInfoImagePreviewPrompt('Prompt', image.positive, 'positive')}
${this.renderInfoImagePreviewPrompt('Negative prompt', image.negative, 'negative')}
`;
},
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 comfyWorkflow = extractComfyWorkflow(image);
const text = key === 'workflow'
? comfyWorkflow?.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;
await copyTextWithFeedback(String(text), button, {
successClass: 'is-copied',
successHtml: `${getSvgIcon('copy')} Copied`,
errorText: 'Failed',
duration: 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,
file_path: model.path
|| model.file_path
|| model.selected_file?.path
|| model.file_info?.path
|| model.file?.path
|| '',
branch: model.branch || '',
civitai_key: tokens.civitai_key || '',
hf_token: tokens.hf_token || ''
})
}, '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,
file_path: details._sourceModel?.path
|| details._sourceModel?.file_path
|| details._sourceModel?.selected_file?.path
|| details._sourceModel?.file_info?.path
|| details._sourceModel?.file?.path
|| '',
branch: data.branch || details._sourceModel?.branch || '',
civitai_key: tokens.civitai_key || '',
hf_token: tokens.hf_token || ''
})
}, '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 isHuggingFace = String(source).toLowerCase() === 'huggingface';
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.renderInfoPreviewMedia(image, { thumbnail: true })}
${this.renderSourceModelImageMeta(image)}
`).join('') : '
No example media 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
${isHuggingFace ? `
` : ''}
${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 getCivitaiModelUrl(modelId, 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) : [];
const source = String(data.source || contextModel.details_source || contextModel.source || '').toLowerCase();
const isHuggingFace = source === 'huggingface';
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 fileHash = this.getSourceModelFileHash(file);
const fileHashes = file.hashes && typeof file.hashes === 'object'
? { ...file.hashes }
: (fileHash ? { SHA256: fileHash } : {});
const selectedFile = {
...file,
name: preferredFilename,
filename: preferredFilename,
download_url: preferredDownloadUrl
};
if (fileHash) {
selectedFile.sha256 = selectedFile.sha256 || fileHash;
selectedFile.hash = selectedFile.hash || fileHash;
}
if (Object.keys(fileHashes).length) {
selectedFile.hashes = { ...fileHashes };
}
const selectedVersion = {
id: version.id || data.version_id || contextModel.version_id,
name: version.name || '',
base_model: version.base_model || contextModel.base_model || '',
trained_words: version.trained_words || version.trainedWords || [],
files: [selectedFile]
};
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,
sha256: fileHash,
hash: fileHash,
hashes: Object.keys(fileHashes).length ? fileHashes : undefined,
file_info: selectedFile,
file: selectedFile,
selected_file: selectedFile,
selected_version: selectedVersion,
base_model: version.base_model || contextModel.base_model,
tags: data.tags || [],
path: file.path || '',
branch: data.branch || contextModel.branch || '',
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 compactBadges = Array.from(new Set([
...summaryBadges,
...fileMeta.badges
]));
const selectionData = this.escapeHtml(encodeURIComponent(JSON.stringify(payload)));
const actionLabel = isTarget ? 'Use matched file' : 'Use this file';
if (isHuggingFace) {
return `
${this.escapeHtml(file.name || `File ${index + 1}`)}
${this.escapeHtml(fileMeta.summary)}
${compactBadges.length
? compactBadges.map(badge => `${this.escapeHtml(badge)} `).join('')
: 'Model file '}
${actionLabel}
${mirrorList ? `
${mirrorList}
` : ''}
`;
}
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('')}
` : ''}
${actionLabel}
${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 = '') {
const normalizedValue = String(value || '');
if (/[\\/]+$/.test(normalizedValue)) return '';
return getFilenameFromPath(normalizedValue)
.trim()
.toLowerCase();
},
getSourceModelFileHash(file = {}) {
return getSha256Field(file, { lowercase: true });
},
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 mirrorHash = this.getSourceModelFileHash(mirror) || basePayload.sha256 || basePayload.hash || '';
const mirrorHashes = mirror.hashes && typeof mirror.hashes === 'object'
? { ...mirror.hashes }
: (
basePayload.hashes && typeof basePayload.hashes === 'object'
? { ...basePayload.hashes }
: (mirrorHash ? { SHA256: mirrorHash } : {})
);
const mirrorFilename = mirror.filename || basePayload.filename || 'Download mirror';
const mirrorFileInfo = {
...(basePayload.file_info && typeof basePayload.file_info === 'object' ? basePayload.file_info : {}),
...mirror,
name: mirrorFilename,
filename: mirrorFilename,
download_url: mirror.url,
url: mirror.url
};
if (mirrorHash) {
mirrorFileInfo.sha256 = mirrorFileInfo.sha256 || mirrorHash;
mirrorFileInfo.hash = mirrorFileInfo.hash || mirrorHash;
}
if (Object.keys(mirrorHashes).length) {
mirrorFileInfo.hashes = { ...mirrorHashes };
}
const mirrorSelectedVersion = basePayload.selected_version && typeof basePayload.selected_version === 'object'
? {
...basePayload.selected_version,
files: [mirrorFileInfo]
}
: undefined;
const payload = {
...basePayload,
download_url: mirror.url,
filename: mirrorFilename,
mirror_source: mirror.source || this.getSourceModelMirrorHost(mirror.url),
mirror_index: mirror._index,
sha256: mirrorHash,
hash: mirrorHash,
hashes: Object.keys(mirrorHashes).length ? mirrorHashes : undefined,
file_info: mirrorFileInfo,
file: mirrorFileInfo,
selected_file: mirrorFileInfo,
selected_version: mirrorSelectedVersion
};
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 || '');
const sourceKey = getSourceKeyFromUrl(mirror.url || '');
if (sourceKey === 'huggingface' || sourceKey === 'civitai') {
return getSourceDisplayLabel(sourceKey);
}
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 = {}) {
return getSha256Field(value);
},
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 isHuggingFace = sourceKey === 'huggingface';
const sourcePageUrl = selection.page_url
|| selection.pageUrl
|| selection.version_url
|| selection.versionUrl
|| selection.url
|| '';
const selectedBaseModel = selection.base_model || contextModel.base_model || '';
const selectionHashes = selection.hashes && typeof selection.hashes === 'object' ? selection.hashes : {};
const selectionFile = selection.file_info || selection.selected_file || selection.file || null;
const selectionFileHashes = selectionFile?.hashes && typeof selectionFile.hashes === 'object' ? selectionFile.hashes : {};
const selectionSha256 = this.getSourceModelFileHash?.(selection)
|| this.getSourceModelFileHash?.(selectionFile || {})
|| String(selection.sha256 || selection.hash || selectionHashes.SHA256 || selectionHashes.sha256 || selectionFileHashes.SHA256 || selectionFileHashes.sha256 || '').trim();
const selectionSelectedVersion = selection.selected_version || selection.selectedVersion || null;
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: isHuggingFace ? selection.download_url : selection.url,
download_url: selection.download_url,
size: selection.size,
base_model: selectedBaseModel,
sha256: selectionSha256,
hash: selectionSha256,
tags: selection.tags || [],
path: selection.path || selectionFile?.path || '',
branch: selection.branch || contextModel.branch || '',
match_type: selection.match_type || 'selected',
confidence: selection.confidence || 100,
searchedAt: new Date().toISOString()
};
if (isHuggingFace) {
sourceResult.repo_id = selection.repo_id || selection.repo || selection.model_id || '';
sourceResult.version_url = sourcePageUrl;
sourceResult.page_url = sourcePageUrl;
sourceResult.model_url = sourcePageUrl;
}
if (Object.keys(selectionHashes).length) sourceResult.hashes = selectionHashes;
if (selectionFile && typeof selectionFile === 'object') {
sourceResult.file_info = selectionFile;
sourceResult.file = selectionFile;
sourceResult.selected_file = selectionFile;
}
if (selectionSelectedVersion && typeof selectionSelectedVersion === 'object') {
sourceResult.selected_version = selectionSelectedVersion;
}
missing.download_source = {
...sourceResult,
url: sourceResult.download_url,
model_url: sourceResult.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.syncRemoteHashMatchesForResult?.(missing, sourceResult, { sourceKey })
?.catch?.(error => {
console.warn('Model Resolver: failed to sync selected model hash:', error);
});
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?._infoDialogResizeStartHandler) {
dialog._infoDialogResizeFinishHandler?.({ flush: true });
dialog._infoDialogResizeHandle?.removeEventListener(
dialog._infoDialogResizeStartEventName,
dialog._infoDialogResizeStartHandler,
true
);
dialog._infoDialogResizeStartHandler = null;
dialog._infoDialogResizeHandle = null;
dialog._infoDialogResizeFinishHandler = null;
}
if (dialog?._infoDialogResizeViewportHandler) {
window.removeEventListener('resize', dialog._infoDialogResizeViewportHandler);
dialog._infoDialogResizeViewportHandler = null;
}
dialog._infoDialogResizeHasCustomLimits = false;
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);
}
}
};