Merge branch 'willmiao:main' into main

This commit is contained in:
Andreas
2025-08-13 17:51:10 +02:00
committed by GitHub
15 changed files with 808 additions and 166 deletions

View File

@@ -756,8 +756,8 @@ class BaseModelRoutes(ABC):
'error': 'No model roots configured' 'error': 'No model roots configured'
}, status=400) }, status=400)
# Check if flat structure is configured # Check if flat structure is configured for this model type
path_template = settings.get('download_path_template', '{base_model}/{first_tag}') path_template = settings.get_download_path_template(self.service.model_type)
is_flat_structure = not path_template is_flat_structure = not path_template
# Prepare results tracking # Prepare results tracking
@@ -832,7 +832,7 @@ class BaseModelRoutes(ABC):
target_dir = current_root target_dir = current_root
else: else:
# Calculate new relative path based on settings # 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 no relative path calculated (insufficient metadata), skip
if not new_relative_path: if not new_relative_path:

View File

@@ -184,7 +184,7 @@ class MiscRoutes:
logger.info(f"Example images path changed to {value} - server restart required") logger.info(f"Example images path changed to {value} - server restart required")
# Special handling for base_model_path_mappings - parse JSON string # 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: try:
value = json.loads(value) value = json.loads(value)
except json.JSONDecodeError: except json.JSONDecodeError:

View File

@@ -258,7 +258,7 @@ class DownloadManager:
save_dir = default_path save_dir = default_path
# Calculate relative path using template # 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 # Update save directory with relative path if provided
if relative_path: 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': 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)} 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 """Calculate relative path using template from settings
Args: Args:
version_info: Version info from Civitai API version_info: Version info from Civitai API
model_type: Type of model ('lora', 'checkpoint', 'embedding')
Returns: Returns:
Relative path string Relative path string
""" """
# Get path template from settings, default to '{base_model}/{first_tag}' # Get path template from settings for specific model type
path_template = settings.get('download_path_template', '{base_model}/{first_tag}') path_template = settings.get_download_path_template(model_type)
# If template is empty, return empty path (flat structure) # If template is empty, return empty path (flat structure)
if not path_template: if not path_template:
@@ -350,6 +351,9 @@ class DownloadManager:
# Get base model name # Get base model name
base_model = version_info.get('baseModel', '') base_model = version_info.get('baseModel', '')
# Get author from creator data
author = version_info.get('creator', {}).get('username', 'Anonymous')
# Apply mapping if available # Apply mapping if available
base_model_mappings = settings.get('base_model_path_mappings', {}) base_model_mappings = settings.get('base_model_path_mappings', {})
mapped_base_model = base_model_mappings.get(base_model, base_model) mapped_base_model = base_model_mappings.get(base_model, base_model)
@@ -372,6 +376,7 @@ class DownloadManager:
formatted_path = path_template formatted_path = path_template
formatted_path = formatted_path.replace('{base_model}', mapped_base_model) formatted_path = formatted_path.replace('{base_model}', mapped_base_model)
formatted_path = formatted_path.replace('{first_tag}', first_tag) formatted_path = formatted_path.replace('{first_tag}', first_tag)
formatted_path = formatted_path.replace('{author}', author)
return formatted_path return formatted_path

View File

@@ -9,6 +9,7 @@ class SettingsManager:
def __init__(self): def __init__(self):
self.settings_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'settings.json') self.settings_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'settings.json')
self.settings = self._load_settings() self.settings = self._load_settings()
self._migrate_download_path_template()
self._auto_set_default_roots() self._auto_set_default_roots()
self._check_environment_variables() self._check_environment_variables()
@@ -22,6 +23,24 @@ class SettingsManager:
logger.error(f"Error loading settings: {e}") logger.error(f"Error loading settings: {e}")
return self._get_default_settings() 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): def _auto_set_default_roots(self):
"""Auto set default root paths if only one folder is present and default is empty.""" """Auto set default root paths if only one folder is present and default is empty."""
folder_paths = self.settings.get('folder_paths', {}) folder_paths = self.settings.get('folder_paths', {})
@@ -81,4 +100,16 @@ class SettingsManager:
except Exception as e: except Exception as e:
logger.error(f"Error saving settings: {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() settings = SettingsManager()

View File

@@ -132,17 +132,18 @@ def calculate_recipe_fingerprint(loras):
return fingerprint 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 """Calculate relative path for existing model using template from settings
Args: Args:
model_data: Model data from scanner cache model_data: Model data from scanner cache
model_type: Type of model ('lora', 'checkpoint', 'embedding')
Returns: Returns:
Relative path string (empty string for flat structure) Relative path string (empty string for flat structure)
""" """
# Get path template from settings, default to '{base_model}/{first_tag}' # Get path template from settings for specific model type
path_template = settings.get('download_path_template', '{base_model}/{first_tag}') path_template = settings.get_download_path_template(model_type)
# If template is empty, return empty path (flat structure) # If template is empty, return empty path (flat structure)
if not path_template: 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 # 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: if civitai_data and civitai_data.get('id') is not None:
base_model = civitai_data.get('baseModel', '') base_model = civitai_data.get('baseModel', '')
# Get author from civitai creator data
author = civitai_data.get('creator', {}).get('username', 'Anonymous')
else: else:
# Fallback to model_data fields for non-CivitAI models # Fallback to model_data fields for non-CivitAI models
base_model = model_data.get('base_model', '') base_model = model_data.get('base_model', '')
author = 'Anonymous' # Default for non-CivitAI models
model_tags = model_data.get('tags', []) 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 = path_template
formatted_path = formatted_path.replace('{base_model}', mapped_base_model) formatted_path = formatted_path.replace('{base_model}', mapped_base_model)
formatted_path = formatted_path.replace('{first_tag}', first_tag) formatted_path = formatted_path.replace('{first_tag}', first_tag)
formatted_path = formatted_path.replace('{author}', author)
return formatted_path return formatted_path

View File

@@ -351,8 +351,8 @@
display: flex; display: flex;
gap: 8px; gap: 8px;
margin-left: 20px; margin-left: 20px;
margin-top: 4px;
align-items: center; align-items: center;
height: 21px;
} }
.create-folder-form input { .create-folder-form input {
@@ -395,9 +395,16 @@
border: 1px dashed var(--border-color); border: 1px dashed var(--border-color);
} }
.path-preview label { .path-preview-header {
display: block; display: flex;
margin-bottom: 8px; align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: var(--space-2);
}
.path-preview-header label {
margin: 0;
color: var(--text-color); color: var(--text-color);
font-size: 0.9em; font-size: 0.9em;
opacity: 0.8; opacity: 0.8;
@@ -416,6 +423,70 @@
border-radius: var(--border-radius-xs); 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 */ /* Dark theme adjustments */
[data-theme="dark"] .version-item { [data-theme="dark"] .version-item {
background: var(--lora-surface); background: var(--lora-surface);
@@ -426,8 +497,18 @@
border-color: var(--lora-border); border-color: var(--lora-border);
} }
[data-theme="dark"] .toggle-slider:before {
background-color: #f0f0f0;
}
/* Enhance the local badge to make it more noticeable */ /* Enhance the local badge to make it more noticeable */
.version-item.exists-locally { .version-item.exists-locally {
background: oklch(var(--lora-accent) / 0.05); background: oklch(var(--lora-accent) / 0.05);
border-left: 4px solid var(--lora-accent); border-left: 4px solid var(--lora-accent);
}
.manual-path-selection.disabled {
opacity: 0.5;
pointer-events: none;
user-select: none;
} }

View File

@@ -482,4 +482,99 @@ input:checked + .toggle-slider:before {
[data-theme="dark"] .base-model-select option { [data-theme="dark"] .base-model-select option {
background-color: #2d2d2d; background-color: #2d2d2d;
color: var(--text-color); 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

@@ -613,7 +613,7 @@ export class BaseModelApiClient {
} }
} }
async downloadModel(modelId, versionId, modelRoot, relativePath, downloadId) { async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId) {
try { try {
const response = await fetch(DOWNLOAD_ENDPOINTS.download, { const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
method: 'POST', method: 'POST',
@@ -623,6 +623,7 @@ export class BaseModelApiClient {
model_version_id: versionId, model_version_id: versionId,
model_root: modelRoot, model_root: modelRoot,
relative_path: relativePath, relative_path: relativePath,
use_default_paths: useDefaultPaths,
download_id: downloadId download_id: downloadId
}) })
}); });

View File

@@ -175,7 +175,18 @@ export class FolderTreeManager {
renderTree() { renderTree() {
const folderTree = document.getElementById(this.getElementId('folderTree')); const folderTree = document.getElementById(this.getElementId('folderTree'));
if (!folderTree) return; if (!folderTree) return;
// Show placeholder if treeData is empty
if (!this.treeData || Object.keys(this.treeData).length === 0) {
folderTree.innerHTML = `
<div class="folder-tree-placeholder" style="padding:24px;text-align:center;color:var(--text-color);opacity:0.7;">
<i class="fas fa-folder-open" style="font-size:2em;opacity:0.5;"></i>
<div>No folders found.<br/>You can create a new folder using the button above.</div>
</div>
`;
return;
}
folderTree.innerHTML = this.renderTreeNode(this.treeData, ''); folderTree.innerHTML = this.renderTreeNode(this.treeData, '');
} }

View File

@@ -1,5 +1,6 @@
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { LoadingManager } from './LoadingManager.js'; import { LoadingManager } from './LoadingManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
@@ -16,7 +17,8 @@ export class DownloadManager {
this.initialized = false; this.initialized = false;
this.selectedFolder = ''; this.selectedFolder = '';
this.apiClient = null; this.apiClient = null;
this.useDefaultPath = false;
this.loadingManager = new LoadingManager(); this.loadingManager = new LoadingManager();
this.folderTreeManager = new FolderTreeManager(); this.folderTreeManager = new FolderTreeManager();
this.folderClickHandler = null; this.folderClickHandler = null;
@@ -29,6 +31,7 @@ export class DownloadManager {
this.handleBackToUrl = this.backToUrl.bind(this); this.handleBackToUrl = this.backToUrl.bind(this);
this.handleBackToVersions = this.backToVersions.bind(this); this.handleBackToVersions = this.backToVersions.bind(this);
this.handleCloseModal = this.closeModal.bind(this); this.handleCloseModal = this.closeModal.bind(this);
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
} }
showDownloadModal() { showDownloadModal() {
@@ -73,6 +76,9 @@ export class DownloadManager {
document.getElementById('backToUrlBtn').addEventListener('click', this.handleBackToUrl); document.getElementById('backToUrlBtn').addEventListener('click', this.handleBackToUrl);
document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions); document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions);
document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal); document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal);
// Default path toggle handler
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
} }
updateModalLabels() { updateModalLabels() {
@@ -126,6 +132,9 @@ export class DownloadManager {
if (this.folderTreeManager) { if (this.folderTreeManager) {
this.folderTreeManager.clearSelection(); this.folderTreeManager.clearSelection();
} }
// Reset default path toggle
this.loadDefaultPathSetting();
} }
async validateAndFetchVersions() { async validateAndFetchVersions() {
@@ -329,12 +338,63 @@ export class DownloadManager {
this.updateTargetPath(); this.updateTargetPath();
}); });
// Load default path setting for current model type
this.loadDefaultPathSetting();
this.updateTargetPath(); this.updateTargetPath();
} catch (error) { } catch (error) {
showToast(error.message, '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() { backToUrl() {
document.getElementById('versionStep').style.display = 'none'; document.getElementById('versionStep').style.display = 'none';
document.getElementById('urlStep').style.display = 'block'; document.getElementById('urlStep').style.display = 'block';
@@ -362,8 +422,16 @@ export class DownloadManager {
return; return;
} }
// Get selected folder path from folder tree manager // Determine target folder and use_default_paths parameter
const targetFolder = this.folderTreeManager.getSelectedPath(); let targetFolder = '';
let useDefaultPaths = false;
if (this.useDefaultPath) {
useDefaultPaths = true;
targetFolder = ''; // Not needed when using default paths
} else {
targetFolder = this.folderTreeManager.getSelectedPath();
}
try { try {
const updateProgress = this.loadingManager.showDownloadProgress(1); const updateProgress = this.loadingManager.showDownloadProgress(1);
@@ -402,12 +470,13 @@ export class DownloadManager {
console.error('WebSocket error:', error); console.error('WebSocket error:', error);
}; };
// Start download // Start download with use_default_paths parameter
await this.apiClient.downloadModel( await this.apiClient.downloadModel(
this.modelId, this.modelId,
this.currentVersion.id, this.currentVersion.id,
modelRoot, modelRoot,
targetFolder, targetFolder,
useDefaultPaths,
downloadId downloadId
); );
@@ -418,19 +487,22 @@ export class DownloadManager {
// Update state and trigger reload // Update state and trigger reload
const pageState = this.apiClient.getPageState(); const pageState = this.apiClient.getPageState();
pageState.activeFolder = targetFolder;
// Save the active folder preference if (!useDefaultPaths) {
setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder); pageState.activeFolder = targetFolder;
// Update UI folder selection // Save the active folder preference
document.querySelectorAll('.folder-tags .tag').forEach(tag => { setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder);
const isActive = tag.dataset.folder === targetFolder;
tag.classList.toggle('active', isActive); // Update UI folder selection
if (isActive && !tag.parentNode.classList.contains('collapsed')) { document.querySelectorAll('.folder-tags .tag').forEach(tag => {
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 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); await resetAndReload(true);
@@ -517,9 +589,23 @@ export class DownloadManager {
let fullPath = modelRoot || `Select a ${config.displayName} root directory`; let fullPath = modelRoot || `Select a ${config.displayName} root directory`;
if (modelRoot) { if (modelRoot) {
const selectedPath = this.folderTreeManager ? this.folderTreeManager.getSelectedPath() : ''; if (this.useDefaultPath) {
if (selectedPath) { // Show actual template path
fullPath += '/' + selectedPath; 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;
}
} }
} }

View File

@@ -3,7 +3,7 @@ import { showToast } from '../utils/uiHelpers.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { resetAndReload } from '../api/modelApiFactory.js'; import { resetAndReload } from '../api/modelApiFactory.js';
import { setStorageItem, getStorageItem } from '../utils/storageHelpers.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 { export class SettingsManager {
constructor() { constructor() {
@@ -73,11 +73,30 @@ export class SettingsManager {
// We can delete the old setting, but keeping it for backwards compatibility // We can delete the old setting, but keeping it for backwards compatibility
} }
// Set default for download path template if undefined // Migrate legacy download_path_template to new structure
if (state.global.settings.download_path_template === undefined) { if (state.global.settings.download_path_template && !state.global.settings.download_path_templates) {
state.global.settings.download_path_template = DOWNLOAD_PATH_TEMPLATES.BASE_MODEL_TAG.value; 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 // Set default for base model path mappings if undefined
if (state.global.settings.base_model_path_mappings === undefined) { if (state.global.settings.base_model_path_mappings === undefined) {
state.global.settings.base_model_path_mappings = {}; state.global.settings.base_model_path_mappings = {};
@@ -105,7 +124,7 @@ export class SettingsManager {
'default_checkpoint_root', 'default_checkpoint_root',
'default_embedding_root', 'default_embedding_root',
'base_model_path_mappings', 'base_model_path_mappings',
'download_path_template' 'download_path_templates'
]; ];
// Build payload for syncing // Build payload for syncing
@@ -113,7 +132,7 @@ export class SettingsManager {
fieldsToSync.forEach(key => { fieldsToSync.forEach(key => {
if (localSettings[key] !== undefined) { 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]); payload[key] = JSON.stringify(localSettings[key]);
} else { } else {
payload[key] = localSettings[key]; payload[key] = localSettings[key];
@@ -164,6 +183,30 @@ export class SettingsManager {
document.querySelectorAll('.toggle-visibility').forEach(button => { document.querySelectorAll('.toggle-visibility').forEach(button => {
button.addEventListener('click', () => this.toggleInputVisibility(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; this.initialized = true;
} }
@@ -211,12 +254,8 @@ export class SettingsManager {
autoDownloadExampleImagesCheckbox.checked = state.global.settings.autoDownloadExampleImages || false; autoDownloadExampleImagesCheckbox.checked = state.global.settings.autoDownloadExampleImages || false;
} }
// Set download path template setting // Load download path templates
const downloadPathTemplateSelect = document.getElementById('downloadPathTemplate'); this.loadDownloadPathTemplates();
if (downloadPathTemplateSelect) {
downloadPathTemplateSelect.value = state.global.settings.download_path_template || '';
this.updatePathTemplatePreview();
}
// Set include trigger words setting // Set include trigger words setting
const includeTriggerWordsCheckbox = document.getElementById('includeTriggerWords'); const includeTriggerWordsCheckbox = document.getElementById('includeTriggerWords');
@@ -529,19 +568,184 @@ export class SettingsManager {
} }
} }
updatePathTemplatePreview() { loadDownloadPathTemplates() {
const templateSelect = document.getElementById('downloadPathTemplate'); const templates = state.global.settings.download_path_templates || DEFAULT_PATH_TEMPLATES;
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);
if (templateInfo) { Object.keys(templates).forEach(modelType => {
previewElement.textContent = templateInfo.example; this.loadTemplateForModelType(modelType, templates[modelType]);
previewElement.style.display = 'block'; });
}
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 { } 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'); state.global.settings.compactMode = (value !== 'default');
} else if (settingKey === 'card_info_display') { } else if (settingKey === 'card_info_display') {
state.global.settings.cardInfoDisplay = value; state.global.settings.cardInfoDisplay = value;
} else if (settingKey === 'download_path_template') {
state.global.settings.download_path_template = value;
this.updatePathTemplatePreview();
} else { } else {
// For any other settings that might be added in the future // For any other settings that might be added in the future
state.global.settings[settingKey] = value; state.global.settings[settingKey] = value;
@@ -664,9 +865,13 @@ export class SettingsManager {
try { try {
// For backend settings, make API call // 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 = {}; 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', { const response = await fetch('/api/settings', {
method: 'POST', method: 'POST',

View File

@@ -114,6 +114,12 @@ export const DOWNLOAD_PATH_TEMPLATES = {
description: 'Organize by base model type', description: 'Organize by base model type',
example: 'Flux.1 D/model-name.safetensors' 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: { FIRST_TAG: {
value: '{first_tag}', value: '{first_tag}',
label: 'By First Tag', label: 'By First Tag',
@@ -125,9 +131,48 @@ export const DOWNLOAD_PATH_TEMPLATES = {
label: 'Base Model + First Tag', label: 'Base Model + First Tag',
description: 'Organize by base model and primary tag', description: 'Organize by base model and primary tag',
example: 'Flux.1 D/style/model-name.safetensors' 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) // Base models available for path mapping (for UI selection)
export const MAPPABLE_BASE_MODELS = Object.values(BASE_MODELS).sort(); export const MAPPABLE_BASE_MODELS = Object.values(BASE_MODELS).sort();

View File

@@ -32,44 +32,57 @@
<!-- Step 3: Location Selection --> <!-- Step 3: Location Selection -->
<div class="download-step" id="locationStep" style="display: none;"> <div class="download-step" id="locationStep" style="display: none;">
<div class="location-selection"> <div class="location-selection">
<!-- Path preview --> <!-- Path preview with inline toggle -->
<div class="path-preview"> <div class="path-preview">
<label>Download Location Preview:</label> <div class="path-preview-header">
<label>Download Location Preview:</label>
<div class="inline-toggle-container" title="When enabled, files are automatically organized using configured path templates">
<span class="inline-toggle-label">Use Default Path</span>
<div class="toggle-switch">
<input type="checkbox" id="useDefaultPath">
<label for="useDefaultPath" class="toggle-slider"></label>
</div>
</div>
</div>
<div class="path-display" id="targetPathDisplay"> <div class="path-display" id="targetPathDisplay">
<span class="path-text">Select a root directory</span> <span class="path-text">Select a root directory</span>
</div> </div>
</div> </div>
<!-- Model Root Selection (always visible) -->
<div class="input-group"> <div class="input-group">
<label for="modelRoot" id="modelRootLabel">Select Model Root:</label> <label for="modelRoot" id="modelRootLabel">Select Model Root:</label>
<select id="modelRoot"></select> <select id="modelRoot"></select>
</div> </div>
<!-- Path input with autocomplete --> <!-- Manual Path Selection (hidden when using default path) -->
<div class="input-group"> <div class="manual-path-selection" id="manualPathSelection">
<label for="folderPath">Target Folder Path:</label> <!-- Path input with autocomplete -->
<div class="path-input-container"> <div class="input-group">
<input type="text" id="folderPath" placeholder="Type folder path or select from tree below..." autocomplete="off" /> <label for="folderPath">Target Folder Path:</label>
<button type="button" id="createFolderBtn" class="create-folder-btn" title="Create new folder"> <div class="path-input-container">
<i class="fas fa-plus"></i> <input type="text" id="folderPath" placeholder="Type folder path or select from tree below..." autocomplete="off" />
</button> <button type="button" id="createFolderBtn" class="create-folder-btn" title="Create new folder">
<i class="fas fa-plus"></i>
</button>
</div>
<div class="path-suggestions" id="pathSuggestions" style="display: none;"></div>
</div> </div>
<div class="path-suggestions" id="pathSuggestions" style="display: none;"></div>
</div> <!-- Breadcrumb navigation -->
<div class="breadcrumb-nav" id="breadcrumbNav">
<!-- Breadcrumb navigation --> <span class="breadcrumb-item root" data-path="">
<div class="breadcrumb-nav" id="breadcrumbNav"> <i class="fas fa-home"></i> Root
<span class="breadcrumb-item root" data-path=""> </span>
<i class="fas fa-home"></i> Root </div>
</span>
</div> <!-- Hierarchical folder tree -->
<div class="input-group">
<!-- Hierarchical folder tree --> <label>Browse Folders:</label>
<div class="input-group"> <div class="folder-tree-container">
<label>Browse Folders:</label> <div class="folder-tree" id="folderTree">
<div class="folder-tree-container"> <!-- Tree will be loaded dynamically -->
<div class="folder-tree" id="folderTree"> </div>
<!-- Tree will be loaded dynamically -->
</div> </div>
</div> </div>
</div> </div>

View File

@@ -90,6 +90,57 @@
</div> </div>
</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>
</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 --> <!-- Add Folder Settings Section -->
<div class="settings-section"> <div class="settings-section">
@@ -149,108 +200,121 @@
<!-- Default Path Customization Section --> <!-- Default Path Customization Section -->
<div class="settings-section"> <div class="settings-section">
<h3>Default Path Customization</h3> <h3>Download Path Templates</h3>
<div class="setting-item"> <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"> <div class="input-help">
Configure path structure for default download locations Configure folder structures for different model types when downloading from Civitai.
<ul class="list-description"> <div class="placeholder-info">
<li><strong>Flat:</strong> All models in root folder</li> <strong>Available placeholders:</strong>
<li><strong>Base Model:</strong> Organized by model type (e.g., Flux.1 D, SDXL)</li> <span class="placeholder-tag">{base_model}</span>
<li><strong>First Tag:</strong> Organized by primary tag (e.g., style, character)</li> <span class="placeholder-tag">{author}</span>
<li><strong>Base Model + Tag:</strong> Two-level organization for better structure</li> <span class="placeholder-tag">{first_tag}</span>
</ul> </div>
</div> </div>
<div id="pathTemplatePreview" class="template-preview"></div>
</div> </div>
<!-- LoRA Template Configuration -->
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<label>Base Model Path Mappings</label> <label for="loraTemplatePreset">LoRA</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>
</div> </div>
<div class="setting-control select-control"> <div class="setting-control select-control">
<select id="displayDensity" onchange="settingsManager.saveSelectSetting('displayDensity', 'display_density')"> <select id="loraTemplatePreset" onchange="settingsManager.updateTemplatePreset('lora', this.value)">
<option value="default">Default</option> <option value="">Flat Structure</option>
<option value="medium">Medium</option> <option value="{base_model}">By Base Model</option>
<option value="compact">Compact</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> </select>
</div> </div>
</div> </div>
<div class="input-help"> <div class="template-custom-row" id="loraCustomRow" style="display: none;">
Choose how many cards to display per row: <input type="text" id="loraCustomTemplate" class="template-custom-input" placeholder="Enter custom template (e.g., {base_model}/{author}/{first_tag})" />
<ul class="list-description"> <div class="template-validation" id="loraValidation"></div>
<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>
<div class="template-preview" id="loraPreview"></div>
</div> </div>
<!-- Add Card Info Display setting --> <!-- Checkpoint Template Configuration -->
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<label for="cardInfoDisplay">Card Info Display</label> <label for="checkpointTemplatePreset">Checkpoint</label>
</div> </div>
<div class="setting-control select-control"> <div class="setting-control select-control">
<select id="cardInfoDisplay" onchange="settingsManager.saveSelectSetting('cardInfoDisplay', 'card_info_display')"> <select id="checkpointTemplatePreset" onchange="settingsManager.updateTemplatePreset('checkpoint', this.value)">
<option value="always">Always Visible</option> <option value="">Flat Structure</option>
<option value="hover">Reveal on Hover</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> </select>
</div> </div>
</div> </div>
<div class="input-help"> <div class="template-custom-row" id="checkpointCustomRow" style="display: none;">
Choose when to display model information and action buttons: <input type="text" id="checkpointCustomTemplate" class="template-custom-input" placeholder="Enter custom template (e.g., {base_model}/{author}/{first_tag})" />
<ul class="list-description"> <div class="template-validation" id="checkpointValidation"></div>
<li><strong>Always Visible:</strong> Headers and footers are always visible</li> </div>
<li><strong>Reveal on Hover:</strong> Headers and footers only appear when hovering over a card</li> <div class="template-preview" id="checkpointPreview"></div>
</ul> </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> </div>
</div> </div>
<!-- Add Example Images Settings Section --> <!-- Add Example Images Settings Section -->
<div class="settings-section"> <div class="settings-section">
<h3>Example Images</h3> <h3>Example Images</h3>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 162 KiB