feat: implement download path templates configuration with support for multiple model types and custom templates

This commit is contained in:
Will Miao
2025-08-13 16:41:36 +08:00
parent d04deff5ca
commit e4195f874d
5 changed files with 513 additions and 104 deletions

View File

@@ -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:

View File

@@ -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;
}
}

View File

@@ -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 = '<i class="fas fa-check"></i> Valid (flat structure)';
validationElement.classList.add('valid');
return true;
}
// Check for invalid characters
const invalidChars = /[<>:"|?*]/;
if (invalidChars.test(template)) {
validationElement.innerHTML = '<i class="fas fa-times"></i> Invalid characters detected';
validationElement.classList.add('invalid');
return false;
}
// Check for double slashes
if (template.includes('//')) {
validationElement.innerHTML = '<i class="fas fa-times"></i> 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 = '<i class="fas fa-times"></i> 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 = `<i class="fas fa-times"></i> Invalid placeholder: ${invalidPlaceholders[0]}`;
validationElement.classList.add('invalid');
return false;
}
// Template is valid
validationElement.innerHTML = '<i class="fas fa-check"></i> 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',

View File

@@ -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();

View File

@@ -90,6 +90,57 @@
</div>
</div>
</div>
<!-- Add Layout Settings Section -->
<div class="settings-section">
<h3>Layout Settings</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="displayDensity">Display Density</label>
</div>
<div class="setting-control select-control">
<select id="displayDensity" onchange="settingsManager.saveSelectSetting('displayDensity', 'display_density')">
<option value="default">Default</option>
<option value="medium">Medium</option>
<option value="compact">Compact</option>
</select>
</div>
</div>
<div class="input-help">
Choose how many cards to display per row:
<ul class="list-description">
<li><strong>Default:</strong> 5 (1080p), 6 (2K), 8 (4K)</li>
<li><strong>Medium:</strong> 6 (1080p), 7 (2K), 9 (4K)</li>
<li><strong>Compact:</strong> 7 (1080p), 8 (2K), 10 (4K)</li>
</ul>
<span class="warning-text">Warning: Higher densities may cause performance issues on systems with limited resources.</span>
</div>
</div>
<!-- Add Card Info Display setting -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="cardInfoDisplay">Card Info Display</label>
</div>
<div class="setting-control select-control">
<select id="cardInfoDisplay" onchange="settingsManager.saveSelectSetting('cardInfoDisplay', 'card_info_display')">
<option value="always">Always Visible</option>
<option value="hover">Reveal on Hover</option>
</select>
</div>
</div>
<div class="input-help">
Choose when to display model information and action buttons:
<ul class="list-description">
<li><strong>Always Visible:</strong> Headers and footers are always visible</li>
<li><strong>Reveal on Hover:</strong> Headers and footers only appear when hovering over a card</li>
</ul>
</div>
</div>
</div>
<!-- Add Folder Settings Section -->
<div class="settings-section">
@@ -149,108 +200,121 @@
<!-- Default Path Customization Section -->
<div class="settings-section">
<h3>Default Path Customization</h3>
<h3>Download Path Templates</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadPathTemplate">Download Path Template</label>
</div>
<div class="setting-control select-control">
<select id="downloadPathTemplate" onchange="settingsManager.saveSelectSetting('downloadPathTemplate', 'download_path_template')">
<option value="">Flat Structure</option>
<option value="{base_model}">By Base Model</option>
<option value="{first_tag}">By First Tag</option>
<option value="{base_model}/{first_tag}">Base Model + First Tag</option>
</select>
</div>
</div>
<div class="input-help">
Configure path structure for default download locations
<ul class="list-description">
<li><strong>Flat:</strong> All models in root folder</li>
<li><strong>Base Model:</strong> Organized by model type (e.g., Flux.1 D, SDXL)</li>
<li><strong>First Tag:</strong> Organized by primary tag (e.g., style, character)</li>
<li><strong>Base Model + Tag:</strong> Two-level organization for better structure</li>
</ul>
Configure folder structures for different model types when downloading from Civitai.
<div class="placeholder-info">
<strong>Available placeholders:</strong>
<span class="placeholder-tag">{base_model}</span>
<span class="placeholder-tag">{author}</span>
<span class="placeholder-tag">{first_tag}</span>
</div>
</div>
<div id="pathTemplatePreview" class="template-preview"></div>
</div>
<!-- LoRA Template Configuration -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>Base Model Path Mappings</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
<i class="fas fa-plus"></i>
<span>Add Mapping</span>
</button>
</div>
</div>
<div class="input-help">
Customize folder names for specific base models (e.g., "Flux.1 D" → "flux")
</div>
<div class="mappings-container">
<div id="baseModelMappingsContainer">
<!-- Mapping rows will be added dynamically -->
</div>
</div>
</div>
</div>
<!-- Add Layout Settings Section -->
<div class="settings-section">
<h3>Layout Settings</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="displayDensity">Display Density</label>
<label for="loraTemplatePreset">LoRA</label>
</div>
<div class="setting-control select-control">
<select id="displayDensity" onchange="settingsManager.saveSelectSetting('displayDensity', 'display_density')">
<option value="default">Default</option>
<option value="medium">Medium</option>
<option value="compact">Compact</option>
<select id="loraTemplatePreset" onchange="settingsManager.updateTemplatePreset('lora', this.value)">
<option value="">Flat Structure</option>
<option value="{base_model}">By Base Model</option>
<option value="{author}">By Author</option>
<option value="{first_tag}">By First Tag</option>
<option value="{base_model}/{first_tag}">Base Model + First Tag</option>
<option value="{base_model}/{author}">Base Model + Author</option>
<option value="{author}/{first_tag}">Author + First Tag</option>
<option value="custom">Custom Template</option>
</select>
</div>
</div>
<div class="input-help">
Choose how many cards to display per row:
<ul class="list-description">
<li><strong>Default:</strong> 5 (1080p), 6 (2K), 8 (4K)</li>
<li><strong>Medium:</strong> 6 (1080p), 7 (2K), 9 (4K)</li>
<li><strong>Compact:</strong> 7 (1080p), 8 (2K), 10 (4K)</li>
</ul>
<span class="warning-text">Warning: Higher densities may cause performance issues on systems with limited resources.</span>
<div class="template-custom-row" id="loraCustomRow" style="display: none;">
<input type="text" id="loraCustomTemplate" class="template-custom-input" placeholder="Enter custom template (e.g., {base_model}/{author}/{first_tag})" />
<div class="template-validation" id="loraValidation"></div>
</div>
<div class="template-preview" id="loraPreview"></div>
</div>
<!-- Add Card Info Display setting -->
<!-- Checkpoint Template Configuration -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="cardInfoDisplay">Card Info Display</label>
<label for="checkpointTemplatePreset">Checkpoint</label>
</div>
<div class="setting-control select-control">
<select id="cardInfoDisplay" onchange="settingsManager.saveSelectSetting('cardInfoDisplay', 'card_info_display')">
<option value="always">Always Visible</option>
<option value="hover">Reveal on Hover</option>
<select id="checkpointTemplatePreset" onchange="settingsManager.updateTemplatePreset('checkpoint', this.value)">
<option value="">Flat Structure</option>
<option value="{base_model}">By Base Model</option>
<option value="{author}">By Author</option>
<option value="{first_tag}">By First Tag</option>
<option value="{base_model}/{first_tag}">Base Model + First Tag</option>
<option value="{base_model}/{author}">Base Model + Author</option>
<option value="{author}/{first_tag}">Author + First Tag</option>
<option value="custom">Custom Template</option>
</select>
</div>
</div>
<div class="input-help">
Choose when to display model information and action buttons:
<ul class="list-description">
<li><strong>Always Visible:</strong> Headers and footers are always visible</li>
<li><strong>Reveal on Hover:</strong> Headers and footers only appear when hovering over a card</li>
</ul>
<div class="template-custom-row" id="checkpointCustomRow" style="display: none;">
<input type="text" id="checkpointCustomTemplate" class="template-custom-input" placeholder="Enter custom template (e.g., {base_model}/{author}/{first_tag})" />
<div class="template-validation" id="checkpointValidation"></div>
</div>
<div class="template-preview" id="checkpointPreview"></div>
</div>
<!-- Embedding Template Configuration -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="embeddingTemplatePreset">Embedding</label>
</div>
<div class="setting-control select-control">
<select id="embeddingTemplatePreset" onchange="settingsManager.updateTemplatePreset('embedding', this.value)">
<option value="">Flat Structure</option>
<option value="{base_model}">By Base Model</option>
<option value="{author}">By Author</option>
<option value="{first_tag}">By First Tag</option>
<option value="{base_model}/{first_tag}">Base Model + First Tag</option>
<option value="{base_model}/{author}">Base Model + Author</option>
<option value="{author}/{first_tag}">Author + First Tag</option>
<option value="custom">Custom Template</option>
</select>
</div>
</div>
<div class="template-custom-row" id="embeddingCustomRow" style="display: none;">
<input type="text" id="embeddingCustomTemplate" class="template-custom-input" placeholder="Enter custom template (e.g., {base_model}/{author}/{first_tag})" />
<div class="template-validation" id="embeddingValidation"></div>
</div>
<div class="template-preview" id="embeddingPreview"></div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>Base Model Path Mappings</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
<i class="fas fa-plus"></i>
<span>Add Mapping</span>
</button>
</div>
</div>
<div class="input-help">
Customize folder names for specific base models (e.g., "Flux.1 D" → "flux")
</div>
<div class="mappings-container">
<div id="baseModelMappingsContainer">
<!-- Mapping rows will be added dynamically -->
</div>
</div>
</div>
<!-- Add Example Images Settings Section -->
<div class="settings-section">
<h3>Example Images</h3>