import { showToast, openCivitai } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import {
toggleShowcase,
setupShowcaseScroll,
scrollToTop,
loadExampleImages
} from './showcase/ShowcaseView.js';
import { setupTabSwitching } from './ModelDescription.js';
import {
setupModelNameEditing,
setupBaseModelEditing,
setupFileNameEditing
} from './ModelMetadata.js';
import { setupTagEditMode } from './ModelTags.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
import { parsePresets, renderPresetTags } from './PresetTags.js';
import { initVersionsTab } from './ModelVersionsTab.js';
import { loadRecipesForLora } from './RecipeTab.js';
import { translate } from '../../utils/i18nHelpers.js';
function getModalFilePath(fallback = '') {
const modalElement = document.getElementById('modelModal');
if (modalElement && modalElement.dataset && modalElement.dataset.filePath) {
return modalElement.dataset.filePath;
}
return fallback;
}
const COMMERCIAL_ICON_CONFIG = [
{
key: 'image',
icon: 'photo-off.svg',
titleKey: 'modals.model.license.noImageSell',
fallback: 'No selling generated content'
},
{
key: 'rentcivit',
icon: 'brush-off.svg',
titleKey: 'modals.model.license.noRentCivit',
fallback: 'No Civitai generation'
},
{
key: 'rent',
icon: 'world-off.svg',
titleKey: 'modals.model.license.noRent',
fallback: 'No generation services'
},
{
key: 'sell',
icon: 'shopping-cart-off.svg',
titleKey: 'modals.model.license.noSell',
fallback: 'No selling models'
}
];
function hasLicenseField(license, field) {
return Object.prototype.hasOwnProperty.call(license || {}, field);
}
function escapeAttribute(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/"/g, '"');
}
function indentMarkup(markup, spaces) {
if (!markup) {
return '';
}
const padding = ' '.repeat(spaces);
return markup
.split('\n')
.map(line => (line ? padding + line : line))
.join('\n');
}
function normalizeCommercialValues(value) {
if (!value && value !== '') {
return ['Sell'];
}
if (Array.isArray(value)) {
return value.filter(item => item !== null && item !== undefined);
}
if (typeof value === 'string') {
return [value];
}
if (value && typeof value[Symbol.iterator] === 'function') {
const result = [];
for (const item of value) {
if (item === null || item === undefined) {
continue;
}
result.push(String(item));
}
if (result.length > 0) {
return result;
}
}
return ['Sell'];
}
function sanitiseCommercialValue(value) {
if (!value && value !== '') {
return '';
}
return String(value)
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, '')
.replace(/[^a-z]/g, '');
}
function resolveCommercialRestrictions(value) {
const normalizedValues = normalizeCommercialValues(value);
const allowed = new Set();
normalizedValues.forEach(item => {
const cleaned = sanitiseCommercialValue(item);
if (!cleaned) {
return;
}
allowed.add(cleaned);
});
if (allowed.has('sell')) {
allowed.add('rent');
allowed.add('rentcivit');
allowed.add('image');
}
if (allowed.has('rent')) {
allowed.add('rentcivit');
}
const disallowed = [];
COMMERCIAL_ICON_CONFIG.forEach(config => {
if (!allowed.has(config.key)) {
disallowed.push(config);
}
});
return disallowed;
}
function createLicenseIconMarkup(icon, label) {
const safeLabel = escapeAttribute(label);
const iconPath = `/loras_static/images/tabler/${icon}`;
return ` `;
}
function renderLicenseIcons(modelData) {
const license = modelData?.civitai?.model;
if (!license) {
return '';
}
const icons = [];
if (hasLicenseField(license, 'allowNoCredit') && license.allowNoCredit === false) {
const label = translate('modals.model.license.creditRequired', {}, 'Creator credit required');
icons.push(createLicenseIconMarkup('user-check.svg', label));
}
if (hasLicenseField(license, 'allowCommercialUse')) {
const restrictions = resolveCommercialRestrictions(license.allowCommercialUse);
restrictions.forEach(({ icon, titleKey, fallback }) => {
const label = translate(titleKey, {}, fallback);
icons.push(createLicenseIconMarkup(icon, label));
});
}
if (hasLicenseField(license, 'allowDerivatives') && license.allowDerivatives === false) {
const label = translate('modals.model.license.noDerivatives', {}, 'No sharing merges');
icons.push(createLicenseIconMarkup('exchange-off.svg', label));
}
if (hasLicenseField(license, 'allowDifferentLicense') && license.allowDifferentLicense === false) {
const label = translate('modals.model.license.noReLicense', {}, 'Same permissions required');
icons.push(createLicenseIconMarkup('rotate-2.svg', label));
}
if (!icons.length) {
return '';
}
const containerLabel = translate('modals.model.license.restrictionsLabel', {}, 'License restrictions');
const safeContainerLabel = escapeAttribute(containerLabel);
return `
${icons.join('\n ')}
`;
}
/**
* Display the model modal with the given model data
* @param {Object} model - Model data object
* @param {string} modelType - Type of model ('lora' or 'checkpoint')
*/
export async function showModelModal(model, modelType) {
const modalId = 'modelModal';
const modalTitle = model.model_name;
// Fetch complete civitai metadata
let completeCivitaiData = model.civitai || {};
if (model.file_path) {
try {
const fullMetadata = await getModelApiClient().fetchModelMetadata(model.file_path);
completeCivitaiData = fullMetadata || model.civitai || {};
} catch (error) {
console.warn('Failed to fetch complete metadata, using existing data:', error);
// Continue with existing data if fetch fails
}
}
// Update model with complete civitai data
const modelWithFullData = {
...model,
civitai: completeCivitaiData
};
const licenseIcons = renderLicenseIcons(modelWithFullData);
const viewOnCivitaiAction = modelWithFullData.from_civitai ? `
${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
`.trim() : '';
const creatorInfoAction = modelWithFullData.civitai?.creator ? `
${modelWithFullData.civitai.creator.image ?
`
` :
`
`
}
${modelWithFullData.civitai.creator.username}
`.trim() : '';
const creatorActionItems = [];
if (viewOnCivitaiAction) {
creatorActionItems.push(indentMarkup(viewOnCivitaiAction, 24));
}
if (creatorInfoAction) {
creatorActionItems.push(indentMarkup(creatorInfoAction, 24));
}
const creatorActionsMarkup = creatorActionItems.length
? [
' ',
creatorActionItems.join('\n'),
'
'
].join('\n')
: '';
const headerActionItems = [];
if (creatorActionsMarkup) {
headerActionItems.push(creatorActionsMarkup);
}
if (licenseIcons) {
headerActionItems.push(indentMarkup(licenseIcons.trim(), 20));
}
const headerActionsMarkup = headerActionItems.length
? [
' '
].join('\n')
: '';
const hasUpdateAvailable = Boolean(modelWithFullData.update_available);
// Prepare LoRA specific data with complete civitai data
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ?
modelWithFullData.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
// Generate model type specific content
let typeSpecificContent;
if (modelType === 'loras') {
typeSpecificContent = renderLoraSpecificContent(modelWithFullData, escapedWords);
} else if (modelType === 'embeddings') {
typeSpecificContent = renderEmbeddingSpecificContent(modelWithFullData, escapedWords);
} else {
typeSpecificContent = '';
}
// Generate tabs based on model type
const examplesText = translate('modals.model.tabs.examples', {}, 'Examples');
const descriptionText = translate('modals.model.tabs.description', {}, 'Model Description');
const recipesText = translate('modals.model.tabs.recipes', {}, 'Recipes');
const versionsText = translate('modals.model.tabs.versions', {}, 'Versions');
const versionsBadgeLabel = translate('modelCard.badges.update', {}, 'Update');
const versionsTabBadge = hasUpdateAvailable
? `${versionsBadgeLabel} `
: '';
const versionsTabClasses = ['tab-btn'];
if (hasUpdateAvailable) {
versionsTabClasses.push('tab-btn--has-update');
}
const versionsTabButton = `
${versionsText}
${versionsTabBadge}
`.trim();
const tabsContent = modelType === 'loras' ?
`${examplesText}
${descriptionText}
${versionsTabButton}
${recipesText} ` :
`${examplesText}
${descriptionText}
${versionsTabButton}`;
const loadingExampleImagesText = translate('modals.model.loading.exampleImages', {}, 'Loading example images...');
const loadingDescriptionText = translate('modals.model.loading.description', {}, 'Loading model description...');
const loadingRecipesText = translate('modals.model.loading.recipes', {}, 'Loading recipes...');
const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...');
const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...');
const civitaiModelId = modelWithFullData.civitai?.modelId || '';
const civitaiVersionId = modelWithFullData.civitai?.id || '';
const tabPanesContent = modelType === 'loras' ?
`
${loadingExampleImagesText}
${loadingDescriptionText}
` :
`
${loadingDescriptionText}
`;
const content = `
×
${translate('modals.model.metadata.version', {}, 'Version')}
${modelWithFullData.civitai?.name || 'N/A'}
${translate('modals.model.metadata.fileName', {}, 'File Name')}
${modelWithFullData.file_name || 'N/A'}
${translate('modals.model.metadata.location', {}, 'Location')}
${modelWithFullData.file_path.replace(/[^/]+$/, '') || 'N/A'}
${translate('modals.model.metadata.baseModel', {}, 'Base Model')}
${modelWithFullData.base_model || translate('modals.model.metadata.unknown', {}, 'Unknown')}
${translate('modals.model.metadata.size', {}, 'Size')}
${formatFileSize(modelWithFullData.file_size)}
${typeSpecificContent}
${translate('modals.model.metadata.additionalNotes', {}, 'Additional Notes')}
${modelWithFullData.notes || translate('modals.model.metadata.addNotesPlaceholder', {}, 'Add your notes here...')}
${translate('modals.model.metadata.aboutThisVersion', {}, 'About this version')}
${modelWithFullData.civitai?.description || 'N/A'}
${tabsContent}
${tabPanesContent}
`;
const onCloseCallback = function() {
// Clean up all handlers when modal closes for LoRA
const modalElement = document.getElementById(modalId);
if (modalElement && modalElement._clickHandler) {
modalElement.removeEventListener('click', modalElement._clickHandler);
delete modalElement._clickHandler;
}
};
modalManager.showModal(modalId, content, null, onCloseCallback);
const activeModalElement = document.getElementById(modalId);
if (activeModalElement) {
activeModalElement.dataset.filePath = modelWithFullData.file_path || '';
}
const versionsTabController = initVersionsTab({
modalId,
modelType,
modelId: civitaiModelId,
currentVersionId: civitaiVersionId,
});
setupEditableFields(modelWithFullData.file_path, modelType);
setupShowcaseScroll(modalId);
setupTabSwitching({
onTabChange: async (tab) => {
if (tab === 'versions') {
await versionsTabController.load();
}
},
});
versionsTabController.load({ eager: true });
setupTagTooltip();
setupTagEditMode(modelType);
setupModelNameEditing(modelWithFullData.file_path);
setupBaseModelEditing(modelWithFullData.file_path);
setupFileNameEditing(modelWithFullData.file_path);
setupEventHandlers(modelWithFullData.file_path);
// LoRA specific setup
if (modelType === 'loras' || modelType === 'embeddings') {
setupTriggerWordsEditMode();
if (modelType == 'loras') {
// Load recipes for this LoRA
loadRecipesForLora(modelWithFullData.model_name, modelWithFullData.sha256);
}
}
// Load example images asynchronously - merge regular and custom images
const regularImages = modelWithFullData.civitai?.images || [];
const customImages = modelWithFullData.civitai?.customImages || [];
// Combine images - regular images first, then custom images
const allImages = [...regularImages, ...customImages];
loadExampleImages(allImages, modelWithFullData.sha256);
}
function renderLoraSpecificContent(lora, escapedWords) {
return `
${translate('modals.model.metadata.usageTips', {}, 'Usage Tips')}
${translate('modals.model.usageTips.addPresetParameter', {}, 'Add preset parameter...')}
${translate('modals.model.usageTips.strengthMin', {}, 'Strength Min')}
${translate('modals.model.usageTips.strengthMax', {}, 'Strength Max')}
${translate('modals.model.usageTips.strength', {}, 'Strength')}
${translate('modals.model.usageTips.clipStrength', {}, 'Clip Strength')}
${translate('modals.model.usageTips.clipSkip', {}, 'Clip Skip')}
${translate('modals.model.usageTips.add', {}, 'Add')}
${renderPresetTags(parsePresets(lora.usage_tips))}
${renderTriggerWords(escapedWords, lora.file_path)}
`;
}
function renderEmbeddingSpecificContent(embedding, escapedWords) {
return `${renderTriggerWords(escapedWords, embedding.file_path)}`;
}
/**
* Sets up event handlers using event delegation for LoRA modal
* @param {string} filePath - Path to the model file
*/
function setupEventHandlers(filePath) {
const modalElement = document.getElementById('modelModal');
// Remove existing event listeners first
modalElement.removeEventListener('click', handleModalClick);
// Create and store the handler function
function handleModalClick(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
switch (action) {
case 'close-modal':
modalManager.closeModal('modelModal');
break;
case 'scroll-to-top':
scrollToTop(target);
break;
case 'view-civitai':
openCivitai(target.dataset.filepath);
break;
case 'view-creator':
const username = target.dataset.username;
if (username) {
window.open(`https://civitai.com/user/${username}`, '_blank');
}
break;
case 'open-file-location':
const filePath = target.dataset.filepath || getModalFilePath();
if (filePath) {
openFileLocation(filePath);
}
break;
}
}
// Add the event listener with the named function
modalElement.addEventListener('click', handleModalClick);
// Store reference to the handler on the element for potential cleanup
modalElement._clickHandler = handleModalClick;
}
/**
* Set up editable fields (notes and usage tips) in the model modal
* @param {string} filePath - The full file path of the model
* @param {string} modelType - Type of model ('loras' or 'checkpoints' or 'embeddings')
*/
function setupEditableFields(filePath, modelType) {
const editableFields = document.querySelectorAll('.editable-field [contenteditable]');
editableFields.forEach(field => {
field.addEventListener('focus', function() {
if (this.textContent === 'Add your notes here...') {
this.textContent = '';
}
});
field.addEventListener('blur', function() {
if (this.textContent.trim() === '') {
if (this.classList.contains('notes-content')) {
this.textContent = 'Add your notes here...';
}
}
});
});
// Add keydown event listeners for notes
const notesContent = document.querySelector('.notes-content');
if (notesContent) {
notesContent.addEventListener('keydown', async function(e) {
if (e.key === 'Enter') {
if (e.shiftKey) {
// Allow shift+enter for new line
return;
}
e.preventDefault();
await saveNotes();
}
});
}
// LoRA specific field setup
if (modelType === 'loras') {
setupLoraSpecificFields(filePath);
}
}
function setupLoraSpecificFields(filePath) {
const presetSelector = document.getElementById('preset-selector');
const presetValue = document.getElementById('preset-value');
const addPresetBtn = document.querySelector('.add-preset-btn');
const presetTags = document.querySelector('.preset-tags');
const resolveFilePath = () => getModalFilePath(filePath);
if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return;
presetSelector.addEventListener('change', function() {
const selected = this.value;
if (selected) {
presetValue.style.display = 'inline-block';
presetValue.min = selected.includes('strength') ? -10 : 0;
presetValue.max = selected.includes('strength') ? 10 : 10;
presetValue.step = 0.5;
if (selected === 'clip_skip') {
presetValue.type = 'number';
presetValue.step = 1;
}
// Add auto-focus
setTimeout(() => presetValue.focus(), 0);
} else {
presetValue.style.display = 'none';
}
});
addPresetBtn.addEventListener('click', async function() {
const key = presetSelector.value;
const value = presetValue.value;
if (!key || !value) return;
const currentPath = resolveFilePath();
if (!currentPath) return;
const loraCard = document.querySelector(`.model-card[data-filepath="${currentPath}"]`) ||
document.querySelector(`.model-card[data-filepath="${filePath}"]`);
const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
currentPresets[key] = parseFloat(value);
const newPresetsJson = JSON.stringify(currentPresets);
await getModelApiClient().saveModelMetadata(currentPath, { usage_tips: newPresetsJson });
presetTags.innerHTML = renderPresetTags(currentPresets);
presetSelector.value = '';
presetValue.value = '';
presetValue.style.display = 'none';
});
// Add keydown event for preset value
presetValue.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addPresetBtn.click();
}
});
}
/**
* Save model notes using the current modal file path.
*/
async function saveNotes() {
const filePath = getModalFilePath();
if (!filePath) {
return;
}
const content = document.querySelector('.notes-content').textContent;
try {
await getModelApiClient().saveModelMetadata(filePath, { notes: content });
showToast('modals.model.notes.saved', {}, 'success');
} catch (error) {
showToast('modals.model.notes.saveFailed', {}, 'error');
}
}
/**
* Call backend to open file location and select the file
* @param {string} filePath
*/
async function openFileLocation(filePath) {
try {
const resp = await fetch('/api/lm/open-file-location', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 'file_path': filePath })
});
if (!resp.ok) throw new Error('Failed to open file location');
showToast('modals.model.openFileLocation.success', {}, 'success');
} catch (err) {
showToast('modals.model.openFileLocation.failed', {}, 'error');
}
}
// Export the model modal API
const modelModal = {
show: showModelModal,
toggleShowcase,
scrollToTop
};
export { modelModal };