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 @@
-
+
-
+
Select a root directory
+
-
-
-