Files
Will Miao 14e90006de fix(previews): detect video previews from the versioned preview URL
Preview URLs now carry a cache-busting `&v=<mtime_ns>` suffix, so the media
type could no longer be read from the end of the string. Model cards,
duplicates, recipe cards and modals, the showcase thumbnail and the ComfyUI
preview tooltip all fell into the image branch and rendered `<img>` for video
previews, which browsers cannot decode.

Move the query-aware video detection that already lived in the versions tab
into a shared mediaTypes helper and use it everywhere a server-provided
preview URL is inspected. The web UI and the ComfyUI widget bundle each keep a
copy because they are served from different mounts. Video previews that turn
out to be webm now also get the right `<source type>`.

Add regression coverage for versioned mp4/webm/image URLs.

Fixes #1168
2026-10-31 16:46:04 +08:00

1037 lines
40 KiB
JavaScript

// Model Duplicates Manager Component for LoRAs and Checkpoints
import { showToast, showActionToast } from '../utils/uiHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { state, getCurrentPageState } from '../state/index.js';
import { formatDate } from '../utils/formatters.js';
import { getPreviewMediaExtension, isVideoPreviewUrl } from '../utils/mediaTypes.js';
import { resetAndReload} from '../api/modelApiFactory.js';
import { getShowDuplicatesNotification, setShowDuplicatesNotification } from '../utils/storageHelpers.js';
export class ModelDuplicatesManager {
constructor(pageManager, modelType = 'loras') {
this.pageManager = pageManager;
this.duplicateGroups = [];
this.inDuplicateMode = false;
this.selectedForDeletion = new Set();
this.modelType = modelType; // Use the provided modelType or default to 'loras'
// Verification tracking
this.verifiedGroups = new Set(); // Track which groups have been verified
this.mismatchedFiles = new Map(); // Map file paths to actual hashes for mismatched files
// Badge visibility preference
this.showBadge = getShowDuplicatesNotification(); // Default to true (show badge)
// Event handler references for cleanup
this.badgeToggleHandler = null;
this.helpTooltipHandlers = {
mouseenter: null,
mouseleave: null,
click: null
};
// Bind methods
this.renderModelCard = this.renderModelCard.bind(this);
this.renderTooltip = this.renderTooltip.bind(this);
this.checkDuplicatesCount = this.checkDuplicatesCount.bind(this);
this.handleVerifyHashes = this.handleVerifyHashes.bind(this);
// Keep track of which controls need to be re-enabled
this.disabledControls = [];
// Check for duplicates on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', this.checkDuplicatesCount);
} else {
this.checkDuplicatesCount();
}
}
// Method to check for duplicates count using existing endpoint
async checkDuplicatesCount() {
try {
const params = this._buildFilterQueryParams();
const endpoint = `/api/lm/${this.modelType}/find-duplicates`;
const url = params.toString() ? `${endpoint}?${params}` : endpoint;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to get duplicates count: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
const duplicatesCount = (data.duplicates || []).length;
this.updateDuplicatesBadge(duplicatesCount);
} else {
this.updateDuplicatesBadge(0);
}
} catch (error) {
console.error('Error checking duplicates count:', error);
this.updateDuplicatesBadge(0);
}
}
// Method to update the badge
updateDuplicatesBadge(count) {
const badge = document.getElementById('duplicatesBadge');
if (!badge) return;
// Check if badge should be hidden based on user preference
if (!this.showBadge && !this.inDuplicateMode) {
badge.style.display = 'none';
badge.textContent = '';
badge.classList.remove('pulse');
return;
}
if (count > 0) {
badge.style.display = 'inline-flex';
badge.textContent = count;
badge.classList.add('pulse');
} else {
badge.textContent = '';
badge.classList.remove('pulse');
}
}
// Toggle method to enter/exit duplicates mode
toggleDuplicateMode() {
if (this.inDuplicateMode) {
this.exitDuplicateMode();
} else {
this.findDuplicates();
}
}
async findDuplicates() {
try {
const params = this._buildFilterQueryParams();
const endpoint = `/api/lm/${this.modelType}/find-duplicates`;
const url = params.toString() ? `${endpoint}?${params}` : endpoint;
const response = await fetch(url);
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 || [];
this._pruneVerificationState();
// Update the badge with the current count
this.updateDuplicatesBadge(this.duplicateGroups.length);
if (this.duplicateGroups.length === 0) {
showToast('toast.duplicates.noDuplicatesFound', { type: this.modelType }, 'info');
// If already in duplicate mode, exit to clear the display
if (this.inDuplicateMode) {
this.exitDuplicateMode();
}
return false;
}
this.enterDuplicateMode();
return true;
} catch (error) {
console.error('Error finding duplicates:', error);
showToast('toast.duplicates.findFailed', { message: error.message }, 'error');
return false;
}
}
/**
* Build query parameters from current filter state for duplicate finding.
* @returns {URLSearchParams} The query parameters to append to the API endpoint
*/
_buildFilterQueryParams() {
const params = new URLSearchParams();
const pageState = getCurrentPageState();
const filters = pageState?.filters;
if (!filters) return params;
// Base model filters
if (filters.baseModel && Array.isArray(filters.baseModel)) {
filters.baseModel.forEach(m => params.append('base_model', m));
}
// Tag filters (tri-state: include/exclude)
if (filters.tags && typeof filters.tags === 'object') {
Object.entries(filters.tags).forEach(([tag, state]) => {
if (state === 'include') {
params.append('tag_include', tag);
} else if (state === 'exclude') {
params.append('tag_exclude', tag);
}
});
}
// Model type filters
if (filters.modelTypes && Array.isArray(filters.modelTypes)) {
filters.modelTypes.forEach(t => params.append('model_type', t));
}
// Folder filter (from active folder state)
if (pageState.activeFolder) {
params.append('folder', pageState.activeFolder);
}
// Favorites filter
if (pageState.showFavoritesOnly) {
params.append('favorites_only', 'true');
}
return params;
}
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';
// Setup help tooltip behavior
this.setupHelpTooltip();
// Setup badge toggle control
this.setupBadgeToggle();
}
// 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();
// Update Duplicates button to show active state
const duplicatesBtn = document.getElementById('findDuplicatesBtn');
if (duplicatesBtn) {
duplicatesBtn.classList.add('active');
duplicatesBtn.title = 'Exit Duplicates Mode';
// Change icon and text to indicate it's now an exit button
duplicatesBtn.innerHTML = '<i class="fas fa-times"></i> Exit Duplicates';
}
// Disable all control buttons except the duplicates button
this.disableControlButtons();
}
exitDuplicateMode() {
this.inDuplicateMode = false;
this.selectedForDeletion.clear();
// Update state
const pageState = getCurrentPageState();
pageState.duplicatesMode = false;
// Clean up event handlers before hiding banner
this.cleanupEventHandlers();
// Hide duplicates banner
const banner = document.getElementById('duplicatesBanner');
if (banner) {
banner.style.display = 'none';
}
// Remove duplicate-mode class from the body
document.body.classList.remove('duplicate-mode');
// Clear the model grid first
const modelGrid = document.getElementById('modelGrid');
if (modelGrid) {
modelGrid.innerHTML = '';
}
// Re-enable virtual scrolling
state.virtualScroller.enable();
// Restore Duplicates button to its original state
const duplicatesBtn = document.getElementById('findDuplicatesBtn');
if (duplicatesBtn) {
duplicatesBtn.classList.remove('active');
duplicatesBtn.title = 'Find duplicate models';
duplicatesBtn.innerHTML = 'Duplicates <span id="duplicatesBadge" class="badge"></span>';
// Restore badge
const newBadge = duplicatesBtn.querySelector('#duplicatesBadge');
const oldBadge = document.getElementById('duplicatesBadge');
if (oldBadge && oldBadge.textContent) {
newBadge.textContent = oldBadge.textContent;
newBadge.classList.add('pulse');
}
}
// Re-enable all control buttons
this.enableControlButtons();
this.checkDuplicatesCount();
}
// Disable all control buttons except the duplicates button
disableControlButtons() {
this.disabledControls = [];
// Select all control buttons except the duplicates button
const controlButtons = document.querySelectorAll('.control-group button:not(#findDuplicatesBtn), .dropdown-group, .toggle-folders-btn, #favoriteFilterBtn');
controlButtons.forEach(button => {
// Only disable enabled buttons (don't disable already disabled buttons)
if (!button.disabled && !button.classList.contains('disabled')) {
this.disabledControls.push(button);
button.disabled = true;
button.classList.add('disabled-during-duplicates');
}
});
}
// Re-enable all previously disabled control buttons
enableControlButtons() {
this.disabledControls.forEach(button => {
button.disabled = false;
button.classList.remove('disabled-during-duplicates');
});
this.disabledControls = [];
}
renderDuplicateGroups() {
const modelGrid = document.getElementById('modelGrid');
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';
// Create verification status badge
const verificationBadge = document.createElement('span');
verificationBadge.className = 'verification-badge';
if (this.verifiedGroups.has(group.hash)) {
verificationBadge.classList.add('verified');
verificationBadge.innerHTML = '<i class="fas fa-check-circle"></i> Verified';
} else {
verificationBadge.classList.add('metadata');
verificationBadge.innerHTML = '<i class="fas fa-tag"></i> Metadata Hash';
}
header.innerHTML = `
<span>Duplicate Group #${groupIndex + 1} (${group.models.length} models with same hash: ${group.hash})</span>
<span>
<button class="btn-verify-hashes" data-hash="${group.hash}" title="Recalculate SHA256 hashes to verify if these are true duplicates">
<i class="fas fa-fingerprint"></i> Verify Hashes
</button>
<button class="btn-select-all" onclick="modelDuplicatesManager.toggleSelectAllInGroup('${group.hash}')">
Select All
</button>
</span>
`;
// Insert verification badge after the group title
const headerFirstSpan = header.querySelector('span:first-child');
headerFirstSpan.appendChild(verificationBadge);
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);
// Add event listener to the verify hashes button
const verifyButton = header.querySelector('.btn-verify-hashes');
if (verifyButton) {
verifyButton.addEventListener('click', (e) => {
e.stopPropagation();
this.handleVerifyHashes(group);
});
}
});
}
_getGroupFilePaths(group) {
return new Set((group?.models || []).map(model => model.file_path));
}
_clearMismatchStateForGroup(group) {
this._getGroupFilePaths(group).forEach(filePath => {
this.mismatchedFiles.delete(filePath);
});
}
_pruneVerificationState() {
const visiblePaths = new Set();
const visibleHashes = new Set();
this.duplicateGroups.forEach(group => {
visibleHashes.add(group.hash);
this._getGroupFilePaths(group).forEach(filePath => visiblePaths.add(filePath));
});
Array.from(this.mismatchedFiles.keys()).forEach(filePath => {
if (!visiblePaths.has(filePath)) {
this.mismatchedFiles.delete(filePath);
}
});
Array.from(this.selectedForDeletion).forEach(filePath => {
if (!visiblePaths.has(filePath)) {
this.selectedForDeletion.delete(filePath);
}
});
Array.from(this.verifiedGroups).forEach(hash => {
if (!visibleHashes.has(hash)) {
this.verifiedGroups.delete(hash);
}
});
}
renderModelCard(model, groupHash) {
// Create basic card structure
const card = document.createElement('div');
card.className = 'model-card duplicate';
card.dataset.hash = model.sha256;
card.dataset.filePath = model.file_path;
// Check if this model is a mismatched file
const isMismatched = this.mismatchedFiles.has(model.file_path);
// Add mismatched class if needed
if (isMismatched) {
card.classList.add('hash-mismatch');
}
// 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 (the URL may be versioned, so the
// extension lives in the decoded `path` query parameter)
const isVideo = isVideoPreviewUrl(model.preview_url);
let preview;
if (isVideo) {
// Create video element for video 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 = getPreviewMediaExtension(model.preview_url) === '.webm' ? 'video/webm' : '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);
// Add hash mismatch badge if needed
if (isMismatched) {
const mismatchBadge = document.createElement('div');
mismatchBadge.className = 'mismatch-badge';
mismatchBadge.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Different Hash';
previewContainer.appendChild(mismatchBadge);
}
// Mark as latest if applicable
if (model.is_latest) {
card.classList.add('latest');
}
// 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');
}
// Disable checkbox for mismatched files
if (isMismatched) {
checkbox.disabled = true;
checkbox.title = "This file has a different actual hash and can't be 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;
}
// Don't toggle if it's a mismatched file
if (isMismatched) {
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';
// Check if this model is a mismatched file and get the actual hash
const isMismatched = this.mismatchedFiles.has(model.file_path);
const actualHash = isMismatched ? this.mismatchedFiles.get(model.file_path) : null;
// Add model information to tooltip
let tooltipContent = `
<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><strong>Metadata Hash:</strong> <span class="hash-value">${model.sha256}</span></div>
`;
// Add actual hash information if available
if (isMismatched && actualHash) {
tooltipContent += `<div class="hash-mismatch-info"><strong>Actual Hash:</strong> <span class="hash-value">${actualHash}</span></div>`;
}
tooltipContent += `</div>`;
tooltip.innerHTML = tooltipContent;
// 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 selectableCheckboxes = Array.from(checkboxes).filter(checkbox => !checkbox.disabled);
const allSelected = selectableCheckboxes.length > 0 && selectableCheckboxes.every(checkbox => checkbox.checked);
// If all are selected, deselect all; otherwise select all
selectableCheckboxes.forEach(checkbox => {
checkbox.checked = !allSelected;
const filePath = checkbox.dataset.filePath;
const card = checkbox.closest('.model-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('toast.duplicates.noItemsSelected', { type: this.modelType }, '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('toast.duplicates.deleteError', { message: 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/lm/${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');
}
const results = Array.isArray(data.results) ? data.results : [];
const failures = results.filter(entry => entry && entry.success === false);
const batchIds = !data.batch_id && Array.isArray(data.batch_ids) && data.batch_ids.length
? data.batch_ids
: null;
if (data.batch_id || batchIds) {
// One undo action restores the whole selected group
const refreshFn = () => resetAndReload(true);
const onAction = data.batch_id
? () => handleUndoDelete(data.batch_id, refreshFn)
: async () => {
for (const id of batchIds) {
const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false });
if (!succeeded) {
showToast('toast.undo.failed', { error: '' }, 'error');
return;
}
}
refreshFn();
showToast('toast.undo.restored', {}, 'success');
};
showActionToast('toast.undo.deletedBulk', { count: data.total_deleted }, 'success', {
actionText: translate('toast.undo.action'),
onAction,
});
} else {
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: this.modelType }, 'success');
}
// Per-file failures are reported in `results` while the request as
// a whole still succeeded; never hide them behind the success toast.
if (failures.length > 0) {
showToast('toast.duplicates.deletePartialFailed', {
failed: failures.length,
type: this.modelType,
message: failures[0].error || '',
}, 'error');
}
// Files are gone from disk but the library cache was not updated:
// the deleted cards would otherwise reappear after the refresh and
// only a full rebuild can clear them.
if (data.cache_updated === false && data.total_deleted > 0) {
showToast('toast.duplicates.cacheUpdateFailed', {}, 'warning');
}
// If models were successfully deleted
if (data.total_deleted > 0) {
await resetAndReload(true);
// Check if there are still duplicates
try {
const endpoint = `/api/lm/${this.modelType}/find-duplicates`;
const dupResponse = await fetch(endpoint);
if (!dupResponse.ok) {
throw new Error(`Failed to get duplicates: ${dupResponse.statusText}`);
}
const dupData = await dupResponse.json();
const remainingDuplicatesCount = (dupData.duplicates || []).length;
// Update badge count
this.updateDuplicatesBadge(remainingDuplicatesCount);
// If no more duplicates, exit duplicate mode
if (remainingDuplicatesCount === 0) {
this.exitDuplicateMode();
} else {
// If duplicates remain, refresh duplicate groups display
this.duplicateGroups = dupData.duplicates || [];
this.selectedForDeletion.clear();
this.renderDuplicateGroups();
this.updateSelectedCount();
}
} catch (error) {
console.error('Error checking remaining duplicates:', error);
}
}
} catch (error) {
console.error('Error deleting models:', error);
showToast('toast.duplicates.deleteFailed', { type: this.modelType, message: error.message }, 'error');
}
}
// Public method to update the badge after refresh
updateDuplicatesBadgeAfterRefresh() {
// Use this method after refresh operations
this.checkDuplicatesCount();
}
// Add this new method for tooltip behavior
setupHelpTooltip() {
const helpIcon = document.getElementById('duplicatesHelp');
const helpTooltip = document.getElementById('duplicatesHelpTooltip');
if (!helpIcon || !helpTooltip) return;
// Clean up existing handlers first
this.cleanupHelpTooltipHandlers();
// Create new handler functions and store references
this.helpTooltipHandlers.mouseenter = (e) => {
// Get the container's positioning context
const bannerContent = helpIcon.closest('.banner-content');
// Get positions relative to the viewport
const iconRect = helpIcon.getBoundingClientRect();
const bannerRect = bannerContent.getBoundingClientRect();
// Set initial position relative to the banner content
helpTooltip.style.display = 'block';
helpTooltip.style.top = `${iconRect.bottom - bannerRect.top + 10}px`;
helpTooltip.style.left = `${iconRect.left - bannerRect.left - 10}px`;
// Check if the tooltip is going off-screen to the right
const tooltipRect = helpTooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
if (tooltipRect.right > viewportWidth - 20) {
// Reposition relative to container if too close to right edge
helpTooltip.style.left = `${bannerContent.offsetWidth - tooltipRect.width - 20}px`;
}
};
this.helpTooltipHandlers.mouseleave = () => {
helpTooltip.style.display = 'none';
};
this.helpTooltipHandlers.click = (e) => {
if (!helpIcon.contains(e.target)) {
helpTooltip.style.display = 'none';
}
};
// Add event listeners
helpIcon.addEventListener('mouseenter', this.helpTooltipHandlers.mouseenter);
helpIcon.addEventListener('mouseleave', this.helpTooltipHandlers.mouseleave);
document.addEventListener('click', this.helpTooltipHandlers.click);
}
// Handle verify hashes button click
async handleVerifyHashes(group) {
try {
const groupHash = group.hash;
// Check if already verified
if (this.verifiedGroups.has(groupHash)) {
showToast('toast.models.verificationAlreadyDone', {}, 'info');
return;
}
// Show loading state
state.loadingManager.showSimpleLoading('Verifying hashes...');
// Get file paths for all models in the group
const filePaths = group.models.map(model => model.file_path);
// Make API request to verify hashes
const response = await fetch(`/api/lm/${this.modelType}/verify-duplicates`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ file_paths: filePaths })
});
if (!response.ok) {
throw new Error(`Verification failed: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Unknown error during verification');
}
// Process verification results
const verifiedAsDuplicates = data.verified_as_duplicates;
const mismatchedFiles = data.mismatched_files || [];
this._clearMismatchStateForGroup(group);
// Update mismatchedFiles map
if (data.new_hash_map) {
Object.entries(data.new_hash_map).forEach(([path, hash]) => {
this.mismatchedFiles.set(path, hash);
this.selectedForDeletion.delete(path);
});
}
// Mark this group as verified
this.verifiedGroups.add(groupHash);
// Re-render the duplicate groups to show verification status
this.renderDuplicateGroups();
this.updateSelectedCount();
// Show appropriate toast message
if (mismatchedFiles.length > 0) {
showToast('toast.models.verificationCompleteMismatch', { count: mismatchedFiles.length }, 'warning');
} else {
showToast('toast.models.verificationCompleteSuccess', {}, 'success');
}
} catch (error) {
console.error('Error verifying hashes:', error);
showToast('toast.models.verificationFailed', { message: error.message }, 'error');
} finally {
// Hide loading state
state.loadingManager.hide();
}
}
// Add this new method for badge toggle setup
setupBadgeToggle() {
const toggleControl = document.getElementById('badgeToggleControl');
const toggleInput = document.getElementById('badgeToggleInput');
if (!toggleControl || !toggleInput) return;
// Clean up existing handler first
this.cleanupBadgeToggleHandler();
// Set initial state based on stored preference (default to true/checked)
toggleInput.checked = this.showBadge;
// Create and store the handler function
this.badgeToggleHandler = (e) => {
this.showBadge = e.target.checked;
setShowDuplicatesNotification(this.showBadge);
// Update badge visibility immediately if not in duplicate mode
if (!this.inDuplicateMode) {
this.updateDuplicatesBadge(this.duplicateGroups.length);
}
showToast(
this.showBadge ? 'Duplicates notification will be shown' : 'Duplicates notification will be hidden',
'info'
);
};
// Add change event listener
toggleInput.addEventListener('change', this.badgeToggleHandler);
}
// Clean up all event handlers
cleanupEventHandlers() {
this.cleanupBadgeToggleHandler();
this.cleanupHelpTooltipHandlers();
}
// Clean up badge toggle event handler
cleanupBadgeToggleHandler() {
if (this.badgeToggleHandler) {
const toggleInput = document.getElementById('badgeToggleInput');
if (toggleInput) {
toggleInput.removeEventListener('change', this.badgeToggleHandler);
}
this.badgeToggleHandler = null;
}
}
// Clean up help tooltip event handlers
cleanupHelpTooltipHandlers() {
const helpIcon = document.getElementById('duplicatesHelp');
if (helpIcon && this.helpTooltipHandlers.mouseenter) {
helpIcon.removeEventListener('mouseenter', this.helpTooltipHandlers.mouseenter);
}
if (helpIcon && this.helpTooltipHandlers.mouseleave) {
helpIcon.removeEventListener('mouseleave', this.helpTooltipHandlers.mouseleave);
}
if (this.helpTooltipHandlers.click) {
document.removeEventListener('click', this.helpTooltipHandlers.click);
}
// Reset handler references
this.helpTooltipHandlers = {
mouseenter: null,
mouseleave: null,
click: null
};
// Hide tooltip if it's visible
const helpTooltip = document.getElementById('duplicatesHelpTooltip');
if (helpTooltip) {
helpTooltip.style.display = 'none';
}
}
}