/** * Model Scan Dialog * * Provides a comprehensive interface for scanning model folders with options for: * - Folder selection * - Force metadata refresh * - Include cached models * - Rate limiting for Civitai API * - Progress tracking with cancel support */ import { notifications } from '../shared/notifications.js'; class ModelScanDialog { constructor(options = {}) { this.dialog = null; this.isScanning = false; this.cancelRequested = false; this.currentRequest = null; this.progressInterval = null; this.scanResults = { totalFiles: 0, processed: 0, errors: 0, startTime: null, completed: false }; this.onScanComplete = options.onScanComplete || null; } /** * Show the model scan dialog */ async show() { if (this.dialog) { this.dialog.remove(); } this.dialog = this.createDialog(); document.body.appendChild(this.dialog); // Center the dialog this.centerDialog(); // Load available folders await this.loadAvailableFolders(); } /** * Create the dialog DOM structure */ createDialog() { const dialog = document.createElement('div'); dialog.className = 'model-scan-dialog-overlay'; dialog.innerHTML = `

Model Metadata Scan

Loading folders...
Check to re-download metadata even if recently cached
Uncheck to only scan new/modified models
Delay between API requests to prevent rate limiting (recommended: 1000ms)
`; this.addDialogStyles(); this.bindEvents(dialog); return dialog; } /** * Add CSS styles for the dialog */ addDialogStyles() { if (document.getElementById('model-scan-dialog-styles')) { return; } const style = document.createElement('style'); style.id = 'model-scan-dialog-styles'; style.textContent = ` .model-scan-dialog-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center; z-index: 10000; } .model-scan-dialog { background: var(--comfy-menu-bg); border: 1px solid var(--border-color); border-radius: 8px; width: 90%; max-width: 600px; max-height: 80vh; display: flex; flex-direction: column; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); } .model-scan-header { padding: 16px 20px; border-bottom: 1px solid var(--border-color); display: flex; align-items: center; justify-content: space-between; } .model-scan-header h3 { margin: 0; color: var(--input-text); font-size: 18px; } .close-btn { background: none; border: none; font-size: 24px; cursor: pointer; color: var(--input-text); padding: 0; line-height: 1; } .close-btn:hover { color: var(--error-text); } .model-scan-content { flex: 1; padding: 20px; overflow-y: auto; } .option-group { margin-bottom: 20px; } .option-label { display: block; margin-bottom: 8px; color: var(--input-text); font-weight: 500; } .checkbox-label { display: flex; align-items: center; gap: 8px; color: var(--input-text); cursor: pointer; } .checkbox-label input[type="checkbox"] { margin: 0; } .help-text { display: block; margin-top: 4px; color: var(--descrip-text); font-size: 12px; line-height: 1.4; } .folder-list { border: 1px solid var(--border-color); border-radius: 4px; max-height: 150px; overflow-y: auto; background: var(--comfy-input-bg); } .folder-item { padding: 8px 12px; border-bottom: 1px solid var(--border-color); display: flex; align-items: center; gap: 8px; } .folder-item:last-child { border-bottom: none; } .folder-item:hover { background: var(--comfy-input-bg); filter: brightness(1.1); } .folder-item input[type="checkbox"] { margin: 0; } .folder-name { flex: 1; color: var(--input-text); font-family: monospace; font-size: 13px; } .folder-count { color: var(--descrip-text); font-size: 12px; } .loading { padding: 20px; text-align: center; color: var(--descrip-text); } .folder-summary { margin-top: 8px; padding: 8px 12px; background: var(--comfy-menu-bg); border: 1px solid var(--border-color); border-radius: 4px; } .folder-summary .help-text { margin: 0; font-weight: 500; color: var(--input-text); } #rateLimitDelay { width: 100px; padding: 6px 8px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--comfy-input-bg); color: var(--input-text); } .scan-progress { border: 1px solid var(--border-color); border-radius: 6px; padding: 16px; background: var(--comfy-input-bg); } .progress-info { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .progress-stats { flex: 1; } #progressText { display: block; color: var(--input-text); font-weight: 500; margin-bottom: 4px; } .stats-text { color: var(--descrip-text); font-size: 13px; } .progress-time { color: var(--descrip-text); font-size: 14px; font-family: monospace; } .progress-bar-container { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } .progress-bar { flex: 1; height: 20px; background: #2a2a2a; background: var(--comfy-menu-bg, #2a2a2a); border: 1px solid #555; border: 1px solid var(--border-color, #555); border-radius: 10px; overflow: hidden; position: relative; } .progress-fill { height: 100%; background: linear-gradient(90deg, #007acc, #005a9e); background: linear-gradient(90deg, var(--primary-color, #007acc), var(--primary-color-hover, #005a9e)); transition: width 0.3s ease; width: 0%; } .progress-percentage { min-width: 45px; text-align: right; color: var(--input-text); font-family: monospace; font-size: 13px; } .scan-log { max-height: 120px; overflow-y: auto; font-family: monospace; font-size: 12px; line-height: 1.4; background: var(--comfy-menu-bg); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; } .log-entry { margin-bottom: 2px; color: var(--descrip-text); } .log-entry.success { color: var(--success-text); } .log-entry.error { color: var(--error-text); } .log-entry.info { color: var(--info-text); } .model-scan-footer { padding: 16px 20px; border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; gap: 12px; } .btn { padding: 8px 16px; border: none; border-radius: 4px; font-size: 14px; cursor: pointer; transition: all 0.2s; } .btn-primary { background: var(--primary-color); color: white; } .btn-primary:hover { background: var(--primary-color-hover); } .btn-primary:disabled { background: var(--border-color); cursor: not-allowed; } .btn-secondary { background: var(--comfy-input-bg); color: var(--input-text); border: 1px solid var(--border-color); } .btn-secondary:hover { background: var(--comfy-menu-bg); } `; document.head.appendChild(style); } /** * Bind event handlers */ bindEvents(dialog) { // Close button dialog.querySelector('.close-btn').addEventListener('click', () => { this.close(); }); // Cancel button const cancelBtn = dialog.querySelector('#cancelBtn'); cancelBtn.addEventListener('click', () => { if (this.isScanning) { this.cancelScan(); } else { this.close(); } }); // Start scan button dialog.querySelector('#startScanBtn').addEventListener('click', () => { this.startScan(); }); // Close on overlay click dialog.addEventListener('click', (e) => { if (e.target === dialog) { if (!this.isScanning) { this.close(); } } }); // Prevent closing during scan with Escape document.addEventListener('keydown', this.handleKeydown.bind(this)); } /** * Handle keyboard events */ handleKeydown(e) { if (e.key === 'Escape' && !this.isScanning) { this.close(); } } /** * Center the dialog on screen */ centerDialog() { // Already centered with CSS flexbox, but we could add animation here requestAnimationFrame(() => { this.dialog.style.opacity = '0'; this.dialog.style.transform = 'scale(0.95)'; this.dialog.style.transition = 'opacity 0.2s, transform 0.2s'; requestAnimationFrame(() => { this.dialog.style.opacity = '1'; this.dialog.style.transform = 'scale(1)'; }); }); } /** * Load available model folders from server */ async loadAvailableFolders() { try { const response = await fetch('/sage_cache/scan_model_folders'); if (!response.ok) { throw new Error(`Failed to load folders: ${response.status}`); } const data = await response.json(); this.renderFolderList(data.folders || []); } catch (error) { console.error('Error loading folders:', error); this.renderFolderList([], 'Failed to load folders'); } } /** * Render the folder selection list */ renderFolderList(folders, errorMessage = null) { const folderList = this.dialog.querySelector('#folderList'); if (errorMessage) { folderList.innerHTML = `
${errorMessage}
`; return; } if (!folders.length) { folderList.innerHTML = '
No model folders found
'; return; } folderList.innerHTML = folders.map(folder => `
(${folder.count || 0} files)
`).join(''); // Add change listeners to update summary const checkboxes = folderList.querySelectorAll('input[type="checkbox"]'); checkboxes.forEach(checkbox => { checkbox.addEventListener('change', () => this.updateFolderSummary(folders)); }); this.updateFolderSummary(folders); } /** * Update the folder selection summary */ updateFolderSummary(allFolders) { const selectedFolders = Array.from(this.dialog.querySelectorAll('.folder-item input:checked')) .map(checkbox => { try { // Try to parse as JSON array (new format) return JSON.parse(checkbox.value); } catch { // Fallback to single path (old format) return [checkbox.value]; } }) .flat(); // Flatten array of arrays into single array of paths const selectedFolderData = allFolders.filter(folder => { // Check if any of this folder's paths are selected const folderPaths = folder.paths || [folder.path]; return folderPaths.some(path => selectedFolders.includes(path)); }); const totalFiles = selectedFolderData.reduce((sum, folder) => sum + (folder.count || 0), 0); const summaryElement = this.dialog.querySelector('#folderSummary'); const selectedCountElement = this.dialog.querySelector('#selectedFolderCount'); const totalFileCountElement = this.dialog.querySelector('#totalFileCount'); if (summaryElement && selectedCountElement && totalFileCountElement) { selectedCountElement.textContent = selectedFolderData.length; // Number of categories, not paths totalFileCountElement.textContent = totalFiles; summaryElement.style.display = selectedFolderData.length > 0 ? 'block' : 'none'; } } /** * Get selected scan options */ getScanOptions() { const selectedFolders = Array.from(this.dialog.querySelectorAll('.folder-item input:checked')) .map(checkbox => { try { // Try to parse as JSON array (new format) return JSON.parse(checkbox.value); } catch { // Fallback to single path (old format) return [checkbox.value]; } }) .flat(); // Flatten array of arrays into single array of paths return { folders: selectedFolders, forceRefresh: this.dialog.querySelector('#forceRefresh').checked, includeCached: this.dialog.querySelector('#includeCached').checked, rateLimitDelay: parseInt(this.dialog.querySelector('#rateLimitDelay').value, 10) }; } /** * Start the model scan process */ async startScan() { const options = this.getScanOptions(); if (!options.folders.length) { notifications.error('Please select at least one folder to scan'); return; } this.isScanning = true; this.cancelRequested = false; this.scanResults = { totalFiles: 0, processed: 0, errors: 0, startTime: Date.now() }; // Update UI for scanning state this.updateScanUI(true); try { // Start the scan await this.performScan(options); // showScanComplete is called from pollScanProgress when complete } catch (error) { console.error('Scan error:', error); this.addLogEntry(`Scan failed: ${error.message}`, 'error'); notifications.error('Scan failed', error.message); } finally { this.isScanning = false; this.updateScanUI(false); } } /** * Perform the actual scan process */ async performScan(options) { this.addLogEntry('Starting model scan...', 'info'); this.updateProgress('Initializing scan...', 0); // Call the new POST endpoint to start the scan const scanData = { folders: options.folders, force: options.forceRefresh, include_cached: options.includeCached }; try { // Start the scan (non-blocking) const startResponse = await fetch('/sage_cache/scan_model_folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(scanData) }); if (!startResponse.ok) { const errorText = await startResponse.text(); throw new Error(`Server error ${startResponse.status}: ${errorText}`); } // Start polling for progress updates await this.pollScanProgress(); } catch (error) { throw error; } } /** * Poll the server for scan progress updates */ async pollScanProgress() { return new Promise((resolve, reject) => { const pollInterval = setInterval(async () => { try { if (this.cancelRequested) { clearInterval(pollInterval); await this.cancelServerScan(); resolve(); return; } const response = await fetch('/sage_cache/scan_progress'); if (!response.ok) { throw new Error(`Failed to get progress: ${response.status}`); } const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to get progress'); } const progress = data.progress; console.log('Received progress data:', progress); // Update UI based on progress if (progress.active) { // Calculate percentage const percentage = progress.total > 0 ? (progress.current / progress.total) * 100 : 0; // Update progress display const statusText = progress.current_file || progress.status || 'Processing...'; console.log(`Updating progress: ${statusText}, ${percentage}%`); this.updateProgress(statusText, percentage); // Update scan results for stats display this.scanResults.totalFiles = progress.total; this.scanResults.processed = progress.current; // Log status updates if (progress.status === 'discovering_folders') { this.addLogEntry('Discovering model folders...', 'info'); } else if (progress.status === 'scanning_files') { this.addLogEntry(`Found ${progress.total} files to process`, 'info'); } else if (progress.status === 'processing_metadata' && progress.current_file) { // Only log every 10th file to avoid spam if (progress.current % 10 === 0 || progress.current === progress.total) { this.addLogEntry(`Processing: ${progress.current_file}`, 'success'); } } } else { // Scan is complete or stopped clearInterval(pollInterval); if (progress.status === 'completed') { this.updateProgress('Scan completed successfully', 100); this.addLogEntry(`Scan completed: ${progress.current}/${progress.total} files processed`, 'success'); this.showScanComplete(); resolve(); } else if (progress.status === 'cancelled') { this.updateProgress('Scan cancelled', progress.total > 0 ? (progress.current / progress.total) * 100 : 0); this.addLogEntry('Scan cancelled by user', 'info'); resolve(); } else if (progress.status === 'error') { this.updateProgress('Scan failed', 0); this.addLogEntry(`Scan failed: ${progress.error}`, 'error'); reject(new Error(progress.error || 'Scan failed')); } else { // Unknown status, assume completion this.updateProgress('Scan completed', 100); this.addLogEntry('Scan completed', 'success'); this.showScanComplete(); resolve(); } } } catch (error) { clearInterval(pollInterval); console.error('Progress polling error:', error); reject(error); } }, 1000); // Poll every second }); } /** * Cancel the scan on the server */ async cancelServerScan() { try { const response = await fetch('/sage_cache/cancel_scan', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); if (response.ok) { this.addLogEntry('Scan cancellation requested', 'info'); } } catch (error) { console.error('Failed to cancel scan on server:', error); } } /** * Cancel the current scan */ async cancelScan() { this.cancelRequested = true; this.addLogEntry('Cancelling scan...', 'info'); // Immediately call the server to cancel try { await this.cancelServerScan(); } catch (error) { console.error('Error cancelling scan:', error); this.addLogEntry('Error cancelling scan: ' + error.message, 'error'); } } /** * Update the scan UI state */ updateScanUI(scanning) { const startBtn = this.dialog.querySelector('#startScanBtn'); const cancelBtn = this.dialog.querySelector('#cancelBtn'); const progressSection = this.dialog.querySelector('#scanProgress'); const optionsSection = this.dialog.querySelector('.scan-options'); if (scanning) { startBtn.disabled = true; startBtn.textContent = 'Scanning...'; cancelBtn.textContent = 'Cancel Scan'; progressSection.style.display = 'block'; optionsSection.style.display = 'none'; this.startProgressTimer(); } else { startBtn.disabled = false; startBtn.textContent = 'Start Scan'; cancelBtn.textContent = 'Close'; this.stopProgressTimer(); } } /** * Start the progress timer */ startProgressTimer() { this.progressInterval = setInterval(() => { if (this.scanResults.startTime) { const elapsed = Date.now() - this.scanResults.startTime; const minutes = Math.floor(elapsed / 60000); const seconds = Math.floor((elapsed % 60000) / 1000); const timeStr = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; this.dialog.querySelector('#elapsedTime').textContent = timeStr; // Show file processing stats if available const { processed, totalFiles, errors } = this.scanResults; let statsText = 'Scanning model folders...'; if (totalFiles > 0) { statsText = `${processed}/${totalFiles} processed`; if (errors > 0) { statsText += `, ${errors} errors`; } } this.dialog.querySelector('#progressStats').textContent = statsText; } }, 1000); } /** * Stop the progress timer */ stopProgressTimer() { if (this.progressInterval) { clearInterval(this.progressInterval); this.progressInterval = null; } } /** * Update progress display */ updateProgress(text, percentage) { console.log(`UpdateProgress called: ${text}, ${percentage}%`); const progressFill = this.dialog.querySelector('#progressFill'); const progressText = this.dialog.querySelector('#progressText'); const progressPercentage = this.dialog.querySelector('#progressPercentage'); if (progressText) { progressText.textContent = text; } if (progressFill) { const width = `${Math.max(0, Math.min(100, percentage))}%`; console.log(`Setting progress bar width to: ${width}`); console.log('Progress fill element:', progressFill); console.log('Current computed style:', window.getComputedStyle(progressFill)); // Set the width progressFill.style.width = width; // Add inline background color as fallback in case CSS variables aren't working progressFill.style.background = 'linear-gradient(90deg, #007acc, #005a9e)'; progressFill.style.height = '100%'; // Force a repaint to ensure the change is applied progressFill.offsetHeight; console.log('After setting width - computed style:', window.getComputedStyle(progressFill)); } else { console.error('Progress fill element not found!'); } if (progressPercentage) { progressPercentage.textContent = `${Math.round(percentage)}%`; } } /** * Add entry to scan log */ addLogEntry(message, type = 'info') { const logContainer = this.dialog.querySelector('#scanLog'); const entry = document.createElement('div'); entry.className = `log-entry ${type}`; entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; logContainer.appendChild(entry); logContainer.scrollTop = logContainer.scrollHeight; } /** * Show scan completion summary */ showScanComplete() { // This method is called from the polling system when scan completes // The progress and log messages are already updated in pollScanProgress this.scanResults.completed = true; notifications.success('Model scan completed successfully'); // Call the completion callback if provided if (this.onScanComplete && typeof this.onScanComplete === 'function') { try { this.onScanComplete(); } catch (error) { console.error('Error in scan completion callback:', error); } } } /** * Utility function for delays */ sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Close and cleanup the dialog */ close() { if (this.isScanning) { // Don't allow closing during scan return; } this.stopProgressTimer(); document.removeEventListener('keydown', this.handleKeydown.bind(this)); if (this.dialog) { this.dialog.style.opacity = '0'; this.dialog.style.transform = 'scale(0.95)'; setTimeout(() => { if (this.dialog && this.dialog.parentNode) { this.dialog.remove(); } this.dialog = null; }, 200); } } } // Export for use in other modules export { ModelScanDialog };