import { modalManager } from './ModalManager.js'; import { showToast } from '../utils/uiHelpers.js'; import { state } from '../state/index.js'; import { LoadingManager } from './LoadingManager.js'; import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { FolderTreeManager } from '../components/FolderTreeManager.js'; import { translate } from '../utils/i18nHelpers.js'; import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js'; export class DownloadManager { constructor() { this.currentVersion = null; this.versions = []; this.modelInfo = null; this.modelVersionId = null; this.modelId = null; this.source = null; this.initialized = false; this.selectedFolder = ''; this.apiClient = null; this.useDefaultPath = false; // Batch mode state this.batchModels = []; this.isBatchMode = false; this.editingBatchIndex = -1; this.loadingManager = new LoadingManager(); this.folderTreeManager = new FolderTreeManager(); 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.handleBackToVersionFromFiles = this.backToVersionFromFiles.bind(this); this.handleConfirmFileSelection = this.confirmFileSelection.bind(this); this.handleCloseModal = this.closeModal.bind(this); this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this); this.handleBackToUrlFromBatch = this.backToUrlFromBatch.bind(this); this.handleNextFromBatch = this.nextFromBatch.bind(this); } showDownloadModal() { 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) { const modal = document.getElementById('downloadModal'); if (!modal) { 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, () => { this.cleanupFolderBrowser(); }); this.resetSteps(); // Auto-focus on the URL input setTimeout(() => { const urlInput = document.getElementById('modelUrl'); if (urlInput) { urlInput.focus(); } }, 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); // File selection step buttons document.getElementById('backToVersionFromFilesBtn').addEventListener('click', this.handleBackToVersionFromFiles); document.getElementById('confirmFileSelection').addEventListener('click', this.handleConfirmFileSelection); // Batch preview buttons document.getElementById('backToUrlFromBatchBtn').addEventListener('click', this.handleBackToUrlFromBatch); document.getElementById('nextFromBatchBtn').addEventListener('click', this.handleNextFromBatch); // Default path toggle handler document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath); } updateModalLabels() { const config = this.apiClient.apiConfig.config; // Update modal title document.getElementById('downloadModalTitle').textContent = translate('modals.download.titleWithType', { type: config.displayName }); // Update URL label document.getElementById('modelUrlLabel').textContent = translate('modals.download.civitaiUrl'); // Update root selection label document.getElementById('modelRootLabel').textContent = translate('modals.download.selectTypeRoot', { type: config.displayName }); // Update path preview labels const pathLabels = document.querySelectorAll('.path-preview label'); pathLabels.forEach(label => { if (label.textContent.includes('Location Preview')) { label.textContent = translate('modals.download.locationPreview') + ':'; } }); // Update initial path text const pathText = document.querySelector('#targetPathDisplay .path-text'); if (pathText) { pathText.textContent = translate('modals.download.selectTypeRoot', { type: config.displayName }); } } resetSteps() { document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none'); document.getElementById('urlStep').style.display = 'block'; document.getElementById('modelUrl').value = ''; document.getElementById('urlError').textContent = ''; // Clear folder path input const folderPathInput = document.getElementById('folderPath'); if (folderPathInput) { folderPathInput.value = ''; } this.currentVersion = null; this.versions = []; this.modelInfo = null; this.modelId = null; this.modelVersionId = null; this.source = null; this.selectedFile = null; this.selectedFolder = ''; this.batchModels = []; this.isBatchMode = false; this.editingBatchIndex = -1; // Clear folder tree selection if (this.folderTreeManager) { this.folderTreeManager.clearSelection(); } // Reset default path toggle this.loadDefaultPathSetting(); } async retrieveVersionsForModel(modelId, source = null) { this.versions = await this.apiClient.fetchCivitaiVersions(modelId, source); if (!this.versions || !this.versions.length) { throw new Error(translate('modals.download.errors.noVersions')); } return this.versions; } async validateAndFetchVersions() { const rawText = document.getElementById('modelUrl').value.trim(); const errorElement = document.getElementById('urlError'); const urls = rawText.split('\n').map(l => l.trim()).filter(Boolean); if (urls.length === 0) { errorElement.textContent = translate('modals.download.errors.invalidUrl'); return; } if (urls.length === 1) { this.isBatchMode = false; try { this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions')); this.modelId = this.extractModelId(urls[0]); if (!this.modelId) { throw new Error(translate('modals.download.errors.invalidUrl')); } await this.retrieveVersionsForModel(this.modelId, this.source); 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(); } return; } // Multi-URL batch mode this.isBatchMode = true; this.batchModels = []; errorElement.textContent = ''; const seen = new Set(); const parsed = []; for (const url of urls) { const result = DownloadManager.parseModelUrl(url); if (!result.modelId) { parsed.push({ url, error: translate('modals.download.errors.invalidUrl') }); continue; } // Dedup by modelId + modelVersionId combo so users can download // different versions of the same model (e.g. latest + a specific version) const dedupKey = result.modelVersionId ? `${result.modelId}:${result.modelVersionId}` : result.modelId; if (seen.has(dedupKey)) continue; seen.add(dedupKey); parsed.push({ url, ...result, error: null }); } if (parsed.length === 0) { errorElement.textContent = translate('modals.download.errors.invalidUrl'); return; } this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions')); let fetched = 0; const total = parsed.filter(p => !p.error).length; this.batchModels = new Array(parsed.length); const fetchPromises = parsed.map(async (item, index) => { if (item.error) { this.batchModels[index] = { ...item, versions: [], selectedVersion: null }; return; } try { const versions = await this.apiClient.fetchCivitaiVersions(item.modelId, item.source); fetched++; this.loadingManager.setStatus(`${fetched}/${total}`); let selectedVersion = null; if (versions && versions.length > 0) { if (item.modelVersionId) { selectedVersion = versions.find(v => v.id.toString() === item.modelVersionId) || versions[0]; } else { selectedVersion = versions[0]; } } this.batchModels[index] = { ...item, versions: versions || [], selectedVersion }; } catch (err) { this.batchModels[index] = { ...item, versions: [], selectedVersion: null, error: err.message }; } }); await Promise.all(fetchPromises); this.loadingManager.hide(); this.showBatchPreviewStep(); } async fetchVersionsForCurrentModel() { const errorElement = document.getElementById('urlError'); if (errorElement) { errorElement.textContent = ''; } try { this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions')); await this.retrieveVersionsForModel(this.modelId, this.source); if (this.modelVersionId) { this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId); } this.showVersionStep(); } catch (error) { if (errorElement) { errorElement.textContent = error.message; } } finally { this.loadingManager.hide(); } } static parseModelUrl(url) { const civarchiveMatch = url.match(/https?:\/\/(?:www\.)?(?:civitaiarchive|civarchive)\.com\/models\/(\d+)/i); if (civarchiveMatch) { const versionMatch = url.match(/modelVersionId=(\d+)/i); return { modelId: civarchiveMatch[1], modelVersionId: versionMatch ? versionMatch[1] : null, source: 'civarchive', }; } const { modelId, modelVersionId } = extractCivitaiModelUrlParts(url); if (modelId) { return { modelId, modelVersionId, source: null }; } return { modelId: null, modelVersionId: null, source: null }; } extractModelId(url) { const result = DownloadManager.parseModelUrl(url); this.modelVersionId = result.modelVersionId; this.source = result.source; return result.modelId; } async openForModelVersion(modelType, modelId, versionId = null) { try { this.apiClient = getModelApiClient(modelType); } catch (error) { this.apiClient = getModelApiClient(); } this.showDownloadModal(); this.modelId = modelId ? modelId.toString() : null; this.modelVersionId = versionId ? versionId.toString() : null; this.source = null; if (!this.modelId) { return; } await this.fetchVersionsForCurrentModel(); } showVersionStep() { document.getElementById('urlStep').style.display = 'none'; document.getElementById('versionStep').style.display = 'block'; const versionList = document.getElementById('versionList'); const newList = versionList.cloneNode(false); versionList.parentNode.replaceChild(newList, versionList); newList.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'; // Count model-type files per version const modelFiles = (version.files || []).filter(f => f.type === 'Model'); const primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {}; const fileSize = version.modelSizeKB ? (version.modelSizeKB / 1024).toFixed(2) : ((primaryFile.sizeKB || 0) / 1024).toFixed(2); const existsLocally = version.existsLocally; const hasBeenDownloaded = version.hasBeenDownloaded && !existsLocally; const localPath = version.localPath; const isEarlyAccess = version.availability === 'EarlyAccess'; let earlyAccessBadge = ''; if (isEarlyAccess) { earlyAccessBadge = `
${translate('modals.download.earlyAccess')}
`; } let localStatus = ''; if (existsLocally) { localStatus = `
${translate('modals.download.inLibrary')}
${localPath || ''}
`; } else if (hasBeenDownloaded) { const downloadedTooltip = translate( 'modals.download.downloadedTooltip', {}, 'Previously downloaded, but it is not currently in your library.' ); localStatus = `
${translate('modals.download.downloaded', {}, 'Downloaded')}
`; } const fileBadge = modelFiles.length > 1 && !existsLocally ? ` ${modelFiles.length} ${translate('modals.download.fileSelection.files')} ` : ''; return `
${translate('modals.download.versionPreview')}

${version.name}

${localStatus}
${version.baseModel ? `
${version.baseModel}
` : ''} ${earlyAccessBadge}
${new Date(version.createdAt).toLocaleDateString()} ${fileSize} MB ${fileBadge}
`; }).join(''); // Add click handlers for version selection and file badge newList.addEventListener('click', (event) => { const badge = event.target.closest('.file-select-badge'); if (badge) { event.stopPropagation(); const versionId = badge.dataset.versionId; this.selectVersion(versionId); this.showFileSelectionStep(versionId); return; } 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()); } this.updateNextButtonState(); } selectVersion(versionId) { this.currentVersion = this.versions.find(v => v.id.toString() === versionId.toString()); if (!this.currentVersion) return; document.querySelectorAll('.version-item').forEach(item => { item.classList.toggle('selected', item.dataset.versionId === versionId); }); this.updateNextButtonState(); } updateNextButtonState() { const nextButton = document.getElementById('nextFromVersion'); if (!nextButton) return; const existsLocally = this.currentVersion?.existsLocally; if (existsLocally) { nextButton.disabled = true; nextButton.classList.add('disabled'); nextButton.textContent = translate('modals.download.alreadyInLibrary'); } else { nextButton.disabled = false; nextButton.classList.remove('disabled'); nextButton.textContent = translate('common.actions.next'); } } showFileSelectionStep(versionId) { const version = this.versions.find(v => v.id.toString() === versionId.toString()); if (!version) return; this.currentVersion = version; const modelFiles = (version.files || []).filter(f => f.type === 'Model'); document.getElementById('versionStep').style.display = 'none'; document.getElementById('fileSelectionStep').style.display = 'block'; const nameEl = document.getElementById('fileSelectionVersionName'); if (nameEl) { nameEl.textContent = `${version.name} ยท ${version.baseModel || ''}`; } const container = document.getElementById('fileSelectionList'); container.innerHTML = modelFiles.map(file => { const meta = file.metadata || {}; const sizeGB = file.sizeKB ? (file.sizeKB / (1024 * 1024)).toFixed(2) : '--'; const isSelected = this.selectedFile?.id === file.id; const tags = []; if (meta.size) tags.push(`${meta.size}`); if (meta.format) tags.push(`${meta.format}`); if (meta.fp) tags.push(`${meta.fp}`); const fileName = file.name || ''; return `
${tags.join(' ')}
${fileName}
${sizeGB} GB
`; }).join(''); container.querySelectorAll('.file-option').forEach(el => { el.addEventListener('click', () => { container.querySelectorAll('.file-option').forEach(o => o.classList.remove('selected')); el.classList.add('selected'); const radio = el.querySelector('input[type="radio"]'); if (radio) radio.checked = true; }); }); } confirmFileSelection() { const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked'); if (!selectedRadio) return; const version = this.currentVersion; if (!version) return; const modelFiles = (version.files || []).filter(f => f.type === 'Model'); this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value); document.getElementById('fileSelectionStep').style.display = 'none'; document.getElementById('locationStep').style.display = 'block'; this.proceedToLocationContent(); } backToVersionFromFiles() { document.getElementById('fileSelectionStep').style.display = 'none'; document.getElementById('versionStep').style.display = 'block'; } async proceedToLocation() { // If editing a batch item's version, save and return to batch preview if (this.isBatchMode && this.editingBatchIndex >= 0) { if (this.currentVersion) { this.batchModels[this.editingBatchIndex].selectedVersion = this.currentVersion; } this.editingBatchIndex = -1; document.getElementById('versionStep').style.display = 'none'; this.showBatchPreviewStep(); return; } // In single-URL mode, validate version selection if (!this.isBatchMode) { if (!this.currentVersion) { showToast('toast.loras.pleaseSelectVersion', {}, 'error'); return; } if (this.currentVersion.existsLocally) { showToast('toast.loras.versionExists', {}, 'info'); return; } } document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none'); document.getElementById('locationStep').style.display = 'block'; await this.proceedToLocationContent(); } async proceedToLocationContent() { try { // Fetch model roots const rootsData = await this.apiClient.fetchModelRoots(); const modelRoot = document.getElementById('modelRoot'); modelRoot.innerHTML = rootsData.roots.map(root => `` ).join(''); // Set default root if available const singularType = this.apiClient.modelType.replace(/s$/, ''); const defaultRootKey = `default_${singularType}_root`; const defaultRoot = state.global.settings[defaultRootKey]; console.log(`Default root for ${this.apiClient.modelType}:`, defaultRoot); console.log('Available roots:', rootsData.roots); if (defaultRoot && rootsData.roots.includes(defaultRoot)) { console.log(`Setting default root: ${defaultRoot}`); modelRoot.value = defaultRoot; } // Set autocomplete="off" on folderPath input const folderPathInput = document.getElementById('folderPath'); if (folderPathInput) { folderPathInput.setAttribute('autocomplete', 'off'); } // Initialize folder tree await this.initializeFolderTree(); // Setup folder tree manager this.folderTreeManager.init({ onPathChange: (path) => { this.selectedFolder = path; this.updateTargetPath(); } }); // Setup model root change handler modelRoot.addEventListener('change', async () => { await this.initializeFolderTree(); this.updateTargetPath(); }); // Load default path setting for current model type this.loadDefaultPathSetting(); this.updateTargetPath(); } catch (error) { showToast('toast.downloads.loadError', { message: error.message }, 'error'); } } loadDefaultPathSetting() { const modelType = this.apiClient.modelType; const storageKey = `use_default_path_${modelType}`; this.useDefaultPath = getStorageItem(storageKey, false); const toggleInput = document.getElementById('useDefaultPath'); if (toggleInput) { toggleInput.checked = this.useDefaultPath; this.updatePathSelectionUI(); } } toggleDefaultPath(event) { this.useDefaultPath = event.target.checked; // Save to localStorage per model type const modelType = this.apiClient.modelType; const storageKey = `use_default_path_${modelType}`; setStorageItem(storageKey, this.useDefaultPath); this.updatePathSelectionUI(); this.updateTargetPath(); } async executeDownloadWithProgress({ modelId, versionId, versionName = '', modelRoot = '', targetFolder = '', useDefaultPaths = false, source = null, fileParams = null, closeModal = false, }) { const config = this.apiClient?.apiConfig?.config; if (!this.apiClient || !config) { throw new Error('Download manager is not initialized with an API client'); } const displayName = versionName || `#${versionId}`; let ws = null; let updateProgress = () => { }; try { this.loadingManager.restoreProgressBar(); updateProgress = this.loadingManager.showDownloadProgress(1); updateProgress(0, 0, displayName); const downloadId = Date.now().toString(); const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`); ws.onmessage = event => { const data = JSON.parse(event.data); if (data.type === 'download_id') { console.log(`Connected to download progress with ID: ${data.download_id}`); return; } if (data.status === 'progress' && data.download_id === downloadId) { const metrics = { bytesDownloaded: data.bytes_downloaded, totalBytes: data.total_bytes, bytesPerSecond: data.bytes_per_second, }; updateProgress(data.progress, 0, displayName, metrics); if (data.progress < 3) { this.loadingManager.setStatus(translate('modals.download.status.preparing')); } else if (data.progress === 3) { this.loadingManager.setStatus(translate('modals.download.status.downloadedPreview')); } else if (data.progress > 3 && data.progress < 100) { this.loadingManager.setStatus( translate('modals.download.status.downloadingFile', { type: config.singularName }) ); } else { this.loadingManager.setStatus(translate('modals.download.status.finalizing')); } } }; ws.onerror = error => { console.error('WebSocket error:', error); }; const response = await this.apiClient.downloadModel( modelId, versionId, modelRoot, targetFolder, useDefaultPaths, downloadId, source, fileParams ); if (response?.skipped) { this.loadingManager.setStatus(translate('modals.download.status.finalizing')); updateProgress(100, 0, displayName); showToast('toast.loras.downloadSkippedByBaseModel', { baseModel: response.base_model || 'Unknown' }, 'warning'); if (closeModal) { modalManager.closeModal('downloadModal'); } return true; } showToast('toast.loras.downloadCompleted', {}, 'success'); if (closeModal) { modalManager.closeModal('downloadModal'); } if (ws && ws.readyState === WebSocket.OPEN) { ws.close(); ws = null; } const pageState = this.apiClient.getPageState(); if (!useDefaultPaths && targetFolder) { pageState.activeFolder = targetFolder; setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder); 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' }); } }); } await resetAndReload(true); return true; } catch (error) { console.error('Failed to download model version:', error); showToast('toast.downloads.downloadError', { message: error?.message }, 'error'); return false; } finally { try { if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { ws.close(); } } catch (closeError) { console.debug('Failed to close download progress socket:', closeError); } this.loadingManager.hide(); } } updatePathSelectionUI() { const manualSelection = document.getElementById('manualPathSelection'); // Always show manual path selection, but disable/enable based on useDefaultPath manualSelection.style.display = 'block'; if (this.useDefaultPath) { manualSelection.classList.add('disabled'); // Disable all inputs and buttons inside manualSelection manualSelection.querySelectorAll('input, select, button').forEach(el => { el.disabled = true; el.tabIndex = -1; }); } else { manualSelection.classList.remove('disabled'); manualSelection.querySelectorAll('input, select, button').forEach(el => { el.disabled = false; el.tabIndex = 0; }); } // Always update the main path display this.updateTargetPath(); } showBatchPreviewStep() { document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none'); document.getElementById('batchPreviewStep').style.display = 'block'; const validCount = this.batchModels.filter(m => !m.error && m.selectedVersion).length; document.getElementById('downloadModalTitle').textContent = translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) + ` (${validCount})`; const list = document.getElementById('batchPreviewList'); list.innerHTML = this.batchModels.map((item, index) => { if (item.error) { return `
${item.url}
${item.error}
`; } const ver = item.selectedVersion; const firstImage = ver?.images?.find(img => !img.url.endsWith('.mp4')); const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png'; const fileSize = ver?.modelSizeKB ? (ver.modelSizeKB / 1024).toFixed(1) : (ver?.files?.[0]?.sizeKB ? (ver.files[0].sizeKB / 1024).toFixed(1) : '?'); const existsLocally = ver?.existsLocally; return `
${ver?.name || `Model #${item.modelId}`}
${ver?.baseModel ? `${ver.baseModel}` : ''} ${fileSize} MB ${existsLocally ? ` ${translate('modals.download.inLibrary')}` : ''}
${item.versions.length > 1 ? ` ` : ''}
`; }).join(''); list.onclick = (e) => { const removeBtn = e.target.closest('.batch-preview-remove'); if (removeBtn) { const idx = parseInt(removeBtn.dataset.index); this.batchModels.splice(idx, 1); this.showBatchPreviewStep(); return; } const changeBtn = e.target.closest('.batch-preview-change-version'); if (changeBtn) { const idx = parseInt(changeBtn.dataset.index); this.openBatchVersionEditor(idx); } }; const nextBtn = document.getElementById('nextFromBatchBtn'); nextBtn.disabled = validCount === 0; nextBtn.classList.toggle('disabled', validCount === 0); } openBatchVersionEditor(index) { this.editingBatchIndex = index; const item = this.batchModels[index]; this.versions = item.versions; this.currentVersion = item.selectedVersion; document.getElementById('batchPreviewStep').style.display = 'none'; this.showVersionStep(); } backToUrlFromBatch() { document.getElementById('batchPreviewStep').style.display = 'none'; document.getElementById('urlStep').style.display = 'block'; } nextFromBatch() { const validModels = this.batchModels.filter(m => !m.error && m.selectedVersion); if (validModels.length === 0) return; this.proceedToLocation(); } backToUrl() { document.getElementById('versionStep').style.display = 'none'; if (this.isBatchMode && this.editingBatchIndex >= 0) { this.editingBatchIndex = -1; this.showBatchPreviewStep(); } else { document.getElementById('urlStep').style.display = 'block'; } } backToVersions() { document.getElementById('locationStep').style.display = 'none'; if (this.isBatchMode) { document.getElementById('batchPreviewStep').style.display = 'block'; } else { document.getElementById('versionStep').style.display = 'block'; } } closeModal() { // Clean up folder tree manager if (this.folderTreeManager) { this.folderTreeManager.destroy(); } modalManager.closeModal('downloadModal'); } async startDownload() { const modelRoot = document.getElementById('modelRoot').value; const config = this.apiClient.apiConfig.config; if (!modelRoot) { showToast('toast.models.pleaseSelectRoot', { type: config.displayName }, 'error'); return; } let targetFolder = ''; let useDefaultPaths = false; if (this.useDefaultPath) { useDefaultPaths = true; } else { targetFolder = this.folderTreeManager.getSelectedPath(); } if (!this.isBatchMode) { const fileParams = this.selectedFile ? { type: 'Model', format: this.selectedFile.metadata?.format || 'SafeTensor', size: this.selectedFile.metadata?.size || 'full', fp: this.selectedFile.metadata?.fp, } : null; return this.executeDownloadWithProgress({ modelId: this.modelId, versionId: this.currentVersion.id, versionName: this.currentVersion.name, modelRoot, targetFolder, useDefaultPaths, source: this.source, fileParams, closeModal: true, }); } // Batch download mode const downloadItems = this.batchModels.filter(m => !m.error && m.selectedVersion && !m.selectedVersion.existsLocally); if (downloadItems.length === 0) { showToast('toast.loras.downloadCompleted', {}, 'info'); modalManager.closeModal('downloadModal'); return; } modalManager.closeModal('downloadModal'); const batchDownloadId = Date.now().toString(); const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`); const loadingManager = state.loadingManager || this.loadingManager; const updateProgress = loadingManager.showDownloadProgress(downloadItems.length); let completedDownloads = 0; let failedDownloads = 0; ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === 'download_id') return; if (data.status === 'progress' && data.download_id?.startsWith(batchDownloadId)) { const current = downloadItems[completedDownloads + failedDownloads]; const name = current?.selectedVersion?.name || `#${completedDownloads + failedDownloads + 1}`; const metrics = { bytesDownloaded: data.bytes_downloaded, totalBytes: data.total_bytes, bytesPerSecond: data.bytes_per_second, }; updateProgress(data.progress, completedDownloads, name, metrics); } }; await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; }); for (let i = 0; i < downloadItems.length; i++) { const item = downloadItems[i]; const ver = item.selectedVersion; const name = ver?.name || `Model #${item.modelId}`; updateProgress(0, completedDownloads, name); loadingManager.setStatus(`${i + 1}/${downloadItems.length}: ${name}`); try { const response = await this.apiClient.downloadModel( item.modelId, ver.id, modelRoot, targetFolder, useDefaultPaths, batchDownloadId, item.source ); if (!response.success) { failedDownloads++; } else { completedDownloads++; updateProgress(100, completedDownloads, ''); } } catch (err) { console.error(`Failed to download ${name}:`, err); failedDownloads++; } } ws.close(); loadingManager.hide(); if (failedDownloads === 0) { showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success'); } else { showToast('toast.loras.downloadPartialSuccess', { completed: completedDownloads, total: downloadItems.length, }, 'warning'); } await resetAndReload(true); } async downloadVersionWithDefaults(modelType, modelId, versionId, { versionName = '', source = null, modelRoot = '', targetFolder = '' } = {}) { try { this.apiClient = getModelApiClient(modelType); } catch (error) { this.apiClient = getModelApiClient(); } this.modelId = modelId ? modelId.toString() : null; this.source = source; const useDefaultPaths = !modelRoot; return this.executeDownloadWithProgress({ modelId, versionId, versionName, modelRoot: modelRoot || '', targetFolder: targetFolder || '', useDefaultPaths, source, closeModal: false, }); } async initializeFolderTree() { try { // Fetch unified folder tree const treeData = await this.apiClient.fetchUnifiedFolderTree(); if (treeData.success) { // Load tree data into folder tree manager await this.folderTreeManager.loadTree(treeData.tree); } else { console.error('Failed to fetch folder tree:', treeData.error); showToast('toast.import.folderTreeFailed', {}, 'error'); } } catch (error) { console.error('Error initializing folder tree:', error); showToast('toast.import.folderTreeError', {}, 'error'); } } initializeFolderBrowser() { const folderBrowser = document.getElementById('folderBrowser'); if (!folderBrowser) return; this.cleanupFolderBrowser(); 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; } this.updateTargetPath(); }; folderBrowser.addEventListener('click', this.folderClickHandler); const modelRoot = document.getElementById('modelRoot'); const newFolder = document.getElementById('newFolder'); modelRoot.addEventListener('change', this.updateTargetPath); newFolder.addEventListener('input', this.updateTargetPath); this.updateTargetPath(); } cleanupFolderBrowser() { if (this.folderClickHandler) { const folderBrowser = document.getElementById('folderBrowser'); if (folderBrowser) { folderBrowser.removeEventListener('click', this.folderClickHandler); this.folderClickHandler = null; } } const modelRoot = document.getElementById('modelRoot'); const newFolder = document.getElementById('newFolder'); if (modelRoot) modelRoot.removeEventListener('change', this.updateTargetPath); if (newFolder) newFolder.removeEventListener('input', this.updateTargetPath); } updateTargetPath() { const pathDisplay = document.getElementById('targetPathDisplay'); const modelRoot = document.getElementById('modelRoot').value; const config = this.apiClient.apiConfig.config; let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: config.displayName }); if (modelRoot) { if (this.useDefaultPath) { // Show actual template path try { const singularType = this.apiClient.modelType.replace(/s$/, ''); const templates = state.global.settings.download_path_templates; const template = templates[singularType]; fullPath += `/${template}`; } catch (error) { console.error('Failed to fetch template:', error); fullPath += '/' + translate('modals.download.autoOrganizedPath'); } } else { // Show manual path selection const selectedPath = this.folderTreeManager ? this.folderTreeManager.getSelectedPath() : ''; if (selectedPath) { fullPath += '/' + selectedPath; } } } pathDisplay.innerHTML = `${fullPath}`; } } // Create global instance export const downloadManager = new DownloadManager(); // Expose to window for browser extension integration if (typeof window !== 'undefined') { window.downloadManager = downloadManager; }