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, escapeAttribute, escapeHtml } 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'; import { state } from '../../state/index.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' } ]; let navigationKeyHandler = null; let navigationModelType = null; let navigationInProgress = false; function hasLicenseField(license, field) { return Object.prototype.hasOwnProperty.call(license || {}, field); } function indentMarkup(markup, spaces) { if (!markup) { return ''; } const padding = ' '.repeat(spaces); return markup .split('\n') .map(line => (line ? padding + line : line)) .join('\n'); } function splitAggregateCommercialValue(value) { const trimmed = String(value ?? '').trim(); const looksAggregate = trimmed.includes(',') || (trimmed.startsWith('{') && trimmed.endsWith('}')); if (!looksAggregate) { return [value]; } let inner = trimmed; if (inner.startsWith('{') && inner.endsWith('}')) { inner = inner.slice(1, -1); } const parts = inner .split(',') .map(part => part.trim()) .filter(Boolean); return parts.length ? parts : [value]; } function normalizeCommercialValues(value) { if (!value && value !== '') { return ['Sell']; } if (Array.isArray(value)) { const flattened = []; value.forEach(item => { if (item === null || item === undefined) { return; } if (typeof item === 'string') { flattened.push(...splitAggregateCommercialValue(item)); return; } flattened.push(String(item)); }); if (flattened.length > 0) { return flattened; } if (value.length === 0) { return []; } } if (typeof value === 'string') { return splitAggregateCommercialValue(value); } if (value && typeof value[Symbol.iterator] === 'function') { const result = []; for (const item of value) { if (item === null || item === undefined) { continue; } if (typeof item === 'string') { result.push(...splitAggregateCommercialValue(item)); 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; cleanupNavigationShortcuts(); detachModalHandlers(modalId); // 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 escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || ''); const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A'); 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}
` : `
` } ${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); const updateAvailabilityState = { hasUpdateAvailable }; const updateBadgeTooltip = translate('modelCard.badges.updateAvailable', {}, 'Update available'); // Prepare LoRA specific data with complete civitai data const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ? modelWithFullData.civitai.trainedWords : []; // 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 = ``.trim(); const tabsContent = modelType === 'loras' ? ` ${versionsTabButton} ` : ` ${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 navAriaLabel = translate('modals.model.navigation.label', {}, 'Model navigation'); const previousTitle = translate('modals.model.navigation.previousWithShortcut', {}, 'Previous model (←)'); const nextTitle = translate('modals.model.navigation.nextWithShortcut', {}, 'Next model (→)'); const navigationControls = ` `.trim(); const tabPanesContent = modelType === 'loras' ? `
${loadingExampleImagesText}
${loadingDescriptionText}
${loadingVersionsText}
${loadingRecipesText}
` : `
${loadingExamplesText}
${loadingDescriptionText}
${loadingVersionsText}
`; const content = ` `; function updateVersionsTabBadge(hasUpdate) { const modalElement = document.getElementById(modalId); if (!modalElement) return; const tabButton = modalElement.querySelector('.tab-btn[data-tab="versions"]'); if (!tabButton) return; tabButton.classList.toggle('tab-btn--has-update', hasUpdate); let badge = tabButton.querySelector('.tab-badge--update'); if (hasUpdate) { if (!badge) { badge = document.createElement('span'); badge.className = 'tab-badge tab-badge--update'; badge.textContent = versionsBadgeLabel; badge.title = updateBadgeTooltip; tabButton.appendChild(badge); } else { badge.textContent = versionsBadgeLabel; badge.title = updateBadgeTooltip; } } else if (badge) { badge.remove(); } } function updateCardUpdateAvailability(hasUpdate) { const filePath = modelWithFullData.file_path; if (!filePath) return; let updatedViaScroller = false; if (state.virtualScroller?.updateSingleItem) { updatedViaScroller = state.virtualScroller.updateSingleItem(filePath, { update_available: hasUpdate, }); } if (updatedViaScroller) { return; } const escapedPath = window.CSS && typeof window.CSS.escape === 'function' ? window.CSS.escape(filePath) : filePath.replace(/["\\]/g, '\\$&'); const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`); if (!card) return; card.dataset.update_available = hasUpdate ? 'true' : 'false'; card.classList.toggle('has-update', hasUpdate); const headerInfo = card.querySelector('.card-header-info'); if (!headerInfo) return; let badge = headerInfo.querySelector('.model-update-badge'); if (hasUpdate) { if (!badge) { badge = document.createElement('span'); badge.className = 'model-update-badge'; badge.title = updateBadgeTooltip; badge.textContent = versionsBadgeLabel; headerInfo.appendChild(badge); } } else if (badge) { badge.remove(); } } function handleUpdateStatusChange(hasUpdate) { if (updateAvailabilityState.hasUpdateAvailable === hasUpdate) { return; } updateAvailabilityState.hasUpdateAvailable = hasUpdate; updateVersionsTabBadge(hasUpdate); updateCardUpdateAvailability(hasUpdate); } let showcaseCleanup; 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; } if (showcaseCleanup) { showcaseCleanup(); showcaseCleanup = null; } cleanupNavigationShortcuts(); }; modalManager.showModal(modalId, content, null, onCloseCallback); const activeModalElement = document.getElementById(modalId); if (activeModalElement) { activeModalElement.dataset.filePath = modelWithFullData.file_path || ''; } updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable); const versionsTabController = initVersionsTab({ modalId, modelType, modelId: civitaiModelId, currentVersionId: civitaiVersionId, currentBaseModel: modelWithFullData.base_model, onUpdateStatusChange: handleUpdateStatusChange, }); setupEditableFields(modelWithFullData.file_path, modelType); showcaseCleanup = 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, modelType); setupNavigationShortcuts(modelType); updateNavigationControls(); // 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 `
${renderPresetTags(parsePresets(lora.usage_tips))}
${renderTriggerWords(escapedWords, lora.file_path)} `; } function renderEmbeddingSpecificContent(embedding, escapedWords) { return `${renderTriggerWords(escapedWords, embedding.file_path)}`; } function detachModalHandlers(modalId) { const modalElement = document.getElementById(modalId); if (modalElement && modalElement._clickHandler) { modalElement.removeEventListener('click', modalElement._clickHandler); delete modalElement._clickHandler; } } /** * Sets up event handlers using event delegation for LoRA modal * @param {string} filePath - Path to the model file * @param {string} modelType - Current model type */ function setupEventHandlers(filePath, modelType) { 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; case 'nav-prev': handleDirectionalNavigation('prev', modelType); break; case 'nav-next': handleDirectionalNavigation('next', modelType); 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'; if (selected === 'strength_range') { presetValue.type = 'text'; presetValue.placeholder = 'e.g. 0.8-1.2'; presetValue.removeAttribute('min'); presetValue.removeAttribute('max'); presetValue.removeAttribute('step'); } else { presetValue.type = 'number'; presetValue.placeholder = translate('modals.model.usageTips.valuePlaceholder', {}, 'Value'); presetValue.min = selected.includes('strength') ? -10 : 0; presetValue.max = selected.includes('strength') ? 10 : 10; presetValue.step = 0.5; if (selected === 'clip_skip') { 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); if (key === 'strength_range') { const rangeMatch = value.match(/^(-?\d*\.?\d+)\s*[-~]\s*(-?\d*\.?\d+)$/); if (rangeMatch) { currentPresets['strength_min'] = parseFloat(rangeMatch[1]); currentPresets['strength_max'] = parseFloat(rangeMatch[2]); } else { showToast('modals.model.usageTips.invalidRange', {}, 'error', 'Invalid range format. Use x.x-y.y'); return; } } else { 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'); } } function shouldIgnoreNavigationKey(event) { const target = event.target; if (!target) return false; const tagName = target.tagName ? target.tagName.toLowerCase() : ''; return target.isContentEditable || ['input', 'textarea', 'select', 'button'].includes(tagName); } function updateNavigationControls() { const modalElement = document.getElementById('modelModal'); if (!modalElement) return; const prevBtn = modalElement.querySelector('[data-action="nav-prev"]'); const nextBtn = modalElement.querySelector('[data-action="nav-next"]'); if (!prevBtn || !nextBtn) return; const scroller = state.virtualScroller; if (!scroller || typeof scroller.getNavigationState !== 'function') { prevBtn.disabled = true; nextBtn.disabled = true; return; } const { hasPrev, hasNext } = scroller.getNavigationState(modalElement.dataset.filePath || ''); prevBtn.disabled = navigationInProgress || !hasPrev; nextBtn.disabled = navigationInProgress || !hasNext; } function cleanupNavigationShortcuts() { if (navigationKeyHandler) { document.removeEventListener('keydown', navigationKeyHandler); navigationKeyHandler = null; } navigationModelType = null; navigationInProgress = false; } function setupNavigationShortcuts(modelType) { const modalElement = document.getElementById('modelModal'); if (!modalElement) return; navigationModelType = modelType; cleanupNavigationShortcuts(); navigationKeyHandler = (event) => { if (shouldIgnoreNavigationKey(event)) return; if (event.key === 'ArrowLeft') { event.preventDefault(); handleDirectionalNavigation('prev', navigationModelType); } else if (event.key === 'ArrowRight') { event.preventDefault(); handleDirectionalNavigation('next', navigationModelType); } }; document.addEventListener('keydown', navigationKeyHandler); } async function handleDirectionalNavigation(direction, modelType) { if (navigationInProgress) return; const modalElement = document.getElementById('modelModal'); const scroller = state.virtualScroller; const filePath = modalElement?.dataset?.filePath || ''; if (!modalElement || !filePath || !scroller || typeof scroller.getAdjacentItemByFilePath !== 'function') { return; } navigationInProgress = true; updateNavigationControls(); try { const adjacent = await scroller.getAdjacentItemByFilePath(filePath, direction); if (!adjacent || !adjacent.item) { const toastKey = direction === 'prev' ? 'modals.model.navigation.noPrevious' : 'modals.model.navigation.noNext'; const toastFallback = direction === 'prev' ? 'No previous model available' : 'No next model available'; showToast(toastKey, {}, 'info', toastFallback); return; } navigationModelType = modelType || navigationModelType; await showModelModal(adjacent.item, navigationModelType || modelType); } finally { navigationInProgress = false; updateNavigationControls(); } } /** * 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 };