feat: Move LoRA related components to shared directory for consistency

- Added PresetTags.js to handle LoRA model preset parameter tags.
- Introduced RecipeTab.js for managing recipes associated with LoRA models.
- Created TriggerWords.js to manage trigger word functionality for LoRA models.
- Implemented utility functions in utils.js for general model modal operations.
This commit is contained in:
Will Miao
2025-07-22 16:00:04 +08:00
parent 67b403f8ca
commit fcfc868e57
26 changed files with 1240 additions and 2673 deletions

View File

@@ -1,334 +1,14 @@
import { showToast, copyToClipboard, openExampleImagesFolder, openCivitai } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { showCheckpointModal } from './checkpointModal/index.js';
import { NSFW_LEVELS } from '../utils/constants.js';
import { replaceCheckpointPreview as apiReplaceCheckpointPreview, saveModelMetadata } from '../api/checkpointApi.js';
import { showDeleteModal } from '../utils/modalUtils.js';
// Add a global event delegation handler
export function setupCheckpointCardEventDelegation() {
const gridElement = document.getElementById('checkpointGrid');
if (!gridElement) return;
// Remove any existing event listener to prevent duplication
gridElement.removeEventListener('click', handleCheckpointCardEvent);
// Add the event delegation handler
gridElement.addEventListener('click', handleCheckpointCardEvent);
}
// Event delegation handler for all checkpoint card events
function handleCheckpointCardEvent(event) {
// Find the closest card element
const card = event.target.closest('.lora-card');
if (!card) return;
// Handle specific elements within the card
if (event.target.closest('.toggle-blur-btn')) {
event.stopPropagation();
toggleBlurContent(card);
return;
}
if (event.target.closest('.show-content-btn')) {
event.stopPropagation();
showBlurredContent(card);
return;
}
if (event.target.closest('.fa-star')) {
event.stopPropagation();
toggleFavorite(card);
return;
}
if (event.target.closest('.fa-globe')) {
event.stopPropagation();
if (card.dataset.from_civitai === 'true') {
openCivitai(card.dataset.filepath);
}
return;
}
if (event.target.closest('.fa-copy')) {
event.stopPropagation();
copyCheckpointName(card);
return;
}
if (event.target.closest('.fa-trash')) {
event.stopPropagation();
showDeleteModal(card.dataset.filepath);
return;
}
if (event.target.closest('.fa-image')) {
event.stopPropagation();
replaceCheckpointPreview(card.dataset.filepath);
return;
}
if (event.target.closest('.fa-folder-open')) {
event.stopPropagation();
openExampleImagesFolder(card.dataset.sha256);
return;
}
// If no specific element was clicked, handle the card click (show modal)
showCheckpointModalFromCard(card);
}
// Helper functions for event handling
function toggleBlurContent(card) {
const preview = card.querySelector('.card-preview');
const isBlurred = preview.classList.toggle('blurred');
const icon = card.querySelector('.toggle-blur-btn 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 = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = isBlurred ? 'flex' : 'none';
}
}
function showBlurredContent(card) {
const preview = card.querySelector('.card-preview');
preview.classList.remove('blurred');
// Update the toggle button icon
const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
// Hide the overlay
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = 'none';
}
}
async function toggleFavorite(card) {
const starIcon = card.querySelector('.fa-star');
const isFavorite = starIcon.classList.contains('fas');
const newFavoriteState = !isFavorite;
try {
// Save the new favorite state to the server
await saveModelMetadata(card.dataset.filepath, {
favorite: newFavoriteState
});
if (newFavoriteState) {
showToast('Added to favorites', 'success');
} else {
showToast('Removed from favorites', 'success');
}
} catch (error) {
console.error('Failed to update favorite status:', error);
showToast('Failed to update favorite status', 'error');
}
}
async function copyCheckpointName(card) {
const checkpointName = card.dataset.file_name;
try {
await copyToClipboard(checkpointName, 'Checkpoint name copied');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
}
function showCheckpointModalFromCard(card) {
// Get the page-specific previewVersions map
const previewVersions = state.pages.checkpoints.previewVersions || new Map();
const version = previewVersions.get(card.dataset.filepath);
const previewUrl = card.dataset.preview_url || '/loras_static/images/no-preview.png';
const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl;
// Show checkpoint details modal
const checkpointMeta = {
sha256: card.dataset.sha256,
file_path: card.dataset.filepath,
model_name: card.dataset.name,
file_name: card.dataset.file_name,
folder: card.dataset.folder,
modified: card.dataset.modified,
file_size: parseInt(card.dataset.file_size || '0'),
from_civitai: card.dataset.from_civitai === 'true',
base_model: card.dataset.base_model,
notes: card.dataset.notes || '',
preview_url: versionedPreviewUrl,
// Parse civitai metadata from the card's dataset
civitai: (() => {
try {
return JSON.parse(card.dataset.meta || '{}');
} catch (e) {
console.error('Failed to parse civitai metadata:', e);
return {}; // Return empty object on error
}
})(),
tags: (() => {
try {
return JSON.parse(card.dataset.tags || '[]');
} catch (e) {
console.error('Failed to parse tags:', e);
return []; // Return empty array on error
}
})(),
modelDescription: card.dataset.modelDescription || ''
};
showCheckpointModal(checkpointMeta);
}
function replaceCheckpointPreview(filePath) {
if (window.replaceCheckpointPreview) {
window.replaceCheckpointPreview(filePath);
} else {
apiReplaceCheckpointPreview(filePath);
}
}
// Legacy CheckpointCard.js - now using shared ModelCard component
import {
createModelCard,
setupModelCardEventDelegation
} from './shared/ModelCard.js';
// Re-export functions with original names for backwards compatibility
export function createCheckpointCard(checkpoint) {
const card = document.createElement('div');
card.className = 'lora-card'; // Reuse the same class for styling
card.dataset.sha256 = checkpoint.sha256;
card.dataset.filepath = checkpoint.file_path;
card.dataset.name = checkpoint.model_name;
card.dataset.file_name = checkpoint.file_name;
card.dataset.folder = checkpoint.folder;
card.dataset.modified = checkpoint.modified;
card.dataset.file_size = checkpoint.file_size;
card.dataset.from_civitai = checkpoint.from_civitai;
card.dataset.notes = checkpoint.notes || '';
card.dataset.base_model = checkpoint.base_model || 'Unknown';
card.dataset.favorite = checkpoint.favorite ? 'true' : 'false';
return createModelCard(checkpoint, 'checkpoint');
}
// Store metadata if available
if (checkpoint.civitai) {
card.dataset.meta = JSON.stringify(checkpoint.civitai || {});
}
// Store tags if available
if (checkpoint.tags && Array.isArray(checkpoint.tags)) {
card.dataset.tags = JSON.stringify(checkpoint.tags);
}
if (checkpoint.modelDescription) {
card.dataset.modelDescription = checkpoint.modelDescription;
}
// Store NSFW level if available
const nsfwLevel = checkpoint.preview_nsfw_level !== undefined ? checkpoint.preview_nsfw_level : 0;
card.dataset.nsfwLevel = nsfwLevel;
// Determine if the preview should be blurred based on NSFW level and user settings
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
if (shouldBlur) {
card.classList.add('nsfw-content');
}
// Determine preview URL
const previewUrl = checkpoint.preview_url || '/loras_static/images/no-preview.png';
// Get the page-specific previewVersions map
const previewVersions = state.pages.checkpoints.previewVersions || new Map();
const version = previewVersions.get(checkpoint.file_path);
const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl;
// Determine NSFW warning text based on level
let nsfwText = "Mature Content";
if (nsfwLevel >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
// Check if autoplayOnHover is enabled for video previews
const autoplayOnHover = state.global?.settings?.autoplayOnHover || false;
const isVideo = previewUrl.endsWith('.mp4');
const videoAttrs = autoplayOnHover ? 'controls muted loop' : 'controls autoplay muted loop';
// Get favorite status from checkpoint data
const isFavorite = checkpoint.favorite === true;
card.innerHTML = `
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ?
`<video ${videoAttrs}>
<source src="${versionedPreviewUrl}" type="video/mp4">
</video>` :
`<img src="${versionedPreviewUrl}" alt="${checkpoint.model_name}">`
}
<div class="card-header">
${shouldBlur ?
`<button class="toggle-blur-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>` : ''}
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${checkpoint.base_model}">
${checkpoint.base_model}
</span>
<div class="card-actions">
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}"
title="${isFavorite ? 'Remove from favorites' : 'Add to favorites'}">
</i>
<i class="fas fa-globe"
title="${checkpoint.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}"
${!checkpoint.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
</i>
<i class="fas fa-copy"
title="Copy Checkpoint Name">
</i>
<i class="fas fa-trash"
title="Delete Model">
</i>
</div>
</div>
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
</div>
</div>
` : ''}
<div class="card-footer">
<div class="model-info">
<span class="model-name">${checkpoint.model_name}</span>
</div>
<div class="card-actions">
<i class="fas fa-folder-open"
title="Open Example Images Folder">
</i>
</div>
</div>
</div>
`;
// Add video auto-play on hover functionality if needed
const videoElement = card.querySelector('video');
if (videoElement && autoplayOnHover) {
const cardPreview = card.querySelector('.card-preview');
// Remove autoplay attribute and pause initially
videoElement.removeAttribute('autoplay');
videoElement.pause();
// Add mouse events to trigger play/pause using event attributes
cardPreview.setAttribute('onmouseenter', 'this.querySelector("video")?.play()');
cardPreview.setAttribute('onmouseleave', 'const v=this.querySelector("video"); if(v){v.pause();v.currentTime=0;}');
}
return card;
export function setupCheckpointCardEventDelegation() {
setupModelCardEventDelegation('checkpoint');
}

View File

@@ -1,508 +1,17 @@
import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../state/index.js';
import { showLoraModal } from './loraModal/index.js';
import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS } from '../utils/constants.js';
import { replacePreview, saveModelMetadata } from '../api/loraApi.js'
// Add a global event delegation handler
export function setupLoraCardEventDelegation() {
const gridElement = document.getElementById('loraGrid');
if (!gridElement) return;
// Remove any existing event listener to prevent duplication
gridElement.removeEventListener('click', handleLoraCardEvent);
// Add the event delegation handler
gridElement.addEventListener('click', handleLoraCardEvent);
}
// Event delegation handler for all lora card events
function handleLoraCardEvent(event) {
// Find the closest card element
const card = event.target.closest('.lora-card');
if (!card) return;
// Handle specific elements within the card
if (event.target.closest('.toggle-blur-btn')) {
event.stopPropagation();
toggleBlurContent(card);
return;
}
if (event.target.closest('.show-content-btn')) {
event.stopPropagation();
showBlurredContent(card);
return;
}
if (event.target.closest('.fa-star')) {
event.stopPropagation();
toggleFavorite(card);
return;
}
if (event.target.closest('.fa-globe')) {
event.stopPropagation();
if (card.dataset.from_civitai === 'true') {
openCivitai(card.dataset.filepath);
}
return;
}
if (event.target.closest('.fa-paper-plane')) {
event.stopPropagation();
sendLoraToComfyUI(card, event.shiftKey);
return;
}
if (event.target.closest('.fa-copy')) {
event.stopPropagation();
copyLoraSyntax(card);
return;
}
if (event.target.closest('.fa-image')) {
event.stopPropagation();
replacePreview(card.dataset.filepath);
return;
}
if (event.target.closest('.fa-folder-open')) {
event.stopPropagation();
handleExampleImagesAccess(card);
return;
}
// If no specific element was clicked, handle the card click (show modal or toggle selection)
const pageState = getCurrentPageState();
if (state.bulkMode) {
// Toggle selection using the bulk manager
bulkManager.toggleCardSelection(card);
} else if (pageState && pageState.duplicatesMode) {
// In duplicates mode, don't open modal when clicking cards
return;
} else {
// Normal behavior - show modal
const loraMeta = {
sha256: card.dataset.sha256,
file_path: card.dataset.filepath,
model_name: card.dataset.name,
file_name: card.dataset.file_name,
folder: card.dataset.folder,
modified: card.dataset.modified,
file_size: card.dataset.file_size,
from_civitai: card.dataset.from_civitai === 'true',
base_model: card.dataset.base_model,
usage_tips: card.dataset.usage_tips,
notes: card.dataset.notes,
favorite: card.dataset.favorite === 'true',
// Parse civitai metadata from the card's dataset
civitai: (() => {
try {
// Attempt to parse the JSON string
return JSON.parse(card.dataset.meta || '{}');
} catch (e) {
console.error('Failed to parse civitai metadata:', e);
return {}; // Return empty object on error
}
})(),
tags: JSON.parse(card.dataset.tags || '[]'),
modelDescription: card.dataset.modelDescription || ''
};
showLoraModal(loraMeta);
}
}
// Helper functions for event handling
function toggleBlurContent(card) {
const preview = card.querySelector('.card-preview');
const isBlurred = preview.classList.toggle('blurred');
const icon = card.querySelector('.toggle-blur-btn 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 = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = isBlurred ? 'flex' : 'none';
}
}
function showBlurredContent(card) {
const preview = card.querySelector('.card-preview');
preview.classList.remove('blurred');
// Update the toggle button icon
const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
// Hide the overlay
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = 'none';
}
}
async function toggleFavorite(card) {
const starIcon = card.querySelector('.fa-star');
const isFavorite = starIcon.classList.contains('fas');
const newFavoriteState = !isFavorite;
try {
// Save the new favorite state to the server
await saveModelMetadata(card.dataset.filepath, {
favorite: newFavoriteState
});
if (newFavoriteState) {
showToast('Added to favorites', 'success');
} else {
showToast('Removed from favorites', 'success');
}
} catch (error) {
console.error('Failed to update favorite status:', error);
showToast('Failed to update favorite status', 'error');
}
}
// Function to send LoRA to ComfyUI workflow
async function sendLoraToComfyUI(card, replaceMode) {
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
const strength = usageTips.strength || 1;
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
}
// Add function to copy lora syntax
function copyLoraSyntax(card) {
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
const strength = usageTips.strength || 1;
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard');
}
// New function to handle example images access
async function handleExampleImagesAccess(card) {
const modelHash = card.dataset.sha256;
try {
// Check if example images exist
const response = await fetch(`/api/has-example-images?model_hash=${modelHash}`);
const data = await response.json();
if (data.has_images) {
// If images exist, open the folder directly (existing behavior)
openExampleImagesFolder(modelHash);
} else {
// If no images exist, show the new modal
showExampleAccessModal(card);
}
} catch (error) {
console.error('Error checking for example images:', error);
showToast('Error checking for example images', 'error');
}
}
// Function to show the example access modal
function showExampleAccessModal(card) {
const modal = document.getElementById('exampleAccessModal');
if (!modal) return;
// Get download button and determine if download should be enabled
const downloadBtn = modal.querySelector('#downloadExamplesBtn');
let hasRemoteExamples = false;
try {
const metaData = JSON.parse(card.dataset.meta || '{}');
hasRemoteExamples = metaData.images &&
Array.isArray(metaData.images) &&
metaData.images.length > 0 &&
metaData.images[0].url;
} catch (e) {
console.error('Error parsing meta data:', e);
}
// Enable or disable download button
if (downloadBtn) {
if (hasRemoteExamples) {
downloadBtn.classList.remove('disabled');
downloadBtn.removeAttribute('title'); // Remove any previous tooltip
downloadBtn.onclick = () => {
modalManager.closeModal('exampleAccessModal');
// Open settings modal and scroll to example images section
const settingsModal = document.getElementById('settingsModal');
if (settingsModal) {
modalManager.showModal('settingsModal');
// Scroll to example images section after modal is visible
setTimeout(() => {
const exampleSection = settingsModal.querySelector('.settings-section:nth-child(5)'); // Example Images section
if (exampleSection) {
exampleSection.scrollIntoView({ behavior: 'smooth' });
}
}, 300);
}
};
} else {
downloadBtn.classList.add('disabled');
downloadBtn.setAttribute('title', 'No remote example images available for this model on Civitai');
downloadBtn.onclick = null;
}
}
// Set up import button
const importBtn = modal.querySelector('#importExamplesBtn');
if (importBtn) {
importBtn.onclick = () => {
modalManager.closeModal('exampleAccessModal');
// Get the lora data from card dataset
const loraMeta = {
sha256: card.dataset.sha256,
file_path: card.dataset.filepath,
model_name: card.dataset.name,
file_name: card.dataset.file_name,
// Other properties needed for showLoraModal
folder: card.dataset.folder,
modified: card.dataset.modified,
file_size: card.dataset.file_size,
from_civitai: card.dataset.from_civitai === 'true',
base_model: card.dataset.base_model,
usage_tips: card.dataset.usage_tips,
notes: card.dataset.notes,
favorite: card.dataset.favorite === 'true',
civitai: (() => {
try {
return JSON.parse(card.dataset.meta || '{}');
} catch (e) {
return {};
}
})(),
tags: JSON.parse(card.dataset.tags || '[]'),
modelDescription: card.dataset.modelDescription || ''
};
// Show the lora modal
showLoraModal(loraMeta);
// Scroll to import area after modal is visible
setTimeout(() => {
const importArea = document.querySelector('.example-import-area');
if (importArea) {
const showcaseTab = document.getElementById('showcase-tab');
if (showcaseTab) {
// First make sure showcase tab is visible
const tabBtn = document.querySelector('.tab-btn[data-tab="showcase"]');
if (tabBtn && !tabBtn.classList.contains('active')) {
tabBtn.click();
}
// Then toggle showcase if collapsed
const carousel = showcaseTab.querySelector('.carousel');
if (carousel && carousel.classList.contains('collapsed')) {
const scrollIndicator = showcaseTab.querySelector('.scroll-indicator');
if (scrollIndicator) {
scrollIndicator.click();
}
}
// Finally scroll to the import area
importArea.scrollIntoView({ behavior: 'smooth' });
}
}
}, 500);
};
}
// Show the modal
modalManager.showModal('exampleAccessModal');
}
// Legacy LoraCard.js - now using shared ModelCard component
import {
createModelCard,
setupModelCardEventDelegation,
updateCardsForBulkMode
} from './shared/ModelCard.js';
// Re-export functions with original names for backwards compatibility
export function createLoraCard(lora) {
const card = document.createElement('div');
card.className = 'lora-card';
card.dataset.sha256 = lora.sha256;
card.dataset.filepath = lora.file_path;
card.dataset.name = lora.model_name;
card.dataset.file_name = lora.file_name;
card.dataset.folder = lora.folder;
card.dataset.modified = lora.modified;
card.dataset.file_size = lora.file_size;
card.dataset.from_civitai = lora.from_civitai;
card.dataset.base_model = lora.base_model;
card.dataset.usage_tips = lora.usage_tips;
card.dataset.notes = lora.notes;
card.dataset.meta = JSON.stringify(lora.civitai || {});
card.dataset.favorite = lora.favorite ? 'true' : 'false';
// Store tags and model description
if (lora.tags && Array.isArray(lora.tags)) {
card.dataset.tags = JSON.stringify(lora.tags);
}
if (lora.modelDescription) {
card.dataset.modelDescription = lora.modelDescription;
}
// Store NSFW level if available
const nsfwLevel = lora.preview_nsfw_level !== undefined ? lora.preview_nsfw_level : 0;
card.dataset.nsfwLevel = nsfwLevel;
// Determine if the preview should be blurred based on NSFW level and user settings
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
if (shouldBlur) {
card.classList.add('nsfw-content');
}
// Apply selection state if in bulk mode and this card is in the selected set
if (state.bulkMode && state.selectedLoras.has(lora.file_path)) {
card.classList.add('selected');
}
// Get the page-specific previewVersions map
const previewVersions = state.pages.loras.previewVersions || new Map();
const version = previewVersions.get(lora.file_path);
const previewUrl = lora.preview_url || '/loras_static/images/no-preview.png';
const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl;
// Determine NSFW warning text based on level
let nsfwText = "Mature Content";
if (nsfwLevel >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
// Check if autoplayOnHover is enabled for video previews
const autoplayOnHover = state.global.settings.autoplayOnHover || false;
const isVideo = previewUrl.endsWith('.mp4');
const videoAttrs = autoplayOnHover ? 'controls muted loop' : 'controls autoplay muted loop';
// Get favorite status from the lora data
const isFavorite = lora.favorite === true;
card.innerHTML = `
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ?
`<video ${videoAttrs}>
<source src="${versionedPreviewUrl}" type="video/mp4">
</video>` :
`<img src="${versionedPreviewUrl}" alt="${lora.model_name}">`
}
<div class="card-header">
${shouldBlur ?
`<button class="toggle-blur-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>` : ''}
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${lora.base_model}">
${lora.base_model}
</span>
<div class="card-actions">
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}"
title="${isFavorite ? 'Remove from favorites' : 'Add to favorites'}">
</i>
<i class="fas fa-globe"
title="${lora.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}"
${!lora.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
</i>
<i class="fas fa-paper-plane"
title="Send to ComfyUI (Click: Append, Shift+Click: Replace)">
</i>
<i class="fas fa-copy"
title="Copy LoRA Syntax">
</i>
</div>
</div>
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
</div>
</div>
` : ''}
<div class="card-footer">
<div class="model-info">
<span class="model-name">${lora.model_name}</span>
</div>
<div class="card-actions">
<i class="fas fa-folder-open"
title="Open Example Images Folder">
</i>
</div>
</div>
</div>
`;
// Add a special class for virtual scroll positioning if needed
if (state.virtualScroller) {
card.classList.add('virtual-scroll-item');
}
// Add video auto-play on hover functionality if needed
const videoElement = card.querySelector('video');
if (videoElement && autoplayOnHover) {
const cardPreview = card.querySelector('.card-preview');
// Remove autoplay attribute and pause initially
videoElement.removeAttribute('autoplay');
videoElement.pause();
// Add mouse events to trigger play/pause using event attributes
// This approach reduces the number of event listeners created
cardPreview.setAttribute('onmouseenter', 'this.querySelector("video")?.play()');
cardPreview.setAttribute('onmouseleave', 'const v=this.querySelector("video"); if(v){v.pause();v.currentTime=0;}');
}
return card;
return createModelCard(lora, 'lora');
}
// Add a method to update card appearance based on bulk mode
export function updateCardsForBulkMode(isBulkMode) {
// Update the state
state.bulkMode = isBulkMode;
document.body.classList.toggle('bulk-mode', isBulkMode);
// Get all lora cards - this can now be from the DOM or through the virtual scroller
const loraCards = document.querySelectorAll('.lora-card');
loraCards.forEach(card => {
// Get all action containers for this card
const actions = card.querySelectorAll('.card-actions');
// Handle display property based on mode
if (isBulkMode) {
// Hide actions when entering bulk mode
actions.forEach(actionGroup => {
actionGroup.style.display = 'none';
});
} else {
// Ensure actions are visible when exiting bulk mode
actions.forEach(actionGroup => {
// We need to reset to default display style which is flex
actionGroup.style.display = 'flex';
});
}
});
// If using virtual scroller, we need to rerender after toggling bulk mode
if (state.virtualScroller && typeof state.virtualScroller.scheduleRender === 'function') {
state.virtualScroller.scheduleRender();
}
// Apply selection state to cards if entering bulk mode
if (isBulkMode) {
bulkManager.applySelectionState();
}
}
export function setupLoraCardEventDelegation() {
setupModelCardEventDelegation('lora');
}
export { updateCardsForBulkMode };

View File

@@ -184,7 +184,7 @@ export class ModelDuplicatesManager {
document.body.classList.remove('duplicate-mode');
// Clear the model grid first
const modelGrid = document.getElementById(this.modelType === 'loras' ? 'loraGrid' : 'checkpointGrid');
const modelGrid = document.getElementById('modelGrid');
if (modelGrid) {
modelGrid.innerHTML = '';
}
@@ -241,7 +241,7 @@ export class ModelDuplicatesManager {
}
renderDuplicateGroups() {
const modelGrid = document.getElementById(this.modelType === 'loras' ? 'loraGrid' : 'checkpointGrid');
const modelGrid = document.getElementById('modelGrid');
if (!modelGrid) return;
// Clear existing content

View File

@@ -1,436 +0,0 @@
/**
* ModelMetadata.js
* Handles checkpoint model metadata editing functionality
*/
import { showToast } from '../../utils/uiHelpers.js';
import { BASE_MODELS } from '../../utils/constants.js';
import { state } from '../../state/index.js';
import { saveModelMetadata, renameCheckpointFile } from '../../api/checkpointApi.js';
/**
* Set up model name editing functionality
* @param {string} filePath - The full file path of the model.
*/
export function setupModelNameEditing(filePath) {
const modelNameContent = document.querySelector('.model-name-content');
const editBtn = document.querySelector('.edit-model-name-btn');
if (!modelNameContent || !editBtn) return;
// Store the file path in a data attribute for later use
modelNameContent.dataset.filePath = filePath;
// Show edit button on hover
const modelNameHeader = document.querySelector('.model-name-header');
modelNameHeader.addEventListener('mouseenter', () => {
editBtn.classList.add('visible');
});
modelNameHeader.addEventListener('mouseleave', () => {
if (!modelNameHeader.classList.contains('editing')) {
editBtn.classList.remove('visible');
}
});
// Handle edit button click
editBtn.addEventListener('click', () => {
modelNameHeader.classList.add('editing');
modelNameContent.setAttribute('contenteditable', 'true');
// Store original value for comparison later
modelNameContent.dataset.originalValue = modelNameContent.textContent.trim();
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 keyboard events in edit mode
modelNameContent.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();
}
});
// Limit model name length
modelNameContent.addEventListener('input', function() {
if (!this.getAttribute('contenteditable')) return;
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');
}
});
// Handle focus out - save changes
modelNameContent.addEventListener('blur', async function() {
if (!this.getAttribute('contenteditable')) return;
const newModelName = this.textContent.trim();
const originalValue = this.dataset.originalValue;
// Basic validation
if (!newModelName) {
// Restore original value if empty
this.textContent = originalValue;
showToast('Model name cannot be empty', 'error');
exitEditMode();
return;
}
if (newModelName === originalValue) {
// No changes, just exit edit mode
exitEditMode();
return;
}
try {
// Get the file path from the dataset
const filePath = this.dataset.filePath;
await saveModelMetadata(filePath, { model_name: newModelName });
showToast('Model name updated successfully', 'success');
} catch (error) {
console.error('Error updating model name:', error);
this.textContent = originalValue; // Restore original model name
showToast('Failed to update model name', 'error');
} finally {
exitEditMode();
}
});
function exitEditMode() {
modelNameContent.removeAttribute('contenteditable');
modelNameHeader.classList.remove('editing');
editBtn.classList.remove('visible');
}
}
/**
* Set up base model editing functionality
* @param {string} filePath - The full file path of the model.
*/
export function setupBaseModelEditing(filePath) {
const baseModelContent = document.querySelector('.base-model-content');
const editBtn = document.querySelector('.edit-base-model-btn');
if (!baseModelContent || !editBtn) return;
// Store the file path in a data attribute for later use
baseModelContent.dataset.filePath = filePath;
// 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.LTXV, BASE_MODELS.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO],
'Other Models': [
BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, 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.HIDREAM,
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) {
// Use the passed filePath for saving
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();
}
});
});
}
/**
* Save base model
* @param {string} filePath - File path
* @param {string} originalValue - Original value (for comparison)
*/
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 });
showToast('Base model updated successfully', 'success');
} catch (error) {
showToast('Failed to update base model', 'error');
}
}
/**
* Set up file name editing functionality
* @param {string} filePath - The full file path of the model.
*/
export function setupFileNameEditing(filePath) {
const fileNameContent = document.querySelector('.file-name-content');
const editBtn = document.querySelector('.edit-file-name-btn');
if (!fileNameContent || !editBtn) return;
// Store the original file path
fileNameContent.dataset.filePath = filePath;
// 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 {
// Use the passed filePath (which includes the original filename)
// Call API to rename the file using the new function from checkpointApi.js
const result = await renameCheckpointFile(filePath, newFileName);
if (result.success) {
showToast('File name updated successfully', 'success');
const newFilePath = filePath.replace(originalValue, newFileName);
state.virtualScroller.updateSingleItem(filePath, { file_name: newFileName, file_path: newFilePath });
this.textContent = newFileName;
} 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');
}
}

View File

@@ -1,471 +0,0 @@
/**
* ModelTags.js
* Module for handling checkpoint model tag editing functionality
*/
import { showToast } from '../../utils/uiHelpers.js';
import { saveModelMetadata } from '../../api/checkpointApi.js';
// Preset tag suggestions
const PRESET_TAGS = [
'character', 'style', 'concept', 'clothing', 'base model',
'poses', 'background', 'vehicle', 'buildings',
'objects', 'animal'
];
// Create a named function so we can remove it later
let saveTagsHandler = null;
/**
* Set up tag editing mode
*/
export function setupTagEditMode() {
const editBtn = document.querySelector('.edit-tags-btn');
if (!editBtn) return;
// Store original tags for restoring on cancel
let originalTags = [];
// Remove any previously attached click handler
if (editBtn._hasClickHandler) {
editBtn.removeEventListener('click', editBtn._clickHandler);
}
// Create new handler and store reference
const editBtnClickHandler = function() {
const tagsSection = document.querySelector('.model-tags-container');
const isEditMode = tagsSection.classList.toggle('edit-mode');
const filePath = this.dataset.filePath;
// Toggle edit mode UI elements
const compactTagsDisplay = tagsSection.querySelector('.model-tags-compact');
const tagsEditContainer = tagsSection.querySelector('.metadata-edit-container');
if (isEditMode) {
// Enter edit mode
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
this.title = "Cancel editing";
// Get all tags from tooltip, not just the visible ones in compact display
originalTags = Array.from(
tagsSection.querySelectorAll('.tooltip-tag')
).map(tag => tag.textContent);
// Hide compact display, show edit container
compactTagsDisplay.style.display = 'none';
// If edit container doesn't exist yet, create it
if (!tagsEditContainer) {
const editContainer = document.createElement('div');
editContainer.className = 'metadata-edit-container';
// Move the edit button inside the container header for better visibility
const editBtnClone = editBtn.cloneNode(true);
editBtnClone.classList.add('metadata-header-btn');
// Create edit UI with edit button in the header
editContainer.innerHTML = createTagEditUI(originalTags, editBtnClone.outerHTML);
tagsSection.appendChild(editContainer);
// Setup the tag input field behavior
setupTagInput();
// Create and add preset suggestions dropdown
const tagForm = editContainer.querySelector('.metadata-add-form');
const suggestionsDropdown = createSuggestionsDropdown(originalTags);
tagForm.appendChild(suggestionsDropdown);
// Setup delete buttons for existing tags
setupDeleteButtons();
// Transfer click event from original button to the cloned one
const newEditBtn = editContainer.querySelector('.metadata-header-btn');
if (newEditBtn) {
newEditBtn.addEventListener('click', function() {
editBtn.click();
});
}
// Hide the original button when in edit mode
editBtn.style.display = 'none';
} else {
// Just show the existing edit container
tagsEditContainer.style.display = 'block';
editBtn.style.display = 'none';
}
} else {
// Exit edit mode
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
this.title = "Edit tags";
editBtn.style.display = 'block';
// Show compact display, hide edit container
compactTagsDisplay.style.display = 'flex';
if (tagsEditContainer) tagsEditContainer.style.display = 'none';
// Check if we're exiting edit mode due to "Save" or "Cancel"
if (!this.dataset.skipRestore) {
// If canceling, restore original tags
restoreOriginalTags(tagsSection, originalTags);
} else {
// Reset the skip restore flag
delete this.dataset.skipRestore;
}
}
};
// Store the handler reference on the button itself
editBtn._clickHandler = editBtnClickHandler;
editBtn._hasClickHandler = true;
editBtn.addEventListener('click', editBtnClickHandler);
// Clean up any previous document click handler
if (saveTagsHandler) {
document.removeEventListener('click', saveTagsHandler);
}
// Create new save handler and store reference
saveTagsHandler = function(e) {
if (e.target.classList.contains('save-tags-btn') ||
e.target.closest('.save-tags-btn')) {
saveTags();
}
};
// Add the new handler
document.addEventListener('click', saveTagsHandler);
}
/**
* Create the tag editing UI
* @param {Array} currentTags - Current tags
* @param {string} editBtnHTML - HTML for the edit button to include in header
* @returns {string} HTML markup for tag editing UI
*/
function createTagEditUI(currentTags, editBtnHTML = '') {
return `
<div class="metadata-edit-content">
<div class="metadata-edit-header">
<label>Edit Tags</label>
${editBtnHTML}
</div>
<div class="metadata-items">
${currentTags.map(tag => `
<div class="metadata-item" data-tag="${tag}">
<span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn">
<i class="fas fa-times"></i>
</button>
</div>
`).join('')}
</div>
<div class="metadata-edit-controls">
<button class="save-tags-btn" title="Save changes">
<i class="fas fa-save"></i> Save
</button>
</div>
<div class="metadata-add-form">
<input type="text" class="metadata-input" placeholder="Type to add or click suggestions below">
</div>
</div>
`;
}
/**
* Create suggestions dropdown with preset tags
* @param {Array} existingTags - Already added tags
* @returns {HTMLElement} - Dropdown element
*/
function createSuggestionsDropdown(existingTags = []) {
const dropdown = document.createElement('div');
dropdown.className = 'metadata-suggestions-dropdown';
// Create header
const header = document.createElement('div');
header.className = 'metadata-suggestions-header';
header.innerHTML = `
<span>Suggested Tags</span>
<small>Click to add</small>
`;
dropdown.appendChild(header);
// Create tag container
const container = document.createElement('div');
container.className = 'metadata-suggestions-container';
// Add each preset tag as a suggestion
PRESET_TAGS.forEach(tag => {
const isAdded = existingTags.includes(tag);
const item = document.createElement('div');
item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
item.title = tag;
item.innerHTML = `
<span class="metadata-suggestion-text">${tag}</span>
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
`;
if (!isAdded) {
item.addEventListener('click', () => {
addNewTag(tag);
// Also populate the input field for potential editing
const input = document.querySelector('.metadata-input');
if (input) input.value = tag;
// Focus on the input
if (input) input.focus();
// Update dropdown without removing it
updateSuggestionsDropdown();
});
}
container.appendChild(item);
});
dropdown.appendChild(container);
return dropdown;
}
/**
* Set up tag input behavior
*/
function setupTagInput() {
const tagInput = document.querySelector('.metadata-input');
if (tagInput) {
tagInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addNewTag(this.value);
this.value = ''; // Clear input after adding
}
});
}
}
/**
* Set up delete buttons for tags
*/
function setupDeleteButtons() {
document.querySelectorAll('.metadata-delete-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const tag = this.closest('.metadata-item');
tag.remove();
// Update status of items in the suggestion dropdown
updateSuggestionsDropdown();
});
});
}
/**
* Add a new tag
* @param {string} tag - Tag to add
*/
function addNewTag(tag) {
tag = tag.trim().toLowerCase();
if (!tag) return;
const tagsContainer = document.querySelector('.metadata-items');
if (!tagsContainer) return;
// Validation: Check length
if (tag.length > 30) {
showToast('Tag should not exceed 30 characters', 'error');
return;
}
// Validation: Check total number
const currentTags = tagsContainer.querySelectorAll('.metadata-item');
if (currentTags.length >= 30) {
showToast('Maximum 30 tags allowed', 'error');
return;
}
// Validation: Check for duplicates
const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag);
if (existingTags.includes(tag)) {
showToast('This tag already exists', 'error');
return;
}
// Create new tag
const newTag = document.createElement('div');
newTag.className = 'metadata-item';
newTag.dataset.tag = tag;
newTag.innerHTML = `
<span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn">
<i class="fas fa-times"></i>
</button>
`;
// Add event listener to delete button
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
deleteBtn.addEventListener('click', function(e) {
e.stopPropagation();
newTag.remove();
// Update status of items in the suggestion dropdown
updateSuggestionsDropdown();
});
tagsContainer.appendChild(newTag);
// Update status of items in the suggestions dropdown
updateSuggestionsDropdown();
}
/**
* Update status of items in the suggestions dropdown
*/
function updateSuggestionsDropdown() {
const dropdown = document.querySelector('.metadata-suggestions-dropdown');
if (!dropdown) return;
// Get all current tags
const currentTags = document.querySelectorAll('.metadata-item');
const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag);
// Update status of each item in dropdown
dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => {
const tagText = item.querySelector('.metadata-suggestion-text').textContent;
const isAdded = existingTags.includes(tagText);
if (isAdded) {
item.classList.add('already-added');
// Add indicator if it doesn't exist
let indicator = item.querySelector('.added-indicator');
if (!indicator) {
indicator = document.createElement('span');
indicator.className = 'added-indicator';
indicator.innerHTML = '<i class="fas fa-check"></i>';
item.appendChild(indicator);
}
// Remove click event
item.onclick = null;
} else {
// Re-enable items that are no longer in the list
item.classList.remove('already-added');
// Remove indicator if it exists
const indicator = item.querySelector('.added-indicator');
if (indicator) indicator.remove();
// Restore click event if not already set
if (!item.onclick) {
item.onclick = () => {
const tag = item.querySelector('.metadata-suggestion-text').textContent;
addNewTag(tag);
// Also populate the input field
const input = document.querySelector('.metadata-input');
if (input) input.value = tag;
// Focus the input
if (input) input.focus();
};
}
}
});
}
/**
* Restore original tags when canceling edit
* @param {HTMLElement} section - The tags section
* @param {Array} originalTags - Original tags array
*/
function restoreOriginalTags(section, originalTags) {
// Nothing to do here as we're just hiding the edit UI
// and showing the original compact tags which weren't modified
}
/**
* Save tags
*/
async function saveTags() {
const editBtn = document.querySelector('.edit-tags-btn');
if (!editBtn) return;
const filePath = editBtn.dataset.filePath;
const tagElements = document.querySelectorAll('.metadata-item');
const tags = Array.from(tagElements).map(tag => tag.dataset.tag);
// Get original tags to compare
const originalTagElements = document.querySelectorAll('.tooltip-tag');
const originalTags = Array.from(originalTagElements).map(tag => tag.textContent);
// Check if tags have actually changed
const tagsChanged = JSON.stringify(tags) !== JSON.stringify(originalTags);
if (!tagsChanged) {
// No changes made, just exit edit mode without API call
editBtn.dataset.skipRestore = "true";
editBtn.click();
return;
}
try {
// Save tags metadata
await saveModelMetadata(filePath, { tags: tags });
// Set flag to skip restoring original tags when exiting edit mode
editBtn.dataset.skipRestore = "true";
// Update the compact tags display
const compactTagsContainer = document.querySelector('.model-tags-container');
if (compactTagsContainer) {
// Generate new compact tags HTML
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact');
if (compactTagsDisplay) {
// Clear current tags
compactTagsDisplay.innerHTML = '';
// Add visible tags (up to 5)
const visibleTags = tags.slice(0, 5);
visibleTags.forEach(tag => {
const span = document.createElement('span');
span.className = 'model-tag-compact';
span.textContent = tag;
compactTagsDisplay.appendChild(span);
});
// Add more indicator if needed
const remainingCount = Math.max(0, tags.length - 5);
if (remainingCount > 0) {
const more = document.createElement('span');
more.className = 'model-tag-more';
more.dataset.count = remainingCount;
more.textContent = `+${remainingCount}`;
compactTagsDisplay.appendChild(more);
}
}
// Update tooltip content
const tooltipContent = compactTagsContainer.querySelector('.tooltip-content');
if (tooltipContent) {
tooltipContent.innerHTML = '';
tags.forEach(tag => {
const span = document.createElement('span');
span.className = 'tooltip-tag';
span.textContent = tag;
tooltipContent.appendChild(span);
});
}
}
// Exit edit mode
editBtn.click();
showToast('Tags updated successfully', 'success');
} catch (error) {
console.error('Error saving tags:', error);
showToast('Failed to update tags', 'error');
}
}

View File

@@ -1,239 +1,11 @@
/**
* CheckpointModal - Main entry point
*
* Modularized checkpoint modal component that handles checkpoint model details display
* Legacy CheckpointModal - now using shared ModelModal component
*/
import { showToast } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import {
toggleShowcase,
setupShowcaseScroll,
scrollToTop,
loadExampleImages
} from '../shared/showcase/ShowcaseView.js';
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
import {
setupModelNameEditing,
setupBaseModelEditing,
setupFileNameEditing
} from './ModelMetadata.js';
import { setupTagEditMode } from './ModelTags.js'; // Add import for tag editing
import { saveModelMetadata } from '../../api/checkpointApi.js';
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
import { showModelModal } from '../shared/ModelModal.js';
/**
* Display the checkpoint modal with the given checkpoint data
* @param {Object} checkpoint - Checkpoint data object
*/
// Re-export function with original name for backwards compatibility
export function showCheckpointModal(checkpoint) {
const content = `
<div class="modal-content">
<button class="close" onclick="modalManager.closeModal('checkpointModal')">&times;</button>
<header class="modal-header">
<div class="model-name-header">
<h2 class="model-name-content">${checkpoint.model_name || 'Checkpoint Details'}</h2>
<button class="edit-model-name-btn" title="Edit model name">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
${checkpoint.civitai?.creator ? `
<div class="creator-info">
${checkpoint.civitai.creator.image ?
`<div class="creator-avatar">
<img src="${checkpoint.civitai.creator.image}" alt="${checkpoint.civitai.creator.username}" onerror="this.onerror=null; this.src='static/icons/user-placeholder.png';">
</div>` :
`<div class="creator-avatar creator-placeholder">
<i class="fas fa-user"></i>
</div>`
}
<span class="creator-username">${checkpoint.civitai.creator.username}</span>
</div>` : ''}
${renderCompactTags(checkpoint.tags || [], checkpoint.file_path)}
</header>
<div class="modal-body">
<div class="info-section">
<div class="info-grid">
<div class="info-item">
<label>Version</label>
<span>${checkpoint.civitai?.name || 'N/A'}</span>
</div>
<div class="info-item">
<label>File Name</label>
<div class="file-name-wrapper">
<span id="file-name" class="file-name-content">${checkpoint.file_name || 'N/A'}</span>
<button class="edit-file-name-btn" title="Edit file name">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div>
<div class="info-item location-size">
<div class="location-wrapper">
<label>Location</label>
<span class="file-path">${checkpoint.file_path.replace(/[^/]+$/, '')}</span>
</div>
</div>
<div class="info-item base-size">
<div class="base-wrapper">
<label>Base Model</label>
<div class="base-model-display">
<span class="base-model-content">${checkpoint.base_model || 'Unknown'}</span>
<button class="edit-base-model-btn" title="Edit base model">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div>
<div class="size-wrapper">
<label>Size</label>
<span>${formatFileSize(checkpoint.file_size)}</span>
</div>
</div>
<div class="info-item notes">
<label>Additional Notes</label>
<div class="editable-field">
<div class="notes-content" contenteditable="true" spellcheck="false">${checkpoint.notes || 'Add your notes here...'}</div>
<button class="save-btn" onclick="saveCheckpointNotes('${checkpoint.file_path}')">
<i class="fas fa-save"></i>
</button>
</div>
</div>
<div class="info-item full-width">
<label>About this version</label>
<div class="description-text">${checkpoint.civitai?.description || 'N/A'}</div>
</div>
</div>
</div>
<div class="showcase-section" data-model-hash="${checkpoint.sha256 || ''}" data-filepath="${checkpoint.file_path}">
<div class="showcase-tabs">
<button class="tab-btn active" data-tab="showcase">Examples</button>
<button class="tab-btn" data-tab="description">Model Description</button>
</div>
<div class="tab-content">
<div id="showcase-tab" class="tab-pane active">
<div class="recipes-loading">
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
</div>
</div>
<div id="description-tab" class="tab-pane">
<div class="model-description-container">
<div class="model-description-loading">
<i class="fas fa-spinner fa-spin"></i> Loading model description...
</div>
<div class="model-description-content">
${checkpoint.modelDescription || ''}
</div>
</div>
</div>
</div>
<button class="back-to-top" onclick="scrollToTopCheckpoint(this)">
<i class="fas fa-arrow-up"></i>
</button>
</div>
</div>
</div>
`;
modalManager.showModal('checkpointModal', content);
setupEditableFields(checkpoint.file_path);
setupShowcaseScroll('checkpointModal');
setupTabSwitching();
setupTagTooltip();
setupTagEditMode(); // Initialize tag editing functionality
setupModelNameEditing(checkpoint.file_path);
setupBaseModelEditing(checkpoint.file_path);
setupFileNameEditing(checkpoint.file_path);
// If we have a model ID but no description, fetch it
if (checkpoint.civitai?.modelId && !checkpoint.modelDescription) {
loadModelDescription(checkpoint.civitai.modelId, checkpoint.file_path);
}
// Load example images asynchronously - merge regular and custom images
const regularImages = checkpoint.civitai?.images || [];
const customImages = checkpoint.civitai?.customImages || [];
// Combine images - regular images first, then custom images
const allImages = [...regularImages, ...customImages];
loadExampleImages(allImages, checkpoint.sha256);
}
/**
* Set up editable fields in the checkpoint modal
* @param {string} filePath - The full file path of the model.
*/
function setupEditableFields(filePath) {
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(filePath);
}
});
}
}
/**
* Save checkpoint notes
* @param {string} filePath - Path to the checkpoint file
*/
async function saveNotes(filePath) {
const content = document.querySelector('.notes-content').textContent;
try {
await saveModelMetadata(filePath, { notes: content });
showToast('Notes saved successfully', 'success');
} catch (error) {
showToast('Failed to save notes', 'error');
}
}
// Export the checkpoint modal API
const checkpointModal = {
show: showCheckpointModal,
toggleShowcase,
scrollToTop
};
export { checkpointModal };
// Define global functions for use in HTML
window.toggleShowcase = function(element) {
toggleShowcase(element);
};
window.scrollToTopCheckpoint = function(button) {
scrollToTop(button);
};
window.saveCheckpointNotes = function(filePath) {
saveNotes(filePath);
};
return showModelModal(checkpoint, 'checkpoint');
}

View File

@@ -1,102 +0,0 @@
/**
* 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 = '<div class="no-description">No model description available</div>';
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 = `<div class="error-message">Failed to load model description. ${error.message}</div>`;
}
// Show empty state message in the description container
const descriptionContainer = document.querySelector('.model-description-content');
if (descriptionContainer) {
descriptionContainer.innerHTML = '<div class="no-description">No model description available</div>';
descriptionContainer.classList.remove('hidden');
}
}
}

View File

@@ -1,338 +1,7 @@
/**
* LoraModal - 主入口点
*
* 将原始的LoraModal.js拆分成多个功能模块后的主入口文件
*/
import { showToast } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import {
setupShowcaseScroll,
scrollToTop,
loadExampleImages
} from '../shared/showcase/ShowcaseView.js';
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
import { parsePresets, renderPresetTags } from './PresetTags.js';
import { loadRecipesForLora } from './RecipeTab.js';
import { setupTagEditMode } from './ModelTags.js'; // Add import for tag editing
import {
setupModelNameEditing,
setupBaseModelEditing,
setupFileNameEditing
} from './ModelMetadata.js';
import { saveModelMetadata } from '../../api/loraApi.js';
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
// Legacy LoraModal - now using shared ModelModal component
import { showModelModal } from '../shared/ModelModal.js';
/**
* 显示LoRA模型弹窗
* @param {Object} lora - LoRA模型数据
*/
// Re-export function with original name for backwards compatibility
export function showLoraModal(lora) {
const escapedWords = lora.civitai?.trainedWords?.length ?
lora.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
const content = `
<div class="modal-content">
<button class="close" onclick="modalManager.closeModal('loraModal')">&times;</button>
<header class="modal-header">
<div class="model-name-header">
<h2 class="model-name-content">${lora.model_name}</h2>
<button class="edit-model-name-btn" title="Edit model name">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
${lora.civitai?.creator ? `
<div class="creator-info">
${lora.civitai.creator.image ?
`<div class="creator-avatar">
<img src="${lora.civitai.creator.image}" alt="${lora.civitai.creator.username}" onerror="this.onerror=null; this.src='static/icons/user-placeholder.png';">
</div>` :
`<div class="creator-avatar creator-placeholder">
<i class="fas fa-user"></i>
</div>`
}
<span class="creator-username">${lora.civitai.creator.username}</span>
</div>` : ''}
${renderCompactTags(lora.tags || [], lora.file_path)}
</header>
<div class="modal-body">
<div class="info-section">
<div class="info-grid">
<div class="info-item">
<label>Version</label>
<span>${lora.civitai.name || 'N/A'}</span>
</div>
<div class="info-item">
<label>File Name</label>
<div class="file-name-wrapper">
<span id="file-name" class="file-name-content">${lora.file_name || 'N/A'}</span>
<button class="edit-file-name-btn" title="Edit file name">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div>
<div class="info-item location-size">
<div class="location-wrapper">
<label>Location</label>
<span class="file-path">${lora.file_path.replace(/[^/]+$/, '') || 'N/A'}</span>
</div>
</div>
<div class="info-item base-size">
<div class="base-wrapper">
<label>Base Model</label>
<div class="base-model-display">
<span class="base-model-content">${lora.base_model || 'N/A'}</span>
<button class="edit-base-model-btn" title="Edit base model">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div>
<div class="size-wrapper">
<label>Size</label>
<span>${formatFileSize(lora.file_size)}</span>
</div>
</div>
<div class="info-item usage-tips">
<label>Usage Tips</label>
<div class="editable-field">
<div class="preset-controls">
<select id="preset-selector">
<option value="">Add preset parameter...</option>
<option value="strength_min">Strength Min</option>
<option value="strength_max">Strength Max</option>
<option value="strength">Strength</option>
<option value="clip_skip">Clip Skip</option>
</select>
<input type="number" id="preset-value" step="0.01" placeholder="Value" style="display:none;">
<button class="add-preset-btn">Add</button>
</div>
<div class="preset-tags">
${renderPresetTags(parsePresets(lora.usage_tips))}
</div>
</div>
</div>
${renderTriggerWords(escapedWords, lora.file_path)}
<div class="info-item notes">
<label>Additional Notes <i class="fas fa-info-circle notes-hint" title="Press Enter to save, Shift+Enter for new line"></i></label>
<div class="editable-field">
<div class="notes-content" contenteditable="true" spellcheck="false">${lora.notes || 'Add your notes here...'}</div>
</div>
</div>
<div class="info-item full-width">
<label>About this version</label>
<div class="description-text">${lora.civitai?.description || 'N/A'}</div>
</div>
</div>
</div>
<div class="showcase-section" data-model-hash="${lora.sha256 || ''}" data-filepath="${lora.file_path}">
<div class="showcase-tabs">
<button class="tab-btn active" data-tab="showcase">Examples</button>
<button class="tab-btn" data-tab="description">Model Description</button>
<button class="tab-btn" data-tab="recipes">Recipes</button>
</div>
<div class="tab-content">
<div id="showcase-tab" class="tab-pane active">
<div class="example-images-loading">
<i class="fas fa-spinner fa-spin"></i> Loading example images...
</div>
</div>
<div id="description-tab" class="tab-pane">
<div class="model-description-container">
<div class="model-description-loading">
<i class="fas fa-spinner fa-spin"></i> Loading model description...
</div>
<div class="model-description-content">
${lora.modelDescription || ''}
</div>
</div>
</div>
<div id="recipes-tab" class="tab-pane">
<div class="recipes-loading">
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
</div>
</div>
</div>
<button class="back-to-top" data-action="scroll-to-top">
<i class="fas fa-arrow-up"></i>
</button>
</div>
</div>
</div>
`;
modalManager.showModal('loraModal', content, null, function() {
// Clean up all handlers when modal closes
const modalElement = document.getElementById('loraModal');
if (modalElement && modalElement._clickHandler) {
modalElement.removeEventListener('click', modalElement._clickHandler);
delete modalElement._clickHandler;
}
});
setupEditableFields(lora.file_path);
setupShowcaseScroll('loraModal');
setupTabSwitching();
setupTagTooltip();
setupTriggerWordsEditMode();
setupModelNameEditing(lora.file_path);
setupBaseModelEditing(lora.file_path);
setupFileNameEditing(lora.file_path);
setupTagEditMode(); // Initialize tag editing functionality
setupEventHandlers(lora.file_path);
// If we have a model ID but no description, fetch it
if (lora.civitai?.modelId && !lora.modelDescription) {
loadModelDescription(lora.civitai.modelId, lora.file_path);
}
// Load recipes for this Lora
loadRecipesForLora(lora.model_name, lora.sha256);
// Load example images asynchronously - merge regular and custom images
const regularImages = lora.civitai?.images || [];
const customImages = lora.civitai?.customImages || [];
// Combine images - regular images first, then custom images
const allImages = [...regularImages, ...customImages];
loadExampleImages(allImages, lora.sha256);
}
/**
* Sets up event handlers using event delegation
* @param {string} filePath - Path to the model file
*/
function setupEventHandlers(filePath) {
const modalElement = document.getElementById('loraModal');
// 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('loraModal');
break;
case 'scroll-to-top':
scrollToTop(target);
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;
}
async function saveNotes(filePath) {
const content = document.querySelector('.notes-content').textContent;
try {
await saveModelMetadata(filePath, { notes: content });
showToast('Notes saved successfully', 'success');
} catch (error) {
showToast('Failed to save notes', 'error');
}
};
function setupEditableFields(filePath) {
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 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
});
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();
await saveNotes(filePath);
}
});
}
// Add keydown event for preset value
presetValue.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addPresetBtn.click();
}
});
return showModelModal(lora, 'lora');
}

View File

@@ -1,81 +0,0 @@
/**
* 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 - 标签数组
* @param {string} filePath - 文件路径,用于编辑按钮
* @returns {string} HTML内容
*/
export function renderCompactTags(tags, filePath = '') {
// Remove the early return and always render the container
const tagsList = tags || [];
// Display up to 5 tags, with a tooltip indicator if there are more
const visibleTags = tagsList.slice(0, 5);
const remainingCount = Math.max(0, tagsList.length - 5);
return `
<div class="model-tags-container">
<div class="model-tags-header">
<div class="model-tags-compact">
${visibleTags.map(tag => `<span class="model-tag-compact">${tag}</span>`).join('')}
${remainingCount > 0 ?
`<span class="model-tag-more" data-count="${remainingCount}">+${remainingCount}</span>` :
''}
${tagsList.length === 0 ? `<span class="model-tag-empty">No tags</span>` : ''}
</div>
<button class="edit-tags-btn" data-file-path="${filePath}" title="Edit tags">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
${tagsList.length > 0 ?
`<div class="model-tags-tooltip">
<div class="tooltip-content">
${tagsList.map(tag => `<span class="tooltip-tag">${tag}</span>`).join('')}
</div>
</div>` :
''}
</div>
`;
}
/**
* 设置标签提示功能
*/
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');
});
}
}

View File

@@ -0,0 +1,570 @@
import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../../state/index.js';
import { showModelModal } from './ModelModal.js';
import { bulkManager } from '../../managers/BulkManager.js';
import { modalManager } from '../../managers/ModalManager.js';
import { NSFW_LEVELS } from '../../utils/constants.js';
import { replacePreview, saveModelMetadata as saveLoraMetadata } from '../../api/loraApi.js';
import { replaceCheckpointPreview as apiReplaceCheckpointPreview, saveModelMetadata as saveCheckpointMetadata } from '../../api/checkpointApi.js';
import { showDeleteModal } from '../../utils/modalUtils.js';
// Add global event delegation handlers
export function setupModelCardEventDelegation(modelType) {
const gridElement = document.getElementById('modelGrid');
if (!gridElement) return;
// Remove any existing event listener to prevent duplication
gridElement.removeEventListener('click', gridElement._handleModelCardEvent);
// Create event handler with modelType context
const handleModelCardEvent = (event) => handleModelCardEvent_internal(event, modelType);
// Add the event delegation handler
gridElement.addEventListener('click', handleModelCardEvent);
// Store reference to the handler for cleanup
gridElement._handleModelCardEvent = handleModelCardEvent;
}
// Event delegation handler for all model card events
function handleModelCardEvent_internal(event, modelType) {
// Find the closest card element
const card = event.target.closest('.lora-card');
if (!card) return;
// Handle specific elements within the card
if (event.target.closest('.toggle-blur-btn')) {
event.stopPropagation();
toggleBlurContent(card);
return;
}
if (event.target.closest('.show-content-btn')) {
event.stopPropagation();
showBlurredContent(card);
return;
}
if (event.target.closest('.fa-star')) {
event.stopPropagation();
toggleFavorite(card, modelType);
return;
}
if (event.target.closest('.fa-globe')) {
event.stopPropagation();
if (card.dataset.from_civitai === 'true') {
openCivitai(card.dataset.filepath);
}
return;
}
if (event.target.closest('.fa-paper-plane')) {
event.stopPropagation();
handleSendToWorkflow(card, event.shiftKey, modelType);
return;
}
if (event.target.closest('.fa-copy')) {
event.stopPropagation();
handleCopyAction(card, modelType);
return;
}
if (event.target.closest('.fa-trash')) {
event.stopPropagation();
showDeleteModal(card.dataset.filepath, modelType);
return;
}
if (event.target.closest('.fa-image')) {
event.stopPropagation();
handleReplacePreview(card.dataset.filepath, modelType);
return;
}
if (event.target.closest('.fa-folder-open')) {
event.stopPropagation();
handleExampleImagesAccess(card, modelType);
return;
}
// If no specific element was clicked, handle the card click (show modal or toggle selection)
handleCardClick(card, modelType);
}
// Helper functions for event handling
function toggleBlurContent(card) {
const preview = card.querySelector('.card-preview');
const isBlurred = preview.classList.toggle('blurred');
const icon = card.querySelector('.toggle-blur-btn 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 = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = isBlurred ? 'flex' : 'none';
}
}
function showBlurredContent(card) {
const preview = card.querySelector('.card-preview');
preview.classList.remove('blurred');
// Update the toggle button icon
const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
// Hide the overlay
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = 'none';
}
}
async function toggleFavorite(card, modelType) {
const starIcon = card.querySelector('.fa-star');
const isFavorite = starIcon.classList.contains('fas');
const newFavoriteState = !isFavorite;
try {
// Use the appropriate save function based on model type
const saveFunction = modelType === 'lora' ? saveLoraMetadata : saveCheckpointMetadata;
await saveFunction(card.dataset.filepath, {
favorite: newFavoriteState
});
if (newFavoriteState) {
showToast('Added to favorites', 'success');
} else {
showToast('Removed from favorites', 'success');
}
} catch (error) {
console.error('Failed to update favorite status:', error);
showToast('Failed to update favorite status', 'error');
}
}
function handleSendToWorkflow(card, replaceMode, modelType) {
if (modelType === 'lora') {
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
const strength = usageTips.strength || 1;
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
} else {
// Checkpoint send functionality - to be implemented
showToast('Send checkpoint to workflow - feature to be implemented', 'info');
}
}
function handleCopyAction(card, modelType) {
if (modelType === 'lora') {
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
const strength = usageTips.strength || 1;
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard');
} else {
// Checkpoint copy functionality - copy checkpoint name
const checkpointName = card.dataset.file_name;
copyToClipboard(checkpointName, 'Checkpoint name copied');
}
}
function handleReplacePreview(filePath, modelType) {
if (modelType === 'lora') {
replacePreview(filePath);
} else {
if (window.replaceCheckpointPreview) {
window.replaceCheckpointPreview(filePath);
} else {
apiReplaceCheckpointPreview(filePath);
}
}
}
async function handleExampleImagesAccess(card, modelType) {
const modelHash = card.dataset.sha256;
try {
const response = await fetch(`/api/has-example-images?model_hash=${modelHash}`);
const data = await response.json();
if (data.has_images) {
openExampleImagesFolder(modelHash);
} else {
showExampleAccessModal(card, modelType);
}
} catch (error) {
console.error('Error checking for example images:', error);
showToast('Error checking for example images', 'error');
}
}
function handleCardClick(card, modelType) {
const pageState = getCurrentPageState();
if (state.bulkMode) {
// Toggle selection using the bulk manager
bulkManager.toggleCardSelection(card);
} else if (pageState && pageState.duplicatesMode) {
// In duplicates mode, don't open modal when clicking cards
return;
} else {
// Normal behavior - show modal
showModelModalFromCard(card, modelType);
}
}
function showModelModalFromCard(card, modelType) {
// Get the appropriate preview versions map
const previewVersionsKey = modelType === 'lora' ? 'loras' : 'checkpoints';
const previewVersions = state.pages[previewVersionsKey]?.previewVersions || new Map();
const version = previewVersions.get(card.dataset.filepath);
const previewUrl = card.dataset.preview_url || '/loras_static/images/no-preview.png';
const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl;
// Create model metadata object
const modelMeta = {
sha256: card.dataset.sha256,
file_path: card.dataset.filepath,
model_name: card.dataset.name,
file_name: card.dataset.file_name,
folder: card.dataset.folder,
modified: card.dataset.modified,
file_size: parseInt(card.dataset.file_size || '0'),
from_civitai: card.dataset.from_civitai === 'true',
base_model: card.dataset.base_model,
notes: card.dataset.notes || '',
preview_url: versionedPreviewUrl,
favorite: card.dataset.favorite === 'true',
// Parse civitai metadata from the card's dataset
civitai: JSON.parse(card.dataset.meta || '{}'),
tags: JSON.parse(card.dataset.tags || '[]'),
modelDescription: card.dataset.modelDescription || '',
// LoRA specific fields
...(modelType === 'lora' && {
usage_tips: card.dataset.usage_tips,
})
};
showModelModal(modelMeta, modelType);
}
// Function to show the example access modal (generalized for lora and checkpoint)
function showExampleAccessModal(card, modelType) {
const modal = document.getElementById('exampleAccessModal');
if (!modal) return;
// Get download button and determine if download should be enabled
const downloadBtn = modal.querySelector('#downloadExamplesBtn');
let hasRemoteExamples = false;
try {
const metaData = JSON.parse(card.dataset.meta || '{}');
hasRemoteExamples = metaData.images &&
Array.isArray(metaData.images) &&
metaData.images.length > 0 &&
metaData.images[0].url;
} catch (e) {
console.error('Error parsing meta data:', e);
}
// Enable or disable download button
if (downloadBtn) {
if (hasRemoteExamples) {
downloadBtn.classList.remove('disabled');
downloadBtn.removeAttribute('title');
downloadBtn.onclick = () => {
modalManager.closeModal('exampleAccessModal');
// Open settings modal and scroll to example images section
const settingsModal = document.getElementById('settingsModal');
if (settingsModal) {
modalManager.showModal('settingsModal');
setTimeout(() => {
const exampleSection = settingsModal.querySelector('.settings-section:nth-child(5)');
if (exampleSection) {
exampleSection.scrollIntoView({ behavior: 'smooth' });
}
}, 300);
}
};
} else {
downloadBtn.classList.add('disabled');
downloadBtn.setAttribute('title', 'No remote example images available for this model on Civitai');
downloadBtn.onclick = null;
}
}
// Set up import button
const importBtn = modal.querySelector('#importExamplesBtn');
if (importBtn) {
importBtn.onclick = () => {
modalManager.closeModal('exampleAccessModal');
// Get the model data from card dataset (works for both lora and checkpoint)
const modelMeta = {
sha256: card.dataset.sha256,
file_path: card.dataset.filepath,
model_name: card.dataset.name,
file_name: card.dataset.file_name,
folder: card.dataset.folder,
modified: card.dataset.modified,
file_size: card.dataset.file_size,
from_civitai: card.dataset.from_civitai === 'true',
base_model: card.dataset.base_model,
notes: card.dataset.notes,
favorite: card.dataset.favorite === 'true',
civitai: JSON.parse(card.dataset.meta || '{}'),
tags: JSON.parse(card.dataset.tags || '[]'),
modelDescription: card.dataset.modelDescription || ''
};
// Add usage_tips if present (for lora)
if (card.dataset.usage_tips) {
modelMeta.usage_tips = card.dataset.usage_tips;
}
// Show the model modal
showModelModal(modelMeta, modelType);
// Scroll to import area after modal is visible
setTimeout(() => {
const importArea = document.querySelector('.example-import-area');
if (importArea) {
const showcaseTab = document.getElementById('showcase-tab');
if (showcaseTab) {
// First make sure showcase tab is visible
const tabBtn = document.querySelector('.tab-btn[data-tab="showcase"]');
if (tabBtn && !tabBtn.classList.contains('active')) {
tabBtn.click();
}
// Then toggle showcase if collapsed
const carousel = showcaseTab.querySelector('.carousel');
if (carousel && carousel.classList.contains('collapsed')) {
const scrollIndicator = showcaseTab.querySelector('.scroll-indicator');
if (scrollIndicator) {
scrollIndicator.click();
}
}
// Finally scroll to the import area
importArea.scrollIntoView({ behavior: 'smooth' });
}
}
}, 500);
};
}
// Show the modal
modalManager.showModal('exampleAccessModal');
}
export function createModelCard(model, modelType) {
const card = document.createElement('div');
card.className = 'lora-card'; // Reuse the same class for styling
card.dataset.sha256 = model.sha256;
card.dataset.filepath = model.file_path;
card.dataset.name = model.model_name;
card.dataset.file_name = model.file_name;
card.dataset.folder = model.folder;
card.dataset.modified = model.modified;
card.dataset.file_size = model.file_size;
card.dataset.from_civitai = model.from_civitai;
card.dataset.notes = model.notes || '';
card.dataset.base_model = model.base_model || (modelType === 'checkpoint' ? 'Unknown' : '');
card.dataset.favorite = model.favorite ? 'true' : 'false';
// LoRA specific data
if (modelType === 'lora') {
card.dataset.usage_tips = model.usage_tips;
}
// Store metadata if available
if (model.civitai) {
card.dataset.meta = JSON.stringify(model.civitai || {});
}
// Store tags if available
if (model.tags && Array.isArray(model.tags)) {
card.dataset.tags = JSON.stringify(model.tags);
}
if (model.modelDescription) {
card.dataset.modelDescription = model.modelDescription;
}
// Store NSFW level if available
const nsfwLevel = model.preview_nsfw_level !== undefined ? model.preview_nsfw_level : 0;
card.dataset.nsfwLevel = nsfwLevel;
// Determine if the preview should be blurred based on NSFW level and user settings
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
if (shouldBlur) {
card.classList.add('nsfw-content');
}
// Apply selection state if in bulk mode and this card is in the selected set (LoRA only)
if (modelType === 'lora' && state.bulkMode && state.selectedLoras.has(model.file_path)) {
card.classList.add('selected');
}
// Get the appropriate preview versions map
const previewVersionsKey = modelType === 'lora' ? 'loras' : 'checkpoints';
const previewVersions = state.pages[previewVersionsKey]?.previewVersions || new Map();
const version = previewVersions.get(model.file_path);
const previewUrl = model.preview_url || '/loras_static/images/no-preview.png';
const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl;
// Determine NSFW warning text based on level
let nsfwText = "Mature Content";
if (nsfwLevel >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
// Check if autoplayOnHover is enabled for video previews
const autoplayOnHover = state.global?.settings?.autoplayOnHover || false;
const isVideo = previewUrl.endsWith('.mp4');
const videoAttrs = autoplayOnHover ? 'controls muted loop' : 'controls autoplay muted loop';
// Get favorite status from model data
const isFavorite = model.favorite === true;
// Generate action icons based on model type
const actionIcons = modelType === 'lora' ?
`<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}"
title="${isFavorite ? 'Remove from favorites' : 'Add to favorites'}">
</i>
<i class="fas fa-globe"
title="${model.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}"
${!model.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
</i>
<i class="fas fa-paper-plane"
title="Send to ComfyUI (Click: Append, Shift+Click: Replace)">
</i>
<i class="fas fa-copy"
title="Copy LoRA Syntax">
</i>` :
`<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}"
title="${isFavorite ? 'Remove from favorites' : 'Add to favorites'}">
</i>
<i class="fas fa-globe"
title="${model.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}"
${!model.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
</i>
<i class="fas fa-paper-plane"
title="Send to workflow - feature to be implemented">
</i>
<i class="fas fa-copy"
title="Copy Checkpoint Name">
</i>`;
card.innerHTML = `
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ?
`<video ${videoAttrs}>
<source src="${versionedPreviewUrl}" type="video/mp4">
</video>` :
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">`
}
<div class="card-header">
${shouldBlur ?
`<button class="toggle-blur-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>` : ''}
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${model.base_model}">
${model.base_model}
</span>
<div class="card-actions">
${actionIcons}
</div>
</div>
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
</div>
</div>
` : ''}
<div class="card-footer">
<div class="model-info">
<span class="model-name">${model.model_name}</span>
</div>
<div class="card-actions">
<i class="fas fa-folder-open"
title="Open Example Images Folder">
</i>
</div>
</div>
</div>
`;
// Add video auto-play on hover functionality if needed
const videoElement = card.querySelector('video');
if (videoElement && autoplayOnHover) {
const cardPreview = card.querySelector('.card-preview');
// Remove autoplay attribute and pause initially
videoElement.removeAttribute('autoplay');
videoElement.pause();
// Add mouse events to trigger play/pause using event attributes
cardPreview.setAttribute('onmouseenter', 'this.querySelector("video")?.play()');
cardPreview.setAttribute('onmouseleave', 'const v=this.querySelector("video"); if(v){v.pause();v.currentTime=0;}');
}
return card;
}
// Add a method to update card appearance based on bulk mode (LoRA only)
export function updateCardsForBulkMode(isBulkMode) {
// Update the state
state.bulkMode = isBulkMode;
document.body.classList.toggle('bulk-mode', isBulkMode);
// Get all lora cards - this can now be from the DOM or through the virtual scroller
const loraCards = document.querySelectorAll('.lora-card');
loraCards.forEach(card => {
// Get all action containers for this card
const actions = card.querySelectorAll('.card-actions');
// Handle display property based on mode
if (isBulkMode) {
// Hide actions when entering bulk mode
actions.forEach(actionGroup => {
actionGroup.style.display = 'none';
});
} else {
// Ensure actions are visible when exiting bulk mode
actions.forEach(actionGroup => {
// We need to reset to default display style which is flex
actionGroup.style.display = 'flex';
});
}
});
// If using virtual scroller, we need to rerender after toggling bulk mode
if (state.virtualScroller && typeof state.virtualScroller.scheduleRender === 'function') {
state.virtualScroller.scheduleRender();
}
// Apply selection state to cards if entering bulk mode
if (isBulkMode) {
bulkManager.applySelectionState();
}
}

View File

@@ -1,8 +1,7 @@
/**
* ModelDescription.js
* Handles checkpoint model descriptions
* Handles model description related functionality - General version
*/
import { showToast } from '../../utils/uiHelpers.js';
/**
* Set up tab switching functionality
@@ -25,7 +24,7 @@ export function setupTabSwitching() {
const tabId = `${button.dataset.tab}-tab`;
document.getElementById(tabId).classList.add('active');
// If switching to description tab, make sure content is properly loaded and displayed
// 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) {
@@ -44,9 +43,9 @@ export function setupTabSwitching() {
}
/**
* Load model description from API
* @param {string} modelId - The Civitai model ID
* @param {string} filePath - File path for the model
* Load model description - General version supports both LoRA and Checkpoint
* @param {string} modelId - Model ID
* @param {string} filePath - File path
*/
export async function loadModelDescription(modelId, filePath) {
try {
@@ -59,8 +58,17 @@ export async function loadModelDescription(modelId, filePath) {
loadingElement.classList.remove('hidden');
descriptionContainer.classList.add('hidden');
// Determine API endpoint based on file path or context
let apiEndpoint = `/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`;
// If this is a checkpoint (can be determined from file path or other context)
if (filePath.includes('.safetensors') || filePath.includes('.ckpt')) {
// For now, use the same endpoint - can be updated later if checkpoint-specific endpoint is needed
apiEndpoint = `/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`;
}
// Try to get model description from API
const response = await fetch(`/api/checkpoint-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`);
const response = await fetch(apiEndpoint);
if (!response.ok) {
throw new Error(`Failed to fetch model description: ${response.statusText}`);

View File

@@ -1,15 +1,16 @@
/**
* ModelMetadata.js
* 处理LoRA模型元数据编辑相关的功能模块
* Handles model metadata editing functionality - General version
*/
import { showToast } from '../../utils/uiHelpers.js';
import { BASE_MODELS } from '../../utils/constants.js';
import { state } from '../../state/index.js';
import { saveModelMetadata, renameLoraFile } from '../../api/loraApi.js';
import { saveModelMetadata as saveLoraMetadata, renameLoraFile } from '../../api/loraApi.js';
import { saveModelMetadata as saveCheckpointMetadata, renameCheckpointFile } from '../../api/checkpointApi.js';
/**
* 设置模型名称编辑功能
* @param {string} filePath - 文件路径
* Set up model name editing functionality
* @param {string} filePath - File path
*/
export function setupModelNameEditing(filePath) {
const modelNameContent = document.querySelector('.model-name-content');
@@ -113,7 +114,11 @@ export function setupModelNameEditing(filePath) {
// Get the file path from the dataset
const filePath = this.dataset.filePath;
await saveModelMetadata(filePath, { model_name: newModelName });
// Determine model type based on file extension
const isCheckpoint = filePath.includes('.safetensors') || filePath.includes('.ckpt');
const saveFunction = isCheckpoint ? saveCheckpointMetadata : saveLoraMetadata;
await saveFunction(filePath, { model_name: newModelName });
showToast('Model name updated successfully', 'success');
} catch (error) {
@@ -133,8 +138,8 @@ export function setupModelNameEditing(filePath) {
}
/**
* 设置基础模型编辑功能
* @param {string} filePath - 文件路径
* Set up base model editing functionality
* @param {string} filePath - File path
*/
export function setupBaseModelEditing(filePath) {
const baseModelContent = document.querySelector('.base-model-content');
@@ -278,9 +283,9 @@ export function setupBaseModelEditing(filePath) {
}
/**
* 保存基础模型
* @param {string} filePath - 文件路径
* @param {string} originalValue - 原始值用于比较
* Save base model
* @param {string} filePath - File path
* @param {string} originalValue - Original value (for comparison)
*/
async function saveBaseModel(filePath, originalValue) {
const baseModelElement = document.querySelector('.base-model-content');
@@ -292,7 +297,11 @@ async function saveBaseModel(filePath, originalValue) {
}
try {
await saveModelMetadata(filePath, { base_model: newBaseModel });
// Determine model type based on file extension
const isCheckpoint = filePath.includes('.safetensors') || filePath.includes('.ckpt');
const saveFunction = isCheckpoint ? saveCheckpointMetadata : saveLoraMetadata;
await saveFunction(filePath, { base_model: newBaseModel });
showToast('Base model updated successfully', 'success');
} catch (error) {
@@ -301,8 +310,8 @@ async function saveBaseModel(filePath, originalValue) {
}
/**
* 设置文件名编辑功能
* @param {string} filePath - 文件路径
* Set up file name editing functionality
* @param {string} filePath - File path
*/
export function setupFileNameEditing(filePath) {
const fileNameContent = document.querySelector('.file-name-content');
@@ -411,17 +420,36 @@ export function setupFileNameEditing(filePath) {
try {
// Get the file path from the dataset
const filePath = this.dataset.filePath;
// Call API to rename the file using the new function from loraApi.js
const result = await renameLoraFile(filePath, newFileName);
// Determine model type and use appropriate rename function
const isCheckpoint = filePath.includes('.safetensors') || filePath.includes('.ckpt');
let result;
if (isCheckpoint) {
// Use checkpoint rename function if it exists, otherwise fallback to generic approach
if (typeof renameCheckpointFile === 'function') {
result = await renameCheckpointFile(filePath, newFileName);
} else {
// Fallback: use checkpoint metadata save function
await saveCheckpointMetadata(filePath, { file_name: newFileName });
result = { success: true };
}
} else {
// Use LoRA rename function
result = await renameLoraFile(filePath, newFileName);
}
if (result.success) {
showToast('File name updated successfully', 'success');
// Get the new file path and update the card
const newFilePath = filePath.replace(originalValue, newFileName);
;
state.virtualScroller.updateSingleItem(filePath, { file_name: newFileName, file_path: newFilePath });
// Update virtual scroller if available (mainly for LoRAs)
if (state.virtualScroller && typeof state.virtualScroller.updateSingleItem === 'function') {
const newFilePath = filePath.replace(originalValue, newFileName);
state.virtualScroller.updateSingleItem(filePath, {
file_name: newFileName,
file_path: newFilePath
});
}
} else {
throw new Error(result.error || 'Unknown error');
}

View File

@@ -0,0 +1,443 @@
import { showToast } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import {
toggleShowcase,
setupShowcaseScroll,
scrollToTop,
loadExampleImages
} from './showcase/ShowcaseView.js';
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
import {
setupModelNameEditing,
setupBaseModelEditing,
setupFileNameEditing
} from './ModelMetadata.js';
import { setupTagEditMode } from './ModelTags.js';
import { saveModelMetadata as saveLoraMetadata } from '../../api/loraApi.js';
import { saveModelMetadata as saveCheckpointMetadata } from '../../api/checkpointApi.js';
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
import { parsePresets, renderPresetTags } from './PresetTags.js';
import { loadRecipesForLora } from './RecipeTab.js';
/**
* 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 function showModelModal(model, modelType) {
const modalId = 'modelModal';
const modalTitle = model.model_name;
// Prepare LoRA specific data
const escapedWords = modelType === 'lora' && model.civitai?.trainedWords?.length ?
model.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
// Generate model type specific content
const typeSpecificContent = modelType === 'lora' ? renderLoraSpecificContent(model, escapedWords) : '';
// Generate tabs based on model type
const tabsContent = modelType === 'lora' ?
`<button class="tab-btn active" data-tab="showcase">Examples</button>
<button class="tab-btn" data-tab="description">Model Description</button>
<button class="tab-btn" data-tab="recipes">Recipes</button>` :
`<button class="tab-btn active" data-tab="showcase">Examples</button>
<button class="tab-btn" data-tab="description">Model Description</button>`;
const tabPanesContent = modelType === 'lora' ?
`<div id="showcase-tab" class="tab-pane active">
<div class="example-images-loading">
<i class="fas fa-spinner fa-spin"></i> Loading example images...
</div>
</div>
<div id="description-tab" class="tab-pane">
<div class="model-description-container">
<div class="model-description-loading">
<i class="fas fa-spinner fa-spin"></i> Loading model description...
</div>
<div class="model-description-content">
${model.modelDescription || ''}
</div>
</div>
</div>
<div id="recipes-tab" class="tab-pane">
<div class="recipes-loading">
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
</div>
</div>` :
`<div id="showcase-tab" class="tab-pane active">
<div class="recipes-loading">
<i class="fas fa-spinner fa-spin"></i> Loading examples...
</div>
</div>
<div id="description-tab" class="tab-pane">
<div class="model-description-container">
<div class="model-description-loading">
<i class="fas fa-spinner fa-spin"></i> Loading model description...
</div>
<div class="model-description-content">
${model.modelDescription || ''}
</div>
</div>
</div>`;
const content = `
<div class="modal-content">
<button class="close" onclick="modalManager.closeModal('${modalId}')">&times;</button>
<header class="modal-header">
<div class="model-name-header">
<h2 class="model-name-content">${modalTitle}</h2>
<button class="edit-model-name-btn" title="Edit model name">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
${model.civitai?.creator ? `
<div class="creator-info">
${model.civitai.creator.image ?
`<div class="creator-avatar">
<img src="${model.civitai.creator.image}" alt="${model.civitai.creator.username}" onerror="this.onerror=null; this.src='static/icons/user-placeholder.png';">
</div>` :
`<div class="creator-avatar creator-placeholder">
<i class="fas fa-user"></i>
</div>`
}
<span class="creator-username">${model.civitai.creator.username}</span>
</div>` : ''}
${renderCompactTags(model.tags || [], model.file_path)}
</header>
<div class="modal-body">
<div class="info-section">
<div class="info-grid">
<div class="info-item">
<label>Version</label>
<span>${model.civitai?.name || 'N/A'}</span>
</div>
<div class="info-item">
<label>File Name</label>
<div class="file-name-wrapper">
<span id="file-name" class="file-name-content">${model.file_name || 'N/A'}</span>
<button class="edit-file-name-btn" title="Edit file name">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div>
<div class="info-item location-size">
<div class="location-wrapper">
<label>Location</label>
<span class="file-path">${model.file_path.replace(/[^/]+$/, '') || 'N/A'}</span>
</div>
</div>
<div class="info-item base-size">
<div class="base-wrapper">
<label>Base Model</label>
<div class="base-model-display">
<span class="base-model-content">${model.base_model || (modelType === 'checkpoint' ? 'Unknown' : 'N/A')}</span>
<button class="edit-base-model-btn" title="Edit base model">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div>
<div class="size-wrapper">
<label>Size</label>
<span>${formatFileSize(model.file_size)}</span>
</div>
</div>
${typeSpecificContent}
<div class="info-item notes">
<label>Additional Notes ${modelType === 'lora' ? '<i class="fas fa-info-circle notes-hint" title="Press Enter to save, Shift+Enter for new line"></i>' : ''}</label>
<div class="editable-field">
<div class="notes-content" contenteditable="true" spellcheck="false">${model.notes || 'Add your notes here...'}</div>
${modelType === 'checkpoint' ? `<button class="save-btn" onclick="saveModelNotes('${model.file_path}', '${modelType}')">
<i class="fas fa-save"></i>
</button>` : ''}
</div>
</div>
<div class="info-item full-width">
<label>About this version</label>
<div class="description-text">${model.civitai?.description || 'N/A'}</div>
</div>
</div>
</div>
<div class="showcase-section" data-model-hash="${model.sha256 || ''}" data-filepath="${model.file_path}">
<div class="showcase-tabs">
${tabsContent}
</div>
<div class="tab-content">
${tabPanesContent}
</div>
<button class="back-to-top" data-action="scroll-to-top">
<i class="fas fa-arrow-up"></i>
</button>
</div>
</div>
</div>
`;
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);
setupEditableFields(model.file_path, modelType);
setupShowcaseScroll(modalId);
setupTabSwitching();
setupTagTooltip();
setupTagEditMode();
setupModelNameEditing(model.file_path);
setupBaseModelEditing(model.file_path);
setupFileNameEditing(model.file_path);
setupEventHandlers(model.file_path);
// LoRA specific setup
if (modelType === 'lora') {
setupTriggerWordsEditMode();
// Load recipes for this LoRA
loadRecipesForLora(model.model_name, model.sha256);
}
// If we have a model ID but no description, fetch it
if (model.civitai?.modelId && !model.modelDescription) {
loadModelDescription(model.civitai.modelId, model.file_path);
}
// Load example images asynchronously - merge regular and custom images
const regularImages = model.civitai?.images || [];
const customImages = model.civitai?.customImages || [];
// Combine images - regular images first, then custom images
const allImages = [...regularImages, ...customImages];
loadExampleImages(allImages, model.sha256);
}
function renderLoraSpecificContent(lora, escapedWords) {
return `
<div class="info-item usage-tips">
<label>Usage Tips</label>
<div class="editable-field">
<div class="preset-controls">
<select id="preset-selector">
<option value="">Add preset parameter...</option>
<option value="strength_min">Strength Min</option>
<option value="strength_max">Strength Max</option>
<option value="strength">Strength</option>
<option value="clip_skip">Clip Skip</option>
</select>
<input type="number" id="preset-value" step="0.01" placeholder="Value" style="display:none;">
<button class="add-preset-btn">Add</button>
</div>
<div class="preset-tags">
${renderPresetTags(parsePresets(lora.usage_tips))}
</div>
</div>
</div>
${renderTriggerWords(escapedWords, lora.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;
}
}
// 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 in the model modal
* @param {string} filePath - The full file path of the model
* @param {string} modelType - Type of model ('lora' or 'checkpoint')
*/
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(filePath, modelType);
}
});
}
// LoRA specific field setup
if (modelType === 'lora') {
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');
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 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 saveLoraMetadata(filePath, {
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
* @param {string} filePath - Path to the model file
* @param {string} modelType - Type of model ('lora' or 'checkpoint')
*/
async function saveNotes(filePath, modelType) {
const content = document.querySelector('.notes-content').textContent;
try {
const saveFunction = modelType === 'lora' ? saveLoraMetadata : saveCheckpointMetadata;
await saveFunction(filePath, { notes: content });
showToast('Notes saved successfully', 'success');
} catch (error) {
showToast('Failed to save notes', 'error');
}
}
// Export the model modal API
const modelModal = {
show: showModelModal,
toggleShowcase,
scrollToTop
};
export { modelModal };
// Define global functions for use in HTML
window.toggleShowcase = function(element) {
toggleShowcase(element);
};
window.scrollToTopModel = function(button) {
scrollToTop(button);
};
// Legacy global functions for backward compatibility
window.scrollToTopLora = function(button) {
scrollToTop(button);
};
window.scrollToTopCheckpoint = function(button) {
scrollToTop(button);
};
window.saveModelNotes = function(filePath, modelType) {
saveNotes(filePath, modelType);
};
// Legacy functions
window.saveLoraNotes = function(filePath) {
saveNotes(filePath, 'lora');
};
window.saveCheckpointNotes = function(filePath) {
saveNotes(filePath, 'checkpoint');
};

View File

@@ -1,9 +1,10 @@
/**
* ModelTags.js
* Module for handling model tag editing functionality
* Module for handling model tag editing functionality - 共享版本
*/
import { showToast } from '../../utils/uiHelpers.js';
import { saveModelMetadata } from '../../api/loraApi.js';
import { saveModelMetadata as saveLoraMetadata } from '../../api/loraApi.js';
import { saveModelMetadata as saveCheckpointMetadata } from '../../api/checkpointApi.js';
// Preset tag suggestions
const PRESET_TAGS = [
@@ -135,6 +136,98 @@ export function setupTagEditMode() {
document.addEventListener('click', saveTagsHandler);
}
// ...existing helper functions...
/**
* Save tags - 支持LoRA和Checkpoint
*/
async function saveTags() {
const editBtn = document.querySelector('.edit-tags-btn');
if (!editBtn) return;
const filePath = editBtn.dataset.filePath;
const tagElements = document.querySelectorAll('.metadata-item');
const tags = Array.from(tagElements).map(tag => tag.dataset.tag);
// Get original tags to compare
const originalTagElements = document.querySelectorAll('.tooltip-tag');
const originalTags = Array.from(originalTagElements).map(tag => tag.textContent);
// Check if tags have actually changed
const tagsChanged = JSON.stringify(tags) !== JSON.stringify(originalTags);
if (!tagsChanged) {
// No changes made, just exit edit mode without API call
editBtn.dataset.skipRestore = "true";
editBtn.click();
return;
}
try {
// Determine model type and use appropriate save function
const isCheckpoint = filePath.includes('.safetensors') || filePath.includes('.ckpt');
const saveFunction = isCheckpoint ? saveCheckpointMetadata : saveLoraMetadata;
// Save tags metadata
await saveFunction(filePath, { tags: tags });
// Set flag to skip restoring original tags when exiting edit mode
editBtn.dataset.skipRestore = "true";
// Update the compact tags display
const compactTagsContainer = document.querySelector('.model-tags-container');
if (compactTagsContainer) {
// Generate new compact tags HTML
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact');
if (compactTagsDisplay) {
// Clear current tags
compactTagsDisplay.innerHTML = '';
// Add visible tags (up to 5)
const visibleTags = tags.slice(0, 5);
visibleTags.forEach(tag => {
const span = document.createElement('span');
span.className = 'model-tag-compact';
span.textContent = tag;
compactTagsDisplay.appendChild(span);
});
// Add more indicator if needed
const remainingCount = Math.max(0, tags.length - 5);
if (remainingCount > 0) {
const more = document.createElement('span');
more.className = 'model-tag-more';
more.dataset.count = remainingCount;
more.textContent = `+${remainingCount}`;
compactTagsDisplay.appendChild(more);
}
}
// Update tooltip content
const tooltipContent = compactTagsContainer.querySelector('.tooltip-content');
if (tooltipContent) {
tooltipContent.innerHTML = '';
tags.forEach(tag => {
const span = document.createElement('span');
span.className = 'tooltip-tag';
span.textContent = tag;
tooltipContent.appendChild(span);
});
}
}
// Exit edit mode
editBtn.click();
showToast('Tags updated successfully', 'success');
} catch (error) {
console.error('Error saving tags:', error);
showToast('Failed to update tags', 'error');
}
}
/**
* Create the tag editing UI
* @param {Array} currentTags - Current tags
@@ -382,90 +475,4 @@ function updateSuggestionsDropdown() {
function restoreOriginalTags(section, originalTags) {
// Nothing to do here as we're just hiding the edit UI
// and showing the original compact tags which weren't modified
}
/**
* Save tags
*/
async function saveTags() {
const editBtn = document.querySelector('.edit-tags-btn');
if (!editBtn) return;
const filePath = editBtn.dataset.filePath;
const tagElements = document.querySelectorAll('.metadata-item');
const tags = Array.from(tagElements).map(tag => tag.dataset.tag);
// Get original tags to compare
const originalTagElements = document.querySelectorAll('.tooltip-tag');
const originalTags = Array.from(originalTagElements).map(tag => tag.textContent);
// Check if tags have actually changed
const tagsChanged = JSON.stringify(tags) !== JSON.stringify(originalTags);
if (!tagsChanged) {
// No changes made, just exit edit mode without API call
editBtn.dataset.skipRestore = "true";
editBtn.click();
return;
}
try {
// Save tags metadata
await saveModelMetadata(filePath, { tags: tags });
// Set flag to skip restoring original tags when exiting edit mode
editBtn.dataset.skipRestore = "true";
// Update the compact tags display
const compactTagsContainer = document.querySelector('.model-tags-container');
if (compactTagsContainer) {
// Generate new compact tags HTML
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact');
if (compactTagsDisplay) {
// Clear current tags
compactTagsDisplay.innerHTML = '';
// Add visible tags (up to 5)
const visibleTags = tags.slice(0, 5);
visibleTags.forEach(tag => {
const span = document.createElement('span');
span.className = 'model-tag-compact';
span.textContent = tag;
compactTagsDisplay.appendChild(span);
});
// Add more indicator if needed
const remainingCount = Math.max(0, tags.length - 5);
if (remainingCount > 0) {
const more = document.createElement('span');
more.className = 'model-tag-more';
more.dataset.count = remainingCount;
more.textContent = `+${remainingCount}`;
compactTagsDisplay.appendChild(more);
}
}
// Update tooltip content
const tooltipContent = compactTagsContainer.querySelector('.tooltip-content');
if (tooltipContent) {
tooltipContent.innerHTML = '';
tags.forEach(tag => {
const span = document.createElement('span');
span.className = 'tooltip-tag';
span.textContent = tag;
tooltipContent.appendChild(span);
});
}
}
// Exit edit mode
editBtn.click();
showToast('Tags updated successfully', 'success');
} catch (error) {
console.error('Error saving tags:', error);
showToast('Failed to update tags', 'error');
}
}
}

View File

@@ -1,13 +1,13 @@
/**
* PresetTags.js
* 处理LoRA模型预设参数标签相关的功能模块
* Handles LoRA model preset parameter tags - Shared version
*/
import { saveModelMetadata } from '../../api/loraApi.js';
/**
* 解析预设参数
* @param {string} usageTips - 包含预设参数的JSON字符串
* @returns {Object} 解析后的预设参数对象
* Parse preset parameters
* @param {string} usageTips - JSON string containing preset parameters
* @returns {Object} Parsed preset parameters object
*/
export function parsePresets(usageTips) {
if (!usageTips) return {};
@@ -19,9 +19,9 @@ export function parsePresets(usageTips) {
}
/**
* 渲染预设标签
* @param {Object} presets - 预设参数对象
* @returns {string} HTML内容
* Render preset tags
* @param {Object} presets - Preset parameters object
* @returns {string} HTML content
*/
export function renderPresetTags(presets) {
return Object.entries(presets).map(([key, value]) => `
@@ -33,9 +33,9 @@ export function renderPresetTags(presets) {
}
/**
* 格式化预设键名
* @param {string} key - 预设键名
* @returns {string} 格式化后的键名
* Format preset key name
* @param {string} key - Preset key name
* @returns {string} Formatted key name
*/
function formatPresetKey(key) {
return key.split('_').map(word =>
@@ -44,13 +44,13 @@ function formatPresetKey(key) {
}
/**
* 移除预设参数
* @param {string} key - 要移除的预设键名
* Remove preset parameter
* @param {string} key - Preset key name to remove
*/
window.removePreset = async function(key) {
const filePath = document.querySelector('#loraModal .modal-content')
const filePath = document.querySelector('#modelModal .modal-content')
.querySelector('.file-path').textContent +
document.querySelector('#loraModal .modal-content')
document.querySelector('#modelModal .modal-content')
.querySelector('#file-name').textContent + '.safetensors';
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
const currentPresets = parsePresets(loraCard.dataset.usage_tips);

View File

@@ -1,5 +1,6 @@
/**
* RecipeTab - Handles the recipes tab in the Lora Modal
* RecipeTab - Handles the recipes tab in model modals (LoRA specific functionality)
* Moved to shared directory for consistency
*/
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
@@ -189,7 +190,7 @@ function copyRecipeSyntax(recipeId) {
function navigateToRecipesPage(loraName, loraHash) {
// Close the current modal
if (window.modalManager) {
modalManager.closeModal('loraModal');
modalManager.closeModal('modelModal');
}
// Clear any previous filters first
@@ -212,7 +213,7 @@ function navigateToRecipesPage(loraName, loraHash) {
function navigateToRecipeDetails(recipeId) {
// Close the current modal
if (window.modalManager) {
modalManager.closeModal('loraModal');
modalManager.closeModal('modelModal');
}
// Clear any previous filters first

View File

@@ -1,6 +1,7 @@
/**
* TriggerWords.js
* Module that handles trigger word functionality for LoRA models
* Moved to shared directory for consistency
*/
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { saveModelMetadata } from '../../api/loraApi.js';

View File

@@ -1,18 +1,16 @@
/**
* utils.js
* CheckpointModal component utility functions
* Helper functions for the Model Modal component - General version
*/
import { showToast } from '../../utils/uiHelpers.js';
/**
* Format file size for display
* @param {number} bytes - File size in bytes
* @returns {string} - Formatted file size
* Format file size
* @param {number} bytes - Number of bytes
* @returns {string} Formatted file size
*/
export function formatFileSize(bytes) {
if (!bytes) return 'N/A';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;

View File

@@ -10,7 +10,6 @@ import { helpManager } from './managers/HelpManager.js';
import { showToast, initTheme, initBackToTop } from './utils/uiHelpers.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { migrateStorageItems } from './utils/storageHelpers.js';
import { setupLoraCardEventDelegation } from './components/LoraCard.js';
// Core application class
export class AppCore {
@@ -68,11 +67,6 @@ export class AppCore {
initializePageFeatures() {
const pageType = this.getPageType();
// Setup event delegation for lora cards if on the loras page
if (pageType === 'loras') {
setupLoraCardEventDelegation();
}
// Initialize virtual scroll for pages that need it
if (['loras', 'recipes', 'checkpoints'].includes(pageType)) {
initializeInfiniteScroll(pageType);

View File

@@ -1,6 +1,5 @@
import { appCore } from './core.js';
import { state } from './state/index.js';
import { showLoraModal } from './components/loraModal/index.js';
import { loadMoreLoras } from './api/loraApi.js';
import { updateCardsForBulkMode } from './components/LoraCard.js';
import { bulkManager } from './managers/BulkManager.js';
@@ -36,7 +35,6 @@ class LoraPageManager {
// Only expose what's still needed globally
// Most functionality is now handled by the PageControls component
window.loadMoreLoras = loadMoreLoras;
window.showLoraModal = showLoraModal;
window.confirmDelete = confirmDelete;
window.closeDeleteModal = closeDeleteModal;
window.confirmExclude = confirmExclude;
@@ -70,12 +68,6 @@ class LoraPageManager {
// Initialize common page features (virtual scroll)
appCore.initializePageFeatures();
// Add virtual scroll class to grid for CSS adjustments
const loraGrid = document.getElementById('loraGrid');
if (loraGrid && state.virtualScroller) {
loraGrid.classList.add('virtual-scroll');
}
}
}

View File

@@ -12,25 +12,12 @@ export class ModalManager {
this.boundHandleEscape = this.handleEscape.bind(this);
// Register all modals - only if they exist in the current page
const loraModal = document.getElementById('loraModal');
if (loraModal) {
this.registerModal('loraModal', {
element: loraModal,
const modelModal = document.getElementById('modelModal');
if (modelModal) {
this.registerModal('modelModal', {
element: modelModal,
onClose: () => {
this.getModal('loraModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add checkpointModal registration
const checkpointModal = document.getElementById('checkpointModal');
if (checkpointModal) {
this.registerModal('checkpointModal', {
element: checkpointModal,
onClose: () => {
this.getModal('checkpointModal').element.style.display = 'none';
this.getModal('modelModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true

View File

@@ -82,11 +82,9 @@ async function initializeVirtualScroll(pageType) {
gridId = 'recipeGrid';
break;
case 'checkpoints':
gridId = 'checkpointGrid';
break;
case 'loras':
default:
gridId = 'loraGrid';
gridId = 'modelGrid';
break;
}

View File

@@ -55,7 +55,7 @@
</div>
<!-- Checkpoint cards container -->
<div class="card-grid" id="checkpointGrid">
<div class="card-grid" id="modelGrid">
<!-- Cards will be dynamically inserted here -->
</div>
{% endblock %}

View File

@@ -1,7 +1,7 @@
<!-- Checkpoint Modals -->
<!-- Checkpoint details Modal -->
<div id="checkpointModal" class="modal"></div>
<div id="modelModal" class="modal"></div>
<!-- Download Checkpoint from URL Modal -->
<div id="checkpointDownloadModal" class="modal">

View File

@@ -1,5 +1,5 @@
<!-- Model details Modal -->
<div id="loraModal" class="modal"></div>
<div id="modelModal" class="modal"></div>
<!-- Download from URL Modal (for LoRAs) -->
<div id="downloadModal" class="modal">

View File

@@ -43,7 +43,7 @@
</div>
<!-- Lora卡片容器 -->
<div class="card-grid" id="loraGrid">
<div class="card-grid" id="modelGrid">
<!-- Cards will be dynamically inserted here -->
</div>
<!-- Bulk operations panel will be inserted here by JavaScript -->