mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-24 14:42:11 -03:00
Implement move to folder
This commit is contained in:
@@ -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"""
|
||||||
|
|||||||
@@ -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()
|
||||||
# 获取当前运行的事件循环
|
# 获取当前运行的事件循环
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
130
static/js/managers/MoveManager.js
Normal file
130
static/js/managers/MoveManager.js
Normal 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();
|
||||||
@@ -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')">×</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">
|
||||||
|
|||||||
Reference in New Issue
Block a user