feat: Implement model move, import, and download functionalities with corresponding UI and API updates.

This commit is contained in:
Will Miao
2025-12-28 21:18:27 +08:00
parent e5b557504e
commit 5d5a2a998a
9 changed files with 420 additions and 308 deletions

View File

@@ -120,7 +120,7 @@ class BaseModelRoutes(ABC):
self.service = service self.service = service
self.model_type = service.model_type self.model_type = service.model_type
self.model_file_service = ModelFileService(service.scanner, service.model_type) self.model_file_service = ModelFileService(service.scanner, service.model_type)
self.model_move_service = ModelMoveService(service.scanner) self.model_move_service = ModelMoveService(service.scanner, service.model_type)
self.model_lifecycle_service = ModelLifecycleService( self.model_lifecycle_service = ModelLifecycleService(
scanner=service.scanner, scanner=service.scanner,
metadata_manager=MetadataManager, metadata_manager=MetadataManager,
@@ -270,7 +270,7 @@ class BaseModelRoutes(ABC):
def _ensure_move_service(self) -> ModelMoveService: def _ensure_move_service(self) -> ModelMoveService:
if self.model_move_service is None: if self.model_move_service is None:
service = self._ensure_service() service = self._ensure_service()
self.model_move_service = ModelMoveService(service.scanner) self.model_move_service = ModelMoveService(service.scanner, service.model_type)
return self.model_move_service return self.model_move_service
def _ensure_lifecycle_service(self) -> ModelLifecycleService: def _ensure_lifecycle_service(self) -> ModelLifecycleService:

View File

@@ -1052,9 +1052,10 @@ class ModelMoveHandler:
data = await request.json() data = await request.json()
file_path = data.get("file_path") file_path = data.get("file_path")
target_path = data.get("target_path") target_path = data.get("target_path")
use_default_paths = data.get("use_default_paths", False)
if not file_path or not target_path: if not file_path or not target_path:
return web.Response(text="File path and target path are required", status=400) return web.Response(text="File path and target path are required", status=400)
result = await self._move_service.move_model(file_path, target_path) result = await self._move_service.move_model(file_path, target_path, use_default_paths=use_default_paths)
status = 200 if result.get("success") else 500 status = 200 if result.get("success") else 500
return web.json_response(result, status=status) return web.json_response(result, status=status)
except Exception as exc: except Exception as exc:
@@ -1066,9 +1067,10 @@ class ModelMoveHandler:
data = await request.json() data = await request.json()
file_paths = data.get("file_paths", []) file_paths = data.get("file_paths", [])
target_path = data.get("target_path") target_path = data.get("target_path")
use_default_paths = data.get("use_default_paths", False)
if not file_paths or not target_path: if not file_paths or not target_path:
return web.Response(text="File paths and target path are required", status=400) return web.Response(text="File paths and target path are required", status=400)
result = await self._move_service.move_models_bulk(file_paths, target_path) result = await self._move_service.move_models_bulk(file_paths, target_path, use_default_paths=use_default_paths)
return web.json_response(result) return web.json_response(result)
except Exception as exc: except Exception as exc:
self._logger.error("Error moving models in bulk: %s", exc, exc_info=True) self._logger.error("Error moving models in bulk: %s", exc, exc_info=True)

View File

@@ -446,25 +446,46 @@ class ModelFileService:
class ModelMoveService: class ModelMoveService:
"""Service for handling individual model moves""" """Service for handling individual model moves"""
def __init__(self, scanner): def __init__(self, scanner, model_type: str):
"""Initialize the service """Initialize the service
Args: Args:
scanner: Model scanner instance scanner: Model scanner instance
model_type: Type of model (e.g., 'lora', 'checkpoint')
""" """
self.scanner = scanner self.scanner = scanner
self.model_type = model_type
async def move_model(self, file_path: str, target_path: str) -> Dict[str, Any]: async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
"""Move a single model file """Move a single model file
Args: Args:
file_path: Source file path file_path: Source file path
target_path: Target directory path target_path: Target directory path (used as root if use_default_paths is True)
use_default_paths: Whether to use default path template for organization
Returns: Returns:
Dictionary with move result Dictionary with move result
""" """
try: try:
if use_default_paths:
# Find the model in cache to get metadata
cache = await self.scanner.get_cached_data()
model_data = next((m for m in cache.raw_data if m.get('file_path') == file_path), None)
if model_data:
from ..utils.utils import calculate_relative_path_for_model
relative_path = calculate_relative_path_for_model(model_data, self.model_type)
if relative_path:
target_path = os.path.join(target_path, relative_path).replace(os.sep, '/')
elif not get_settings_manager().get_download_path_template(self.model_type):
# Flat structure, target_path remains the root
pass
else:
# Could not calculate relative path (e.g. missing metadata)
# Fallback to manual target_path or skip?
pass
source_dir = os.path.dirname(file_path) source_dir = os.path.dirname(file_path)
if os.path.normpath(source_dir) == os.path.normpath(target_path): if os.path.normpath(source_dir) == os.path.normpath(target_path):
logger.info(f"Source and target directories are the same: {source_dir}") logger.info(f"Source and target directories are the same: {source_dir}")
@@ -498,12 +519,13 @@ class ModelMoveService:
'new_file_path': None 'new_file_path': None
} }
async def move_models_bulk(self, file_paths: List[str], target_path: str) -> Dict[str, Any]: async def move_models_bulk(self, file_paths: List[str], target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
"""Move multiple model files """Move multiple model files
Args: Args:
file_paths: List of source file paths file_paths: List of source file paths
target_path: Target directory path target_path: Target directory path (used as root if use_default_paths is True)
use_default_paths: Whether to use default path template for organization
Returns: Returns:
Dictionary with bulk move results Dictionary with bulk move results
@@ -512,7 +534,7 @@ class ModelMoveService:
results = [] results = []
for file_path in file_paths: for file_path in file_paths:
result = await self.move_model(file_path, target_path) result = await self.move_model(file_path, target_path, use_default_paths=use_default_paths)
results.append({ results.append({
"original_file_path": file_path, "original_file_path": file_path,
"new_file_path": result.get('new_file_path'), "new_file_path": result.get('new_file_path'),

View File

@@ -895,13 +895,13 @@ export class BaseModelApiClient {
} }
} }
async moveSingleModel(filePath, targetPath) { async moveSingleModel(filePath, targetPath, useDefaultPaths = false) {
// Only allow move if supported // Only allow move if supported
if (!this.apiConfig.config.supportsMove) { if (!this.apiConfig.config.supportsMove) {
showToast('toast.api.moveNotSupported', { type: this.apiConfig.config.displayName }, 'warning'); showToast('toast.api.moveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
return null; return null;
} }
if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) { if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath && !useDefaultPaths) {
showToast('toast.api.alreadyInFolder', { type: this.apiConfig.config.displayName }, 'info'); showToast('toast.api.alreadyInFolder', { type: this.apiConfig.config.displayName }, 'info');
return null; return null;
} }
@@ -913,7 +913,8 @@ export class BaseModelApiClient {
}, },
body: JSON.stringify({ body: JSON.stringify({
file_path: filePath, file_path: filePath,
target_path: targetPath target_path: targetPath,
use_default_paths: useDefaultPaths
}) })
}); });
@@ -941,12 +942,12 @@ export class BaseModelApiClient {
return null; return null;
} }
async moveBulkModels(filePaths, targetPath) { async moveBulkModels(filePaths, targetPath, useDefaultPaths = false) {
if (!this.apiConfig.config.supportsMove) { if (!this.apiConfig.config.supportsMove) {
showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning'); showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
return []; return [];
} }
const movedPaths = filePaths.filter(path => { const movedPaths = useDefaultPaths ? filePaths : filePaths.filter(path => {
return path.substring(0, path.lastIndexOf('/')) !== targetPath; return path.substring(0, path.lastIndexOf('/')) !== targetPath;
}); });
@@ -962,7 +963,8 @@ export class BaseModelApiClient {
}, },
body: JSON.stringify({ body: JSON.stringify({
file_paths: movedPaths, file_paths: movedPaths,
target_path: targetPath target_path: targetPath,
use_default_paths: useDefaultPaths
}) })
}); });

View File

@@ -6,6 +6,8 @@ import { getModelApiClient } from '../api/modelApiFactory.js';
import { RecipeSidebarApiClient } from '../api/recipeApi.js'; import { RecipeSidebarApiClient } from '../api/recipeApi.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js'; import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { sidebarManager } from '../components/SidebarManager.js'; import { sidebarManager } from '../components/SidebarManager.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
class MoveManager { class MoveManager {
constructor() { constructor() {
@@ -14,9 +16,11 @@ class MoveManager {
this.folderTreeManager = new FolderTreeManager(); this.folderTreeManager = new FolderTreeManager();
this.initialized = false; this.initialized = false;
this.recipeApiClient = null; this.recipeApiClient = null;
this.useDefaultPath = false;
// Bind methods // Bind methods
this.updateTargetPath = this.updateTargetPath.bind(this); this.updateTargetPath = this.updateTargetPath.bind(this);
this.handleToggleDefaultPath = this.handleToggleDefaultPath.bind(this);
} }
_getApiClient(modelType = null) { _getApiClient(modelType = null) {
@@ -40,6 +44,12 @@ class MoveManager {
this.updateTargetPath(); this.updateTargetPath();
}); });
// Default path toggle handler
const toggleInput = document.getElementById('moveUseDefaultPath');
if (toggleInput) {
toggleInput.addEventListener('change', this.handleToggleDefaultPath);
}
this.initialized = true; this.initialized = true;
} }
@@ -117,6 +127,9 @@ class MoveManager {
// Initialize folder tree // Initialize folder tree
await this.initializeFolderTree(); await this.initializeFolderTree();
// Load default path setting
this.loadDefaultPathSetting(apiClient.modelType);
this.updateTargetPath(); this.updateTargetPath();
modalManager.showModal('moveModal', null, () => { modalManager.showModal('moveModal', null, () => {
// Cleanup on modal close // Cleanup on modal close
@@ -131,6 +144,50 @@ class MoveManager {
} }
} }
loadDefaultPathSetting(modelType) {
const storageKey = `use_default_path_${modelType}`;
this.useDefaultPath = getStorageItem(storageKey, false);
const toggleInput = document.getElementById('moveUseDefaultPath');
if (toggleInput) {
toggleInput.checked = this.useDefaultPath;
this.updatePathSelectionUI();
}
}
handleToggleDefaultPath(event) {
this.useDefaultPath = event.target.checked;
// Save to localStorage per model type
const apiClient = this._getApiClient();
const modelType = apiClient.modelType;
const storageKey = `use_default_path_${modelType}`;
setStorageItem(storageKey, this.useDefaultPath);
this.updatePathSelectionUI();
this.updateTargetPath();
}
updatePathSelectionUI() {
const manualSelection = document.getElementById('moveManualPathSelection');
if (!manualSelection) return;
if (this.useDefaultPath) {
manualSelection.classList.add('disabled');
// Disable all inputs and buttons inside manualSelection
manualSelection.querySelectorAll('input, select, button').forEach(el => {
el.disabled = true;
el.tabIndex = -1;
});
} else {
manualSelection.classList.remove('disabled');
manualSelection.querySelectorAll('input, select, button').forEach(el => {
el.disabled = false;
el.tabIndex = 0;
});
}
}
async initializeFolderTree() { async initializeFolderTree() {
try { try {
const apiClient = this._getApiClient(); const apiClient = this._getApiClient();
@@ -156,14 +213,28 @@ class MoveManager {
const apiClient = this._getApiClient(); const apiClient = this._getApiClient();
const config = apiClient.apiConfig.config; const config = apiClient.apiConfig.config;
let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`; let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: config.displayName });
if (modelRoot) { if (modelRoot) {
if (this.useDefaultPath) {
// Show actual template path
try {
const singularType = apiClient.modelType.replace(/s$/, '');
const templates = state.global.settings.download_path_templates;
const template = templates[singularType];
fullPath += `/${template}`;
} catch (error) {
console.error('Failed to fetch template:', error);
fullPath += '/' + translate('modals.download.autoOrganizedPath');
}
} else {
// Show manual path selection
const selectedPath = this.folderTreeManager ? this.folderTreeManager.getSelectedPath() : ''; const selectedPath = this.folderTreeManager ? this.folderTreeManager.getSelectedPath() : '';
if (selectedPath) { if (selectedPath) {
fullPath += '/' + selectedPath; fullPath += '/' + selectedPath;
} }
} }
}
pathDisplay.innerHTML = `<span class="path-text">${fullPath}</span>`; pathDisplay.innerHTML = `<span class="path-text">${fullPath}</span>`;
} }
@@ -189,7 +260,7 @@ class MoveManager {
try { try {
if (this.bulkFilePaths) { if (this.bulkFilePaths) {
// Bulk move mode // Bulk move mode
const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath); const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
// Update virtual scroller if in active folder view // Update virtual scroller if in active folder view
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
@@ -216,7 +287,7 @@ class MoveManager {
} }
} else { } else {
// Single move mode // Single move mode
const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath); const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath);
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
if (result && result.new_file_path) { if (result && result.new_file_path) {

View File

@@ -6,14 +6,25 @@
<span class="close" onclick="modalManager.closeModal('moveModal')">&times;</span> <span class="close" onclick="modalManager.closeModal('moveModal')">&times;</span>
</div> </div>
<div class="location-selection"> <div class="location-selection">
<!-- Path preview --> <!-- Path preview with inline toggle -->
<div class="path-preview"> <div class="path-preview">
<label>{{ t('modals.moveModel.targetLocationPreview') }}</label> <div class="path-preview-header">
<label>{{ t('modals.moveModel.targetLocationPreview') }}:</label>
<div class="inline-toggle-container" title="{{ t('modals.download.useDefaultPathTooltip') }}">
<span class="inline-toggle-label">{{ t('modals.download.useDefaultPath') }}</span>
<div class="toggle-switch">
<input type="checkbox" id="moveUseDefaultPath">
<label for="moveUseDefaultPath" class="toggle-slider"></label>
</div>
</div>
</div>
<div class="path-display" id="moveTargetPathDisplay"> <div class="path-display" id="moveTargetPathDisplay">
<span class="path-text">{{ t('modals.download.selectRootDirectory') }}</span> <span class="path-text">{{ t('modals.download.selectRootDirectory') }}</span>
</div> </div>
</div> </div>
<!-- Manual Location Selection (disabled when using default path) -->
<div id="moveManualPathSelection" class="manual-path-selection">
<div class="input-group"> <div class="input-group">
<label for="moveModelRoot" id="moveRootLabel">{{ t('modals.moveModel.selectModelRoot') }}</label> <label for="moveModelRoot" id="moveRootLabel">{{ t('modals.moveModel.selectModelRoot') }}</label>
<select id="moveModelRoot"></select> <select id="moveModelRoot"></select>
@@ -23,8 +34,10 @@
<div class="input-group"> <div class="input-group">
<label for="moveFolderPath">{{ t('modals.moveModel.targetFolderPath') }}</label> <label for="moveFolderPath">{{ t('modals.moveModel.targetFolderPath') }}</label>
<div class="path-input-container"> <div class="path-input-container">
<input type="text" id="moveFolderPath" placeholder="{{ t('modals.moveModel.pathPlaceholder') }}" autocomplete="off" /> <input type="text" id="moveFolderPath" placeholder="{{ t('modals.moveModel.pathPlaceholder') }}"
<button type="button" id="moveCreateFolderBtn" class="create-folder-btn" title="{{ t('modals.moveModel.createNewFolder') }}"> autocomplete="off" />
<button type="button" id="moveCreateFolderBtn" class="create-folder-btn"
title="{{ t('modals.moveModel.createNewFolder') }}">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
</button> </button>
</div> </div>
@@ -48,8 +61,10 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button class="cancel-btn" onclick="modalManager.closeModal('moveModal')">{{ t('common.actions.cancel') }}</button> <button class="cancel-btn" onclick="modalManager.closeModal('moveModal')">{{ t('common.actions.cancel')
}}</button>
<button class="primary-btn" onclick="moveManager.moveModel()">{{ t('common.actions.move') }}</button> <button class="primary-btn" onclick="moveManager.moveModel()">{{ t('common.actions.move') }}</button>
</div> </div>
</div> </div>