diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py index df1bd2fd..8d6b1369 100644 --- a/py/routes/recipe_routes.py +++ b/py/routes/recipe_routes.py @@ -231,42 +231,49 @@ class RecipeRoutes: # Get additional info from Civitai civitai_info = await self.civitai_client.get_model_version_info(model_version_id) - # Check if this LoRA exists locally by SHA256 hash - exists_locally = False - local_path = None - sha256 = '' - - if civitai_info and 'files' in civitai_info: - # Find the model file (type="Model") in the files list - model_file = next((file for file in civitai_info.get('files', []) - if file.get('type') == 'Model'), None) - - if model_file: - sha256 = model_file.get('hashes', {}).get('SHA256', '') - if sha256: - exists_locally = self.recipe_scanner._lora_scanner.has_lora_hash(sha256) - if exists_locally: - local_path = self.recipe_scanner._lora_scanner.get_lora_path_by_hash(sha256) - - # Create LoRA entry for frontend display + # Initialize lora entry with default values lora_entry = { 'id': model_version_id, 'name': resource.get('modelName', ''), 'version': resource.get('modelVersionName', ''), 'type': resource.get('type', 'lora'), 'weight': resource.get('weight', 1.0), - 'existsLocally': exists_locally, - 'localPath': local_path, - 'file_name': os.path.splitext(os.path.basename(local_path))[0] if local_path else '', - 'hash': sha256, + 'existsLocally': False, + 'localPath': None, + 'file_name': '', + 'hash': '', 'thumbnailUrl': '', 'baseModel': '', 'size': 0, - 'downloadUrl': '' + 'downloadUrl': '', + 'isDeleted': False # New flag to indicate if the LoRA is deleted from Civitai } - # Add Civitai info if available - if civitai_info: + # Check if this LoRA exists locally by SHA256 hash + if civitai_info and civitai_info.get("error") != "Model not found": + # LoRA exists on Civitai, process its information + if 'files' in civitai_info: + # Find the model file (type="Model") in the files list + model_file = next((file for file in civitai_info.get('files', []) + if file.get('type') == 'Model'), None) + + if model_file: + sha256 = model_file.get('hashes', {}).get('SHA256', '') + if sha256: + exists_locally = self.recipe_scanner._lora_scanner.has_lora_hash(sha256) + if exists_locally: + local_path = self.recipe_scanner._lora_scanner.get_lora_path_by_hash(sha256) + lora_entry['existsLocally'] = True + lora_entry['localPath'] = local_path + lora_entry['file_name'] = os.path.splitext(os.path.basename(local_path))[0] + else: + # For missing LoRAs, get file_name from model_file.name + file_name = model_file.get('name', '') + lora_entry['file_name'] = os.path.splitext(file_name)[0] if file_name else '' + + lora_entry['hash'] = sha256 + lora_entry['size'] = model_file.get('sizeKB', 0) * 1024 + # Get thumbnail URL from first image if 'images' in civitai_info and civitai_info['images']: lora_entry['thumbnailUrl'] = civitai_info['images'][0].get('url', '') @@ -274,12 +281,12 @@ class RecipeRoutes: # Get base model lora_entry['baseModel'] = civitai_info.get('baseModel', '') - # Get file size from model file - if model_file: - lora_entry['size'] = model_file.get('sizeKB', 0) * 1024 - # Get download URL lora_entry['downloadUrl'] = civitai_info.get('downloadUrl', '') + else: + # LoRA is deleted from Civitai or not found + lora_entry['isDeleted'] = True + lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png' loras.append(lora_entry) @@ -385,6 +392,10 @@ class RecipeRoutes: # Format loras data according to the recipe.json format loras_data = [] for lora in metadata.get("loras", []): + # Skip deleted LoRAs if they're marked to be excluded + if lora.get("isDeleted", False) and lora.get("exclude", False): + continue + # Convert frontend lora format to recipe format lora_entry = { "file_name": lora.get("file_name", "") or os.path.splitext(os.path.basename(lora.get("localPath", "")))[0], @@ -392,7 +403,8 @@ class RecipeRoutes: "strength": float(lora.get("weight", 1.0)), "modelVersionId": lora.get("id", ""), "modelName": lora.get("name", ""), - "modelVersionName": lora.get("version", "") + "modelVersionName": lora.get("version", ""), + "isDeleted": lora.get("isDeleted", False) # Preserve deletion status in saved recipe } loras_data.append(lora_entry) @@ -434,15 +446,17 @@ class RecipeRoutes: json_path = os.path.join(recipes_dir, json_filename) with open(json_path, 'w', encoding='utf-8') as f: json.dump(recipe_data, f, indent=4, ensure_ascii=False) - - # Add the new recipe directly to the cache instead of forcing a refresh - try: - # Use a timeout to prevent deadlocks - async with asyncio.timeout(5.0): - cache = await self.recipe_scanner.get_cached_data() - await cache.add_recipe(recipe_data) - except asyncio.TimeoutError: - logger.warning("Timeout adding recipe to cache - will be picked up on next refresh") + + # Simplified cache update approach + # Instead of trying to update the cache directly, just set it to None + # to force a refresh on the next get_cached_data call + if self.recipe_scanner._cache is not None: + # Add the recipe to the raw data if the cache exists + # This is a simple direct update without locks or timeouts + self.recipe_scanner._cache.raw_data.append(recipe_data) + # Schedule a background task to resort the cache + asyncio.create_task(self.recipe_scanner._cache.resort()) + logger.info(f"Added recipe {recipe_id} to cache") return web.json_response({ 'success': True, @@ -486,14 +500,16 @@ class RecipeRoutes: os.remove(image_path) logger.info(f"Deleted recipe image: {image_path}") - # Remove from cache without forcing a full refresh - try: - # Use a timeout to prevent deadlocks - async with asyncio.timeout(5.0): - cache = await self.recipe_scanner.get_cached_data(force_refresh=False) - await cache.remove_recipe(recipe_id) - except asyncio.TimeoutError: - logger.warning("Timeout removing recipe from cache - will be picked up on next refresh") + # Simplified cache update approach + if self.recipe_scanner._cache is not None: + # Remove the recipe from raw_data if it exists + self.recipe_scanner._cache.raw_data = [ + r for r in self.recipe_scanner._cache.raw_data + if str(r.get('id', '')) != recipe_id + ] + # Schedule a background task to resort the cache + asyncio.create_task(self.recipe_scanner._cache.resort()) + logger.info(f"Removed recipe {recipe_id} from cache") return web.json_response({"success": True, "message": "Recipe deleted successfully"}) except Exception as e: diff --git a/static/css/base.css b/static/css/base.css index 985cb62f..971d4d85 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -38,6 +38,7 @@ html, body { --lora-border: oklch(90% 0.02 256 / 0.15); --lora-text: oklch(95% 0.02 256); --lora-error: oklch(75% 0.32 29); + --lora-warning: oklch(75% 0.25 80); /* Add warning color for deleted LoRAs */ /* Spacing Scale */ --space-1: calc(8px * 1); @@ -68,6 +69,7 @@ html, body { --lora-surface: oklch(25% 0.02 256 / 0.98); --lora-border: oklch(90% 0.02 256 / 0.15); --lora-text: oklch(98% 0.02 256); + --lora-warning: oklch(75% 0.25 80); /* Add warning color for dark theme too */ } body { diff --git a/static/css/components/import-modal.css b/static/css/components/import-modal.css index 7fbb3c20..9764d99b 100644 --- a/static/css/components/import-modal.css +++ b/static/css/components/import-modal.css @@ -161,6 +161,11 @@ border-left: 4px solid var(--lora-error); } +.lora-item.is-deleted { + background: oklch(var(--lora-warning) / 0.05); + border-left: 4px solid var(--lora-warning); +} + .lora-thumbnail { width: 80px; height: 80px; @@ -521,4 +526,89 @@ .modal-content { padding-right: calc(var(--space-2) + var(--scrollbar-width)); /* Add extra padding for scrollbar */ } +} + +/* Deleted LoRA styles - Fix layout issues */ +.lora-item.is-deleted { + background: oklch(var(--lora-warning) / 0.05); + border-left: 4px solid var(--lora-warning); +} + +.deleted-badge { + display: inline-flex; + align-items: center; + background: var(--lora-warning); + color: white; + padding: 4px 8px; + border-radius: var(--border-radius-xs); + font-size: 0.8em; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; +} + +.deleted-badge i { + margin-right: 4px; + font-size: 0.9em; +} + +.exclude-lora-checkbox { + display: none; +} + +/* Deleted LoRAs warning - redesigned to not interfere with modal buttons */ +.deleted-loras-warning { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + background: oklch(var(--lora-warning) / 0.1); + border: 1px solid var(--lora-warning); + border-radius: var(--border-radius-sm); + color: var(--text-color); + margin-bottom: var(--space-2); +} + +.warning-icon { + color: var(--lora-warning); + font-size: 1.2em; + padding-top: 2px; +} + +.warning-content { + flex: 1; +} + +.warning-title { + font-weight: 600; + margin-bottom: 4px; +} + +.warning-text { + font-size: 0.9em; + line-height: 1.4; +} + +/* Remove the old warning-message styles that were causing layout issues */ +.warning-message { + display: none; /* Hide the old style */ +} + +/* Update deleted badge to be more prominent */ +.deleted-badge { + display: inline-flex; + align-items: center; + background: var(--lora-warning); + color: white; + padding: 4px 8px; + border-radius: var(--border-radius-xs); + font-size: 0.8em; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; +} + +.deleted-badge i { + margin-right: 4px; + font-size: 0.9em; } \ No newline at end of file diff --git a/static/js/managers/ImportManager.js b/static/js/managers/ImportManager.js index bfcb0911..90d85e4a 100644 --- a/static/js/managers/ImportManager.js +++ b/static/js/managers/ImportManager.js @@ -255,31 +255,39 @@ export class ImportManager { if (lorasList) { lorasList.innerHTML = this.recipeData.loras.map(lora => { const existsLocally = lora.existsLocally; + const isDeleted = lora.isDeleted; const localPath = lora.localPath || ''; - // Create local status badge - const localStatus = existsLocally ? - `
- In Library -
${localPath}
-
` : - `
- Not in Library -
`; + // Create status badge based on LoRA status + let statusBadge; + if (isDeleted) { + statusBadge = `
+ Deleted from Civitai +
`; + } else { + statusBadge = existsLocally ? + `
+ In Library +
${localPath}
+
` : + `
+ Not in Library +
`; + } // Format size if available const sizeDisplay = lora.size ? `
${this.formatFileSize(lora.size)}
` : ''; return ` -
+
LoRA preview

${lora.name}

- ${localStatus} + ${statusBadge}
${lora.version ? `
${lora.version}
` : ''}
@@ -301,12 +309,55 @@ export class ImportManager { const nextButton = document.querySelector('#detailsStep .primary-btn'); if (!nextButton) return; - // If we have missing LoRAs, show "Download Missing LoRAs" - // Otherwise show "Save Recipe" - if (this.missingLoras.length > 0) { - nextButton.textContent = 'Download Missing LoRAs'; + // Count deleted LoRAs + const deletedLoras = this.recipeData.loras.filter(lora => lora.isDeleted).length; + + // If we have deleted LoRAs, show a warning and update button text + if (deletedLoras > 0) { + // Remove any existing warning + const existingWarning = document.getElementById('deletedLorasWarning'); + if (existingWarning) { + existingWarning.remove(); + } + + // Create a new warning container above the buttons + const buttonsContainer = document.querySelector('#detailsStep .modal-actions') || nextButton.parentNode; + const warningContainer = document.createElement('div'); + warningContainer.id = 'deletedLorasWarning'; + warningContainer.className = 'deleted-loras-warning'; + + // Create warning message + warningContainer.innerHTML = ` +
+
+
${deletedLoras} LoRA(s) have been deleted from Civitai
+
These LoRAs cannot be downloaded. If you continue, they will be removed from the recipe.
+
+ `; + + // Insert before the buttons container + buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); + + // Update next button text to be more clear + nextButton.textContent = 'Continue Without Deleted LoRAs'; } else { - nextButton.textContent = 'Save Recipe'; + // Remove warning if no deleted LoRAs + const warningMsg = document.getElementById('deletedLorasWarning'); + if (warningMsg) { + warningMsg.remove(); + } + + // If we have missing LoRAs (not deleted), show "Download Missing LoRAs" + // Otherwise show "Save Recipe" + const missingNotDeleted = this.recipeData.loras.filter( + lora => !lora.existsLocally && !lora.isDeleted + ).length; + + if (missingNotDeleted > 0) { + nextButton.textContent = 'Download Missing LoRAs'; + } else { + nextButton.textContent = 'Save Recipe'; + } } } @@ -356,8 +407,23 @@ export class ImportManager { return; } - // If we have missing LoRAs, go to location step + // Automatically mark all deleted LoRAs as excluded + if (this.recipeData && this.recipeData.loras) { + this.recipeData.loras.forEach(lora => { + if (lora.isDeleted) { + lora.exclude = true; + } + }); + } + + // Update missing LoRAs list to exclude deleted LoRAs + this.missingLoras = this.recipeData.loras.filter(lora => + !lora.existsLocally && !lora.isDeleted); + + // If we have downloadable missing LoRAs, go to location step if (this.missingLoras.length > 0) { + // Store only downloadable LoRAs for the download step + this.downloadableLoRAs = this.missingLoras; this.proceedToLocation(); } else { // Otherwise, save the recipe directly @@ -396,9 +462,9 @@ export class ImportManager { try { // Display missing LoRAs that will be downloaded const missingLorasList = document.getElementById('missingLorasList'); - if (missingLorasList && this.missingLoras.length > 0) { + if (missingLorasList && this.downloadableLoRAs.length > 0) { // Calculate total size - const totalSize = this.missingLoras.reduce((sum, lora) => { + const totalSize = this.downloadableLoRAs.reduce((sum, lora) => { return sum + (lora.size ? parseInt(lora.size) : 0); }, 0); @@ -411,11 +477,11 @@ export class ImportManager { // Update header to include count of missing LoRAs const missingLorasHeader = document.querySelector('.summary-header h3'); if (missingLorasHeader) { - missingLorasHeader.innerHTML = `Missing LoRAs (${this.missingLoras.length}) ${this.formatFileSize(totalSize)}`; + missingLorasHeader.innerHTML = `Missing LoRAs (${this.downloadableLoRAs.length}) ${this.formatFileSize(totalSize)}`; } // Generate missing LoRAs list - missingLorasList.innerHTML = this.missingLoras.map(lora => { + missingLorasList.innerHTML = this.downloadableLoRAs.map(lora => { const sizeDisplay = lora.size ? this.formatFileSize(lora.size) : 'Unknown size'; const baseModel = lora.baseModel ? `${lora.baseModel}` : ''; @@ -538,7 +604,7 @@ export class ImportManager { // Check if we need to download LoRAs - if (this.missingLoras.length > 0) { + if (this.downloadableLoRAs && this.downloadableLoRAs.length > 0) { // For download, we need to validate the target path const loraRoot = document.getElementById('importLoraRoot')?.value; if (!loraRoot) { @@ -561,7 +627,7 @@ export class ImportManager { const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`); // Show enhanced loading with progress details for multiple items - const updateProgress = this.loadingManager.showDownloadProgress(this.missingLoras.length); + const updateProgress = this.loadingManager.showDownloadProgress(this.downloadableLoRAs.length); let completedDownloads = 0; let currentLoraProgress = 0; @@ -574,7 +640,7 @@ export class ImportManager { currentLoraProgress = data.progress; // Get current LoRA name - const currentLora = this.missingLoras[completedDownloads]; + const currentLora = this.downloadableLoRAs[completedDownloads]; const loraName = currentLora ? currentLora.name : ''; // Update progress display @@ -583,32 +649,32 @@ export class ImportManager { // Add more detailed status messages based on progress if (currentLoraProgress < 3) { this.loadingManager.setStatus( - `Preparing download for LoRA ${completedDownloads+1}/${this.missingLoras.length}` + `Preparing download for LoRA ${completedDownloads+1}/${this.downloadableLoRAs.length}` ); } else if (currentLoraProgress === 3) { this.loadingManager.setStatus( - `Downloaded preview for LoRA ${completedDownloads+1}/${this.missingLoras.length}` + `Downloaded preview for LoRA ${completedDownloads+1}/${this.downloadableLoRAs.length}` ); } else if (currentLoraProgress > 3 && currentLoraProgress < 100) { this.loadingManager.setStatus( - `Downloading LoRA ${completedDownloads+1}/${this.missingLoras.length}` + `Downloading LoRA ${completedDownloads+1}/${this.downloadableLoRAs.length}` ); } else { this.loadingManager.setStatus( - `Finalizing LoRA ${completedDownloads+1}/${this.missingLoras.length}` + `Finalizing LoRA ${completedDownloads+1}/${this.downloadableLoRAs.length}` ); } } }; - for (let i = 0; i < this.missingLoras.length; i++) { - const lora = this.missingLoras[i]; + for (let i = 0; i < this.downloadableLoRAs.length; i++) { + const lora = this.downloadableLoRAs[i]; // Reset current LoRA progress for new download currentLoraProgress = 0; // Initial status update for new LoRA - this.loadingManager.setStatus(`Starting download for LoRA ${i+1}/${this.missingLoras.length}`); + this.loadingManager.setStatus(`Starting download for LoRA ${i+1}/${this.downloadableLoRAs.length}`); updateProgress(0, completedDownloads, lora.name); try { @@ -633,9 +699,9 @@ export class ImportManager { // Update progress to show completion of current LoRA updateProgress(100, completedDownloads, ''); - if (completedDownloads < this.missingLoras.length) { + if (completedDownloads < this.downloadableLoRAs.length) { this.loadingManager.setStatus( - `Completed ${completedDownloads}/${this.missingLoras.length} LoRAs. Starting next download...` + `Completed ${completedDownloads}/${this.downloadableLoRAs.length} LoRAs. Starting next download...` ); } } @@ -649,10 +715,10 @@ export class ImportManager { ws.close(); // Show final completion message - if (completedDownloads === this.missingLoras.length) { + if (completedDownloads === this.downloadableLoRAs.length) { showToast(`All ${completedDownloads} LoRAs downloaded successfully`, 'success'); } else { - showToast(`Downloaded ${completedDownloads} of ${this.missingLoras.length} LoRAs`, 'warning'); + showToast(`Downloaded ${completedDownloads} of ${this.downloadableLoRAs.length} LoRAs`, 'warning'); } }