diff --git a/routes/api_routes.py b/routes/api_routes.py index 1fc291be..e8fc9bb0 100644 --- a/routes/api_routes.py +++ b/routes/api_routes.py @@ -39,6 +39,7 @@ class ApiRoutes: app.router.add_get('/api/civitai/versions/{model_id}', routes.get_civitai_versions) app.router.add_post('/api/download-lora', routes.download_lora) app.router.add_post('/api/settings', routes.update_settings) + app.router.add_post('/api/move_model', routes.move_model) async def delete_model(self, request: web.Request) -> web.Response: """Handle model deletion request""" @@ -513,6 +514,28 @@ class ApiRoutes: logger.error(f"Error updating settings: {e}", exc_info=True) # 添加 exc_info=True 以获取完整堆栈 return web.Response(status=500, text=str(e)) + async def move_model(self, request: web.Request) -> web.Response: + """Handle model move request""" + try: + data = await request.json() + file_path = data.get('file_path') + target_path = data.get('target_path') + + if not file_path or not target_path: + return web.Response(text='File path and target path are required', status=400) + + # Call scanner to handle the move operation + success = await self.scanner.move_model(file_path, target_path) + + if success: + return web.json_response({'success': True}) + else: + return web.Response(text='Failed to move model', status=500) + + except Exception as e: + logger.error(f"Error moving model: {e}", exc_info=True) + return web.Response(text=str(e), status=500) + @classmethod async def cleanup(cls): """Add cleanup method for application shutdown""" diff --git a/services/file_monitor.py b/services/file_monitor.py index f1f261b5..3387ea8b 100644 --- a/services/file_monitor.py +++ b/services/file_monitor.py @@ -57,6 +57,8 @@ class LoraFileHandler(FileSystemEventHandler): def on_deleted(self, event): if event.is_directory or not event.src_path.endswith('.safetensors'): return + if self._should_ignore(event.src_path): + return logger.info(f"LoRA file deleted: {event.src_path}") self._schedule_update('remove', event.src_path) @@ -131,6 +133,7 @@ class LoraFileMonitor: def __init__(self, scanner: LoraScanner, roots: List[str]): self.scanner = scanner + scanner.set_file_monitor(self) self.roots = roots self.observer = Observer() # 获取当前运行的事件循环 diff --git a/services/lora_scanner.py b/services/lora_scanner.py index 16cf4eab..52ade509 100644 --- a/services/lora_scanner.py +++ b/services/lora_scanner.py @@ -1,7 +1,9 @@ +import json import os import logging import time import asyncio +import shutil from typing import List, Dict, Optional from dataclasses import dataclass from operator import itemgetter @@ -30,6 +32,11 @@ class LoraScanner: self._initialization_lock = asyncio.Lock() self._initialization_task: Optional[asyncio.Task] = None self._initialized = True + self.file_monitor = None # Add this line + + def set_file_monitor(self, monitor): + """Set file monitor instance""" + self.file_monitor = monitor @classmethod async def get_instance(cls): @@ -39,7 +46,7 @@ class LoraScanner: cls._instance = cls() return cls._instance - async def get_cached_data(self, force_refresh: bool = False) -> LoraCache: + async def get_cached_data(self, force_refresh: bool = False) -> LoraCache: """Get cached LoRA data, refresh if needed""" async with self._initialization_lock: @@ -295,17 +302,7 @@ class LoraScanner: if not metadata: return None - # 计算相对于 lora_roots 的文件夹路径 - folder = None - file_dir = os.path.dirname(file_path) - for root in config.loras_roots: - if file_dir.startswith(root): - rel_path = os.path.relpath(file_dir, root) - if rel_path == '.': - folder = '' # 根目录 - else: - folder = rel_path.replace(os.sep, '/') - break + folder = self._calculate_folder(file_path) # 确保 folder 字段存在 metadata_dict = metadata.to_dict() @@ -316,4 +313,119 @@ class LoraScanner: except Exception as e: logger.error(f"Error scanning {file_path}: {e}") return None + + def _calculate_folder(self, file_path: str) -> str: + """Calculate the folder path for a LoRA file""" + for root in config.loras_roots: + if file_path.startswith(root): + rel_path = os.path.relpath(file_path, root) + return os.path.dirname(rel_path).replace(os.path.sep, '/') + return '' + + async def move_model(self, source_path: str, target_path: str) -> bool: + """Move a model and its associated files to a new location + + Args: + source_path: Full path to the source lora file + target_path: Full path to the target directory + + Returns: + bool: True if successful, False otherwise + """ + try: + # Ensure paths are normalized + source_path = source_path.replace(os.sep, '/') + target_path = target_path.replace(os.sep, '/') + + # Get base name without extension + base_name = os.path.splitext(os.path.basename(source_path))[0] + source_dir = os.path.dirname(source_path) + + # Create target directory if it doesn't exist + os.makedirs(target_path, exist_ok=True) + + # Calculate target lora path + target_lora = os.path.join(target_path, f"{base_name}.safetensors").replace(os.sep, '/') + + # Get source file size for timeout calculation + file_size = os.path.getsize(source_path) + + # Tell file monitor to ignore these paths + if self.file_monitor: + self.file_monitor.handler.add_ignore_path( + source_path, + file_size + ) + self.file_monitor.handler.add_ignore_path( + target_lora, + file_size + ) + + # Move main lora file + shutil.move(source_path, target_lora) + + # Move associated files + source_metadata = os.path.join(source_dir, f"{base_name}.metadata.json") + if os.path.exists(source_metadata): + target_metadata = os.path.join(target_path, f"{base_name}.metadata.json") + shutil.move(source_metadata, target_metadata) + lora_data = await self._update_metadata_paths(target_metadata, target_lora) + + # Move preview file if exists + preview_extensions = ['.preview.png', '.preview.jpeg', '.preview.jpg', '.preview.mp4', + '.png', '.jpeg', '.jpg', '.mp4'] + for ext in preview_extensions: + source_preview = os.path.join(source_dir, f"{base_name}{ext}") + if os.path.exists(source_preview): + target_preview = os.path.join(target_path, f"{base_name}{ext}") + shutil.move(source_preview, target_preview) + break + + # Update cache folders + cache = await self.get_cached_data() + cache.raw_data = [ + item for item in cache.raw_data + if item['file_path'] != source_path + ] + if lora_data: + cache.raw_data.append(lora_data) + all_folders = set(cache.folders) + all_folders.add(lora_data['folder']) + cache.folders = sorted(list(all_folders), key=lambda x: x.lower()) + + # Resort cache + await cache.resort() + + return True + + except Exception as e: + logger.error(f"Error moving model: {e}", exc_info=True) + return False + + async def _update_metadata_paths(self, metadata_path: str, lora_path: str) -> Dict: + """Update file paths in metadata file""" + try: + with open(metadata_path, 'r', encoding='utf-8') as f: + metadata = json.load(f) + + # Update file_path + metadata['file_path'] = lora_path.replace(os.sep, '/') + + # Update preview_url if exists + if 'preview_url' in metadata: + preview_dir = os.path.dirname(lora_path) + preview_name = os.path.splitext(os.path.basename(metadata['preview_url']))[0] + preview_ext = os.path.splitext(metadata['preview_url'])[1] + new_preview_path = os.path.join(preview_dir, f"{preview_name}{preview_ext}") + metadata['preview_url'] = new_preview_path.replace(os.sep, '/') + + # Save updated metadata + with open(metadata_path, 'w', encoding='utf-8') as f: + json.dump(metadata, f, indent=2, ensure_ascii=False) + + metadata['folder'] = self._calculate_folder(lora_path) + return metadata + + except Exception as e: + logger.error(f"Error updating metadata paths: {e}", exc_info=True) diff --git a/static/js/components/ContextMenu.js b/static/js/components/ContextMenu.js index acf7b8dc..af7961fd 100644 --- a/static/js/components/ContextMenu.js +++ b/static/js/components/ContextMenu.js @@ -42,8 +42,7 @@ export class LoraContextMenu { this.currentCard.querySelector('.fa-trash')?.click(); break; case 'move': - // To be implemented - console.log('Move to folder feature coming soon'); + moveManager.showMoveModal(this.currentCard.dataset.filepath); break; } diff --git a/static/js/main.js b/static/js/main.js index 9a2989ec..2bc4ad1e 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -23,6 +23,7 @@ import { SearchManager } from './utils/search.js'; import { DownloadManager } from './managers/DownloadManager.js'; import { SettingsManager, toggleApiKeyVisibility } from './managers/SettingsManager.js'; import { LoraContextMenu } from './components/ContextMenu.js'; +import { moveManager } from './managers/MoveManager.js'; // Export all functions that need global access window.loadMoreLoras = loadMoreLoras; @@ -43,6 +44,7 @@ window.showToast = showToast window.toggleFolderTags = toggleFolderTags; window.settingsManager = new SettingsManager(); window.toggleApiKeyVisibility = toggleApiKeyVisibility; +window.moveManager = moveManager; // Initialize everything when DOM is ready document.addEventListener('DOMContentLoaded', () => { diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index 6684657a..2dfb3a3e 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -44,6 +44,15 @@ export class ModalManager { } }); + // Add moveModal registration + this.registerModal('moveModal', { + element: document.getElementById('moveModal'), + onClose: () => { + this.getModal('moveModal').element.style.display = 'none'; + document.body.classList.remove('modal-open'); + } + }); + document.addEventListener('keydown', this.boundHandleEscape); this.initialized = true; } diff --git a/static/js/managers/MoveManager.js b/static/js/managers/MoveManager.js new file mode 100644 index 00000000..2fc549d3 --- /dev/null +++ b/static/js/managers/MoveManager.js @@ -0,0 +1,130 @@ +import { showToast } from '../utils/uiHelpers.js'; +import { resetAndReload } from '../api/loraApi.js'; +import { modalManager } from './ModalManager.js'; + +class MoveManager { + constructor() { + this.currentFilePath = null; + this.modal = document.getElementById('moveModal'); + this.loraRootSelect = document.getElementById('moveLoraRoot'); + this.folderBrowser = document.getElementById('moveFolderBrowser'); + this.newFolderInput = document.getElementById('moveNewFolder'); + this.pathDisplay = document.getElementById('moveTargetPathDisplay'); + + this.initializeEventListeners(); + } + + initializeEventListeners() { + // 初始化LoRA根目录选择器 + this.loraRootSelect.addEventListener('change', () => this.updatePathPreview()); + + // 文件夹选择事件 + this.folderBrowser.addEventListener('click', (e) => { + const folderItem = e.target.closest('.folder-item'); + if (!folderItem) return; + + // 取消其他选中状态 + this.folderBrowser.querySelectorAll('.folder-item').forEach(item => { + item.classList.remove('selected'); + }); + + // 设置当前选中状态 + folderItem.classList.add('selected'); + this.updatePathPreview(); + }); + + // 新文件夹输入事件 + this.newFolderInput.addEventListener('input', () => this.updatePathPreview()); + } + + async showMoveModal(filePath) { + this.currentFilePath = filePath; + + // 清除之前的选择 + this.folderBrowser.querySelectorAll('.folder-item').forEach(item => { + item.classList.remove('selected'); + }); + this.newFolderInput.value = ''; + + try { + const response = await fetch('/api/lora-roots'); + if (!response.ok) { + throw new Error('Failed to fetch LoRA roots'); + } + + const data = await response.json(); + if (!data.roots || data.roots.length === 0) { + throw new Error('No LoRA roots found'); + } + + // 填充LoRA根目录选择器 + this.loraRootSelect.innerHTML = data.roots.map(root => + `` + ).join(''); + + this.updatePathPreview(); + modalManager.showModal('moveModal'); + + } catch (error) { + console.error('Error fetching LoRA roots:', error); + showToast(error.message, 'error'); + } + } + + updatePathPreview() { + const selectedRoot = this.loraRootSelect.value; + const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || ''; + const newFolder = this.newFolderInput.value.trim(); + + let targetPath = selectedRoot; + if (selectedFolder) { + targetPath = `${targetPath}/${selectedFolder}`; + } + if (newFolder) { + targetPath = `${targetPath}/${newFolder}`; + } + + this.pathDisplay.querySelector('.path-text').textContent = targetPath; + } + + async moveModel() { + const selectedRoot = this.loraRootSelect.value; + const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || ''; + const newFolder = this.newFolderInput.value.trim(); + + let targetPath = selectedRoot; + if (selectedFolder) { + targetPath = `${targetPath}/${selectedFolder}`; + } + if (newFolder) { + targetPath = `${targetPath}/${newFolder}`; + } + + try { + const response = await fetch('/api/move_model', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_path: this.currentFilePath, + target_path: targetPath + }) + }); + + if (!response.ok) { + throw new Error('Failed to move model'); + } + + showToast('Model moved successfully', 'success'); + modalManager.closeModal('moveModal'); + await resetAndReload(true); + + } catch (error) { + console.error('Error moving model:', error); + showToast('Failed to move model: ' + error.message, 'error'); + } + } +} + +export const moveManager = new MoveManager(); diff --git a/templates/components/modals.html b/templates/components/modals.html index e7afa039..c0a29649 100644 --- a/templates/components/modals.html +++ b/templates/components/modals.html @@ -83,6 +83,47 @@ + +