diff --git a/py/routes/base_model_routes.py b/py/routes/base_model_routes.py index d4522871..37680bc9 100644 --- a/py/routes/base_model_routes.py +++ b/py/routes/base_model_routes.py @@ -756,8 +756,8 @@ class BaseModelRoutes(ABC): 'error': 'No model roots configured' }, status=400) - # Check if flat structure is configured - path_template = settings.get('download_path_template', '{base_model}/{first_tag}') + # Check if flat structure is configured for this model type + path_template = settings.get_download_path_template(self.service.model_type) is_flat_structure = not path_template # Prepare results tracking @@ -832,7 +832,7 @@ class BaseModelRoutes(ABC): target_dir = current_root else: # Calculate new relative path based on settings - new_relative_path = calculate_relative_path_for_model(model) + new_relative_path = calculate_relative_path_for_model(model, self.service.model_type) # If no relative path calculated (insufficient metadata), skip if not new_relative_path: diff --git a/py/routes/misc_routes.py b/py/routes/misc_routes.py index 1ecd5a5a..f3d21c6a 100644 --- a/py/routes/misc_routes.py +++ b/py/routes/misc_routes.py @@ -184,7 +184,7 @@ class MiscRoutes: logger.info(f"Example images path changed to {value} - server restart required") # Special handling for base_model_path_mappings - parse JSON string - if key == 'base_model_path_mappings' and value: + if (key == 'base_model_path_mappings' or key == 'download_path_templates') and value: try: value = json.loads(value) except json.JSONDecodeError: diff --git a/py/services/download_manager.py b/py/services/download_manager.py index 55e8715d..1ee46b5b 100644 --- a/py/services/download_manager.py +++ b/py/services/download_manager.py @@ -258,7 +258,7 @@ class DownloadManager: save_dir = default_path # Calculate relative path using template - relative_path = self._calculate_relative_path(version_info) + relative_path = self._calculate_relative_path(version_info, model_type) # Update save directory with relative path if provided if relative_path: @@ -331,17 +331,18 @@ class DownloadManager: return {'success': False, 'error': f"Early access restriction: {str(e)}. Please ensure you have purchased early access and are logged in to Civitai."} return {'success': False, 'error': str(e)} - def _calculate_relative_path(self, version_info: Dict) -> str: + def _calculate_relative_path(self, version_info: Dict, model_type: str = 'lora') -> str: """Calculate relative path using template from settings Args: version_info: Version info from Civitai API + model_type: Type of model ('lora', 'checkpoint', 'embedding') Returns: Relative path string """ - # Get path template from settings, default to '{base_model}/{first_tag}' - path_template = settings.get('download_path_template', '{base_model}/{first_tag}') + # Get path template from settings for specific model type + path_template = settings.get_download_path_template(model_type) # If template is empty, return empty path (flat structure) if not path_template: @@ -350,6 +351,9 @@ class DownloadManager: # Get base model name base_model = version_info.get('baseModel', '') + # Get author from creator data + author = version_info.get('creator', {}).get('username', 'Anonymous') + # Apply mapping if available base_model_mappings = settings.get('base_model_path_mappings', {}) mapped_base_model = base_model_mappings.get(base_model, base_model) @@ -372,6 +376,7 @@ class DownloadManager: formatted_path = path_template formatted_path = formatted_path.replace('{base_model}', mapped_base_model) formatted_path = formatted_path.replace('{first_tag}', first_tag) + formatted_path = formatted_path.replace('{author}', author) return formatted_path diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index d9e2d866..a6a6e24a 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -9,6 +9,7 @@ class SettingsManager: def __init__(self): self.settings_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'settings.json') self.settings = self._load_settings() + self._migrate_download_path_template() self._auto_set_default_roots() self._check_environment_variables() @@ -22,6 +23,24 @@ class SettingsManager: logger.error(f"Error loading settings: {e}") return self._get_default_settings() + def _migrate_download_path_template(self): + """Migrate old download_path_template to new download_path_templates""" + old_template = self.settings.get('download_path_template') + templates = self.settings.get('download_path_templates') + + # If old template exists and new templates don't exist, migrate + if old_template is not None and not templates: + logger.info("Migrating download_path_template to download_path_templates") + self.settings['download_path_templates'] = { + 'lora': old_template, + 'checkpoint': old_template, + 'embedding': old_template + } + # Remove old setting + del self.settings['download_path_template'] + self._save_settings() + logger.info("Migration completed") + def _auto_set_default_roots(self): """Auto set default root paths if only one folder is present and default is empty.""" folder_paths = self.settings.get('folder_paths', {}) @@ -81,4 +100,16 @@ class SettingsManager: except Exception as e: logger.error(f"Error saving settings: {e}") + def get_download_path_template(self, model_type: str) -> str: + """Get download path template for specific model type + + Args: + model_type: The type of model ('lora', 'checkpoint', 'embedding') + + Returns: + Template string for the model type, defaults to '{base_model}/{first_tag}' + """ + templates = self.settings.get('download_path_templates', {}) + return templates.get(model_type, '{base_model}/{first_tag}') + settings = SettingsManager() diff --git a/py/utils/utils.py b/py/utils/utils.py index fe97c5da..2c67c645 100644 --- a/py/utils/utils.py +++ b/py/utils/utils.py @@ -132,17 +132,18 @@ def calculate_recipe_fingerprint(loras): return fingerprint -def calculate_relative_path_for_model(model_data: Dict) -> str: +def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora') -> str: """Calculate relative path for existing model using template from settings Args: model_data: Model data from scanner cache + model_type: Type of model ('lora', 'checkpoint', 'embedding') Returns: Relative path string (empty string for flat structure) """ - # Get path template from settings, default to '{base_model}/{first_tag}' - path_template = settings.get('download_path_template', '{base_model}/{first_tag}') + # Get path template from settings for specific model type + path_template = settings.get_download_path_template(model_type) # If template is empty, return empty path (flat structure) if not path_template: @@ -154,9 +155,12 @@ def calculate_relative_path_for_model(model_data: Dict) -> str: # For CivitAI models, prefer civitai data only if 'id' exists; for non-CivitAI models, use model_data directly if civitai_data and civitai_data.get('id') is not None: base_model = civitai_data.get('baseModel', '') + # Get author from civitai creator data + author = civitai_data.get('creator', {}).get('username', 'Anonymous') else: # Fallback to model_data fields for non-CivitAI models base_model = model_data.get('base_model', '') + author = 'Anonymous' # Default for non-CivitAI models model_tags = model_data.get('tags', []) @@ -182,6 +186,7 @@ def calculate_relative_path_for_model(model_data: Dict) -> str: formatted_path = path_template formatted_path = formatted_path.replace('{base_model}', mapped_base_model) formatted_path = formatted_path.replace('{first_tag}', first_tag) + formatted_path = formatted_path.replace('{author}', author) return formatted_path diff --git a/static/css/components/modal/download-modal.css b/static/css/components/modal/download-modal.css index 9d717c0d..a3d99641 100644 --- a/static/css/components/modal/download-modal.css +++ b/static/css/components/modal/download-modal.css @@ -351,8 +351,8 @@ display: flex; gap: 8px; margin-left: 20px; - margin-top: 4px; align-items: center; + height: 21px; } .create-folder-form input { @@ -395,9 +395,16 @@ border: 1px dashed var(--border-color); } -.path-preview label { - display: block; - margin-bottom: 8px; +.path-preview-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + gap: var(--space-2); +} + +.path-preview-header label { + margin: 0; color: var(--text-color); font-size: 0.9em; opacity: 0.8; @@ -416,6 +423,70 @@ border-radius: var(--border-radius-xs); } +/* Inline Toggle Styles */ +.inline-toggle-container { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; + position: relative; +} + +.inline-toggle-label { + font-size: 0.85em; + color: var(--text-color); + opacity: 0.9; + white-space: nowrap; +} + +.inline-toggle-container .toggle-switch { + position: relative; + width: 36px; + height: 18px; + flex-shrink: 0; +} + +.inline-toggle-container .toggle-switch input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +.inline-toggle-container .toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--border-color); + transition: all 0.3s ease; + border-radius: 18px; +} + +.inline-toggle-container .toggle-slider:before { + position: absolute; + content: ""; + height: 12px; + width: 12px; + left: 3px; + bottom: 3px; + background-color: white; + transition: all 0.3s ease; + border-radius: 50%; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.inline-toggle-container .toggle-switch input:checked + .toggle-slider { + background-color: var(--lora-accent); +} + +.inline-toggle-container .toggle-switch input:checked + .toggle-slider:before { + transform: translateX(18px); +} + /* Dark theme adjustments */ [data-theme="dark"] .version-item { background: var(--lora-surface); @@ -426,8 +497,18 @@ border-color: var(--lora-border); } +[data-theme="dark"] .toggle-slider:before { + background-color: #f0f0f0; +} + /* Enhance the local badge to make it more noticeable */ .version-item.exists-locally { background: oklch(var(--lora-accent) / 0.05); border-left: 4px solid var(--lora-accent); +} + +.manual-path-selection.disabled { + opacity: 0.5; + pointer-events: none; + user-select: none; } \ No newline at end of file diff --git a/static/css/components/modal/settings-modal.css b/static/css/components/modal/settings-modal.css index 7d67e79e..a13dc856 100644 --- a/static/css/components/modal/settings-modal.css +++ b/static/css/components/modal/settings-modal.css @@ -482,4 +482,99 @@ input:checked + .toggle-slider:before { [data-theme="dark"] .base-model-select option { background-color: #2d2d2d; color: var(--text-color); +} + +/* Template Configuration Styles */ +.placeholder-info { + margin-top: var(--space-1); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-1); +} + +.placeholder-tag { + display: inline-block; + background: var(--lora-accent); + color: white; + padding: 2px 6px; + border-radius: 3px; + font-family: monospace; + font-size: 1em; + font-weight: 500; +} + +.template-custom-row { + margin-top: 8px; + animation: slideDown 0.2s ease-out; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.template-custom-input { + width: 96%; + padding: 6px 10px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background-color: var(--lora-surface); + color: var(--text-color); + font-size: 0.95em; + font-family: monospace; + height: 24px; + transition: border-color 0.2s; +} + +.template-custom-input:focus { + border-color: var(--lora-accent); + outline: none; + box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1); +} + +.template-custom-input::placeholder { + color: var(--text-color); + opacity: 0.5; + font-family: inherit; +} + +.template-validation { + margin-top: 6px; + font-size: 0.85em; + display: flex; + align-items: center; + gap: 6px; + min-height: 20px; +} + +.template-validation.valid { + color: var(--lora-success, #22c55e); +} + +.template-validation.invalid { + color: var(--lora-error, #ef4444); +} + +.template-validation i { + width: 12px; +} + +/* Dark theme specific adjustments */ +[data-theme="dark"] .template-custom-input { + background-color: rgba(30, 30, 30, 0.9); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .placeholder-info { + flex-direction: column; + align-items: flex-start; + } } \ No newline at end of file diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index dc5f825e..90db4234 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -613,7 +613,7 @@ export class BaseModelApiClient { } } - async downloadModel(modelId, versionId, modelRoot, relativePath, downloadId) { + async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId) { try { const response = await fetch(DOWNLOAD_ENDPOINTS.download, { method: 'POST', @@ -623,6 +623,7 @@ export class BaseModelApiClient { model_version_id: versionId, model_root: modelRoot, relative_path: relativePath, + use_default_paths: useDefaultPaths, download_id: downloadId }) }); diff --git a/static/js/components/FolderTreeManager.js b/static/js/components/FolderTreeManager.js index 058bd2e1..f8a43219 100644 --- a/static/js/components/FolderTreeManager.js +++ b/static/js/components/FolderTreeManager.js @@ -175,7 +175,18 @@ export class FolderTreeManager { renderTree() { const folderTree = document.getElementById(this.getElementId('folderTree')); if (!folderTree) return; - + + // Show placeholder if treeData is empty + if (!this.treeData || Object.keys(this.treeData).length === 0) { + folderTree.innerHTML = ` +
+ +
No folders found.
You can create a new folder using the button above.
+
+ `; + return; + } + folderTree.innerHTML = this.renderTreeNode(this.treeData, ''); } diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index bbec8781..19849c23 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -1,5 +1,6 @@ import { modalManager } from './ModalManager.js'; import { showToast } from '../utils/uiHelpers.js'; +import { state } from '../state/index.js'; import { LoadingManager } from './LoadingManager.js'; import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; @@ -16,7 +17,8 @@ export class DownloadManager { this.initialized = false; this.selectedFolder = ''; this.apiClient = null; - + this.useDefaultPath = false; + this.loadingManager = new LoadingManager(); this.folderTreeManager = new FolderTreeManager(); this.folderClickHandler = null; @@ -29,6 +31,7 @@ export class DownloadManager { this.handleBackToUrl = this.backToUrl.bind(this); this.handleBackToVersions = this.backToVersions.bind(this); this.handleCloseModal = this.closeModal.bind(this); + this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this); } showDownloadModal() { @@ -73,6 +76,9 @@ export class DownloadManager { document.getElementById('backToUrlBtn').addEventListener('click', this.handleBackToUrl); document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions); document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal); + + // Default path toggle handler + document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath); } updateModalLabels() { @@ -126,6 +132,9 @@ export class DownloadManager { if (this.folderTreeManager) { this.folderTreeManager.clearSelection(); } + + // Reset default path toggle + this.loadDefaultPathSetting(); } async validateAndFetchVersions() { @@ -329,12 +338,63 @@ export class DownloadManager { this.updateTargetPath(); }); + // Load default path setting for current model type + this.loadDefaultPathSetting(); + this.updateTargetPath(); } catch (error) { showToast(error.message, 'error'); } } + loadDefaultPathSetting() { + const modelType = this.apiClient.modelType; + const storageKey = `use_default_path_${modelType}`; + this.useDefaultPath = getStorageItem(storageKey, false); + + const toggleInput = document.getElementById('useDefaultPath'); + if (toggleInput) { + toggleInput.checked = this.useDefaultPath; + this.updatePathSelectionUI(); + } + } + + toggleDefaultPath(event) { + this.useDefaultPath = event.target.checked; + + // Save to localStorage per model type + const modelType = this.apiClient.modelType; + const storageKey = `use_default_path_${modelType}`; + setStorageItem(storageKey, this.useDefaultPath); + + this.updatePathSelectionUI(); + this.updateTargetPath(); + } + + updatePathSelectionUI() { + const manualSelection = document.getElementById('manualPathSelection'); + + // Always show manual path selection, but disable/enable based on useDefaultPath + manualSelection.style.display = 'block'; + 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; + }); + } + + // Always update the main path display + this.updateTargetPath(); + } + backToUrl() { document.getElementById('versionStep').style.display = 'none'; document.getElementById('urlStep').style.display = 'block'; @@ -362,8 +422,16 @@ export class DownloadManager { return; } - // Get selected folder path from folder tree manager - const targetFolder = this.folderTreeManager.getSelectedPath(); + // Determine target folder and use_default_paths parameter + let targetFolder = ''; + let useDefaultPaths = false; + + if (this.useDefaultPath) { + useDefaultPaths = true; + targetFolder = ''; // Not needed when using default paths + } else { + targetFolder = this.folderTreeManager.getSelectedPath(); + } try { const updateProgress = this.loadingManager.showDownloadProgress(1); @@ -402,12 +470,13 @@ export class DownloadManager { console.error('WebSocket error:', error); }; - // Start download + // Start download with use_default_paths parameter await this.apiClient.downloadModel( this.modelId, this.currentVersion.id, modelRoot, targetFolder, + useDefaultPaths, downloadId ); @@ -418,19 +487,22 @@ export class DownloadManager { // Update state and trigger reload const pageState = this.apiClient.getPageState(); - pageState.activeFolder = targetFolder; - // Save the active folder preference - setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder); - - // Update UI folder selection - document.querySelectorAll('.folder-tags .tag').forEach(tag => { - const isActive = tag.dataset.folder === targetFolder; - tag.classList.toggle('active', isActive); - if (isActive && !tag.parentNode.classList.contains('collapsed')) { - tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } - }); + if (!useDefaultPaths) { + pageState.activeFolder = targetFolder; + + // Save the active folder preference + setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder); + + // Update UI folder selection + document.querySelectorAll('.folder-tags .tag').forEach(tag => { + const isActive = tag.dataset.folder === targetFolder; + tag.classList.toggle('active', isActive); + if (isActive && !tag.parentNode.classList.contains('collapsed')) { + tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + }); + } await resetAndReload(true); @@ -517,9 +589,23 @@ export class DownloadManager { let fullPath = modelRoot || `Select a ${config.displayName} root directory`; if (modelRoot) { - const selectedPath = this.folderTreeManager ? this.folderTreeManager.getSelectedPath() : ''; - if (selectedPath) { - fullPath += '/' + selectedPath; + if (this.useDefaultPath) { + // Show actual template path + try { + const singularType = this.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 += '/[Auto-organized by path template]'; + } + } else { + // Show manual path selection + const selectedPath = this.folderTreeManager ? this.folderTreeManager.getSelectedPath() : ''; + if (selectedPath) { + fullPath += '/' + selectedPath; + } } } diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 78ae35f5..dfb0f3c3 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -3,7 +3,7 @@ import { showToast } from '../utils/uiHelpers.js'; import { state } from '../state/index.js'; import { resetAndReload } from '../api/modelApiFactory.js'; import { setStorageItem, getStorageItem } from '../utils/storageHelpers.js'; -import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS } from '../utils/constants.js'; +import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES } from '../utils/constants.js'; export class SettingsManager { constructor() { @@ -73,11 +73,30 @@ export class SettingsManager { // We can delete the old setting, but keeping it for backwards compatibility } - // Set default for download path template if undefined - if (state.global.settings.download_path_template === undefined) { - state.global.settings.download_path_template = DOWNLOAD_PATH_TEMPLATES.BASE_MODEL_TAG.value; + // Migrate legacy download_path_template to new structure + if (state.global.settings.download_path_template && !state.global.settings.download_path_templates) { + const legacyTemplate = state.global.settings.download_path_template; + state.global.settings.download_path_templates = { + lora: legacyTemplate, + checkpoint: legacyTemplate, + embedding: legacyTemplate + }; + delete state.global.settings.download_path_template; + setStorageItem('settings', state.global.settings); } + // Set default for download path templates if undefined + if (state.global.settings.download_path_templates === undefined) { + state.global.settings.download_path_templates = { ...DEFAULT_PATH_TEMPLATES }; + } + + // Ensure all model types have templates + Object.keys(DEFAULT_PATH_TEMPLATES).forEach(modelType => { + if (typeof state.global.settings.download_path_templates[modelType] === 'undefined') { + state.global.settings.download_path_templates[modelType] = DEFAULT_PATH_TEMPLATES[modelType]; + } + }); + // Set default for base model path mappings if undefined if (state.global.settings.base_model_path_mappings === undefined) { state.global.settings.base_model_path_mappings = {}; @@ -105,7 +124,7 @@ export class SettingsManager { 'default_checkpoint_root', 'default_embedding_root', 'base_model_path_mappings', - 'download_path_template' + 'download_path_templates' ]; // Build payload for syncing @@ -113,7 +132,7 @@ export class SettingsManager { fieldsToSync.forEach(key => { if (localSettings[key] !== undefined) { - if (key === 'base_model_path_mappings') { + if (key === 'base_model_path_mappings' || key === 'download_path_templates') { payload[key] = JSON.stringify(localSettings[key]); } else { payload[key] = localSettings[key]; @@ -164,6 +183,30 @@ export class SettingsManager { document.querySelectorAll('.toggle-visibility').forEach(button => { button.addEventListener('click', () => this.toggleInputVisibility(button)); }); + + ['lora', 'checkpoint', 'embedding'].forEach(modelType => { + const customInput = document.getElementById(`${modelType}CustomTemplate`); + if (customInput) { + customInput.addEventListener('input', (e) => { + const template = e.target.value; + settingsManager.validateTemplate(modelType, template); + settingsManager.updateTemplatePreview(modelType, template); + }); + + customInput.addEventListener('blur', (e) => { + const template = e.target.value; + if (settingsManager.validateTemplate(modelType, template)) { + settingsManager.updateTemplate(modelType, template); + } + }); + + customInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.target.blur(); + } + }); + } + }); this.initialized = true; } @@ -211,12 +254,8 @@ export class SettingsManager { autoDownloadExampleImagesCheckbox.checked = state.global.settings.autoDownloadExampleImages || false; } - // Set download path template setting - const downloadPathTemplateSelect = document.getElementById('downloadPathTemplate'); - if (downloadPathTemplateSelect) { - downloadPathTemplateSelect.value = state.global.settings.download_path_template || ''; - this.updatePathTemplatePreview(); - } + // Load download path templates + this.loadDownloadPathTemplates(); // Set include trigger words setting const includeTriggerWordsCheckbox = document.getElementById('includeTriggerWords'); @@ -529,19 +568,184 @@ export class SettingsManager { } } - updatePathTemplatePreview() { - const templateSelect = document.getElementById('downloadPathTemplate'); - const previewElement = document.getElementById('pathTemplatePreview'); - if (!templateSelect || !previewElement) return; - - const template = templateSelect.value; - const templateInfo = Object.values(DOWNLOAD_PATH_TEMPLATES).find(t => t.value === template); + loadDownloadPathTemplates() { + const templates = state.global.settings.download_path_templates || DEFAULT_PATH_TEMPLATES; - if (templateInfo) { - previewElement.textContent = templateInfo.example; - previewElement.style.display = 'block'; + Object.keys(templates).forEach(modelType => { + this.loadTemplateForModelType(modelType, templates[modelType]); + }); + } + + loadTemplateForModelType(modelType, template) { + const presetSelect = document.getElementById(`${modelType}TemplatePreset`); + const customRow = document.getElementById(`${modelType}CustomRow`); + const customInput = document.getElementById(`${modelType}CustomTemplate`); + + if (!presetSelect) return; + + // Find matching preset + const matchingPreset = this.findMatchingPreset(template); + + if (matchingPreset !== null) { + presetSelect.value = matchingPreset; + if (customRow) customRow.style.display = 'none'; } else { - previewElement.style.display = 'none'; + // Custom template + presetSelect.value = 'custom'; + if (customRow) customRow.style.display = 'block'; + if (customInput) { + customInput.value = template; + this.validateTemplate(modelType, template); + } + } + + this.updateTemplatePreview(modelType, template); + } + + findMatchingPreset(template) { + const presetValues = Object.values(DOWNLOAD_PATH_TEMPLATES) + .map(t => t.value) + .filter(v => v !== 'custom'); + + return presetValues.includes(template) ? template : null; + } + + updateTemplatePreset(modelType, value) { + const customRow = document.getElementById(`${modelType}CustomRow`); + const customInput = document.getElementById(`${modelType}CustomTemplate`); + + if (value === 'custom') { + if (customRow) customRow.style.display = 'block'; + if (customInput) customInput.focus(); + return; + } else { + if (customRow) customRow.style.display = 'none'; + } + + // Update template + this.updateTemplate(modelType, value); + } + + updateTemplate(modelType, template) { + // Validate template if it's custom + if (document.getElementById(`${modelType}TemplatePreset`).value === 'custom') { + if (!this.validateTemplate(modelType, template)) { + return; // Don't save invalid templates + } + } + + // Update state + if (!state.global.settings.download_path_templates) { + state.global.settings.download_path_templates = { ...DEFAULT_PATH_TEMPLATES }; + } + state.global.settings.download_path_templates[modelType] = template; + + // Update preview + this.updateTemplatePreview(modelType, template); + + // Save settings + this.saveDownloadPathTemplates(); + } + + validateTemplate(modelType, template) { + const validationElement = document.getElementById(`${modelType}Validation`); + if (!validationElement) return true; + + // Reset validation state + validationElement.innerHTML = ''; + validationElement.className = 'template-validation'; + + if (!template) { + validationElement.innerHTML = ' Valid (flat structure)'; + validationElement.classList.add('valid'); + return true; + } + + // Check for invalid characters + const invalidChars = /[<>:"|?*]/; + if (invalidChars.test(template)) { + validationElement.innerHTML = ' Invalid characters detected'; + validationElement.classList.add('invalid'); + return false; + } + + // Check for double slashes + if (template.includes('//')) { + validationElement.innerHTML = ' Double slashes not allowed'; + validationElement.classList.add('invalid'); + return false; + } + + // Check if it starts or ends with slash + if (template.startsWith('/') || template.endsWith('/')) { + validationElement.innerHTML = ' Cannot start or end with slash'; + validationElement.classList.add('invalid'); + return false; + } + + // Extract placeholders + const placeholderRegex = /\{([^}]+)\}/g; + const matches = template.match(placeholderRegex) || []; + + // Check for invalid placeholders + const invalidPlaceholders = matches.filter(match => + !PATH_TEMPLATE_PLACEHOLDERS.includes(match) + ); + + if (invalidPlaceholders.length > 0) { + validationElement.innerHTML = ` Invalid placeholder: ${invalidPlaceholders[0]}`; + validationElement.classList.add('invalid'); + return false; + } + + // Template is valid + validationElement.innerHTML = ' Valid template'; + validationElement.classList.add('valid'); + return true; + } + + updateTemplatePreview(modelType, template) { + const previewElement = document.getElementById(`${modelType}Preview`); + if (!previewElement) return; + + if (!template) { + previewElement.textContent = 'model-name.safetensors'; + } else { + // Generate example preview + const exampleTemplate = template + .replace('{base_model}', 'Flux.1 D') + .replace('{author}', 'authorname') + .replace('{first_tag}', 'style'); + previewElement.textContent = `${exampleTemplate}/model-name.safetensors`; + } + previewElement.style.display = 'block'; + } + + async saveDownloadPathTemplates() { + try { + // Save to localStorage + setStorageItem('settings', state.global.settings); + + // Save to backend + const response = await fetch('/api/settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + download_path_templates: JSON.stringify(state.global.settings.download_path_templates) + }) + }); + + if (!response.ok) { + throw new Error('Failed to save download path templates'); + } + + showToast('Download path templates updated', 'success'); + + } catch (error) { + console.error('Error saving download path templates:', error); + showToast('Failed to save download path templates: ' + error.message, 'error'); } } @@ -651,9 +855,6 @@ export class SettingsManager { state.global.settings.compactMode = (value !== 'default'); } else if (settingKey === 'card_info_display') { state.global.settings.cardInfoDisplay = value; - } else if (settingKey === 'download_path_template') { - state.global.settings.download_path_template = value; - this.updatePathTemplatePreview(); } else { // For any other settings that might be added in the future state.global.settings[settingKey] = value; @@ -664,9 +865,13 @@ export class SettingsManager { try { // For backend settings, make API call - if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'default_embedding_root' || settingKey === 'download_path_template') { + if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'default_embedding_root' || settingKey === 'download_path_templates') { const payload = {}; - payload[settingKey] = value; + if (settingKey === 'download_path_templates') { + payload[settingKey] = JSON.stringify(state.global.settings.download_path_templates); + } else { + payload[settingKey] = value; + } const response = await fetch('/api/settings', { method: 'POST', diff --git a/static/js/utils/constants.js b/static/js/utils/constants.js index 9b6fd763..90c91d05 100644 --- a/static/js/utils/constants.js +++ b/static/js/utils/constants.js @@ -114,6 +114,12 @@ export const DOWNLOAD_PATH_TEMPLATES = { description: 'Organize by base model type', example: 'Flux.1 D/model-name.safetensors' }, + AUTHOR: { + value: '{author}', + label: 'By Author', + description: 'Organize by model author', + example: 'authorname/model-name.safetensors' + }, FIRST_TAG: { value: '{first_tag}', label: 'By First Tag', @@ -125,9 +131,48 @@ export const DOWNLOAD_PATH_TEMPLATES = { label: 'Base Model + First Tag', description: 'Organize by base model and primary tag', example: 'Flux.1 D/style/model-name.safetensors' + }, + BASE_MODEL_AUTHOR: { + value: '{base_model}/{author}', + label: 'Base Model + Author', + description: 'Organize by base model and author', + example: 'Flux.1 D/authorname/model-name.safetensors' + }, + AUTHOR_TAG: { + value: '{author}/{first_tag}', + label: 'Author + First Tag', + description: 'Organize by author and primary tag', + example: 'authorname/style/model-name.safetensors' + }, + CUSTOM: { + value: 'custom', + label: 'Custom Template', + description: 'Create your own path structure', + example: 'Enter custom template...' } }; +// Valid placeholders for path templates +export const PATH_TEMPLATE_PLACEHOLDERS = [ + '{base_model}', + '{author}', + '{first_tag}' +]; + +// Default templates for each model type +export const DEFAULT_PATH_TEMPLATES = { + lora: '{base_model}/{first_tag}', + checkpoint: '{base_model}', + embedding: '{first_tag}' +}; + +// Model type labels for UI +export const MODEL_TYPE_LABELS = { + lora: 'LoRA Models', + checkpoint: 'Checkpoint Models', + embedding: 'Embedding Models' +}; + // Base models available for path mapping (for UI selection) export const MAPPABLE_BASE_MODELS = Object.values(BASE_MODELS).sort(); diff --git a/templates/components/modals/download_modal.html b/templates/components/modals/download_modal.html index 019458d7..69516318 100644 --- a/templates/components/modals/download_modal.html +++ b/templates/components/modals/download_modal.html @@ -32,44 +32,57 @@