Implement move to folder

This commit is contained in:
Will Miao
2025-02-18 19:01:02 +08:00
parent bbc5aea08c
commit 52b01d1bce
8 changed files with 333 additions and 14 deletions

View File

@@ -39,6 +39,7 @@ class ApiRoutes:
app.router.add_get('/api/civitai/versions/{model_id}', routes.get_civitai_versions) 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/download-lora', routes.download_lora)
app.router.add_post('/api/settings', routes.update_settings) 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: async def delete_model(self, request: web.Request) -> web.Response:
"""Handle model deletion request""" """Handle model deletion request"""
@@ -513,6 +514,28 @@ class ApiRoutes:
logger.error(f"Error updating settings: {e}", exc_info=True) # 添加 exc_info=True 以获取完整堆栈 logger.error(f"Error updating settings: {e}", exc_info=True) # 添加 exc_info=True 以获取完整堆栈
return web.Response(status=500, text=str(e)) 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 @classmethod
async def cleanup(cls): async def cleanup(cls):
"""Add cleanup method for application shutdown""" """Add cleanup method for application shutdown"""

View File

@@ -57,6 +57,8 @@ class LoraFileHandler(FileSystemEventHandler):
def on_deleted(self, event): def on_deleted(self, event):
if event.is_directory or not event.src_path.endswith('.safetensors'): if event.is_directory or not event.src_path.endswith('.safetensors'):
return return
if self._should_ignore(event.src_path):
return
logger.info(f"LoRA file deleted: {event.src_path}") logger.info(f"LoRA file deleted: {event.src_path}")
self._schedule_update('remove', event.src_path) self._schedule_update('remove', event.src_path)
@@ -131,6 +133,7 @@ class LoraFileMonitor:
def __init__(self, scanner: LoraScanner, roots: List[str]): def __init__(self, scanner: LoraScanner, roots: List[str]):
self.scanner = scanner self.scanner = scanner
scanner.set_file_monitor(self)
self.roots = roots self.roots = roots
self.observer = Observer() self.observer = Observer()
# 获取当前运行的事件循环 # 获取当前运行的事件循环

View File

@@ -1,7 +1,9 @@
import json
import os import os
import logging import logging
import time import time
import asyncio import asyncio
import shutil
from typing import List, Dict, Optional from typing import List, Dict, Optional
from dataclasses import dataclass from dataclasses import dataclass
from operator import itemgetter from operator import itemgetter
@@ -30,6 +32,11 @@ class LoraScanner:
self._initialization_lock = asyncio.Lock() self._initialization_lock = asyncio.Lock()
self._initialization_task: Optional[asyncio.Task] = None self._initialization_task: Optional[asyncio.Task] = None
self._initialized = True 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 @classmethod
async def get_instance(cls): async def get_instance(cls):
@@ -39,7 +46,7 @@ class LoraScanner:
cls._instance = cls() cls._instance = cls()
return cls._instance 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""" """Get cached LoRA data, refresh if needed"""
async with self._initialization_lock: async with self._initialization_lock:
@@ -295,17 +302,7 @@ class LoraScanner:
if not metadata: if not metadata:
return None return None
# 计算相对于 lora_roots 的文件夹路径 folder = self._calculate_folder(file_path)
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 字段存在 # 确保 folder 字段存在
metadata_dict = metadata.to_dict() metadata_dict = metadata.to_dict()
@@ -316,4 +313,119 @@ class LoraScanner:
except Exception as e: except Exception as e:
logger.error(f"Error scanning {file_path}: {e}") logger.error(f"Error scanning {file_path}: {e}")
return None 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)

View File

@@ -42,8 +42,7 @@ export class LoraContextMenu {
this.currentCard.querySelector('.fa-trash')?.click(); this.currentCard.querySelector('.fa-trash')?.click();
break; break;
case 'move': case 'move':
// To be implemented moveManager.showMoveModal(this.currentCard.dataset.filepath);
console.log('Move to folder feature coming soon');
break; break;
} }

View File

@@ -23,6 +23,7 @@ import { SearchManager } from './utils/search.js';
import { DownloadManager } from './managers/DownloadManager.js'; import { DownloadManager } from './managers/DownloadManager.js';
import { SettingsManager, toggleApiKeyVisibility } from './managers/SettingsManager.js'; import { SettingsManager, toggleApiKeyVisibility } from './managers/SettingsManager.js';
import { LoraContextMenu } from './components/ContextMenu.js'; import { LoraContextMenu } from './components/ContextMenu.js';
import { moveManager } from './managers/MoveManager.js';
// Export all functions that need global access // Export all functions that need global access
window.loadMoreLoras = loadMoreLoras; window.loadMoreLoras = loadMoreLoras;
@@ -43,6 +44,7 @@ window.showToast = showToast
window.toggleFolderTags = toggleFolderTags; window.toggleFolderTags = toggleFolderTags;
window.settingsManager = new SettingsManager(); window.settingsManager = new SettingsManager();
window.toggleApiKeyVisibility = toggleApiKeyVisibility; window.toggleApiKeyVisibility = toggleApiKeyVisibility;
window.moveManager = moveManager;
// Initialize everything when DOM is ready // Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {

View File

@@ -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); document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true; this.initialized = true;
} }

View File

@@ -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 =>
`<option value="${root}">${root}</option>`
).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();

View File

@@ -83,6 +83,47 @@
</div> </div>
</div> </div>
<!-- Move Model Modal -->
<div id="moveModal" class="modal">
<div class="modal-content">
<button class="close" onclick="modalManager.closeModal('moveModal')">&times;</button>
<h2>Move Model</h2>
<div class="location-selection">
<div class="path-preview">
<label>Target Location Preview:</label>
<div class="path-display" id="moveTargetPathDisplay">
<span class="path-text">Select a LoRA root directory</span>
</div>
</div>
<div class="input-group">
<label>Select LoRA Root:</label>
<select id="moveLoraRoot"></select>
</div>
<div class="input-group">
<label>Target Folder:</label>
<div class="folder-browser" id="moveFolderBrowser">
{% for folder in folders %}
{% if folder %}
<div class="folder-item" data-folder="{{ folder }}">
{{ folder }}
</div>
{% endif %}
{% endfor %}
</div>
</div>
<div class="input-group">
<label for="moveNewFolder">New Folder (optional):</label>
<input type="text" id="moveNewFolder" placeholder="Enter folder name" />
</div>
</div>
<div class="modal-actions">
<button class="cancel-btn" onclick="modalManager.closeModal('moveModal')">Cancel</button>
<button class="primary-btn" onclick="moveManager.moveModel()">Move</button>
</div>
</div>
</div>
<!-- Settings Modal --> <!-- Settings Modal -->
<div id="settingsModal" class="modal"> <div id="settingsModal" class="modal">
<div class="modal-content settings-modal"> <div class="modal-content settings-modal">