diff --git a/static/js/components/LoraCard.js b/static/js/components/LoraCard.js index f266f662..64a59a61 100644 --- a/static/js/components/LoraCard.js +++ b/static/js/components/LoraCard.js @@ -1,6 +1,6 @@ import { showToast } from '../utils/uiHelpers.js'; import { state } from '../state/index.js'; -import { showLoraModal } from './LoraModal.js'; +import { showLoraModal } from './loraModal/index.js'; import { bulkManager } from '../managers/BulkManager.js'; import { NSFW_LEVELS } from '../utils/constants.js'; diff --git a/static/js/components/loraModal/ModelDescription.js b/static/js/components/loraModal/ModelDescription.js new file mode 100644 index 00000000..b4a0eb57 --- /dev/null +++ b/static/js/components/loraModal/ModelDescription.js @@ -0,0 +1,102 @@ +/** + * ModelDescription.js + * 处理LoRA模型描述相关的功能模块 + */ +import { showToast } from '../../utils/uiHelpers.js'; + +/** + * 设置标签页切换功能 + */ +export function setupTabSwitching() { + const tabButtons = document.querySelectorAll('.showcase-tabs .tab-btn'); + + tabButtons.forEach(button => { + button.addEventListener('click', () => { + // Remove active class from all tabs + document.querySelectorAll('.showcase-tabs .tab-btn').forEach(btn => + btn.classList.remove('active') + ); + document.querySelectorAll('.tab-content .tab-pane').forEach(tab => + tab.classList.remove('active') + ); + + // Add active class to clicked tab + button.classList.add('active'); + const tabId = `${button.dataset.tab}-tab`; + document.getElementById(tabId).classList.add('active'); + + // If switching to description tab, make sure content is properly sized + if (button.dataset.tab === 'description') { + const descriptionContent = document.querySelector('.model-description-content'); + if (descriptionContent) { + const hasContent = descriptionContent.innerHTML.trim() !== ''; + document.querySelector('.model-description-loading')?.classList.add('hidden'); + + // If no content, show a message + if (!hasContent) { + descriptionContent.innerHTML = '
No model description available
'; + descriptionContent.classList.remove('hidden'); + } + } + } + }); + }); +} + +/** + * 加载模型描述 + * @param {string} modelId - 模型ID + * @param {string} filePath - 文件路径 + */ +export async function loadModelDescription(modelId, filePath) { + try { + const descriptionContainer = document.querySelector('.model-description-content'); + const loadingElement = document.querySelector('.model-description-loading'); + + if (!descriptionContainer || !loadingElement) return; + + // Show loading indicator + loadingElement.classList.remove('hidden'); + descriptionContainer.classList.add('hidden'); + + // Try to get model description from API + const response = await fetch(`/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`); + + if (!response.ok) { + throw new Error(`Failed to fetch model description: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.success && data.description) { + // Update the description content + descriptionContainer.innerHTML = data.description; + + // Process any links in the description to open in new tab + const links = descriptionContainer.querySelectorAll('a'); + links.forEach(link => { + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer'); + }); + + // Show the description and hide loading indicator + descriptionContainer.classList.remove('hidden'); + loadingElement.classList.add('hidden'); + } else { + throw new Error(data.error || 'No description available'); + } + } catch (error) { + console.error('Error loading model description:', error); + const loadingElement = document.querySelector('.model-description-loading'); + if (loadingElement) { + loadingElement.innerHTML = `
Failed to load model description. ${error.message}
`; + } + + // Show empty state message in the description container + const descriptionContainer = document.querySelector('.model-description-content'); + if (descriptionContainer) { + descriptionContainer.innerHTML = '
No model description available
'; + descriptionContainer.classList.remove('hidden'); + } + } +} \ No newline at end of file diff --git a/static/js/components/loraModal/ModelMetadata.js b/static/js/components/loraModal/ModelMetadata.js new file mode 100644 index 00000000..879ec861 --- /dev/null +++ b/static/js/components/loraModal/ModelMetadata.js @@ -0,0 +1,493 @@ +/** + * ModelMetadata.js + * 处理LoRA模型元数据编辑相关的功能模块 + */ +import { showToast } from '../../utils/uiHelpers.js'; +import { BASE_MODELS } from '../../utils/constants.js'; + +/** + * 保存模型元数据到服务器 + * @param {string} filePath - 文件路径 + * @param {Object} data - 要保存的数据 + * @returns {Promise} 保存操作的Promise + */ +export async function saveModelMetadata(filePath, data) { + const response = await fetch('/loras/api/save-metadata', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_path: filePath, + ...data + }) + }); + + if (!response.ok) { + throw new Error('Failed to save metadata'); + } + + return response.json(); +} + +/** + * 设置模型名称编辑功能 + */ +export function setupModelNameEditing() { + const modelNameContent = document.querySelector('.model-name-content'); + const editBtn = document.querySelector('.edit-model-name-btn'); + + if (!modelNameContent || !editBtn) return; + + // Show edit button on hover + const modelNameHeader = document.querySelector('.model-name-header'); + modelNameHeader.addEventListener('mouseenter', () => { + editBtn.classList.add('visible'); + }); + + modelNameHeader.addEventListener('mouseleave', () => { + if (!modelNameContent.getAttribute('data-editing')) { + editBtn.classList.remove('visible'); + } + }); + + // Handle edit button click + editBtn.addEventListener('click', () => { + modelNameContent.setAttribute('data-editing', 'true'); + modelNameContent.focus(); + + // Place cursor at the end + const range = document.createRange(); + const sel = window.getSelection(); + if (modelNameContent.childNodes.length > 0) { + range.setStart(modelNameContent.childNodes[0], modelNameContent.textContent.length); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } + + editBtn.classList.add('visible'); + }); + + // Handle focus out + modelNameContent.addEventListener('blur', function() { + this.removeAttribute('data-editing'); + editBtn.classList.remove('visible'); + + if (this.textContent.trim() === '') { + // Restore original model name if empty + const filePath = document.querySelector('#loraModal .modal-content') + .querySelector('.file-path').textContent + + document.querySelector('#loraModal .modal-content') + .querySelector('#file-name').textContent + '.safetensors'; + const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + if (loraCard) { + this.textContent = loraCard.dataset.model_name; + } + } + }); + + // Handle enter key + modelNameContent.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + const filePath = document.querySelector('#loraModal .modal-content') + .querySelector('.file-path').textContent + + document.querySelector('#loraModal .modal-content') + .querySelector('#file-name').textContent + '.safetensors'; + saveModelName(filePath); + this.blur(); + } + }); + + // Limit model name length + modelNameContent.addEventListener('input', function() { + // Limit model name length + if (this.textContent.length > 100) { + this.textContent = this.textContent.substring(0, 100); + // Place cursor at the end + const range = document.createRange(); + const sel = window.getSelection(); + range.setStart(this.childNodes[0], 100); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + + showToast('Model name is limited to 100 characters', 'warning'); + } + }); +} + +/** + * 保存模型名称 + * @param {string} filePath - 文件路径 + */ +async function saveModelName(filePath) { + const modelNameElement = document.querySelector('.model-name-content'); + const newModelName = modelNameElement.textContent.trim(); + + // Validate model name + if (!newModelName) { + showToast('Model name cannot be empty', 'error'); + return; + } + + // Check if model name is too long (limit to 100 characters) + if (newModelName.length > 100) { + showToast('Model name is too long (maximum 100 characters)', 'error'); + // Truncate the displayed text + modelNameElement.textContent = newModelName.substring(0, 100); + return; + } + + try { + await saveModelMetadata(filePath, { model_name: newModelName }); + + // Update the corresponding lora card's dataset and display + const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + if (loraCard) { + loraCard.dataset.model_name = newModelName; + const titleElement = loraCard.querySelector('.card-title'); + if (titleElement) { + titleElement.textContent = newModelName; + } + } + + showToast('Model name updated successfully', 'success'); + + // Reload the page to reflect the sorted order + setTimeout(() => { + window.location.reload(); + }, 1500); + } catch (error) { + showToast('Failed to update model name', 'error'); + } +} + +/** + * 设置基础模型编辑功能 + */ +export function setupBaseModelEditing() { + const baseModelContent = document.querySelector('.base-model-content'); + const editBtn = document.querySelector('.edit-base-model-btn'); + + if (!baseModelContent || !editBtn) return; + + // Show edit button on hover + const baseModelDisplay = document.querySelector('.base-model-display'); + baseModelDisplay.addEventListener('mouseenter', () => { + editBtn.classList.add('visible'); + }); + + baseModelDisplay.addEventListener('mouseleave', () => { + if (!baseModelDisplay.classList.contains('editing')) { + editBtn.classList.remove('visible'); + } + }); + + // Handle edit button click + editBtn.addEventListener('click', () => { + baseModelDisplay.classList.add('editing'); + + // Store the original value to check for changes later + const originalValue = baseModelContent.textContent.trim(); + + // Create dropdown selector to replace the base model content + const currentValue = originalValue; + const dropdown = document.createElement('select'); + dropdown.className = 'base-model-selector'; + + // Flag to track if a change was made + let valueChanged = false; + + // Add options from BASE_MODELS constants + const baseModelCategories = { + 'Stable Diffusion 1.x': [BASE_MODELS.SD_1_4, BASE_MODELS.SD_1_5, BASE_MODELS.SD_1_5_LCM, BASE_MODELS.SD_1_5_HYPER], + 'Stable Diffusion 2.x': [BASE_MODELS.SD_2_0, BASE_MODELS.SD_2_1], + 'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO], + 'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER], + 'Video Models': [BASE_MODELS.SVD, BASE_MODELS.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO], + 'Other Models': [ + BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.AURAFLOW, + BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1, + BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, + BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.UNKNOWN + ] + }; + + // Create option groups for better organization + Object.entries(baseModelCategories).forEach(([category, models]) => { + const group = document.createElement('optgroup'); + group.label = category; + + models.forEach(model => { + const option = document.createElement('option'); + option.value = model; + option.textContent = model; + option.selected = model === currentValue; + group.appendChild(option); + }); + + dropdown.appendChild(group); + }); + + // Replace content with dropdown + baseModelContent.style.display = 'none'; + baseModelDisplay.insertBefore(dropdown, editBtn); + + // Hide edit button during editing + editBtn.style.display = 'none'; + + // Focus the dropdown + dropdown.focus(); + + // Handle dropdown change + dropdown.addEventListener('change', function() { + const selectedModel = this.value; + baseModelContent.textContent = selectedModel; + + // Mark that a change was made if the value differs from original + if (selectedModel !== originalValue) { + valueChanged = true; + } else { + valueChanged = false; + } + }); + + // Function to save changes and exit edit mode + const saveAndExit = function() { + // Check if dropdown still exists and remove it + if (dropdown && dropdown.parentNode === baseModelDisplay) { + baseModelDisplay.removeChild(dropdown); + } + + // Show the content and edit button + baseModelContent.style.display = ''; + editBtn.style.display = ''; + + // Remove editing class + baseModelDisplay.classList.remove('editing'); + + // Only save if the value has actually changed + if (valueChanged || baseModelContent.textContent.trim() !== originalValue) { + // Get file path for saving + const filePath = document.querySelector('#loraModal .modal-content') + .querySelector('.file-path').textContent + + document.querySelector('#loraModal .modal-content') + .querySelector('#file-name').textContent + '.safetensors'; + + // Save the changes, passing the original value for comparison + saveBaseModel(filePath, originalValue); + } + + // Remove this event listener + document.removeEventListener('click', outsideClickHandler); + }; + + // Handle outside clicks to save and exit + const outsideClickHandler = function(e) { + // If click is outside the dropdown and base model display + if (!baseModelDisplay.contains(e.target)) { + saveAndExit(); + } + }; + + // Add delayed event listener for outside clicks + setTimeout(() => { + document.addEventListener('click', outsideClickHandler); + }, 0); + + // Also handle dropdown blur event + dropdown.addEventListener('blur', function(e) { + // Only save if the related target is not the edit button or inside the baseModelDisplay + if (!baseModelDisplay.contains(e.relatedTarget)) { + saveAndExit(); + } + }); + }); +} + +/** + * 保存基础模型 + * @param {string} filePath - 文件路径 + * @param {string} originalValue - 原始值(用于比较) + */ +async function saveBaseModel(filePath, originalValue) { + const baseModelElement = document.querySelector('.base-model-content'); + const newBaseModel = baseModelElement.textContent.trim(); + + // Only save if the value has actually changed + if (newBaseModel === originalValue) { + return; // No change, no need to save + } + + try { + await saveModelMetadata(filePath, { base_model: newBaseModel }); + + // Update the corresponding lora card's dataset + const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + if (loraCard) { + loraCard.dataset.base_model = newBaseModel; + } + + showToast('Base model updated successfully', 'success'); + } catch (error) { + showToast('Failed to update base model', 'error'); + } +} + +/** + * 设置文件名编辑功能 + */ +export function setupFileNameEditing() { + const fileNameContent = document.querySelector('.file-name-content'); + const editBtn = document.querySelector('.edit-file-name-btn'); + + if (!fileNameContent || !editBtn) return; + + // Show edit button on hover + const fileNameWrapper = document.querySelector('.file-name-wrapper'); + fileNameWrapper.addEventListener('mouseenter', () => { + editBtn.classList.add('visible'); + }); + + fileNameWrapper.addEventListener('mouseleave', () => { + if (!fileNameWrapper.classList.contains('editing')) { + editBtn.classList.remove('visible'); + } + }); + + // Handle edit button click + editBtn.addEventListener('click', () => { + fileNameWrapper.classList.add('editing'); + fileNameContent.setAttribute('contenteditable', 'true'); + fileNameContent.focus(); + + // Store original value for comparison later + fileNameContent.dataset.originalValue = fileNameContent.textContent.trim(); + + // Place cursor at the end + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(fileNameContent); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + + editBtn.classList.add('visible'); + }); + + // Handle keyboard events in edit mode + fileNameContent.addEventListener('keydown', function(e) { + if (!this.getAttribute('contenteditable')) return; + + if (e.key === 'Enter') { + e.preventDefault(); + this.blur(); // Trigger save on Enter + } else if (e.key === 'Escape') { + e.preventDefault(); + // Restore original value + this.textContent = this.dataset.originalValue; + exitEditMode(); + } + }); + + // Handle input validation + fileNameContent.addEventListener('input', function() { + if (!this.getAttribute('contenteditable')) return; + + // Replace invalid characters for filenames + const invalidChars = /[\\/:*?"<>|]/g; + if (invalidChars.test(this.textContent)) { + const cursorPos = window.getSelection().getRangeAt(0).startOffset; + this.textContent = this.textContent.replace(invalidChars, ''); + + // Restore cursor position + const range = document.createRange(); + const sel = window.getSelection(); + const newPos = Math.min(cursorPos, this.textContent.length); + + if (this.firstChild) { + range.setStart(this.firstChild, newPos); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } + + showToast('Invalid characters removed from filename', 'warning'); + } + }); + + // Handle focus out - save changes + fileNameContent.addEventListener('blur', async function() { + if (!this.getAttribute('contenteditable')) return; + + const newFileName = this.textContent.trim(); + const originalValue = this.dataset.originalValue; + + // Basic validation + if (!newFileName) { + // Restore original value if empty + this.textContent = originalValue; + showToast('File name cannot be empty', 'error'); + exitEditMode(); + return; + } + + if (newFileName === originalValue) { + // No changes, just exit edit mode + exitEditMode(); + return; + } + + try { + // Get the full file path + const filePath = document.querySelector('#loraModal .modal-content') + .querySelector('.file-path').textContent + originalValue + '.safetensors'; + + // Call API to rename the file + const response = await fetch('/api/rename_lora', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_path: filePath, + new_file_name: newFileName + }) + }); + + const result = await response.json(); + + if (result.success) { + showToast('File name updated successfully', 'success'); + + // Update the LoRA card with new file path + const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + if (loraCard) { + const newFilePath = filePath.replace(originalValue, newFileName); + loraCard.dataset.filepath = newFilePath; + } + + // Reload the page after a short delay to reflect changes + setTimeout(() => { + window.location.reload(); + }, 1500); + } else { + throw new Error(result.error || 'Unknown error'); + } + } catch (error) { + console.error('Error renaming file:', error); + this.textContent = originalValue; // Restore original file name + showToast(`Failed to rename file: ${error.message}`, 'error'); + } finally { + exitEditMode(); + } + }); + + function exitEditMode() { + fileNameContent.removeAttribute('contenteditable'); + fileNameWrapper.classList.remove('editing'); + editBtn.classList.remove('visible'); + } +} \ No newline at end of file diff --git a/static/js/components/loraModal/PresetTags.js b/static/js/components/loraModal/PresetTags.js new file mode 100644 index 00000000..a6abab2b --- /dev/null +++ b/static/js/components/loraModal/PresetTags.js @@ -0,0 +1,68 @@ +/** + * PresetTags.js + * 处理LoRA模型预设参数标签相关的功能模块 + */ +import { saveModelMetadata } from './ModelMetadata.js'; +import { showToast } from '../../utils/uiHelpers.js'; + +/** + * 解析预设参数 + * @param {string} usageTips - 包含预设参数的JSON字符串 + * @returns {Object} 解析后的预设参数对象 + */ +export function parsePresets(usageTips) { + if (!usageTips) return {}; + try { + return JSON.parse(usageTips); + } catch { + return {}; + } +} + +/** + * 渲染预设标签 + * @param {Object} presets - 预设参数对象 + * @returns {string} HTML内容 + */ +export function renderPresetTags(presets) { + return Object.entries(presets).map(([key, value]) => ` +
+ ${formatPresetKey(key)}: ${value} + +
+ `).join(''); +} + +/** + * 格式化预设键名 + * @param {string} key - 预设键名 + * @returns {string} 格式化后的键名 + */ +function formatPresetKey(key) { + return key.split('_').map(word => + word.charAt(0).toUpperCase() + word.slice(1) + ).join(' '); +} + +/** + * 移除预设参数 + * @param {string} key - 要移除的预设键名 + */ +window.removePreset = async function(key) { + const filePath = document.querySelector('#loraModal .modal-content') + .querySelector('.file-path').textContent + + document.querySelector('#loraModal .modal-content') + .querySelector('#file-name').textContent + '.safetensors'; + const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + const currentPresets = parsePresets(loraCard.dataset.usage_tips); + + delete currentPresets[key]; + const newPresetsJson = JSON.stringify(currentPresets); + + await saveModelMetadata(filePath, { + usage_tips: newPresetsJson + }); + + loraCard.dataset.usage_tips = newPresetsJson; + document.querySelector('.preset-tags').innerHTML = renderPresetTags(currentPresets); +}; \ No newline at end of file diff --git a/static/js/components/loraModal/ShowcaseView.js b/static/js/components/loraModal/ShowcaseView.js new file mode 100644 index 00000000..2e2858e9 --- /dev/null +++ b/static/js/components/loraModal/ShowcaseView.js @@ -0,0 +1,501 @@ +/** + * ShowcaseView.js + * 处理LoRA模型展示内容(图片、视频)的功能模块 + */ +import { showToast } from '../../utils/uiHelpers.js'; +import { state } from '../../state/index.js'; +import { NSFW_LEVELS } from '../../utils/constants.js'; + +/** + * 渲染展示内容 + * @param {Array} images - 要展示的图片/视频数组 + * @returns {string} HTML内容 + */ +export function renderShowcaseContent(images) { + if (!images?.length) return '
No example images available
'; + + // Filter images based on SFW setting + const showOnlySFW = state.settings.show_only_sfw; + let filteredImages = images; + let hiddenCount = 0; + + if (showOnlySFW) { + filteredImages = images.filter(img => { + const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0; + const isSfw = nsfwLevel < NSFW_LEVELS.R; + if (!isSfw) hiddenCount++; + return isSfw; + }); + } + + // Show message if no images are available after filtering + if (filteredImages.length === 0) { + return ` +
+

All example images are filtered due to NSFW content settings

+

Your settings are currently set to show only safe-for-work content

+

You can change this in Settings

+
+ `; + } + + // Show hidden content notification if applicable + const hiddenNotification = hiddenCount > 0 ? + `
+ ${hiddenCount} ${hiddenCount === 1 ? 'image' : 'images'} hidden due to SFW-only setting +
` : ''; + + return ` +
+ + Scroll or click to show ${filteredImages.length} examples +
+ + `; +} + +/** + * 生成视频包装HTML + */ +function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel) { + return ` +
+ ${shouldBlur ? ` + + ` : ''} + + ${shouldBlur ? ` +
+
+

${nsfwText}

+ +
+
+ ` : ''} + ${metadataPanel} +
+ `; +} + +/** + * 生成图片包装HTML + */ +function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel) { + return ` +
+ ${shouldBlur ? ` + + ` : ''} + Preview + ${shouldBlur ? ` +
+
+

${nsfwText}

+ +
+
+ ` : ''} + ${metadataPanel} +
+ `; +} + +/** + * 切换展示区域的显示状态 + */ +export function toggleShowcase(element) { + const carousel = element.nextElementSibling; + const isCollapsed = carousel.classList.contains('collapsed'); + const indicator = element.querySelector('span'); + const icon = element.querySelector('i'); + + carousel.classList.toggle('collapsed'); + + if (isCollapsed) { + const count = carousel.querySelectorAll('.media-wrapper').length; + indicator.textContent = `Scroll or click to hide examples`; + icon.classList.replace('fa-chevron-down', 'fa-chevron-up'); + initLazyLoading(carousel); + + // Initialize NSFW content blur toggle handlers + initNsfwBlurHandlers(carousel); + + // Initialize metadata panel interaction handlers + initMetadataPanelHandlers(carousel); + } else { + const count = carousel.querySelectorAll('.media-wrapper').length; + indicator.textContent = `Scroll or click to show ${count} examples`; + icon.classList.replace('fa-chevron-up', 'fa-chevron-down'); + + // Make sure any open metadata panels get closed + const carouselContainer = carousel.querySelector('.carousel-container'); + if (carouselContainer) { + carouselContainer.style.height = '0'; + setTimeout(() => { + carouselContainer.style.height = ''; + }, 300); + } + } +} + +/** + * 初始化元数据面板交互处理 + */ +function initMetadataPanelHandlers(container) { + // Find all media wrappers + const mediaWrappers = container.querySelectorAll('.media-wrapper'); + + mediaWrappers.forEach(wrapper => { + // Get the metadata panel + const metadataPanel = wrapper.querySelector('.image-metadata-panel'); + if (!metadataPanel) return; + + // Prevent events from the metadata panel from bubbling + metadataPanel.addEventListener('click', (e) => { + e.stopPropagation(); + }); + + // Handle copy prompt button clicks + const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn'); + copyBtns.forEach(copyBtn => { + const promptIndex = copyBtn.dataset.promptIndex; + const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`); + + copyBtn.addEventListener('click', async (e) => { + e.stopPropagation(); // Prevent bubbling + + if (!promptElement) return; + + try { + await navigator.clipboard.writeText(promptElement.textContent); + showToast('Prompt copied to clipboard', 'success'); + } catch (err) { + console.error('Copy failed:', err); + showToast('Copy failed', 'error'); + } + }); + }); + + // Prevent scrolling in the metadata panel from scrolling the whole modal + metadataPanel.addEventListener('wheel', (e) => { + const isAtTop = metadataPanel.scrollTop === 0; + const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight; + + // Only prevent default if scrolling would cause the panel to scroll + if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) { + e.stopPropagation(); + } + }, { passive: true }); + }); +} + +/** + * 初始化模糊切换处理 + */ +function initNsfwBlurHandlers(container) { + // Handle toggle blur buttons + const toggleButtons = container.querySelectorAll('.toggle-blur-btn'); + toggleButtons.forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const wrapper = btn.closest('.media-wrapper'); + const media = wrapper.querySelector('img, video'); + const isBlurred = media.classList.toggle('blurred'); + const icon = btn.querySelector('i'); + + // Update the icon based on blur state + if (isBlurred) { + icon.className = 'fas fa-eye'; + } else { + icon.className = 'fas fa-eye-slash'; + } + + // Toggle the overlay visibility + const overlay = wrapper.querySelector('.nsfw-overlay'); + if (overlay) { + overlay.style.display = isBlurred ? 'flex' : 'none'; + } + }); + }); + + // Handle "Show" buttons in overlays + const showButtons = container.querySelectorAll('.show-content-btn'); + showButtons.forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const wrapper = btn.closest('.media-wrapper'); + const media = wrapper.querySelector('img, video'); + media.classList.remove('blurred'); + + // Update the toggle button icon + const toggleBtn = wrapper.querySelector('.toggle-blur-btn'); + if (toggleBtn) { + toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; + } + + // Hide the overlay + const overlay = wrapper.querySelector('.nsfw-overlay'); + if (overlay) { + overlay.style.display = 'none'; + } + }); + }); +} + +/** + * 初始化延迟加载 + */ +function initLazyLoading(container) { + const lazyElements = container.querySelectorAll('.lazy'); + + const lazyLoad = (element) => { + if (element.tagName.toLowerCase() === 'video') { + element.src = element.dataset.src; + element.querySelector('source').src = element.dataset.src; + element.load(); + } else { + element.src = element.dataset.src; + } + element.classList.remove('lazy'); + }; + + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + lazyLoad(entry.target); + observer.unobserve(entry.target); + } + }); + }); + + lazyElements.forEach(element => observer.observe(element)); +} + +/** + * 设置展示区域的滚动处理 + */ +export function setupShowcaseScroll() { + // Add event listener to document for wheel events + document.addEventListener('wheel', (event) => { + // Find the active modal content + const modalContent = document.querySelector('#loraModal .modal-content'); + if (!modalContent) return; + + const showcase = modalContent.querySelector('.showcase-section'); + if (!showcase) return; + + const carousel = showcase.querySelector('.carousel'); + const scrollIndicator = showcase.querySelector('.scroll-indicator'); + + if (carousel?.classList.contains('collapsed') && event.deltaY > 0) { + const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100; + + if (isNearBottom) { + toggleShowcase(scrollIndicator); + event.preventDefault(); + } + } + }, { passive: false }); + + // Use MutationObserver instead of deprecated DOMNodeInserted + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'childList' && mutation.addedNodes.length) { + // Check if loraModal content was added + const loraModal = document.getElementById('loraModal'); + if (loraModal && loraModal.querySelector('.modal-content')) { + setupBackToTopButton(loraModal.querySelector('.modal-content')); + } + } + } + }); + + // Start observing the document body for changes + observer.observe(document.body, { childList: true, subtree: true }); + + // Also try to set up the button immediately in case the modal is already open + const modalContent = document.querySelector('#loraModal .modal-content'); + if (modalContent) { + setupBackToTopButton(modalContent); + } +} + +/** + * 设置返回顶部按钮 + */ +function setupBackToTopButton(modalContent) { + // Remove any existing scroll listeners to avoid duplicates + modalContent.onscroll = null; + + // Add new scroll listener + modalContent.addEventListener('scroll', () => { + const backToTopBtn = modalContent.querySelector('.back-to-top'); + if (backToTopBtn) { + if (modalContent.scrollTop > 300) { + backToTopBtn.classList.add('visible'); + } else { + backToTopBtn.classList.remove('visible'); + } + } + }); + + // Trigger a scroll event to check initial position + modalContent.dispatchEvent(new Event('scroll')); +} + +/** + * 滚动到顶部 + */ +export function scrollToTop(button) { + const modalContent = button.closest('.modal-content'); + if (modalContent) { + modalContent.scrollTo({ + top: 0, + behavior: 'smooth' + }); + } +} \ No newline at end of file diff --git a/static/js/components/loraModal/TriggerWords.js b/static/js/components/loraModal/TriggerWords.js new file mode 100644 index 00000000..49253e54 --- /dev/null +++ b/static/js/components/loraModal/TriggerWords.js @@ -0,0 +1,345 @@ +/** + * TriggerWords.js + * 处理LoRA模型触发词相关的功能模块 + */ +import { showToast } from '../../utils/uiHelpers.js'; +import { saveModelMetadata } from './ModelMetadata.js'; + +/** + * 渲染触发词 + * @param {Array} words - 触发词数组 + * @param {string} filePath - 文件路径 + * @returns {string} HTML内容 + */ +export function renderTriggerWords(words, filePath) { + if (!words.length) return ` +
+
+ + +
+
+ No trigger word needed + +
+ + +
+ `; + + return ` +
+
+ + +
+
+
+ ${words.map(word => ` +
+ ${word} + + + + +
+ `).join('')} +
+
+ + +
+ `; +} + +/** + * 设置触发词编辑模式 + */ +export function setupTriggerWordsEditMode() { + const editBtn = document.querySelector('.edit-trigger-words-btn'); + if (!editBtn) return; + + editBtn.addEventListener('click', function() { + const triggerWordsSection = this.closest('.trigger-words'); + const isEditMode = triggerWordsSection.classList.toggle('edit-mode'); + + // Toggle edit mode UI elements + const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag'); + const editControls = triggerWordsSection.querySelector('.trigger-words-edit-controls'); + const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words'); + const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags'); + + if (isEditMode) { + this.innerHTML = ''; // Change to cancel icon + this.title = "Cancel editing"; + editControls.style.display = 'flex'; + + // If we have no trigger words yet, hide the "No trigger word needed" text + // and show the empty tags container + if (noTriggerWords) { + noTriggerWords.style.display = 'none'; + if (tagsContainer) tagsContainer.style.display = 'flex'; + } + + // Disable click-to-copy and show delete buttons + triggerWordTags.forEach(tag => { + tag.onclick = null; + tag.querySelector('.trigger-word-copy').style.display = 'none'; + tag.querySelector('.delete-trigger-word-btn').style.display = 'block'; + }); + } else { + this.innerHTML = ''; // Change back to edit icon + this.title = "Edit trigger words"; + editControls.style.display = 'none'; + + // If we have no trigger words, show the "No trigger word needed" text + // and hide the empty tags container + const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag'); + if (noTriggerWords && currentTags.length === 0) { + noTriggerWords.style.display = ''; + if (tagsContainer) tagsContainer.style.display = 'none'; + } + + // Restore original state + triggerWordTags.forEach(tag => { + const word = tag.dataset.word; + tag.onclick = () => copyTriggerWord(word); + tag.querySelector('.trigger-word-copy').style.display = 'flex'; + tag.querySelector('.delete-trigger-word-btn').style.display = 'none'; + }); + + // Hide add form if open + triggerWordsSection.querySelector('.add-trigger-word-form').style.display = 'none'; + } + }); + + // Set up add trigger word button + const addBtn = document.querySelector('.add-trigger-word-btn'); + if (addBtn) { + addBtn.addEventListener('click', function() { + const triggerWordsSection = this.closest('.trigger-words'); + const addForm = triggerWordsSection.querySelector('.add-trigger-word-form'); + addForm.style.display = 'flex'; + addForm.querySelector('input').focus(); + }); + } + + // Set up confirm and cancel add buttons + const confirmAddBtn = document.querySelector('.confirm-add-trigger-word-btn'); + const cancelAddBtn = document.querySelector('.cancel-add-trigger-word-btn'); + const triggerWordInput = document.querySelector('.new-trigger-word-input'); + + if (confirmAddBtn && triggerWordInput) { + confirmAddBtn.addEventListener('click', function() { + addNewTriggerWord(triggerWordInput.value); + }); + + // Add keydown event to input + triggerWordInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + addNewTriggerWord(this.value); + } + }); + } + + if (cancelAddBtn) { + cancelAddBtn.addEventListener('click', function() { + const addForm = this.closest('.add-trigger-word-form'); + addForm.style.display = 'none'; + addForm.querySelector('input').value = ''; + }); + } + + // Set up save button + const saveBtn = document.querySelector('.save-trigger-words-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', saveTriggerWords); + } + + // Set up delete buttons + document.querySelectorAll('.delete-trigger-word-btn').forEach(btn => { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + const tag = this.closest('.trigger-word-tag'); + tag.remove(); + }); + }); +} + +/** + * 添加新触发词 + * @param {string} word - 要添加的触发词 + */ +function addNewTriggerWord(word) { + word = word.trim(); + if (!word) return; + + const triggerWordsSection = document.querySelector('.trigger-words'); + let tagsContainer = document.querySelector('.trigger-words-tags'); + + // Ensure tags container exists and is visible + if (tagsContainer) { + tagsContainer.style.display = 'flex'; + } else { + // Create tags container if it doesn't exist + const contentDiv = triggerWordsSection.querySelector('.trigger-words-content'); + if (contentDiv) { + tagsContainer = document.createElement('div'); + tagsContainer.className = 'trigger-words-tags'; + contentDiv.appendChild(tagsContainer); + } + } + + if (!tagsContainer) return; + + // Hide "no trigger words" message if it exists + const noTriggerWordsMsg = triggerWordsSection.querySelector('.no-trigger-words'); + if (noTriggerWordsMsg) { + noTriggerWordsMsg.style.display = 'none'; + } + + // Validation: Check length + if (word.split(/\s+/).length > 30) { + showToast('Trigger word should not exceed 30 words', 'error'); + return; + } + + // Validation: Check total number + const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag'); + if (currentTags.length >= 10) { + showToast('Maximum 10 trigger words allowed', 'error'); + return; + } + + // Validation: Check for duplicates + const existingWords = Array.from(currentTags).map(tag => tag.dataset.word); + if (existingWords.includes(word)) { + showToast('This trigger word already exists', 'error'); + return; + } + + // Create new tag + const newTag = document.createElement('div'); + newTag.className = 'trigger-word-tag'; + newTag.dataset.word = word; + newTag.innerHTML = ` + ${word} + + + `; + + // Add event listener to delete button + const deleteBtn = newTag.querySelector('.delete-trigger-word-btn'); + deleteBtn.addEventListener('click', function() { + newTag.remove(); + }); + + tagsContainer.appendChild(newTag); + + // Clear and hide the input form + const triggerWordInput = document.querySelector('.new-trigger-word-input'); + triggerWordInput.value = ''; + document.querySelector('.add-trigger-word-form').style.display = 'none'; +} + +/** + * 保存触发词 + */ +async function saveTriggerWords() { + const filePath = document.querySelector('.edit-trigger-words-btn').dataset.filePath; + const triggerWordTags = document.querySelectorAll('.trigger-word-tag'); + const words = Array.from(triggerWordTags).map(tag => tag.dataset.word); + + try { + // Special format for updating nested civitai.trainedWords + await saveModelMetadata(filePath, { + civitai: { trainedWords: words } + }); + + // Update UI + const editBtn = document.querySelector('.edit-trigger-words-btn'); + editBtn.click(); // Exit edit mode + + // Update the LoRA card's dataset + const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + if (loraCard) { + try { + // Create a proper structure for civitai data + let civitaiData = {}; + + // Parse existing data if available + if (loraCard.dataset.meta) { + civitaiData = JSON.parse(loraCard.dataset.meta); + } + + // Update trainedWords property + civitaiData.trainedWords = words; + + // Update the meta dataset attribute with the full civitai data + loraCard.dataset.meta = JSON.stringify(civitaiData); + } catch (e) { + console.error('Error updating civitai data:', e); + } + } + + // If we saved an empty array and there's a no-trigger-words element, show it + const noTriggerWords = document.querySelector('.no-trigger-words'); + const tagsContainer = document.querySelector('.trigger-words-tags'); + if (words.length === 0 && noTriggerWords) { + noTriggerWords.style.display = ''; + if (tagsContainer) tagsContainer.style.display = 'none'; + } + + showToast('Trigger words updated successfully', 'success'); + } catch (error) { + console.error('Error saving trigger words:', error); + showToast('Failed to update trigger words', 'error'); + } +} + +/** + * 复制触发词到剪贴板 + * @param {string} word - 要复制的触发词 + */ +window.copyTriggerWord = async function(word) { + try { + await navigator.clipboard.writeText(word); + showToast('Trigger word copied', 'success'); + } catch (err) { + console.error('Copy failed:', err); + showToast('Copy failed', 'error'); + } +}; \ No newline at end of file diff --git a/static/js/components/loraModal/index.js b/static/js/components/loraModal/index.js new file mode 100644 index 00000000..051113fe --- /dev/null +++ b/static/js/components/loraModal/index.js @@ -0,0 +1,291 @@ +/** + * LoraModal - 主入口点 + * + * 将原始的LoraModal.js拆分成多个功能模块后的主入口文件 + */ +import { showToast } from '../../utils/uiHelpers.js'; +import { state } from '../../state/index.js'; +import { modalManager } from '../../managers/ModalManager.js'; +import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js'; +import { setupTabSwitching, loadModelDescription } from './ModelDescription.js'; +import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js'; +import { parsePresets, renderPresetTags } from './PresetTags.js'; +import { + setupModelNameEditing, + setupBaseModelEditing, + setupFileNameEditing, + saveModelMetadata +} from './ModelMetadata.js'; +import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js'; + +/** + * 显示LoRA模型弹窗 + * @param {Object} lora - LoRA模型数据 + */ +export function showLoraModal(lora) { + const escapedWords = lora.civitai?.trainedWords?.length ? + lora.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : []; + + const content = ` + + `; + + modalManager.showModal('loraModal', content); + setupEditableFields(); + setupShowcaseScroll(); + setupTabSwitching(); + setupTagTooltip(); + setupTriggerWordsEditMode(); + setupModelNameEditing(); + setupBaseModelEditing(); + setupFileNameEditing(); + + // If we have a model ID but no description, fetch it + if (lora.civitai?.modelId && !lora.modelDescription) { + loadModelDescription(lora.civitai.modelId, lora.file_path); + } +} + +// Copy file name function +window.copyFileName = async function(fileName) { + try { + await navigator.clipboard.writeText(fileName); + showToast('File name copied', 'success'); + } catch (err) { + console.error('Copy failed:', err); + showToast('Copy failed', 'error'); + } +}; + +// Add save note function +window.saveNotes = async function(filePath) { + const content = document.querySelector('.notes-content').textContent; + try { + await saveModelMetadata(filePath, { notes: content }); + + // Update the corresponding lora card's dataset + const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + if (loraCard) { + loraCard.dataset.notes = content; + } + + showToast('Notes saved successfully', 'success'); + } catch (error) { + showToast('Failed to save notes', 'error'); + } +}; + +function setupEditableFields() { + 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...'; + } + } + }); + }); + + 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'); + + 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 filePath = document.querySelector('#loraModal .modal-content') + .querySelector('.file-path').textContent + + document.querySelector('#loraModal .modal-content') + .querySelector('#file-name').textContent + '.safetensors'; + + const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + const currentPresets = parsePresets(loraCard.dataset.usage_tips); + + currentPresets[key] = parseFloat(value); + const newPresetsJson = JSON.stringify(currentPresets); + + await saveModelMetadata(filePath, { + usage_tips: newPresetsJson + }); + + loraCard.dataset.usage_tips = newPresetsJson; + presetTags.innerHTML = renderPresetTags(currentPresets); + + presetSelector.value = ''; + presetValue.value = ''; + presetValue.style.display = 'none'; + }); + + // 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(); + const filePath = document.querySelector('#loraModal .modal-content') + .querySelector('.file-path').textContent + + document.querySelector('#loraModal .modal-content') + .querySelector('#file-name').textContent + '.safetensors'; + await saveNotes(filePath); + } + }); + } + + // Add keydown event for preset value + presetValue.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + addPresetBtn.click(); + } + }); +} + +// Export functions for global access +export { toggleShowcase, scrollToTop }; \ No newline at end of file diff --git a/static/js/components/loraModal/utils.js b/static/js/components/loraModal/utils.js new file mode 100644 index 00000000..d2f5abca --- /dev/null +++ b/static/js/components/loraModal/utils.js @@ -0,0 +1,73 @@ +/** + * utils.js + * LoraModal组件的辅助函数集合 + */ +import { showToast } from '../../utils/uiHelpers.js'; + +/** + * 格式化文件大小 + * @param {number} bytes - 字节数 + * @returns {string} 格式化后的文件大小 + */ +export function formatFileSize(bytes) { + if (!bytes) return 'N/A'; + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(1)} ${units[unitIndex]}`; +} + +/** + * 渲染紧凑标签 + * @param {Array} tags - 标签数组 + * @returns {string} HTML内容 + */ +export function renderCompactTags(tags) { + if (!tags || tags.length === 0) return ''; + + // Display up to 5 tags, with a tooltip indicator if there are more + const visibleTags = tags.slice(0, 5); + const remainingCount = Math.max(0, tags.length - 5); + + return ` +
+
+ ${visibleTags.map(tag => `${tag}`).join('')} + ${remainingCount > 0 ? + `+${remainingCount}` : + ''} +
+ ${tags.length > 0 ? + `
+
+ ${tags.map(tag => `${tag}`).join('')} +
+
` : + ''} +
+ `; +} + +/** + * 设置标签提示功能 + */ +export function setupTagTooltip() { + const tagsContainer = document.querySelector('.model-tags-container'); + const tooltip = document.querySelector('.model-tags-tooltip'); + + if (tagsContainer && tooltip) { + tagsContainer.addEventListener('mouseenter', () => { + tooltip.classList.add('visible'); + }); + + tagsContainer.addEventListener('mouseleave', () => { + tooltip.classList.remove('visible'); + }); + } +} \ No newline at end of file diff --git a/static/js/loras.js b/static/js/loras.js index ba89049b..74a33ce5 100644 --- a/static/js/loras.js +++ b/static/js/loras.js @@ -1,6 +1,6 @@ import { appCore } from './core.js'; import { state } from './state/index.js'; -import { showLoraModal, toggleShowcase, scrollToTop } from './components/LoraModal.js'; +import { showLoraModal, toggleShowcase, scrollToTop } from './components/loraModal/index.js'; import { loadMoreLoras, fetchCivitai, deleteModel, replacePreview, resetAndReload, refreshLoras } from './api/loraApi.js'; import { restoreFolderFilter,