From e4195f874d775343b8b65681f5a3e3103f092068 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Wed, 13 Aug 2025 16:41:36 +0800 Subject: [PATCH] feat: implement download path templates configuration with support for multiple model types and custom templates --- py/routes/misc_routes.py | 2 +- .../css/components/modal/settings-modal.css | 95 +++++++ static/js/managers/SettingsManager.js | 261 ++++++++++++++++-- static/js/utils/constants.js | 45 +++ .../components/modals/settings_modal.html | 214 +++++++++----- 5 files changed, 513 insertions(+), 104 deletions(-) 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/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/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 78ae35f5..6203b17d 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 (!state.global.settings.download_path_templates[modelType]) { + 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) { + 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 4c7520f9..53249ccb 100644 --- a/static/js/utils/constants.js +++ b/static/js/utils/constants.js @@ -112,6 +112,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', @@ -123,9 +129,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/settings_modal.html b/templates/components/modals/settings_modal.html index e2d5d542..4f6ead0a 100644 --- a/templates/components/modals/settings_modal.html +++ b/templates/components/modals/settings_modal.html @@ -90,6 +90,57 @@ + + +