diff --git a/py/routes/api_routes.py b/py/routes/api_routes.py index 10a52a78..6d763406 100644 --- a/py/routes/api_routes.py +++ b/py/routes/api_routes.py @@ -52,6 +52,7 @@ class ApiRoutes: app.router.add_get('/api/loras/top-tags', routes.get_top_tags) # Add new route for top tags app.router.add_get('/api/loras/base-models', routes.get_base_models) # Add new route for base models app.router.add_get('/api/lora-civitai-url', routes.get_lora_civitai_url) # Add new route for Civitai URL + app.router.add_post('/api/rename_lora', routes.rename_lora) # Add new route for renaming LoRA files # Add update check routes UpdateRoutes.setup_routes(app) @@ -929,6 +930,149 @@ class ApiRoutes: }) except Exception as e: logger.error(f"Error retrieving base models: {e}", exc_info=True) + return web.json_response({ + 'success': False, + 'error': str(e) + }, status=500) + + def get_multipart_ext(self, filename): + parts = filename.split(".") + if len(parts) > 2: # 如果包含多级扩展名 + return "." + ".".join(parts[-2:]) # 取最后两部分,如 ".metadata.json" + return os.path.splitext(filename)[1] # 否则取普通扩展名,如 ".safetensors" + + async def rename_lora(self, request: web.Request) -> web.Response: + """Handle renaming a LoRA file and its associated files""" + try: + data = await request.json() + file_path = data.get('file_path') + new_file_name = data.get('new_file_name') + + if not file_path or not new_file_name: + return web.json_response({ + 'success': False, + 'error': 'File path and new file name are required' + }, status=400) + + # Validate the new file name (no path separators or invalid characters) + invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|'] + if any(char in new_file_name for char in invalid_chars): + return web.json_response({ + 'success': False, + 'error': 'Invalid characters in file name' + }, status=400) + + # Get the directory and current file name + target_dir = os.path.dirname(file_path) + old_file_name = os.path.splitext(os.path.basename(file_path))[0] + + # Check if the target file already exists + new_file_path = os.path.join(target_dir, f"{new_file_name}.safetensors").replace(os.sep, '/') + if os.path.exists(new_file_path): + return web.json_response({ + 'success': False, + 'error': 'A file with this name already exists' + }, status=400) + + # Define the patterns for associated files + patterns = [ + f"{old_file_name}.safetensors", # Required + f"{old_file_name}.metadata.json", + f"{old_file_name}.preview.png", + f"{old_file_name}.preview.jpg", + f"{old_file_name}.preview.jpeg", + f"{old_file_name}.preview.webp", + f"{old_file_name}.preview.mp4", + f"{old_file_name}.png", + f"{old_file_name}.jpg", + f"{old_file_name}.jpeg", + f"{old_file_name}.webp", + f"{old_file_name}.mp4" + ] + + # Find all matching files + existing_files = [] + for pattern in patterns: + path = os.path.join(target_dir, pattern) + if os.path.exists(path): + existing_files.append((path, pattern)) + + # Get the hash from the main file to update hash index + hash_value = None + metadata = None + metadata_path = os.path.join(target_dir, f"{old_file_name}.metadata.json") + + if os.path.exists(metadata_path): + try: + with open(metadata_path, 'r', encoding='utf-8') as f: + metadata = json.load(f) + hash_value = metadata.get('sha256') + except Exception as e: + logger.error(f"Error loading metadata for rename: {e}") + + # Rename all files + renamed_files = [] + new_metadata_path = None + + # Notify file monitor to ignore these events + main_file_path = os.path.join(target_dir, f"{old_file_name}.safetensors") + if os.path.exists(main_file_path) and self.download_manager.file_monitor: + # Add old and new paths to ignore list + file_size = os.path.getsize(main_file_path) + self.download_manager.file_monitor.handler.add_ignore_path(main_file_path, file_size) + self.download_manager.file_monitor.handler.add_ignore_path(new_file_path, file_size) + + for old_path, pattern in existing_files: + # Get the file extension like .safetensors or .metadata.json + ext = self.get_multipart_ext(pattern) + + # Create the new path + new_path = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/') + + # Rename the file + os.rename(old_path, new_path) + renamed_files.append(new_path) + + # Keep track of metadata path for later update + if ext == '.metadata.json': + new_metadata_path = new_path + + # Update the metadata file with new file name and paths + if new_metadata_path and metadata: + # Update file_name, file_path and preview_url in metadata + metadata['file_name'] = new_file_name + metadata['file_path'] = new_file_path + + # Update preview_url if it exists + if 'preview_url' in metadata and metadata['preview_url']: + old_preview = metadata['preview_url'] + ext = self.get_multipart_ext(old_preview) + new_preview = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/') + metadata['preview_url'] = new_preview + + # Save updated metadata + with open(new_metadata_path, 'w', encoding='utf-8') as f: + json.dump(metadata, f, indent=2, ensure_ascii=False) + + # Update the scanner cache + if metadata: + await self.scanner.update_single_lora_cache(file_path, new_file_path, metadata) + + # Update recipe files and cache if hash is available + if hash_value: + recipe_scanner = RecipeScanner(self.scanner) + recipes_updated, cache_updated = await recipe_scanner.update_lora_filename_by_hash(hash_value, new_file_name) + logger.info(f"Updated {recipes_updated} recipe files and {cache_updated} cache entries for renamed LoRA") + + return web.json_response({ + 'success': True, + 'new_file_path': new_file_path, + 'renamed_files': renamed_files, + 'reload_required': False + }) + + except Exception as e: + logger.error(f"Error renaming LoRA: {e}", exc_info=True) return web.json_response({ 'success': False, 'error': str(e) diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 01bfb9b0..aeb27516 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -2,7 +2,7 @@ import os import logging import asyncio import json -from typing import List, Dict, Optional, Any +from typing import List, Dict, Optional, Any, Tuple from ..config import config from .recipe_cache import RecipeCache from .lora_scanner import LoraScanner @@ -430,3 +430,87 @@ class RecipeScanner: } return result + + async def update_lora_filename_by_hash(self, hash_value: str, new_file_name: str) -> Tuple[int, int]: + """Update file_name in all recipes that contain a LoRA with the specified hash. + + Args: + hash_value: The SHA256 hash value of the LoRA + new_file_name: The new file_name to set + + Returns: + Tuple[int, int]: (number of recipes updated in files, number of recipes updated in cache) + """ + if not hash_value or not new_file_name: + return 0, 0 + + # Always use lowercase hash for consistency + hash_value = hash_value.lower() + + # Get recipes directory + recipes_dir = self.recipes_dir + if not recipes_dir or not os.path.exists(recipes_dir): + logger.warning(f"Recipes directory not found: {recipes_dir}") + return 0, 0 + + # Check if cache is initialized + cache_initialized = self._cache is not None + cache_updated_count = 0 + file_updated_count = 0 + + # Get all recipe JSON files in the recipes directory + recipe_files = [] + for root, _, files in os.walk(recipes_dir): + for file in files: + if file.lower().endswith('.recipe.json'): + recipe_files.append(os.path.join(root, file)) + + # Process each recipe file + for recipe_path in recipe_files: + try: + # Load the recipe data + with open(recipe_path, 'r', encoding='utf-8') as f: + recipe_data = json.load(f) + + # Skip if no loras or invalid structure + if not recipe_data or not isinstance(recipe_data, dict) or 'loras' not in recipe_data: + continue + + # Check if any lora has matching hash + file_updated = False + for lora in recipe_data.get('loras', []): + if 'hash' in lora and lora['hash'].lower() == hash_value: + # Update file_name + old_file_name = lora.get('file_name', '') + lora['file_name'] = new_file_name + file_updated = True + logger.info(f"Updated file_name in recipe {recipe_path}: {old_file_name} -> {new_file_name}") + + # If updated, save the file + if file_updated: + with open(recipe_path, 'w', encoding='utf-8') as f: + json.dump(recipe_data, f, indent=4, ensure_ascii=False) + file_updated_count += 1 + + # Also update in cache if it exists + if cache_initialized: + recipe_id = recipe_data.get('id') + if recipe_id: + for cache_item in self._cache.raw_data: + if cache_item.get('id') == recipe_id: + # Replace loras array with updated version + cache_item['loras'] = recipe_data['loras'] + cache_updated_count += 1 + break + + except Exception as e: + logger.error(f"Error updating recipe file {recipe_path}: {e}") + import traceback + traceback.print_exc(file=sys.stderr) + + # Resort cache if updates were made + if cache_initialized and cache_updated_count > 0: + await self._cache.resort() + logger.info(f"Resorted recipe cache after updating {cache_updated_count} items") + + return file_updated_count, cache_updated_count diff --git a/static/css/components/lora-modal.css b/static/css/components/lora-modal.css index 3fa5d344..66f43a92 100644 --- a/static/css/components/lora-modal.css +++ b/static/css/components/lora-modal.css @@ -543,25 +543,53 @@ display: flex; align-items: center; gap: 8px; - cursor: pointer; padding: 4px; border-radius: var(--border-radius-xs); transition: background-color 0.2s; + position: relative; } .file-name-wrapper:hover { background: oklch(var(--lora-accent) / 0.1); } -.file-name-wrapper i { - color: var(--text-color); - opacity: 0.5; - transition: opacity 0.2s; +.file-name-content { + padding: 2px 4px; + border-radius: var(--border-radius-xs); + border: 1px solid transparent; + flex: 1; } -.file-name-wrapper:hover i { - opacity: 1; - color: var(--lora-accent); +.file-name-wrapper.editing .file-name-content { + border: 1px solid var(--lora-accent); + background: var(--bg-color); + outline: none; +} + +.edit-file-name-btn { + background: transparent; + border: none; + color: var(--text-color); + opacity: 0; + cursor: pointer; + padding: 2px 5px; + border-radius: var(--border-radius-xs); + transition: all 0.2s ease; + margin-left: var(--space-1); +} + +.edit-file-name-btn.visible, +.file-name-wrapper:hover .edit-file-name-btn { + opacity: 0.5; +} + +.edit-file-name-btn:hover { + opacity: 0.8 !important; + background: rgba(0, 0, 0, 0.05); +} + +[data-theme="dark"] .edit-file-name-btn:hover { + background: rgba(255, 255, 255, 0.05); } /* Base Model and Size combined styles */ diff --git a/static/js/components/LoraModal.js b/static/js/components/LoraModal.js index 54454f69..186158b4 100644 --- a/static/js/components/LoraModal.js +++ b/static/js/components/LoraModal.js @@ -29,9 +29,11 @@ export function showLoraModal(lora) {
-
- ${lora.file_name || 'N/A'} - +
+ ${lora.file_name || 'N/A'} +
@@ -130,6 +132,7 @@ export function showLoraModal(lora) { setupTriggerWordsEditMode(); setupModelNameEditing(); setupBaseModelEditing(); + setupFileNameEditing(); // If we have a model ID but no description, fetch it if (lora.civitai?.modelId && !lora.modelDescription) { @@ -1562,3 +1565,169 @@ function setupBaseModelEditing() { }); }); } + +// New function to handle file name editing +function setupFileNameEditing() { + const fileNameContent = document.querySelector('.file-name-content'); + const editBtn = document.querySelector('.edit-file-name-btn'); + + if (!fileNameContent || !editBtn) return; + + // Show edit button on hover + const fileNameWrapper = document.querySelector('.file-name-wrapper'); + fileNameWrapper.addEventListener('mouseenter', () => { + editBtn.classList.add('visible'); + }); + + fileNameWrapper.addEventListener('mouseleave', () => { + if (!fileNameWrapper.classList.contains('editing')) { + editBtn.classList.remove('visible'); + } + }); + + // Handle edit button click + editBtn.addEventListener('click', () => { + fileNameWrapper.classList.add('editing'); + fileNameContent.setAttribute('contenteditable', 'true'); + fileNameContent.focus(); + + // Store original value for comparison later + fileNameContent.dataset.originalValue = fileNameContent.textContent.trim(); + + // Place cursor at the end + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(fileNameContent); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + + editBtn.classList.add('visible'); + }); + + // Handle keyboard events in edit mode + fileNameContent.addEventListener('keydown', function(e) { + if (!this.getAttribute('contenteditable')) return; + + if (e.key === 'Enter') { + e.preventDefault(); + this.blur(); // Trigger save on Enter + } else if (e.key === 'Escape') { + e.preventDefault(); + // Restore original value + this.textContent = this.dataset.originalValue; + exitEditMode(); + } + }); + + // Handle input validation + fileNameContent.addEventListener('input', function() { + if (!this.getAttribute('contenteditable')) return; + + // Replace invalid characters for filenames + const invalidChars = /[\\/:*?"<>|]/g; + if (invalidChars.test(this.textContent)) { + const cursorPos = window.getSelection().getRangeAt(0).startOffset; + this.textContent = this.textContent.replace(invalidChars, ''); + + // Restore cursor position + const range = document.createRange(); + const sel = window.getSelection(); + const newPos = Math.min(cursorPos, this.textContent.length); + + if (this.firstChild) { + range.setStart(this.firstChild, newPos); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } + + showToast('Invalid characters removed from filename', 'warning'); + } + }); + + // Handle focus out - save changes + fileNameContent.addEventListener('blur', async function() { + if (!this.getAttribute('contenteditable')) return; + + const newFileName = this.textContent.trim(); + const originalValue = this.dataset.originalValue; + + // Basic validation + if (!newFileName) { + // Restore original value if empty + this.textContent = originalValue; + showToast('File name cannot be empty', 'error'); + exitEditMode(); + return; + } + + if (newFileName === originalValue) { + // No changes, just exit edit mode + exitEditMode(); + return; + } + + try { + // Get the full file path + const filePath = document.querySelector('#loraModal .modal-content') + .querySelector('.file-path').textContent + originalValue + '.safetensors'; + + // Call API to rename the file + const response = await fetch('/api/rename_lora', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_path: filePath, + new_file_name: newFileName + }) + }); + + const result = await response.json(); + + if (result.success) { + showToast('File name updated successfully', 'success'); + + // Update card in the gallery + const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + if (loraCard) { + // Update the card's filepath attribute to the new path + loraCard.dataset.filepath = result.new_file_path; + loraCard.dataset.file_name = newFileName; + + // Update the filename display in the card + const cardFileName = loraCard.querySelector('.card-filename'); + if (cardFileName) { + cardFileName.textContent = newFileName; + } + } + + // Handle the case where we need to reload the page + if (result.reload_required) { + showToast('Reloading page to apply changes...', 'info'); + setTimeout(() => { + window.location.reload(); + }, 1500); + } + } else { + // Show error and restore original filename + showToast(result.error || 'Failed to update file name', 'error'); + this.textContent = originalValue; + } + } catch (error) { + console.error('Error saving filename:', error); + showToast('Failed to update file name', 'error'); + this.textContent = originalValue; + } finally { + exitEditMode(); + } + }); + + function exitEditMode() { + fileNameContent.removeAttribute('contenteditable'); + fileNameWrapper.classList.remove('editing'); + editBtn.classList.remove('visible'); + } +}