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
This commit is contained in:
Will Miao
2025-07-25 17:35:06 +08:00
parent e587189880
commit 7f205cdcc8
16 changed files with 337 additions and 785 deletions

View File

@@ -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
}
};

View File

@@ -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
*/

View File

@@ -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);

View File

@@ -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

View File

@@ -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

View File

@@ -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: () => {

View File

@@ -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

View File

@@ -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 = `
<div class="early-access-badge" title="Early access required">
<i class="fas fa-clock"></i> Early Access
</div>
`;
}
// Status badge for local models
const localStatus = existsLocally ?
`<div class="local-badge">
<i class="fas fa-check"></i> In Library
<div class="local-path">${localPath || ''}</div>
</div>` : '';
return `
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}
${existsLocally ? 'exists-locally' : ''}
${isEarlyAccess ? 'is-early-access' : ''}"
onclick="checkpointDownloadManager.selectVersion('${version.id}')">
<div class="version-thumbnail">
<img src="${thumbnailUrl}" alt="Version preview">
</div>
<div class="version-content">
<div class="version-header">
<h3>${version.name}</h3>
${localStatus}
</div>
<div class="version-info">
${version.baseModel ? `<div class="base-model">${version.baseModel}</div>` : ''}
${earlyAccessBadge}
</div>
<div class="version-meta">
<span><i class="fas fa-calendar"></i> ${new Date(version.createdAt).toLocaleDateString()}</span>
<span><i class="fas fa-file-archive"></i> ${fileSize} MB</span>
</div>
</div>
</div>
`;
}).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 =>
`<option value="${root}">${root}</option>`
).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 = `<span class="path-text">${fullPath}</span>`;
}
}

View File

@@ -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 ?
`<div class="local-badge">
<i class="fas fa-check"></i> In Library
@@ -167,7 +203,7 @@ export class DownloadManager {
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}
${existsLocally ? 'exists-locally' : ''}
${isEarlyAccess ? 'is-early-access' : ''}"
onclick="downloadManager.selectVersion('${version.id}')">
data-version-id="${version.id}">
<div class="version-thumbnail">
<img src="${thumbnailUrl}" alt="Version preview">
</div>
@@ -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 =>
`<option value="${root}">${root}</option>`
).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 =>
`<div class="folder-item" data-folder="${folder}">${folder}</div>`
).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 = `<span class="path-text">${fullPath}</span>`;
}
}
// Create global instance
export const downloadManager = new DownloadManager();