From 7f205cdcc828aadcaca6daba2d06ce243bbda932 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Fri, 25 Jul 2025 17:35:06 +0800 Subject: [PATCH] refactor: unify model download system across all model types - Add download-related methods to baseModelApi.js for fetching versions, roots, folders, and downloading models - Replace separate download managers with a unified DownloadManager.js supporting all model types - Create a single download_modals.html template that adapts to model type (LoRA, checkpoint, etc.) - Remove old download modals from lora_modals.html and checkpoint_modals.html - Update apiConfig.js to include civitaiVersions endpoints for each model type - Centralize event handler binding in DownloadManager.js (no more inline HTML handlers) - Modal UI and logic now auto-adapt to the current model type, making future extension easier --- static/js/api/apiConfig.js | 5 +- static/js/api/baseModelApi.js | 80 +++ static/js/checkpoints.js | 4 - .../ContextMenu/CheckpointContextMenu.js | 7 +- .../controls/CheckpointsControls.js | 7 +- .../js/components/controls/LorasControls.js | 7 +- static/js/loras.js | 5 - .../js/managers/CheckpointDownloadManager.js | 463 ------------------ static/js/managers/DownloadManager.js | 259 +++++----- templates/checkpoints.html | 1 - templates/components/checkpoint_modals.html | 73 --- templates/components/lora_modals.html | 102 ---- templates/components/modals.html | 7 +- .../components/modals/download_modal.html | 62 +++ templates/components/modals/move_modal.html | 36 ++ templates/loras.html | 4 - 16 files changed, 337 insertions(+), 785 deletions(-) delete mode 100644 static/js/managers/CheckpointDownloadManager.js delete mode 100644 templates/components/checkpoint_modals.html delete mode 100644 templates/components/lora_modals.html create mode 100644 templates/components/modals/download_modal.html create mode 100644 templates/components/modals/move_modal.html diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index 6f02033f..53b3f842 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -103,13 +103,12 @@ export const MODEL_SPECIFIC_ENDPOINTS = { moveBulk: `/api/${MODEL_TYPES.LORA}/move_models_bulk`, getTriggerWordsPost: `/api/${MODEL_TYPES.LORA}/get_trigger_words`, civitaiModelByVersion: `/api/${MODEL_TYPES.LORA}/civitai/model/version`, - civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash` + civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash`, }, [MODEL_TYPES.CHECKPOINT]: { - info: `/api/${MODEL_TYPES.CHECKPOINT}/info` + info: `/api/${MODEL_TYPES.CHECKPOINT}/info`, }, [MODEL_TYPES.EMBEDDING]: { - // Future embedding-specific endpoints } }; diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 692a83db..042871cd 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -581,6 +581,86 @@ class ModelApiClient { return successFilePaths; } + /** + * Fetch Civitai model versions + */ + async fetchCivitaiVersions(modelId) { + try { + const response = await fetch(`${this.apiConfig.endpoints.civitaiVersions}/${modelId}`); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + if (errorData && errorData.error && errorData.error.includes('Model type mismatch')) { + throw new Error(`This model is not a ${this.apiConfig.config.displayName}. Please switch to the appropriate page to download this model type.`); + } + throw new Error('Failed to fetch model versions'); + } + return await response.json(); + } catch (error) { + console.error('Error fetching Civitai versions:', error); + throw error; + } + } + + /** + * Fetch model roots + */ + async fetchModelRoots() { + try { + const response = await fetch(this.apiConfig.endpoints.roots); + if (!response.ok) { + throw new Error(`Failed to fetch ${this.apiConfig.config.displayName} roots`); + } + return await response.json(); + } catch (error) { + console.error('Error fetching model roots:', error); + throw error; + } + } + + /** + * Fetch model folders + */ + async fetchModelFolders() { + try { + const response = await fetch(this.apiConfig.endpoints.folders); + if (!response.ok) { + throw new Error(`Failed to fetch ${this.apiConfig.config.displayName} folders`); + } + return await response.json(); + } catch (error) { + console.error('Error fetching model folders:', error); + throw error; + } + } + + /** + * Download a model + */ + async downloadModel(modelId, versionId, modelRoot, relativePath, downloadId) { + try { + const response = await fetch(DOWNLOAD_ENDPOINTS.download, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model_id: modelId, + model_version_id: versionId, + model_root: modelRoot, + relative_path: relativePath, + download_id: downloadId + }) + }); + + if (!response.ok) { + throw new Error(await response.text()); + } + + return await response.json(); + } catch (error) { + console.error('Error downloading model:', error); + throw error; + } + } + /** * Build query parameters for API requests */ diff --git a/static/js/checkpoints.js b/static/js/checkpoints.js index 69ab6224..cbc02ace 100644 --- a/static/js/checkpoints.js +++ b/static/js/checkpoints.js @@ -2,7 +2,6 @@ import { appCore } from './core.js'; import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js'; 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'; import { MODEL_TYPES } from './api/apiConfig.js'; @@ -13,9 +12,6 @@ class CheckpointsPageManager { // Initialize page controls this.pageControls = createPageControls(MODEL_TYPES.CHECKPOINT); - // Initialize checkpoint download manager - window.checkpointDownloadManager = new CheckpointDownloadManager(); - // Initialize the ModelDuplicatesManager this.duplicatesManager = new ModelDuplicatesManager(this, MODEL_TYPES.CHECKPOINT); diff --git a/static/js/components/ContextMenu/CheckpointContextMenu.js b/static/js/components/ContextMenu/CheckpointContextMenu.js index 8e589b51..b7be4b9f 100644 --- a/static/js/components/ContextMenu/CheckpointContextMenu.js +++ b/static/js/components/ContextMenu/CheckpointContextMenu.js @@ -3,7 +3,7 @@ import { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; import { resetAndReload } from '../../api/checkpointApi.js'; import { getModelApiClient } from '../../api/baseModelApi.js'; import { showToast } from '../../utils/uiHelpers.js'; -import { showExcludeModal } from '../../utils/modalUtils.js'; +import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js'; export class CheckpointContextMenu extends BaseContextMenu { constructor() { @@ -42,10 +42,7 @@ export class CheckpointContextMenu extends BaseContextMenu { apiClient.replaceModelPreview(this.currentCard.dataset.filepath); break; case 'delete': - // Delete checkpoint - if (this.currentCard.querySelector('.fa-trash')) { - this.currentCard.querySelector('.fa-trash').click(); - } + showDeleteModal(this.currentCard.dataset.filepath); break; case 'copyname': // Copy checkpoint name diff --git a/static/js/components/controls/CheckpointsControls.js b/static/js/components/controls/CheckpointsControls.js index f5af38a8..6528a8a5 100644 --- a/static/js/components/controls/CheckpointsControls.js +++ b/static/js/components/controls/CheckpointsControls.js @@ -2,7 +2,7 @@ import { PageControls } from './PageControls.js'; import { loadMoreCheckpoints, resetAndReload, refreshCheckpoints, fetchCivitai } from '../../api/checkpointApi.js'; import { showToast } from '../../utils/uiHelpers.js'; -import { CheckpointDownloadManager } from '../../managers/CheckpointDownloadManager.js'; +import { downloadManager } from '../../managers/DownloadManager.js'; /** * CheckpointsControls class - Extends PageControls for Checkpoint-specific functionality @@ -12,9 +12,6 @@ export class CheckpointsControls extends PageControls { // Initialize with 'checkpoints' page type super('checkpoints'); - // Initialize checkpoint download manager - this.downloadManager = new CheckpointDownloadManager(); - // Register API methods specific to the Checkpoints page this.registerCheckpointsAPI(); } @@ -44,7 +41,7 @@ export class CheckpointsControls extends PageControls { // Add show download modal functionality showDownloadModal: () => { - this.downloadManager.showDownloadModal(); + downloadManager.showDownloadModal(); }, // No clearCustomFilter implementation is needed for checkpoints diff --git a/static/js/components/controls/LorasControls.js b/static/js/components/controls/LorasControls.js index 22f768d4..dee5e128 100644 --- a/static/js/components/controls/LorasControls.js +++ b/static/js/components/controls/LorasControls.js @@ -3,6 +3,7 @@ import { PageControls } from './PageControls.js'; import { loadMoreLoras, fetchCivitai, resetAndReload, refreshLoras } from '../../api/loraApi.js'; import { getSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; import { createAlphabetBar } from '../alphabet/index.js'; +import { downloadManager } from '../../managers/DownloadManager.js'; /** * LorasControls class - Extends PageControls for LoRA-specific functionality @@ -46,11 +47,7 @@ export class LorasControls extends PageControls { }, showDownloadModal: () => { - if (window.downloadManager) { - window.downloadManager.showDownloadModal(); - } else { - console.error('Download manager not available'); - } + downloadManager.showDownloadModal(); }, toggleBulkMode: () => { diff --git a/static/js/loras.js b/static/js/loras.js index c5e2f77a..537ed628 100644 --- a/static/js/loras.js +++ b/static/js/loras.js @@ -3,7 +3,6 @@ import { state } from './state/index.js'; import { loadMoreLoras } from './api/loraApi.js'; import { updateCardsForBulkMode } from './components/LoraCard.js'; import { bulkManager } from './managers/BulkManager.js'; -import { DownloadManager } from './managers/DownloadManager.js'; import { moveManager } from './managers/MoveManager.js'; import { LoraContextMenu } from './components/ContextMenu/index.js'; import { createPageControls } from './components/controls/index.js'; @@ -17,9 +16,6 @@ class LoraPageManager { state.bulkMode = false; state.selectedLoras = new Set(); - // Initialize managers - this.downloadManager = new DownloadManager(); - // Initialize page controls this.pageControls = createPageControls('loras'); @@ -39,7 +35,6 @@ class LoraPageManager { window.closeDeleteModal = closeDeleteModal; window.confirmExclude = confirmExclude; window.closeExcludeModal = closeExcludeModal; - window.downloadManager = this.downloadManager; window.moveManager = moveManager; // Bulk operations diff --git a/static/js/managers/CheckpointDownloadManager.js b/static/js/managers/CheckpointDownloadManager.js deleted file mode 100644 index 7545e617..00000000 --- a/static/js/managers/CheckpointDownloadManager.js +++ /dev/null @@ -1,463 +0,0 @@ -import { modalManager } from './ModalManager.js'; -import { showToast } from '../utils/uiHelpers.js'; -import { LoadingManager } from './LoadingManager.js'; -import { state } from '../state/index.js'; -import { resetAndReload } from '../api/checkpointApi.js'; -import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; - -export class CheckpointDownloadManager { - constructor() { - this.currentVersion = null; - this.versions = []; - this.modelInfo = null; - this.modelVersionId = null; - - this.initialized = false; - this.selectedFolder = ''; - - this.loadingManager = new LoadingManager(); - this.folderClickHandler = null; - this.updateTargetPath = this.updateTargetPath.bind(this); - } - - showDownloadModal() { - console.log('Showing checkpoint download modal...'); - if (!this.initialized) { - const modal = document.getElementById('checkpointDownloadModal'); - if (!modal) { - console.error('Checkpoint download modal element not found'); - return; - } - this.initialized = true; - } - - modalManager.showModal('checkpointDownloadModal', null, () => { - // Cleanup handler when modal closes - this.cleanupFolderBrowser(); - }); - this.resetSteps(); - - // Auto-focus on the URL input - setTimeout(() => { - const urlInput = document.getElementById('checkpointUrl'); - if (urlInput) { - urlInput.focus(); - } - }, 100); // Small delay to ensure the modal is fully displayed - } - - resetSteps() { - document.querySelectorAll('#checkpointDownloadModal .download-step').forEach(step => step.style.display = 'none'); - document.getElementById('cpUrlStep').style.display = 'block'; - document.getElementById('checkpointUrl').value = ''; - document.getElementById('cpUrlError').textContent = ''; - - // Clear new folder input - const newFolderInput = document.getElementById('cpNewFolder'); - if (newFolderInput) { - newFolderInput.value = ''; - } - - this.currentVersion = null; - this.versions = []; - this.modelInfo = null; - this.modelId = null; - this.modelVersionId = null; - - // Clear selected folder and remove selection from UI - this.selectedFolder = ''; - const folderBrowser = document.getElementById('cpFolderBrowser'); - if (folderBrowser) { - folderBrowser.querySelectorAll('.folder-item').forEach(f => - f.classList.remove('selected')); - } - } - - async validateAndFetchVersions() { - const url = document.getElementById('checkpointUrl').value.trim(); - const errorElement = document.getElementById('cpUrlError'); - - try { - this.loadingManager.showSimpleLoading('Fetching model versions...'); - - this.modelId = this.extractModelId(url); - if (!this.modelId) { - throw new Error('Invalid Civitai URL format'); - } - - const response = await fetch(`/api/checkpoints/civitai/versions/${this.modelId}`); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - if (errorData && errorData.error && errorData.error.includes('Model type mismatch')) { - throw new Error('This model is not a Checkpoint. Please switch to the LoRAs page to download LoRA models.'); - } - throw new Error('Failed to fetch model versions'); - } - - this.versions = await response.json(); - if (!this.versions.length) { - throw new Error('No versions available for this model'); - } - - // If we have a version ID from URL, pre-select it - if (this.modelVersionId) { - this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId); - } - - this.showVersionStep(); - } catch (error) { - errorElement.textContent = error.message; - } finally { - this.loadingManager.hide(); - } - } - - extractModelId(url) { - const modelMatch = url.match(/civitai\.com\/models\/(\d+)/); - const versionMatch = url.match(/modelVersionId=(\d+)/); - - if (modelMatch) { - this.modelVersionId = versionMatch ? versionMatch[1] : null; - return modelMatch[1]; - } - return null; - } - - showVersionStep() { - document.getElementById('cpUrlStep').style.display = 'none'; - document.getElementById('cpVersionStep').style.display = 'block'; - - const versionList = document.getElementById('cpVersionList'); - versionList.innerHTML = this.versions.map(version => { - const firstImage = version.images?.find(img => !img.url.endsWith('.mp4')); - const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png'; - - // Use version-level size or fallback to first file - const fileSize = version.modelSizeKB ? - (version.modelSizeKB / 1024).toFixed(2) : - (version.files[0]?.sizeKB / 1024).toFixed(2); - - // Use version-level existsLocally flag - const existsLocally = version.existsLocally; - const localPath = version.localPath; - - // Check if this is an early access version - const isEarlyAccess = version.availability === 'EarlyAccess'; - - // Create early access badge if needed - let earlyAccessBadge = ''; - if (isEarlyAccess) { - earlyAccessBadge = ` -
- Early Access -
- `; - } - - // Status badge for local models - const localStatus = existsLocally ? - `
- In Library -
${localPath || ''}
-
` : ''; - - return ` -
-
- Version preview -
-
-
-

${version.name}

- ${localStatus} -
-
- ${version.baseModel ? `
${version.baseModel}
` : ''} - ${earlyAccessBadge} -
-
- ${new Date(version.createdAt).toLocaleDateString()} - ${fileSize} MB -
-
-
- `; - }).join(''); - - // Auto-select the version if there's only one - if (this.versions.length === 1 && !this.currentVersion) { - this.selectVersion(this.versions[0].id.toString()); - } - - // Update Next button state based on initial selection - this.updateNextButtonState(); - } - - selectVersion(versionId) { - this.currentVersion = this.versions.find(v => v.id.toString() === versionId.toString()); - if (!this.currentVersion) return; - - document.querySelectorAll('#cpVersionList .version-item').forEach(item => { - item.classList.toggle('selected', item.querySelector('h3').textContent === this.currentVersion.name); - }); - - // Update Next button state after selection - this.updateNextButtonState(); - } - - updateNextButtonState() { - const nextButton = document.querySelector('#cpVersionStep .primary-btn'); - if (!nextButton) return; - - const existsLocally = this.currentVersion?.existsLocally; - - if (existsLocally) { - nextButton.disabled = true; - nextButton.classList.add('disabled'); - nextButton.textContent = 'Already in Library'; - } else { - nextButton.disabled = false; - nextButton.classList.remove('disabled'); - nextButton.textContent = 'Next'; - } - } - - async proceedToLocation() { - if (!this.currentVersion) { - showToast('Please select a version', 'error'); - return; - } - - // Double-check if the version exists locally - const existsLocally = this.currentVersion.existsLocally; - if (existsLocally) { - showToast('This version already exists in your library', 'info'); - return; - } - - document.getElementById('cpVersionStep').style.display = 'none'; - document.getElementById('cpLocationStep').style.display = 'block'; - - try { - // Use checkpoint roots endpoint instead of lora roots - const response = await fetch('/api/checkpoints/roots'); - if (!response.ok) { - throw new Error('Failed to fetch checkpoint roots'); - } - - const data = await response.json(); - const checkpointRoot = document.getElementById('checkpointRoot'); - checkpointRoot.innerHTML = data.roots.map(root => - `` - ).join(''); - - // Set default checkpoint root if available - const defaultRoot = getStorageItem('settings', {}).default_checkpoint_root; - if (defaultRoot && data.roots.includes(defaultRoot)) { - checkpointRoot.value = defaultRoot; - } - - // Initialize folder browser after loading roots - this.initializeFolderBrowser(); - } catch (error) { - showToast(error.message, 'error'); - } - } - - backToUrl() { - document.getElementById('cpVersionStep').style.display = 'none'; - document.getElementById('cpUrlStep').style.display = 'block'; - } - - backToVersions() { - document.getElementById('cpLocationStep').style.display = 'none'; - document.getElementById('cpVersionStep').style.display = 'block'; - } - - async startDownload() { - const checkpointRoot = document.getElementById('checkpointRoot').value; - const newFolder = document.getElementById('cpNewFolder').value.trim(); - - if (!checkpointRoot) { - showToast('Please select a checkpoint root directory', 'error'); - return; - } - - // Construct relative path - let targetFolder = ''; - if (this.selectedFolder) { - targetFolder = this.selectedFolder; - } - if (newFolder) { - targetFolder = targetFolder ? - `${targetFolder}/${newFolder}` : newFolder; - } - - try { - // Show enhanced loading with progress details - const updateProgress = this.loadingManager.showDownloadProgress(1); - updateProgress(0, 0, this.currentVersion.name); - - // Generate a unique ID for this download - const downloadId = Date.now().toString(); - - // Setup WebSocket for progress updates using download-specific endpoint - const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; - const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`); - - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - - // Handle download ID confirmation - if (data.type === 'download_id') { - console.log(`Connected to checkpoint download progress with ID: ${data.download_id}`); - return; - } - - // Only process progress updates for our download - if (data.status === 'progress' && data.download_id === downloadId) { - // Update progress display with current progress - updateProgress(data.progress, 0, this.currentVersion.name); - - // Add more detailed status messages based on progress - if (data.progress < 3) { - this.loadingManager.setStatus(`Preparing download...`); - } else if (data.progress === 3) { - this.loadingManager.setStatus(`Downloaded preview image`); - } else if (data.progress > 3 && data.progress < 100) { - this.loadingManager.setStatus(`Downloading checkpoint file`); - } else { - this.loadingManager.setStatus(`Finalizing download...`); - } - } - }; - - ws.onerror = (error) => { - console.error('WebSocket error:', error); - // Continue with download even if WebSocket fails - }; - - // Start download using checkpoint download endpoint with download ID - const response = await fetch('/api/download-model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model_id: this.modelId, - model_version_id: this.currentVersion.id, - model_root: checkpointRoot, - relative_path: targetFolder, - download_id: downloadId - }) - }); - - if (!response.ok) { - throw new Error(await response.text()); - } - - showToast('Download completed successfully', 'success'); - modalManager.closeModal('checkpointDownloadModal'); - - // Update state specifically for the checkpoints page - state.pages.checkpoints.activeFolder = targetFolder; - - // Save the active folder preference to storage - setStorageItem('checkpoints_activeFolder', targetFolder); - - // Update UI to show the folder as selected - document.querySelectorAll('.folder-tags .tag').forEach(tag => { - const isActive = tag.dataset.folder === targetFolder; - tag.classList.toggle('active', isActive); - if (isActive && !tag.parentNode.classList.contains('collapsed')) { - // Scroll the tag into view if folder tags are not collapsed - tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } - }); - - await resetAndReload(true); // Pass true to update folders - - } catch (error) { - showToast(error.message, 'error'); - } finally { - this.loadingManager.hide(); - } - } - - initializeFolderBrowser() { - const folderBrowser = document.getElementById('cpFolderBrowser'); - if (!folderBrowser) return; - - // Cleanup existing handler if any - this.cleanupFolderBrowser(); - - // Create new handler - this.folderClickHandler = (event) => { - const folderItem = event.target.closest('.folder-item'); - if (!folderItem) return; - - if (folderItem.classList.contains('selected')) { - folderItem.classList.remove('selected'); - this.selectedFolder = ''; - } else { - folderBrowser.querySelectorAll('.folder-item').forEach(f => - f.classList.remove('selected')); - folderItem.classList.add('selected'); - this.selectedFolder = folderItem.dataset.folder; - } - - // Update path display after folder selection - this.updateTargetPath(); - }; - - // Add the new handler - folderBrowser.addEventListener('click', this.folderClickHandler); - - // Add event listeners for path updates - const checkpointRoot = document.getElementById('checkpointRoot'); - const newFolder = document.getElementById('cpNewFolder'); - - checkpointRoot.addEventListener('change', this.updateTargetPath); - newFolder.addEventListener('input', this.updateTargetPath); - - // Update initial path - this.updateTargetPath(); - } - - cleanupFolderBrowser() { - if (this.folderClickHandler) { - const folderBrowser = document.getElementById('cpFolderBrowser'); - if (folderBrowser) { - folderBrowser.removeEventListener('click', this.folderClickHandler); - this.folderClickHandler = null; - } - } - - // Remove path update listeners - const checkpointRoot = document.getElementById('checkpointRoot'); - const newFolder = document.getElementById('cpNewFolder'); - - if (checkpointRoot) checkpointRoot.removeEventListener('change', this.updateTargetPath); - if (newFolder) newFolder.removeEventListener('input', this.updateTargetPath); - } - - updateTargetPath() { - const pathDisplay = document.getElementById('cpTargetPathDisplay'); - const checkpointRoot = document.getElementById('checkpointRoot').value; - const newFolder = document.getElementById('cpNewFolder').value.trim(); - - let fullPath = checkpointRoot || 'Select a checkpoint root directory'; - - if (checkpointRoot) { - if (this.selectedFolder) { - fullPath += '/' + this.selectedFolder; - } - if (newFolder) { - fullPath += '/' + newFolder; - } - } - - pathDisplay.innerHTML = `${fullPath}`; - } -} \ No newline at end of file diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index f4f5b9ba..d5c6ab29 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -1,60 +1,111 @@ import { modalManager } from './ModalManager.js'; import { showToast } from '../utils/uiHelpers.js'; import { LoadingManager } from './LoadingManager.js'; -import { state } from '../state/index.js'; -import { resetAndReload } from '../api/loraApi.js'; -import { getStorageItem } from '../utils/storageHelpers.js'; +import { getModelApiClient } from '../api/baseModelApi.js'; +import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; + export class DownloadManager { constructor() { this.currentVersion = null; this.versions = []; this.modelInfo = null; - this.modelVersionId = null; // Add new property for initial version ID + this.modelVersionId = null; + this.modelId = null; - // Add initialization check this.initialized = false; this.selectedFolder = ''; + this.apiClient = null; - // Add LoadingManager instance this.loadingManager = new LoadingManager(); - this.folderClickHandler = null; // Add this line + this.folderClickHandler = null; this.updateTargetPath = this.updateTargetPath.bind(this); + + // Bound methods for event handling + this.handleValidateAndFetchVersions = this.validateAndFetchVersions.bind(this); + this.handleProceedToLocation = this.proceedToLocation.bind(this); + this.handleStartDownload = this.startDownload.bind(this); + this.handleBackToUrl = this.backToUrl.bind(this); + this.handleBackToVersions = this.backToVersions.bind(this); + this.handleCloseModal = this.closeModal.bind(this); } showDownloadModal() { - console.log('Showing download modal...'); // Add debug log + console.log('Showing unified download modal...'); + + // Get API client for current page type + this.apiClient = getModelApiClient(); + const config = this.apiClient.apiConfig.config; + if (!this.initialized) { - // Check if modal exists const modal = document.getElementById('downloadModal'); if (!modal) { - console.error('Download modal element not found'); + console.error('Unified download modal element not found'); return; } + this.initializeEventHandlers(); this.initialized = true; } + // Update modal title and labels based on model type + this.updateModalLabels(); + modalManager.showModal('downloadModal', null, () => { - // Cleanup handler when modal closes this.cleanupFolderBrowser(); }); this.resetSteps(); // Auto-focus on the URL input setTimeout(() => { - const urlInput = document.getElementById('loraUrl'); + const urlInput = document.getElementById('modelUrl'); if (urlInput) { urlInput.focus(); } - }, 100); // Small delay to ensure the modal is fully displayed + }, 100); + } + + initializeEventHandlers() { + // Button event handlers + document.getElementById('nextFromUrl').addEventListener('click', this.handleValidateAndFetchVersions); + document.getElementById('nextFromVersion').addEventListener('click', this.handleProceedToLocation); + document.getElementById('startDownloadBtn').addEventListener('click', this.handleStartDownload); + document.getElementById('backToUrlBtn').addEventListener('click', this.handleBackToUrl); + document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions); + document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal); + } + + updateModalLabels() { + const config = this.apiClient.apiConfig.config; + + // Update modal title + document.getElementById('downloadModalTitle').textContent = `Download ${config.displayName} from URL`; + + // Update URL label + document.getElementById('modelUrlLabel').textContent = 'Civitai URL:'; + + // Update root selection label + document.getElementById('modelRootLabel').textContent = `Select ${config.displayName} Root:`; + + // Update path preview labels + const pathLabels = document.querySelectorAll('.path-preview label'); + pathLabels.forEach(label => { + if (label.textContent.includes('Location Preview')) { + label.textContent = 'Download Location Preview:'; + } + }); + + // Update initial path text + const pathText = document.querySelector('#targetPathDisplay .path-text'); + if (pathText) { + pathText.textContent = `Select a ${config.displayName} root directory`; + } } resetSteps() { document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none'); document.getElementById('urlStep').style.display = 'block'; - document.getElementById('loraUrl').value = ''; + document.getElementById('modelUrl').value = ''; document.getElementById('urlError').textContent = ''; - // Clear new folder input const newFolderInput = document.getElementById('newFolder'); if (newFolderInput) { newFolderInput.value = ''; @@ -66,7 +117,6 @@ export class DownloadManager { this.modelId = null; this.modelVersionId = null; - // Clear selected folder and remove selection from UI this.selectedFolder = ''; const folderBrowser = document.getElementById('folderBrowser'); if (folderBrowser) { @@ -76,7 +126,7 @@ export class DownloadManager { } async validateAndFetchVersions() { - const url = document.getElementById('loraUrl').value.trim(); + const url = document.getElementById('modelUrl').value.trim(); const errorElement = document.getElementById('urlError'); try { @@ -87,16 +137,8 @@ export class DownloadManager { throw new Error('Invalid Civitai URL format'); } - const response = await fetch(`/api/loras/civitai/versions/${this.modelId}`); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - if (errorData && errorData.error && errorData.error.includes('Model type mismatch')) { - throw new Error('This model is not a LoRA. Please switch to the Checkpoints page to download checkpoint models.'); - } - throw new Error('Failed to fetch model versions'); - } + this.versions = await this.apiClient.fetchCivitaiVersions(this.modelId); - this.versions = await response.json(); if (!this.versions.length) { throw new Error('No versions available for this model'); } @@ -134,19 +176,14 @@ export class DownloadManager { const firstImage = version.images?.find(img => !img.url.endsWith('.mp4')); const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png'; - // Use version-level size or fallback to first file const fileSize = version.modelSizeKB ? (version.modelSizeKB / 1024).toFixed(2) : (version.files[0]?.sizeKB / 1024).toFixed(2); - // Use version-level existsLocally flag const existsLocally = version.existsLocally; const localPath = version.localPath; - - // Check if this is an early access version const isEarlyAccess = version.availability === 'EarlyAccess'; - // Create early access badge if needed let earlyAccessBadge = ''; if (isEarlyAccess) { earlyAccessBadge = ` @@ -156,7 +193,6 @@ export class DownloadManager { `; } - // Status badge for local models const localStatus = existsLocally ? `
In Library @@ -167,7 +203,7 @@ export class DownloadManager {
+ data-version-id="${version.id}">
Version preview
@@ -189,12 +225,19 @@ export class DownloadManager { `; }).join(''); + // Add click handlers for version selection + versionList.addEventListener('click', (event) => { + const versionItem = event.target.closest('.version-item'); + if (versionItem) { + this.selectVersion(versionItem.dataset.versionId); + } + }); + // Auto-select the version if there's only one if (this.versions.length === 1 && !this.currentVersion) { this.selectVersion(this.versions[0].id.toString()); } - // Update Next button state based on initial selection this.updateNextButtonState(); } @@ -202,23 +245,15 @@ export class DownloadManager { this.currentVersion = this.versions.find(v => v.id.toString() === versionId.toString()); if (!this.currentVersion) return; - // Remove the toast notification - it's redundant with the visual indicator - // const existsLocally = this.currentVersion.files[0]?.existsLocally; - // if (existsLocally) { - // showToast('This version already exists in your library', 'info'); - // } - document.querySelectorAll('.version-item').forEach(item => { - item.classList.toggle('selected', item.querySelector('h3').textContent === this.currentVersion.name); + item.classList.toggle('selected', item.dataset.versionId === versionId); }); - // Update Next button state after selection this.updateNextButtonState(); } - // Update this method to use version-level existsLocally updateNextButtonState() { - const nextButton = document.querySelector('#versionStep .primary-btn'); + const nextButton = document.getElementById('nextFromVersion'); if (!nextButton) return; const existsLocally = this.currentVersion?.existsLocally; @@ -240,7 +275,6 @@ export class DownloadManager { return; } - // Double-check if the version exists locally const existsLocally = this.currentVersion.existsLocally; if (existsLocally) { showToast('This version already exists in your library', 'info'); @@ -251,39 +285,30 @@ export class DownloadManager { document.getElementById('locationStep').style.display = 'block'; try { - // Fetch LoRA roots - const rootsResponse = await fetch('/api/loras/roots'); - if (!rootsResponse.ok) { - throw new Error('Failed to fetch LoRA roots'); - } + const config = this.apiClient.apiConfig.config; - const rootsData = await rootsResponse.json(); - const loraRoot = document.getElementById('loraRoot'); - loraRoot.innerHTML = rootsData.roots.map(root => + // Fetch model roots + const rootsData = await this.apiClient.fetchModelRoots(); + const modelRoot = document.getElementById('modelRoot'); + modelRoot.innerHTML = rootsData.roots.map(root => `` ).join(''); - // Set default lora root if available - const defaultRoot = getStorageItem('settings', {}).default_loras_root; + // Set default root if available + const defaultRootKey = `default_${this.apiClient.modelType}_root`; + const defaultRoot = getStorageItem('settings', {})[defaultRootKey]; if (defaultRoot && rootsData.roots.includes(defaultRoot)) { - loraRoot.value = defaultRoot; + modelRoot.value = defaultRoot; } - // Fetch folders dynamically - const foldersResponse = await fetch('/api/loras/folders'); - if (!foldersResponse.ok) { - throw new Error('Failed to fetch folders'); - } - - const foldersData = await foldersResponse.json(); + // Fetch folders + const foldersData = await this.apiClient.fetchModelFolders(); const folderBrowser = document.getElementById('folderBrowser'); - // Update folder browser with dynamic content folderBrowser.innerHTML = foldersData.folders.map(folder => `
${folder}
` ).join(''); - // Initialize folder browser after loading roots and folders this.initializeFolderBrowser(); } catch (error) { showToast(error.message, 'error'); @@ -300,12 +325,17 @@ export class DownloadManager { document.getElementById('versionStep').style.display = 'block'; } + closeModal() { + modalManager.closeModal('downloadModal'); + } + async startDownload() { - const loraRoot = document.getElementById('loraRoot').value; + const modelRoot = document.getElementById('modelRoot').value; const newFolder = document.getElementById('newFolder').value.trim(); + const config = this.apiClient.apiConfig.config; - if (!loraRoot) { - showToast('Please select a LoRA root directory', 'error'); + if (!modelRoot) { + showToast(`Please select a ${config.displayName} root directory`, 'error'); return; } @@ -320,38 +350,32 @@ export class DownloadManager { } try { - // Show enhanced loading with progress details const updateProgress = this.loadingManager.showDownloadProgress(1); updateProgress(0, 0, this.currentVersion.name); - // Generate a unique ID for this download const downloadId = Date.now().toString(); - // Setup WebSocket for progress updates - use download-specific endpoint + // Setup WebSocket for progress updates const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`); ws.onmessage = (event) => { const data = JSON.parse(event.data); - // Handle download ID confirmation if (data.type === 'download_id') { console.log(`Connected to download progress with ID: ${data.download_id}`); return; } - // Only process progress updates for our download if (data.status === 'progress' && data.download_id === downloadId) { - // Update progress display with current progress updateProgress(data.progress, 0, this.currentVersion.name); - // Add more detailed status messages based on progress if (data.progress < 3) { this.loadingManager.setStatus(`Preparing download...`); } else if (data.progress === 3) { this.loadingManager.setStatus(`Downloaded preview image`); } else if (data.progress > 3 && data.progress < 100) { - this.loadingManager.setStatus(`Downloading LoRA file`); + this.loadingManager.setStatus(`Downloading ${config.singularName} file`); } else { this.loadingManager.setStatus(`Finalizing download...`); } @@ -360,35 +384,47 @@ export class DownloadManager { ws.onerror = (error) => { console.error('WebSocket error:', error); - // Continue with download even if WebSocket fails }; - // Start download with our download ID - const response = await fetch('/api/download-model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model_id: this.modelId, - model_version_id: this.currentVersion.id, - model_root: loraRoot, - relative_path: targetFolder, - download_id: downloadId - }) - }); - - if (!response.ok) { - throw new Error(await response.text()); - } + // Start download + await this.apiClient.downloadModel( + this.modelId, + this.currentVersion.id, + modelRoot, + targetFolder, + downloadId + ); showToast('Download completed successfully', 'success'); modalManager.closeModal('downloadModal'); - // Close WebSocket after download completes ws.close(); - // Update state and trigger reload with folder update - state.activeFolder = targetFolder; - await resetAndReload(true); // Pass true to update folders + // Update state and trigger reload + const pageState = this.apiClient.getPageState(); + pageState.activeFolder = targetFolder; + + // Save the active folder preference + setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder); + + // Update UI folder selection + document.querySelectorAll('.folder-tags .tag').forEach(tag => { + const isActive = tag.dataset.folder === targetFolder; + tag.classList.toggle('active', isActive); + if (isActive && !tag.parentNode.classList.contains('collapsed')) { + tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + }); + + // Trigger reload with folder update - use dynamic import based on model type + const modelType = this.apiClient.modelType; + if (modelType === 'loras') { + const { resetAndReload } = await import('../api/loraApi.js'); + await resetAndReload(true); + } else if (modelType === 'checkpoints') { + const { resetAndReload } = await import('../api/checkpointApi.js'); + await resetAndReload(true); + } } catch (error) { showToast(error.message, 'error'); @@ -397,15 +433,12 @@ export class DownloadManager { } } - // Add new method to handle folder selection initializeFolderBrowser() { const folderBrowser = document.getElementById('folderBrowser'); if (!folderBrowser) return; - // Cleanup existing handler if any this.cleanupFolderBrowser(); - // Create new handler this.folderClickHandler = (event) => { const folderItem = event.target.closest('.folder-item'); if (!folderItem) return; @@ -420,21 +453,17 @@ export class DownloadManager { this.selectedFolder = folderItem.dataset.folder; } - // Update path display after folder selection this.updateTargetPath(); }; - // Add the new handler folderBrowser.addEventListener('click', this.folderClickHandler); - // Add event listeners for path updates - const loraRoot = document.getElementById('loraRoot'); + const modelRoot = document.getElementById('modelRoot'); const newFolder = document.getElementById('newFolder'); - loraRoot.addEventListener('change', this.updateTargetPath); + modelRoot.addEventListener('change', this.updateTargetPath); newFolder.addEventListener('input', this.updateTargetPath); - // Update initial path this.updateTargetPath(); } @@ -447,23 +476,22 @@ export class DownloadManager { } } - // Remove path update listeners - const loraRoot = document.getElementById('loraRoot'); + const modelRoot = document.getElementById('modelRoot'); const newFolder = document.getElementById('newFolder'); - loraRoot.removeEventListener('change', this.updateTargetPath); - newFolder.removeEventListener('input', this.updateTargetPath); + if (modelRoot) modelRoot.removeEventListener('change', this.updateTargetPath); + if (newFolder) newFolder.removeEventListener('input', this.updateTargetPath); } - // Add new method to update target path updateTargetPath() { const pathDisplay = document.getElementById('targetPathDisplay'); - const loraRoot = document.getElementById('loraRoot').value; + const modelRoot = document.getElementById('modelRoot').value; const newFolder = document.getElementById('newFolder').value.trim(); + const config = this.apiClient.apiConfig.config; - let fullPath = loraRoot || 'Select a LoRA root directory'; + let fullPath = modelRoot || `Select a ${config.displayName} root directory`; - if (loraRoot) { + if (modelRoot) { if (this.selectedFolder) { fullPath += '/' + this.selectedFolder; } @@ -475,3 +503,6 @@ export class DownloadManager { pathDisplay.innerHTML = `${fullPath}`; } } + +// Create global instance +export const downloadManager = new DownloadManager(); diff --git a/templates/checkpoints.html b/templates/checkpoints.html index 47f8295a..f65803ca 100644 --- a/templates/checkpoints.html +++ b/templates/checkpoints.html @@ -12,7 +12,6 @@ {% block init_check_url %}/api/checkpoints?page=1&page_size=1{% endblock %} {% block additional_components %} -{% include 'components/checkpoint_modals.html' %}