Add bulk delete functionality for loras and implement model duplicates management. See #198

- Introduced a new API endpoint for bulk deleting loras.
- Added ModelDuplicatesManager to handle duplicate models for loras and checkpoints.
- Implemented UI components for displaying duplicates and managing selections.
- Enhanced controls with a button for finding duplicates.
- Updated templates to include a duplicates banner and associated actions.
This commit is contained in:
Will Miao
2025-05-31 21:58:48 +08:00
parent 0bd62eef3a
commit 26f9779fbf
21 changed files with 935 additions and 60 deletions

View File

@@ -4,6 +4,7 @@ import { createPageControls } from './components/controls/index.js';
import { loadMoreCheckpoints } from './api/checkpointApi.js';
import { CheckpointDownloadManager } from './managers/CheckpointDownloadManager.js';
import { CheckpointContextMenu } from './components/ContextMenu/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
// Initialize the Checkpoints page
class CheckpointsPageManager {
@@ -14,6 +15,9 @@ class CheckpointsPageManager {
// Initialize checkpoint download manager
window.checkpointDownloadManager = new CheckpointDownloadManager();
// Initialize the ModelDuplicatesManager
this.duplicatesManager = new ModelDuplicatesManager(this);
// Expose only necessary functions to global scope
this._exposeRequiredGlobalFunctions();
}
@@ -29,6 +33,9 @@ class CheckpointsPageManager {
window.checkpointManager = {
loadCheckpoints: (reset) => loadMoreCheckpoints(reset)
};
// Expose duplicates manager
window.modelDuplicatesManager = this.duplicatesManager;
}
async initialize() {

View File

@@ -14,8 +14,6 @@ export class DuplicatesManager {
async findDuplicates() {
try {
document.body.classList.add('loading');
const response = await fetch('/api/recipes/find-duplicates');
if (!response.ok) {
throw new Error('Failed to find duplicates');
@@ -39,8 +37,6 @@ export class DuplicatesManager {
console.error('Error finding duplicates:', error);
showToast('Failed to find duplicates: ' + error.message, 'error');
return false;
} finally {
document.body.classList.remove('loading');
}
}
@@ -234,7 +230,7 @@ export class DuplicatesManager {
}
updateSelectedCount() {
const selectedCountEl = document.getElementById('selectedCount');
const selectedCountEl = document.getElementById('duplicatesSelectedCount');
if (selectedCountEl) {
selectedCountEl.textContent = this.selectedForDeletion.size;
}
@@ -358,9 +354,7 @@ export class DuplicatesManager {
// Add new method to execute deletion after confirmation
async confirmDeleteDuplicates() {
try {
document.body.classList.add('loading');
try {
// Close the modal
modalManager.closeModal('duplicateDeleteModal');
@@ -395,8 +389,6 @@ export class DuplicatesManager {
} catch (error) {
console.error('Error deleting recipes:', error);
showToast('Failed to delete recipes: ' + error.message, 'error');
} finally {
document.body.classList.remove('loading');
}
}
}

View File

@@ -1,5 +1,5 @@
import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../utils/uiHelpers.js';
import { state } from '../state/index.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';
@@ -76,9 +76,13 @@ function handleLoraCardEvent(event) {
}
// 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 = {

View File

@@ -0,0 +1,414 @@
// Model Duplicates Manager Component for LoRAs and Checkpoints
import { showToast } from '../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../state/index.js';
import { initializeInfiniteScroll } from '../utils/infiniteScroll.js';
import { formatDate } from '../utils/formatters.js';
export class ModelDuplicatesManager {
constructor(pageManager) {
this.pageManager = pageManager;
this.duplicateGroups = [];
this.inDuplicateMode = false;
this.selectedForDeletion = new Set();
this.modelType = 'loras'; // Default to loras, could be 'checkpoints'
// Bind methods
this.renderModelCard = this.renderModelCard.bind(this);
this.renderTooltip = this.renderTooltip.bind(this);
}
async findDuplicates() {
try {
// Determine API endpoint based on model type
const endpoint = `/api/${this.modelType}/find-duplicates`;
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error(`Failed to find duplicates: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Unknown error finding duplicates');
}
this.duplicateGroups = data.duplicates || [];
if (this.duplicateGroups.length === 0) {
showToast('No duplicate models found', 'info');
return false;
}
this.enterDuplicateMode();
return true;
} catch (error) {
console.error('Error finding duplicates:', error);
showToast('Failed to find duplicates: ' + error.message, 'error');
return false;
}
}
enterDuplicateMode() {
this.inDuplicateMode = true;
this.selectedForDeletion.clear();
// Update state
const pageState = getCurrentPageState();
pageState.duplicatesMode = true;
// Show duplicates banner
const banner = document.getElementById('duplicatesBanner');
const countSpan = document.getElementById('duplicatesCount');
if (banner && countSpan) {
countSpan.textContent = `Found ${this.duplicateGroups.length} duplicate group${this.duplicateGroups.length !== 1 ? 's' : ''}`;
banner.style.display = 'block';
}
// Disable virtual scrolling if active
if (state.virtualScroller) {
state.virtualScroller.disable();
}
// Add duplicate-mode class to the body
document.body.classList.add('duplicate-mode');
// Render duplicate groups
this.renderDuplicateGroups();
// Update selected count
this.updateSelectedCount();
}
exitDuplicateMode() {
this.inDuplicateMode = false;
this.selectedForDeletion.clear();
// Update state
const pageState = getCurrentPageState();
pageState.duplicatesMode = false;
// Instead of trying to restore the virtual scroller,
// simply redirect to reload the page
// TODO: While this is a workaround rather than a deep fix, it's a pragmatic solution that will immediately resolve the issue for users. We can investigate the underlying cause more thoroughly later when there's time for more extensive debugging.
window.location.href = '/loras';
}
renderDuplicateGroups() {
const modelGrid = document.getElementById('loraGrid');
if (!modelGrid) return;
// Clear existing content
modelGrid.innerHTML = '';
// Render each duplicate group
this.duplicateGroups.forEach((group, groupIndex) => {
const groupDiv = document.createElement('div');
groupDiv.className = 'duplicate-group';
groupDiv.dataset.hash = group.hash;
// Create group header
const header = document.createElement('div');
header.className = 'duplicate-group-header';
header.innerHTML = `
<span>Duplicate Group #${groupIndex + 1} (${group.models.length} models with same hash: ${group.hash})</span>
<span>
<button class="btn-select-all" onclick="modelDuplicatesManager.toggleSelectAllInGroup('${group.hash}')">
Select All
</button>
</span>
`;
groupDiv.appendChild(header);
// Create cards container
const cardsDiv = document.createElement('div');
cardsDiv.className = 'card-group-container';
// Add scrollable class if there are many models in the group
if (group.models.length > 6) {
cardsDiv.classList.add('scrollable');
// Add expand/collapse toggle button
const toggleBtn = document.createElement('button');
toggleBtn.className = 'group-toggle-btn';
toggleBtn.innerHTML = '<i class="fas fa-chevron-down"></i>';
toggleBtn.title = "Expand/Collapse";
toggleBtn.onclick = function() {
cardsDiv.classList.toggle('scrollable');
this.innerHTML = cardsDiv.classList.contains('scrollable') ?
'<i class="fas fa-chevron-down"></i>' :
'<i class="fas fa-chevron-up"></i>';
};
groupDiv.appendChild(toggleBtn);
}
// Add all model cards in this group
group.models.forEach(model => {
const card = this.renderModelCard(model, group.hash);
cardsDiv.appendChild(card);
});
groupDiv.appendChild(cardsDiv);
modelGrid.appendChild(groupDiv);
});
}
renderModelCard(model, groupHash) {
// Create basic card structure
const card = document.createElement('div');
card.className = 'lora-card duplicate';
card.dataset.hash = model.sha256;
card.dataset.filePath = model.file_path;
// Create card content using structure similar to createLoraCard in LoraCard.js
const previewContainer = document.createElement('div');
previewContainer.className = 'card-preview';
// Determine if preview is a video
const isVideo = model.preview_url && model.preview_url.endsWith('.mp4');
let preview;
if (isVideo) {
// Create video element for MP4 previews
preview = document.createElement('video');
preview.loading = 'lazy';
preview.controls = true;
preview.muted = true;
preview.loop = true;
const source = document.createElement('source');
source.src = model.preview_url;
source.type = 'video/mp4';
preview.appendChild(source);
} else {
// Create image element for standard previews
preview = document.createElement('img');
preview.loading = 'lazy';
preview.alt = model.model_name;
if (model.preview_url) {
preview.src = model.preview_url;
} else {
// Use placeholder
preview.src = '/loras_static/images/no-preview.png';
}
}
// Add NSFW blur if needed
if (model.preview_nsfw_level > 0) {
preview.classList.add('nsfw');
}
previewContainer.appendChild(preview);
// Move tooltip listeners to the preview container for consistent behavior
// regardless of whether the preview is an image or video
previewContainer.addEventListener('mouseover', () => this.renderTooltip(card, model));
previewContainer.addEventListener('mouseout', () => {
const tooltip = document.querySelector('.model-tooltip');
if (tooltip) tooltip.remove();
});
// Add card footer with just model name
const footer = document.createElement('div');
footer.className = 'card-footer';
const modelInfo = document.createElement('div');
modelInfo.className = 'model-info';
const modelName = document.createElement('span');
modelName.className = 'model-name';
modelName.textContent = model.model_name;
modelInfo.appendChild(modelName);
footer.appendChild(modelInfo);
previewContainer.appendChild(footer);
card.appendChild(previewContainer);
// Add selection checkbox
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'selector-checkbox';
checkbox.dataset.filePath = model.file_path;
checkbox.dataset.groupHash = groupHash;
// Check if already selected
if (this.selectedForDeletion.has(model.file_path)) {
checkbox.checked = true;
card.classList.add('duplicate-selected');
}
// Add change event to checkbox
checkbox.addEventListener('change', (e) => {
e.stopPropagation();
this.toggleCardSelection(model.file_path, card, checkbox);
});
// Make the entire card clickable for selection
card.addEventListener('click', (e) => {
// Don't toggle if clicking on the checkbox directly or card actions
if (e.target === checkbox || e.target.closest('.card-actions')) {
return;
}
// Toggle checkbox state
checkbox.checked = !checkbox.checked;
this.toggleCardSelection(model.file_path, card, checkbox);
});
card.appendChild(checkbox);
return card;
}
renderTooltip(card, model) {
// Remove any existing tooltips
const existingTooltip = document.querySelector('.model-tooltip');
if (existingTooltip) existingTooltip.remove();
// Create tooltip
const tooltip = document.createElement('div');
tooltip.className = 'model-tooltip';
// Add model information to tooltip
tooltip.innerHTML = `
<div class="tooltip-header">${model.model_name}</div>
<div class="tooltip-info">
<div><strong>Version:</strong> ${model.civitai?.name || 'Unknown'}</div>
<div><strong>Filename:</strong> ${model.file_name}</div>
<div><strong>Path:</strong> ${model.file_path}</div>
<div><strong>Base Model:</strong> ${model.base_model || 'Unknown'}</div>
<div><strong>Modified:</strong> ${formatDate(model.modified)}</div>
</div>
`;
// Position tooltip relative to card
const cardRect = card.getBoundingClientRect();
tooltip.style.top = `${cardRect.top + window.scrollY - 10}px`;
tooltip.style.left = `${cardRect.left + window.scrollX + cardRect.width + 10}px`;
// Add tooltip to document
document.body.appendChild(tooltip);
// Check if tooltip is outside viewport and adjust if needed
const tooltipRect = tooltip.getBoundingClientRect();
if (tooltipRect.right > window.innerWidth) {
tooltip.style.left = `${cardRect.left + window.scrollX - tooltipRect.width - 10}px`;
}
}
// Helper method to toggle card selection state
toggleCardSelection(filePath, card, checkbox) {
if (checkbox.checked) {
this.selectedForDeletion.add(filePath);
card.classList.add('duplicate-selected');
} else {
this.selectedForDeletion.delete(filePath);
card.classList.remove('duplicate-selected');
}
this.updateSelectedCount();
}
updateSelectedCount() {
const selectedCountEl = document.getElementById('duplicatesSelectedCount');
if (selectedCountEl) {
selectedCountEl.textContent = this.selectedForDeletion.size;
}
// Update delete button state
const deleteBtn = document.querySelector('.btn-delete-selected');
if (deleteBtn) {
deleteBtn.disabled = this.selectedForDeletion.size === 0;
deleteBtn.classList.toggle('disabled', this.selectedForDeletion.size === 0);
}
}
toggleSelectAllInGroup(hash) {
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-hash="${hash}"]`);
const allSelected = Array.from(checkboxes).every(checkbox => checkbox.checked);
// If all are selected, deselect all; otherwise select all
checkboxes.forEach(checkbox => {
checkbox.checked = !allSelected;
const filePath = checkbox.dataset.filePath;
const card = checkbox.closest('.lora-card');
if (!allSelected) {
this.selectedForDeletion.add(filePath);
card.classList.add('duplicate-selected');
} else {
this.selectedForDeletion.delete(filePath);
card.classList.remove('duplicate-selected');
}
});
// Update the button text
const button = document.querySelector(`.duplicate-group[data-hash="${hash}"] .btn-select-all`);
if (button) {
button.textContent = !allSelected ? "Deselect All" : "Select All";
}
this.updateSelectedCount();
}
async deleteSelectedDuplicates() {
if (this.selectedForDeletion.size === 0) {
showToast('No models selected for deletion', 'info');
return;
}
try {
// Show the delete confirmation modal instead of a simple confirm
const modelDuplicateDeleteCount = document.getElementById('modelDuplicateDeleteCount');
if (modelDuplicateDeleteCount) {
modelDuplicateDeleteCount.textContent = this.selectedForDeletion.size;
}
// Use the modal manager to show the confirmation modal
modalManager.showModal('modelDuplicateDeleteModal');
} catch (error) {
console.error('Error preparing delete:', error);
showToast('Error: ' + error.message, 'error');
}
}
// Execute deletion after confirmation
async confirmDeleteDuplicates() {
try {
// Close the modal
modalManager.closeModal('modelDuplicateDeleteModal');
// Prepare file paths for deletion
const filePaths = Array.from(this.selectedForDeletion);
// Call API to bulk delete
const response = await fetch(`/api/${this.modelType}/bulk-delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ file_paths: filePaths })
});
if (!response.ok) {
throw new Error('Failed to delete selected models');
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Unknown error deleting models');
}
showToast(`Successfully deleted ${data.total_deleted} models`, 'success');
// Exit duplicate mode if deletions were successful
if (data.total_deleted > 0) {
this.exitDuplicateMode();
}
} catch (error) {
console.error('Error deleting models:', error);
showToast('Failed to delete models: ' + error.message, 'error');
}
}
}

View File

@@ -175,6 +175,12 @@ export class PageControls {
downloadButton.addEventListener('click', () => this.showDownloadModal());
}
// Find duplicates button - available for both loras and checkpoints
const duplicatesButton = document.querySelector('[data-action="find-duplicates"]');
if (duplicatesButton) {
duplicatesButton.addEventListener('click', () => this.findDuplicates());
}
if (this.pageType === 'loras') {
// Bulk operations button - LoRAs only
const bulkButton = document.querySelector('[data-action="bulk"]');
@@ -499,4 +505,15 @@ export class PageControls {
// Reload models with new filter
await this.resetAndReload(true);
}
/**
* Find duplicate models
*/
findDuplicates() {
if (window.modelDuplicatesManager) {
window.modelDuplicatesManager.findDuplicates();
} else {
console.error('Model duplicates manager not available');
}
}
}

View File

@@ -24,7 +24,6 @@ import { updateLoraCard } from '../../utils/cardUpdater.js';
* @param {Object} lora - LoRA模型数据
*/
export function showLoraModal(lora) {
console.log('Lora data:', lora);
const escapedWords = lora.civitai?.trainedWords?.length ?
lora.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];

View File

@@ -9,6 +9,7 @@ import { moveManager } from './managers/MoveManager.js';
import { LoraContextMenu } from './components/ContextMenu/index.js';
import { createPageControls } from './components/controls/index.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
// Initialize the LoRA page
class LoraPageManager {
@@ -23,6 +24,9 @@ class LoraPageManager {
// Initialize page controls
this.pageControls = createPageControls('loras');
// Initialize the ModelDuplicatesManager
this.duplicatesManager = new ModelDuplicatesManager(this);
// Expose necessary functions to the page that still need global access
// These will be refactored in future updates
this._exposeRequiredGlobalFunctions();
@@ -49,6 +53,9 @@ class LoraPageManager {
window.copyAllLorasSyntax = () => bulkManager.copyAllLorasSyntax();
window.updateSelectedCount = () => bulkManager.updateSelectedCount();
window.bulkManager = bulkManager;
// Expose duplicates manager
window.modelDuplicatesManager = this.duplicatesManager;
}
async initialize() {

View File

@@ -170,6 +170,18 @@ export class ModalManager {
}
});
}
// Add modelDuplicateDeleteModal registration
const modelDuplicateDeleteModal = document.getElementById('modelDuplicateDeleteModal');
if (modelDuplicateDeleteModal) {
this.registerModal('modelDuplicateDeleteModal', {
element: modelDuplicateDeleteModal,
onClose: () => {
this.getModal('modelDuplicateDeleteModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
}
// Add clearCacheModal registration
const clearCacheModal = document.getElementById('clearCacheModal');
@@ -263,7 +275,7 @@ export class ModalManager {
// Store current scroll position before showing modal
this.scrollPosition = window.scrollY;
if (id === 'deleteModal' || id === 'excludeModal' || id === 'duplicateDeleteModal' || id === 'clearCacheModal') {
if (id === 'deleteModal' || id === 'excludeModal' || id === 'duplicateDeleteModal' || id === 'modelDuplicateDeleteModal' || id === 'clearCacheModal') {
modal.element.classList.add('show');
} else {
modal.element.style.display = 'block';

View File

@@ -44,6 +44,7 @@ export const state = {
selectedLoras: new Set(),
loraMetadataCache: new Map(),
showFavoritesOnly: false,
duplicatesMode: false,
},
recipes: {
@@ -86,6 +87,7 @@ export const state = {
tags: []
},
showFavoritesOnly: false,
duplicatesMode: false,
}
},

View File

@@ -87,6 +87,11 @@ export class VirtualScroller {
}
calculateLayout() {
const pageState = getCurrentPageState();
if (pageState.duplicatesMode) {
return false
}
// Get container width and style information
const containerWidth = this.containerElement.clientWidth;
const containerStyle = getComputedStyle(this.containerElement);

View File

@@ -10,3 +10,14 @@ export function formatFileSize(bytes) {
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
}
/**
* Convert timestamp to human readable date string
* @param {number} modified - Timestamp in seconds
* @returns {string} Formatted date string
*/
export function formatDate(modified) {
if (!modified) return '';
const date = new Date(modified * 1000);
return date.toLocaleString();
}