Files
ComfyUI-Lora-Manager/static/js/managers/SettingsManager.js
Will Miao 5e1cf68bbd fix(settings): sync loraSyntaxFormat select value from state on modal open (#917)
was missing the line to set the
select element's value from ,
causing the dropdown to always show the first option ("Full Path")
when reopening the settings modal, regardless of the persisted value.
Runtime behavior was unaffected since  reads from
the state directly.
2026-05-25 21:35:15 +08:00

2920 lines
112 KiB
JavaScript

import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js';
import { state, createDefaultSettings } from '../state/index.js';
import { resetAndReload } from '../api/modelApiFactory.js';
import {
DOWNLOAD_PATH_TEMPLATES,
MAPPABLE_BASE_MODELS,
PATH_TEMPLATE_PLACEHOLDERS,
DEFAULT_PATH_TEMPLATES,
DEFAULT_PRIORITY_TAG_CONFIG,
getMappableBaseModelsDynamic
} from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js';
import { i18n } from '../i18n/index.js';
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
import { bannerService } from './BannerService.js';
import { sidebarManager } from '../components/SidebarManager.js';
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
export class SettingsManager {
constructor() {
this.initialized = false;
this.isOpen = false;
this.initializationPromise = null;
this.availableLibraries = {};
this.activeLibrary = '';
this.registeredStartupBannerIds = new Set();
// Add initialization to sync with modal state
this.currentPage = document.body.dataset.page || 'loras';
this.backendSettingKeys = new Set(Object.keys(createDefaultSettings()));
// Start initialization but don't await here to avoid blocking constructor
this.initializationPromise = this.initializeSettings();
this.initialize();
}
// Add method to wait for initialization to complete
async waitForInitialization() {
if (this.initializationPromise) {
await this.initializationPromise;
}
}
async initializeSettings() {
// Reset to defaults before syncing
state.global.settings = createDefaultSettings();
// Sync settings from backend to frontend
await this.syncSettingsFromBackend();
}
async syncSettingsFromBackend() {
try {
const response = await fetch('/api/lm/settings');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.success && data.settings) {
state.global.settings = this.mergeSettingsWithDefaults(data.settings);
this.registerStartupMessages(data.messages);
console.log('Settings synced from backend');
} else {
console.error('Failed to sync settings from backend:', data.error);
state.global.settings = this.mergeSettingsWithDefaults();
this.registerStartupMessages(data?.messages);
}
} catch (error) {
console.error('Failed to sync settings from backend:', error);
state.global.settings = this.mergeSettingsWithDefaults();
this.registerStartupMessages();
}
await this.applyLanguageSetting();
this.applyFrontendSettings();
}
async applyLanguageSetting() {
const desiredLanguage = state?.global?.settings?.language;
if (!desiredLanguage) {
return;
}
try {
if (i18n.getCurrentLocale() !== desiredLanguage) {
await i18n.setLanguage(desiredLanguage);
}
} catch (error) {
console.warn('Failed to apply language from settings:', error);
}
}
mergeSettingsWithDefaults(backendSettings = {}) {
const defaults = createDefaultSettings();
const merged = { ...defaults, ...backendSettings };
const baseMappings = backendSettings?.base_model_path_mappings;
if (baseMappings && typeof baseMappings === 'object' && !Array.isArray(baseMappings)) {
merged.base_model_path_mappings = baseMappings;
} else {
merged.base_model_path_mappings = defaults.base_model_path_mappings;
}
let templates = backendSettings?.download_path_templates;
if (typeof templates === 'string') {
try {
const parsed = JSON.parse(templates);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
templates = parsed;
}
} catch (parseError) {
console.warn('Failed to parse download_path_templates string from backend, using defaults');
templates = null;
}
}
if (!templates || typeof templates !== 'object' || Array.isArray(templates)) {
templates = {};
}
merged.download_path_templates = { ...DEFAULT_PATH_TEMPLATES, ...templates };
const priorityTags = backendSettings?.priority_tags;
const normalizedPriority = { ...DEFAULT_PRIORITY_TAG_CONFIG };
if (priorityTags && typeof priorityTags === 'object' && !Array.isArray(priorityTags)) {
Object.entries(priorityTags).forEach(([modelType, configValue]) => {
if (typeof configValue === 'string') {
normalizedPriority[modelType] = configValue.trim();
}
});
}
merged.priority_tags = normalizedPriority;
merged.auto_organize_exclusions = this.normalizePatternList(
backendSettings?.auto_organize_exclusions ?? defaults.auto_organize_exclusions
);
merged.metadata_refresh_skip_paths = this.normalizePatternList(
backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths
);
merged.skip_previously_downloaded_model_versions =
backendSettings?.skip_previously_downloaded_model_versions
?? defaults.skip_previously_downloaded_model_versions;
merged.download_skip_base_models = this.normalizeDownloadSkipBaseModels(
backendSettings?.download_skip_base_models ?? defaults.download_skip_base_models
);
merged.mature_blur_level = this.normalizeMatureBlurLevel(
backendSettings?.mature_blur_level ?? defaults.mature_blur_level
);
Object.keys(merged).forEach(key => this.backendSettingKeys.add(key));
return merged;
}
normalizeMatureBlurLevel(value) {
if (typeof value === 'string') {
const normalized = value.trim().toUpperCase();
if (VALID_MATURE_BLUR_LEVELS.has(normalized)) {
return normalized;
}
}
return 'R';
}
normalizePatternList(value) {
if (Array.isArray(value)) {
const sanitized = value
.map(item => typeof item === 'string' ? item.trim() : '')
.filter(Boolean);
return [...new Set(sanitized)];
}
if (typeof value === 'string') {
const sanitized = value
.replace(/\n/g, ',')
.replace(/;/g, ',')
.split(',')
.map(part => part.trim())
.filter(Boolean);
return [...new Set(sanitized)];
}
return [];
}
getAvailableDownloadSkipBaseModels() {
// Use dynamic base models if available, fallback to hardcoded
const models = getMappableBaseModelsDynamic();
return models.filter(model => model !== 'Other');
}
normalizeDownloadSkipBaseModels(value) {
const allowed = new Set(this.getAvailableDownloadSkipBaseModels());
return this.normalizePatternList(value).filter(model => allowed.has(model));
}
registerStartupMessages(messages = []) {
if (!Array.isArray(messages) || messages.length === 0) {
return;
}
const severityPriority = {
error: 90,
warning: 60,
info: 30,
};
messages.forEach((message, index) => {
if (!message || typeof message !== 'object') {
return;
}
const bannerId = `startup-${message.code || index}`;
if (this.registeredStartupBannerIds.has(bannerId)) {
return;
}
const severity = (message.severity || 'info').toLowerCase();
const bannerTitle = message.title || 'Configuration notice';
const bannerContent = message.message || message.content || '';
const priority = typeof message.priority === 'number'
? message.priority
: severityPriority[severity] || severityPriority.info;
const dismissible = message.dismissible !== false;
const normalizedActions = Array.isArray(message.actions)
? message.actions.map(action => ({
text: action.label || action.text || 'Review settings',
icon: action.icon || 'fas fa-cog',
action: action.action,
type: action.type || 'primary',
url: action.url,
}))
: [];
bannerService.registerBanner(bannerId, {
id: bannerId,
title: bannerTitle,
content: bannerContent,
actions: normalizedActions,
dismissible,
priority,
onRegister: (bannerElement) => {
normalizedActions.forEach(action => {
if (!action.action) {
return;
}
const button = bannerElement.querySelector(`.banner-action[data-action="${action.action}"]`);
if (button) {
button.addEventListener('click', (event) => {
event.preventDefault();
this.handleStartupBannerAction(action.action);
});
}
});
},
});
this.registeredStartupBannerIds.add(bannerId);
});
}
handleStartupBannerAction(action) {
switch (action) {
case 'open-settings-modal':
modalManager.showModal('settingsModal');
break;
case 'open-settings-location':
this.openSettingsFileLocation();
break;
default:
console.warn('Unhandled startup banner action:', action);
}
}
// Helper method to determine if a setting should be saved to backend
isBackendSetting(settingKey) {
return this.backendSettingKeys.has(settingKey);
}
// Helper method to save setting based on whether it's frontend or backend
async saveSetting(settingKey, value) {
// Update state
state.global.settings[settingKey] = value;
if (!this.isBackendSetting(settingKey)) {
return;
}
// Save to backend
try {
const payload = {};
payload[settingKey] = value;
const response = await fetch('/api/lm/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error('Failed to save setting to backend');
}
// Parse response and check for success
const data = await response.json();
if (data.success === false) {
throw new Error(data.error || 'Failed to save setting to backend');
}
} catch (error) {
console.error(`Failed to save backend setting ${settingKey}:`, error);
throw error;
}
}
initialize() {
if (this.initialized) return;
// Add event listener to sync state when modal is closed via other means (like Escape key)
const settingsModal = document.getElementById('settingsModal');
if (settingsModal) {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
this.isOpen = settingsModal.style.display === 'block';
// When modal is opened, update checkbox state from current settings
if (this.isOpen) {
this.loadSettingsToUI();
}
}
});
});
observer.observe(settingsModal, { attributes: true });
}
// Add event listeners for all toggle-visibility buttons
document.querySelectorAll('.toggle-visibility').forEach(button => {
button.addEventListener('click', () => this.toggleInputVisibility(button));
});
const openSettingsLocationButton = document.querySelector('.settings-open-location-trigger');
if (openSettingsLocationButton) {
openSettingsLocationButton.addEventListener('click', () => {
this.openSettingsFileLocation();
});
}
const openBackupLocationButton = document.getElementById('backupOpenLocationBtn');
if (openBackupLocationButton) {
openBackupLocationButton.addEventListener('click', () => {
this.openBackupLocation();
});
}
['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();
}
});
}
});
const autoOrganizeInput = document.getElementById('autoOrganizeExclusions');
if (autoOrganizeInput) {
autoOrganizeInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.saveAutoOrganizeExclusions();
}
});
}
const metadataRefreshSkipPathsInput = document.getElementById('metadataRefreshSkipPaths');
if (metadataRefreshSkipPathsInput) {
metadataRefreshSkipPathsInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.saveMetadataRefreshSkipPaths();
}
});
}
const downloadSkipBaseModelsContainer = document.getElementById('downloadSkipBaseModelsContainer');
if (downloadSkipBaseModelsContainer) {
downloadSkipBaseModelsContainer.addEventListener('change', (event) => {
if (event.target instanceof HTMLInputElement && event.target.name === 'downloadSkipBaseModel') {
this.saveDownloadSkipBaseModels();
}
});
}
const downloadSkipBaseModelsToggle = document.getElementById('downloadSkipBaseModelsToggle');
if (downloadSkipBaseModelsToggle) {
downloadSkipBaseModelsToggle.addEventListener('click', () => {
this.toggleDownloadSkipBaseModelsPanel();
});
}
const downloadSkipBaseModelsSearch = document.getElementById('downloadSkipBaseModelsSearch');
if (downloadSkipBaseModelsSearch) {
downloadSkipBaseModelsSearch.addEventListener('input', () => {
this.renderDownloadSkipBaseModels();
});
}
const downloadSkipBaseModelsClear = document.getElementById('downloadSkipBaseModelsClear');
if (downloadSkipBaseModelsClear) {
downloadSkipBaseModelsClear.addEventListener('click', () => {
this.clearDownloadSkipBaseModels();
});
}
this.setupPriorityTagInputs();
this.initializeNavigation();
this.initializeSearch();
this.initialized = true;
}
initializeNavigation() {
const navItems = document.querySelectorAll('.settings-nav-item');
const sections = document.querySelectorAll('.settings-section');
if (navItems.length === 0 || sections.length === 0) return;
// Handle navigation item clicks - macOS Settings style: show section instead of scroll
navItems.forEach(item => {
item.addEventListener('click', (e) => {
const sectionId = item.dataset.section;
if (!sectionId) return;
// Hide all sections
sections.forEach(section => {
section.classList.remove('active');
});
// Show target section
const targetSection = document.getElementById(`section-${sectionId}`);
if (targetSection) {
targetSection.classList.add('active');
}
// Update active nav state
navItems.forEach(nav => nav.classList.remove('active'));
item.classList.add('active');
});
});
// Show first section by default
const firstSection = sections[0];
if (firstSection) {
firstSection.classList.add('active');
}
}
initializeSearch() {
const searchInput = document.getElementById('settingsSearchInput');
const searchClear = document.getElementById('settingsSearchClear');
if (!searchInput) return;
// Debounced search handler
let searchTimeout;
const debouncedSearch = (query) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.performSearch(query);
}, 150);
};
// Handle input changes
searchInput.addEventListener('input', (e) => {
const query = e.target.value.trim();
// Show/hide clear button
if (searchClear) {
searchClear.style.display = query ? 'flex' : 'none';
}
debouncedSearch(query);
});
// Handle clear button click
if (searchClear) {
searchClear.addEventListener('click', () => {
searchInput.value = '';
searchClear.style.display = 'none';
searchInput.focus();
this.performSearch('');
});
}
// Handle Escape key to clear search
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (searchInput.value) {
searchInput.value = '';
if (searchClear) searchClear.style.display = 'none';
this.performSearch('');
}
}
});
}
performSearch(query) {
const sections = document.querySelectorAll('.settings-section');
const navItems = document.querySelectorAll('.settings-nav-item');
const settingsForm = document.querySelector('.settings-form');
// Remove existing empty state
const existingEmptyState = settingsForm?.querySelector('.settings-search-empty');
if (existingEmptyState) {
existingEmptyState.remove();
}
if (!query) {
// Reset: remove highlights only, keep current section visible
sections.forEach(section => {
this.removeSearchHighlights(section);
});
return;
}
const lowerQuery = query.toLowerCase();
let firstMatchSection = null;
let firstMatchElement = null;
let matchCount = 0;
sections.forEach(section => {
const sectionText = this.getSectionSearchableText(section);
const hasMatch = sectionText.includes(lowerQuery);
if (hasMatch) {
const firstHighlight = this.highlightSearchMatches(section, lowerQuery);
matchCount++;
// Track first match to auto-switch
if (!firstMatchSection) {
firstMatchSection = section;
firstMatchElement = firstHighlight;
}
} else {
this.removeSearchHighlights(section);
}
});
// Auto-switch to first matching section
if (firstMatchSection) {
const sectionId = firstMatchSection.id.replace('section-', '');
// Hide all sections
sections.forEach(section => section.classList.remove('active'));
// Show matching section
firstMatchSection.classList.add('active');
// Update nav active state
navItems.forEach(item => {
item.classList.remove('active');
if (item.dataset.section === sectionId) {
item.classList.add('active');
}
});
// Scroll to first match after a short delay to allow section to become visible
if (firstMatchElement) {
requestAnimationFrame(() => {
firstMatchElement.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
});
}
}
// Show empty state if no matches found
if (matchCount === 0 && settingsForm) {
const emptyState = document.createElement('div');
emptyState.className = 'settings-search-empty';
emptyState.innerHTML = `
<i class="fas fa-search"></i>
<p>${translate('settings.search.noResults', { query }, `No settings found matching "${query}"`)}</p>
`;
settingsForm.appendChild(emptyState);
}
}
getSectionSearchableText(section) {
// Get all text content from labels, help text, and headers
const labels = section.querySelectorAll('label');
const helpTexts = section.querySelectorAll('.input-help');
const headers = section.querySelectorAll('h3');
let text = '';
labels.forEach(el => text += ' ' + el.textContent);
helpTexts.forEach(el => text += ' ' + el.textContent);
headers.forEach(el => text += ' ' + el.textContent);
return text.toLowerCase();
}
highlightSearchMatches(section, query) {
// Remove existing highlights first
this.removeSearchHighlights(section);
if (!query) return null;
// Highlight in labels and help text
const textElements = section.querySelectorAll('label, .input-help, h3');
let firstHighlight = null;
textElements.forEach(element => {
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
null,
false
);
const textNodes = [];
let node;
while (node = walker.nextNode()) {
if (node.textContent.toLowerCase().includes(query)) {
textNodes.push(node);
}
}
textNodes.forEach(textNode => {
const parent = textNode.parentElement;
const text = textNode.textContent;
const lowerText = text.toLowerCase();
// Split text by query and wrap matches in highlight spans
const parts = [];
let lastIndex = 0;
let index;
while ((index = lowerText.indexOf(query, lastIndex)) !== -1) {
// Add text before match
if (index > lastIndex) {
parts.push(document.createTextNode(text.substring(lastIndex, index)));
}
// Add highlighted match
const highlight = document.createElement('span');
highlight.className = 'settings-search-highlight';
highlight.textContent = text.substring(index, index + query.length);
parts.push(highlight);
// Track first highlight for scrolling
if (!firstHighlight) {
firstHighlight = highlight;
}
lastIndex = index + query.length;
}
// Add remaining text
if (lastIndex < text.length) {
parts.push(document.createTextNode(text.substring(lastIndex)));
}
// Replace original text node with highlighted version
if (parts.length > 1) {
parts.forEach(part => parent.insertBefore(part, textNode));
parent.removeChild(textNode);
}
});
});
return firstHighlight;
}
removeSearchHighlights(section) {
const highlights = section.querySelectorAll('.settings-search-highlight');
highlights.forEach(highlight => {
const parent = highlight.parentElement;
if (parent) {
// Replace highlight with its text content
parent.insertBefore(document.createTextNode(highlight.textContent), highlight);
parent.removeChild(highlight);
// Normalize to merge adjacent text nodes
parent.normalize();
}
});
}
async openSettingsFileLocation() {
try {
const response = await fetch('/api/lm/settings/open-location', {
method: 'POST'
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.json();
if (data.mode === 'clipboard' && data.path) {
try {
await navigator.clipboard.writeText(data.path);
showToast('settings.openSettingsFileLocation.copied', { path: data.path }, 'success');
} catch (clipboardErr) {
console.warn('Clipboard API not available:', clipboardErr);
showToast('settings.openSettingsFileLocation.clipboardFallback', { path: data.path }, 'info');
}
} else {
showToast('settings.openSettingsFileLocation.success', {}, 'success');
}
} catch (error) {
console.error('Failed to open settings file location:', error);
showToast('settings.openSettingsFileLocation.failed', {}, 'error');
}
}
async openBackupLocation() {
try {
const response = await fetch('/api/lm/backup/open-location', {
method: 'POST'
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.json();
if (data.mode === 'clipboard' && data.path) {
try {
await navigator.clipboard.writeText(data.path);
showToast('settings.backup.locationCopied', { path: data.path }, 'success');
} catch (clipboardErr) {
console.warn('Clipboard API not available:', clipboardErr);
showToast('settings.backup.locationClipboardFallback', { path: data.path }, 'info');
}
} else {
showToast('settings.backup.openFolderSuccess', {}, 'success');
}
} catch (error) {
console.error('Failed to open backup folder:', error);
showToast('settings.backup.openFolderFailed', {}, 'error');
}
}
async loadSettingsToUI() {
// Set frontend settings from state
const blurMatureContentCheckbox = document.getElementById('blurMatureContent');
if (blurMatureContentCheckbox) {
blurMatureContentCheckbox.checked = state.global.settings.blur_mature_content ?? true;
}
const showOnlySFWCheckbox = document.getElementById('showOnlySFW');
if (showOnlySFWCheckbox) {
showOnlySFWCheckbox.checked = state.global.settings.show_only_sfw ?? false;
}
const matureBlurLevelSelect = document.getElementById('matureBlurLevel');
if (matureBlurLevelSelect) {
matureBlurLevelSelect.value = this.normalizeMatureBlurLevel(
state.global.settings.mature_blur_level
);
}
const usePortableCheckbox = document.getElementById('usePortableSettings');
if (usePortableCheckbox) {
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
}
const civitaiHostSelect = document.getElementById('civitaiHost');
if (civitaiHostSelect) {
civitaiHostSelect.value = state.global.settings.civitai_host || 'civitai.com';
}
const downloadBackendSelect = document.getElementById('downloadBackend');
if (downloadBackendSelect) {
downloadBackendSelect.value = state.global.settings.download_backend || 'python';
}
const aria2cPathInput = document.getElementById('aria2cPath');
if (aria2cPathInput) {
aria2cPathInput.value = state.global.settings.aria2c_path || '';
}
const recipesPathInput = document.getElementById('recipesPath');
if (recipesPathInput) {
recipesPathInput.value = state.global.settings.recipes_path || '';
}
const autoOrganizeExclusionsInput = document.getElementById('autoOrganizeExclusions');
if (autoOrganizeExclusionsInput) {
const patterns = this.normalizePatternList(state.global.settings.auto_organize_exclusions);
autoOrganizeExclusionsInput.value = patterns.join(', ');
}
const autoOrganizeExclusionsError = document.getElementById('autoOrganizeExclusionsError');
if (autoOrganizeExclusionsError) {
autoOrganizeExclusionsError.textContent = '';
}
const metadataRefreshSkipPathsInput = document.getElementById('metadataRefreshSkipPaths');
if (metadataRefreshSkipPathsInput) {
const skipPaths = this.normalizePatternList(state.global.settings.metadata_refresh_skip_paths);
metadataRefreshSkipPathsInput.value = skipPaths.join(', ');
}
const metadataRefreshSkipPathsError = document.getElementById('metadataRefreshSkipPathsError');
if (metadataRefreshSkipPathsError) {
metadataRefreshSkipPathsError.textContent = '';
}
this.renderDownloadSkipBaseModels();
const downloadSkipBaseModelsError = document.getElementById('downloadSkipBaseModelsError');
if (downloadSkipBaseModelsError) {
downloadSkipBaseModelsError.textContent = '';
}
this.setDownloadSkipBaseModelsPanelOpen(false);
// Set video autoplay on hover setting
const autoplayOnHoverCheckbox = document.getElementById('autoplayOnHover');
if (autoplayOnHoverCheckbox) {
autoplayOnHoverCheckbox.checked = state.global.settings.autoplay_on_hover || false;
}
// Set display density setting
const displayDensitySelect = document.getElementById('displayDensity');
if (displayDensitySelect) {
displayDensitySelect.value = state.global.settings.display_density || 'default';
}
// Set card info display setting
const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay');
if (cardInfoDisplaySelect) {
cardInfoDisplaySelect.value = state.global.settings.card_info_display || 'always';
}
const showFolderSidebarCheckbox = document.getElementById('showFolderSidebar');
if (showFolderSidebarCheckbox) {
const showSidebarSetting = state.global.settings.show_folder_sidebar;
showFolderSidebarCheckbox.checked = showSidebarSetting !== false;
}
// Set model card footer action
const modelCardFooterActionSelect = document.getElementById('modelCardFooterAction');
if (modelCardFooterActionSelect) {
modelCardFooterActionSelect.value = state.global.settings.model_card_footer_action || 'example_images';
}
// Set show version on card
const showVersionOnCardCheckbox = document.getElementById('showVersionOnCard');
if (showVersionOnCardCheckbox) {
showVersionOnCardCheckbox.checked = state.global.settings.show_version_on_card !== false;
}
// Set model name display setting
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
if (modelNameDisplaySelect) {
modelNameDisplaySelect.value = state.global.settings.model_name_display || 'model_name';
}
const updateFlagStrategySelect = document.getElementById('updateFlagStrategy');
if (updateFlagStrategySelect) {
updateFlagStrategySelect.value = state.global.settings.update_flag_strategy || 'same_base';
}
// Set hide early access updates setting
const hideEarlyAccessUpdatesCheckbox = document.getElementById('hideEarlyAccessUpdates');
if (hideEarlyAccessUpdatesCheckbox) {
hideEarlyAccessUpdatesCheckbox.checked = state.global.settings.hide_early_access_updates || false;
}
const skipPreviouslyDownloadedModelVersionsCheckbox = document.getElementById('skipPreviouslyDownloadedModelVersions');
if (skipPreviouslyDownloadedModelVersionsCheckbox) {
skipPreviouslyDownloadedModelVersionsCheckbox.checked =
state.global.settings.skip_previously_downloaded_model_versions || false;
}
// Set optimize example images setting
const optimizeExampleImagesCheckbox = document.getElementById('optimizeExampleImages');
if (optimizeExampleImagesCheckbox) {
optimizeExampleImagesCheckbox.checked = state.global.settings.optimize_example_images ?? true;
}
// Set auto download example images setting
const autoDownloadExampleImagesCheckbox = document.getElementById('autoDownloadExampleImages');
if (autoDownloadExampleImagesCheckbox) {
autoDownloadExampleImagesCheckbox.checked = state.global.settings.auto_download_example_images || false;
}
const exampleImagesOpenModeSelect = document.getElementById('exampleImagesOpenMode');
if (exampleImagesOpenModeSelect) {
exampleImagesOpenModeSelect.value = state.global.settings.example_images_open_mode || 'system';
}
const exampleImagesLocalRootInput = document.getElementById('exampleImagesLocalRoot');
if (exampleImagesLocalRootInput) {
exampleImagesLocalRootInput.value = state.global.settings.example_images_local_root || '';
}
const exampleImagesOpenUriTemplateInput = document.getElementById('exampleImagesOpenUriTemplate');
if (exampleImagesOpenUriTemplateInput) {
exampleImagesOpenUriTemplateInput.value = state.global.settings.example_images_open_uri_template || '';
}
this.updateExampleImagesOpenSettingsVisibility();
// Load download path templates
this.loadDownloadPathTemplates();
// Load priority tag settings
this.loadPriorityTagSettings();
// Set include trigger words setting
const includeTriggerWordsCheckbox = document.getElementById('includeTriggerWords');
if (includeTriggerWordsCheckbox) {
includeTriggerWordsCheckbox.checked = state.global.settings.include_trigger_words || false;
}
// Set lora syntax format
const loraSyntaxFormatSelect = document.getElementById('loraSyntaxFormat');
if (loraSyntaxFormatSelect) {
loraSyntaxFormatSelect.value = state.global.settings.lora_syntax_format || 'legacy';
}
// Load metadata archive settings
await this.loadMetadataArchiveSettings();
// Load backup settings
await this.loadBackupSettings();
// Load base model path mappings
this.loadBaseModelMappings();
// Load library options
await this.loadLibraries();
// Load default lora root
await this.loadLoraRoots();
// Load default checkpoint root
await this.loadCheckpointRoots();
// Load default embedding root
await this.loadEmbeddingRoots();
// Load default unet root
await this.loadUnetRoots();
// Load extra folder paths
this.loadExtraFolderPaths();
// Load language setting
const languageSelect = document.getElementById('languageSelect');
if (languageSelect) {
const currentLanguage = state.global.settings.language || 'en';
languageSelect.value = currentLanguage;
}
this.loadDownloadBackendSettings();
this.loadProxySettings();
}
loadDownloadBackendSettings() {
const downloadBackendSelect = document.getElementById('downloadBackend');
const aria2PathSetting = document.getElementById('aria2PathSetting');
const updateVisibility = () => {
if (!aria2PathSetting || !downloadBackendSelect) {
return;
}
aria2PathSetting.style.display = downloadBackendSelect.value === 'aria2' ? 'block' : 'none';
};
if (downloadBackendSelect) {
downloadBackendSelect.value = state.global.settings.download_backend || 'python';
downloadBackendSelect.onchange = () => {
updateVisibility();
this.saveSelectSetting('downloadBackend', 'download_backend');
};
}
const aria2cPathInput = document.getElementById('aria2cPath');
if (aria2cPathInput) {
aria2cPathInput.value = state.global.settings.aria2c_path || '';
}
updateVisibility();
}
setupPriorityTagInputs() {
['lora', 'checkpoint', 'embedding'].forEach((modelType) => {
const textarea = document.getElementById(`${modelType}PriorityTagsInput`);
if (!textarea) {
return;
}
textarea.addEventListener('input', () => this.handlePriorityTagInput(modelType));
textarea.addEventListener('blur', () => this.handlePriorityTagSave(modelType));
textarea.addEventListener('keydown', (event) => this.handlePriorityTagKeyDown(event, modelType));
});
}
loadPriorityTagSettings() {
const priorityConfig = state.global.settings.priority_tags || {};
['lora', 'checkpoint', 'embedding'].forEach((modelType) => {
const textarea = document.getElementById(`${modelType}PriorityTagsInput`);
if (!textarea) {
return;
}
const storedValue = priorityConfig[modelType] ?? DEFAULT_PRIORITY_TAG_CONFIG[modelType] ?? '';
textarea.value = storedValue;
this.displayPriorityTagValidation(modelType, true, []);
});
}
handlePriorityTagInput(modelType) {
const textarea = document.getElementById(`${modelType}PriorityTagsInput`);
if (!textarea) {
return;
}
const validation = validatePriorityTagString(textarea.value);
this.displayPriorityTagValidation(modelType, validation.valid, validation.errors);
}
handlePriorityTagKeyDown(event, modelType) {
if (event.key !== 'Enter') {
return;
}
if (event.shiftKey) {
return;
}
event.preventDefault();
this.handlePriorityTagSave(modelType);
}
async handlePriorityTagSave(modelType) {
const textarea = document.getElementById(`${modelType}PriorityTagsInput`);
if (!textarea) {
return;
}
const validation = validatePriorityTagString(textarea.value);
if (!validation.valid) {
this.displayPriorityTagValidation(modelType, false, validation.errors);
return;
}
const sanitized = validation.formatted;
const currentValue = state.global.settings.priority_tags?.[modelType] || '';
this.displayPriorityTagValidation(modelType, true, []);
if (sanitized === currentValue) {
textarea.value = sanitized;
return;
}
const updatedConfig = {
...state.global.settings.priority_tags,
[modelType]: sanitized,
};
try {
textarea.value = sanitized;
await this.saveSetting('priority_tags', updatedConfig);
showToast('settings.priorityTags.saveSuccess', {}, 'success');
await this.refreshPriorityTagSuggestions();
} catch (error) {
console.error('Failed to save priority tag configuration:', error);
showToast('settings.priorityTags.saveError', {}, 'error');
}
}
displayPriorityTagValidation(modelType, isValid, errors = []) {
const textarea = document.getElementById(`${modelType}PriorityTagsInput`);
const errorElement = document.getElementById(`${modelType}PriorityTagsError`);
if (!textarea) {
return;
}
if (isValid || errors.length === 0) {
textarea.classList.remove('settings-input-error');
if (errorElement) {
errorElement.textContent = '';
errorElement.style.display = 'none';
}
return;
}
textarea.classList.add('settings-input-error');
if (errorElement) {
const message = this.getPriorityTagErrorMessage(errors[0]);
errorElement.textContent = message;
errorElement.style.display = 'block';
}
}
getPriorityTagErrorMessage(error) {
if (!error) {
return '';
}
const entryIndex = error.index ?? 0;
switch (error.type) {
case 'missingClosingParen':
return translate('settings.priorityTags.validation.missingClosingParen', { index: entryIndex }, `Entry ${entryIndex} is missing a closing parenthesis.`);
case 'missingCanonical':
return translate('settings.priorityTags.validation.missingCanonical', { index: entryIndex }, `Entry ${entryIndex} must include a canonical tag.`);
case 'duplicateCanonical':
return translate('settings.priorityTags.validation.duplicateCanonical', { tag: error.canonical }, `The canonical tag "${error.canonical}" is duplicated.`);
default:
return translate('settings.priorityTags.validation.unknown', {}, 'Invalid priority tag configuration.');
}
}
async refreshPriorityTagSuggestions() {
invalidatePriorityTagSuggestionsCache();
try {
await getPriorityTagSuggestionsMap();
window.dispatchEvent(new CustomEvent('lm:priority-tags-updated'));
} catch (error) {
console.warn('Failed to refresh priority tag suggestions:', error);
}
}
loadProxySettings() {
// Load proxy enabled setting
const proxyEnabledCheckbox = document.getElementById('proxyEnabled');
if (proxyEnabledCheckbox) {
proxyEnabledCheckbox.checked = state.global.settings.proxy_enabled || false;
// Add event listener for toggling proxy settings group visibility
proxyEnabledCheckbox.addEventListener('change', () => {
const proxySettingsGroup = document.getElementById('proxySettingsGroup');
if (proxySettingsGroup) {
proxySettingsGroup.style.display = proxyEnabledCheckbox.checked ? 'block' : 'none';
}
});
// Set initial visibility
const proxySettingsGroup = document.getElementById('proxySettingsGroup');
if (proxySettingsGroup) {
proxySettingsGroup.style.display = proxyEnabledCheckbox.checked ? 'block' : 'none';
}
}
// Load proxy type
const proxyTypeSelect = document.getElementById('proxyType');
if (proxyTypeSelect) {
proxyTypeSelect.value = state.global.settings.proxy_type || 'http';
}
// Load proxy host
const proxyHostInput = document.getElementById('proxyHost');
if (proxyHostInput) {
proxyHostInput.value = state.global.settings.proxy_host || '';
}
// Load proxy port
const proxyPortInput = document.getElementById('proxyPort');
if (proxyPortInput) {
proxyPortInput.value = state.global.settings.proxy_port || '';
}
// Load proxy username
const proxyUsernameInput = document.getElementById('proxyUsername');
if (proxyUsernameInput) {
proxyUsernameInput.value = state.global.settings.proxy_username || '';
}
// Load proxy password
const proxyPasswordInput = document.getElementById('proxyPassword');
if (proxyPasswordInput) {
proxyPasswordInput.value = state.global.settings.proxy_password || '';
}
}
async loadLibraries() {
const librarySelect = document.getElementById('librarySelect');
if (!librarySelect) {
return;
}
const setPlaceholderOption = (textKey, fallback) => {
librarySelect.innerHTML = '';
const option = document.createElement('option');
option.value = '';
option.textContent = translate(textKey, {}, fallback);
librarySelect.appendChild(option);
};
setPlaceholderOption('settings.folderSettings.loadingLibraries', 'Loading libraries...');
librarySelect.disabled = true;
try {
const response = await fetch('/api/lm/settings/libraries');
if (!response.ok) {
throw new Error('Failed to fetch library registry');
}
const data = await response.json();
if (data.success === false) {
throw new Error(data.error || 'Failed to fetch library registry');
}
const libraries = data.libraries && typeof data.libraries === 'object'
? data.libraries
: {};
this.availableLibraries = libraries;
const entries = Object.entries(libraries);
if (entries.length === 0) {
this.activeLibrary = '';
setPlaceholderOption('settings.folderSettings.noLibraries', 'No libraries configured');
return;
}
const activeName = data.active_library && libraries[data.active_library]
? data.active_library
: entries[0][0];
this.activeLibrary = activeName;
librarySelect.innerHTML = '';
const fragment = document.createDocumentFragment();
entries
.sort((a, b) => {
const nameA = this.getLibraryDisplayName(a[0], a[1]).toLowerCase();
const nameB = this.getLibraryDisplayName(b[0], b[1]).toLowerCase();
return nameA.localeCompare(nameB);
})
.forEach(([name, info]) => {
const option = document.createElement('option');
option.value = name;
option.textContent = this.getLibraryDisplayName(name, info);
fragment.appendChild(option);
});
librarySelect.appendChild(fragment);
librarySelect.value = activeName;
librarySelect.disabled = entries.length <= 1;
} catch (error) {
console.error('Error loading libraries:', error);
setPlaceholderOption('settings.folderSettings.noLibraries', 'No libraries configured');
this.availableLibraries = {};
this.activeLibrary = '';
librarySelect.disabled = true;
showToast('toast.settings.libraryLoadFailed', { message: error.message }, 'error');
}
}
getLibraryDisplayName(libraryName, libraryData = {}) {
if (libraryData && typeof libraryData === 'object') {
const metadata = libraryData.metadata;
if (metadata && typeof metadata === 'object' && metadata.display_name) {
return metadata.display_name;
}
if (libraryData.display_name) {
return libraryData.display_name;
}
}
return libraryName;
}
async handleLibraryChange() {
const librarySelect = document.getElementById('librarySelect');
if (!librarySelect) {
return;
}
const selectedLibrary = librarySelect.value;
if (!selectedLibrary || selectedLibrary === this.activeLibrary) {
librarySelect.value = this.activeLibrary;
return;
}
librarySelect.disabled = true;
try {
state.loadingManager.showSimpleLoading('Activating library...');
await this.activateLibrary(selectedLibrary);
// Add a short delay before reloading the page
await new Promise(resolve => setTimeout(resolve, 300));
window.location.reload();
} catch (error) {
console.error('Failed to activate library:', error);
showToast('toast.settings.libraryActivateFailed', { message: error.message }, 'error');
await this.loadLibraries();
} finally {
state.loadingManager.hide();
if (!document.hidden) {
librarySelect.disabled = librarySelect.options.length <= 1;
}
}
}
async activateLibrary(libraryName) {
const response = await fetch('/api/lm/settings/libraries/activate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ library: libraryName }),
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.json();
if (data.success === false) {
throw new Error(data.error || 'Failed to activate library');
}
const activeName = data.active_library || libraryName;
this.activeLibrary = activeName;
return data;
}
async loadLoraRoots() {
try {
const defaultLoraRootSelect = document.getElementById('defaultLoraRoot');
if (!defaultLoraRootSelect) return;
// Fetch lora roots
const response = await fetch('/api/lm/loras/roots');
if (!response.ok) {
throw new Error('Failed to fetch LoRA roots');
}
const data = await response.json();
if (!data.roots || data.roots.length === 0) {
throw new Error('No LoRA roots found');
}
defaultLoraRootSelect.innerHTML = '';
// Add options for each root
data.roots.forEach(root => {
const option = document.createElement('option');
option.value = root;
option.textContent = root;
defaultLoraRootSelect.appendChild(option);
});
const defaultRoot = state.global.settings.default_lora_root || '';
defaultLoraRootSelect.value = data.roots.includes(defaultRoot) ? defaultRoot : data.roots[0];
} catch (error) {
console.error('Error loading LoRA roots:', error);
showToast('toast.settings.loraRootsFailed', { message: error.message }, 'error');
}
}
async loadCheckpointRoots() {
try {
const defaultCheckpointRootSelect = document.getElementById('defaultCheckpointRoot');
if (!defaultCheckpointRootSelect) return;
// Fetch checkpoint roots (checkpoint paths only, not unet)
const response = await fetch('/api/lm/checkpoints/checkpoints_roots');
if (!response.ok) {
throw new Error('Failed to fetch checkpoint roots');
}
const data = await response.json();
if (!data.roots || data.roots.length === 0) {
throw new Error('No checkpoint roots found');
}
defaultCheckpointRootSelect.innerHTML = '';
// Add options for each root
data.roots.forEach(root => {
const option = document.createElement('option');
option.value = root;
option.textContent = root;
defaultCheckpointRootSelect.appendChild(option);
});
const defaultRoot = state.global.settings.default_checkpoint_root || '';
defaultCheckpointRootSelect.value = data.roots.includes(defaultRoot) ? defaultRoot : data.roots[0];
} catch (error) {
console.error('Error loading checkpoint roots:', error);
showToast('toast.settings.checkpointRootsFailed', { message: error.message }, 'error');
}
}
async loadUnetRoots() {
try {
const defaultUnetRootSelect = document.getElementById('defaultUnetRoot');
if (!defaultUnetRootSelect) return;
// Fetch unet roots (diffusion model paths only)
const response = await fetch('/api/lm/checkpoints/unet_roots');
if (!response.ok) {
throw new Error('Failed to fetch diffusion model roots');
}
const data = await response.json();
if (!data.roots || data.roots.length === 0) {
throw new Error('No diffusion model roots found');
}
defaultUnetRootSelect.innerHTML = '';
// Add options for each root
data.roots.forEach(root => {
const option = document.createElement('option');
option.value = root;
option.textContent = root;
defaultUnetRootSelect.appendChild(option);
});
const defaultRoot = state.global.settings.default_unet_root || '';
defaultUnetRootSelect.value = data.roots.includes(defaultRoot) ? defaultRoot : data.roots[0];
} catch (error) {
console.error('Error loading diffusion model roots:', error);
showToast('toast.settings.unetRootsFailed', { message: error.message }, 'error');
}
}
async loadEmbeddingRoots() {
try {
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
if (!defaultEmbeddingRootSelect) return;
// Fetch embedding roots
const response = await fetch('/api/lm/embeddings/roots');
if (!response.ok) {
throw new Error('Failed to fetch embedding roots');
}
const data = await response.json();
if (!data.roots || data.roots.length === 0) {
throw new Error('No embedding roots found');
}
defaultEmbeddingRootSelect.innerHTML = '';
// Add options for each root
data.roots.forEach(root => {
const option = document.createElement('option');
option.value = root;
option.textContent = root;
defaultEmbeddingRootSelect.appendChild(option);
});
const defaultRoot = state.global.settings.default_embedding_root || '';
defaultEmbeddingRootSelect.value = data.roots.includes(defaultRoot) ? defaultRoot : data.roots[0];
} catch (error) {
console.error('Error loading embedding roots:', error);
showToast('toast.settings.embeddingRootsFailed', { message: error.message }, 'error');
}
}
loadExtraFolderPaths() {
const extraFolderPaths = state.global.settings.extra_folder_paths || {};
// Load paths for each model type
['loras', 'checkpoints', 'unet', 'embeddings'].forEach((modelType) => {
const container = document.getElementById(`extraFolderPaths-${modelType}`);
if (!container) return;
// Clear existing paths
container.innerHTML = '';
// Add existing paths
const paths = extraFolderPaths[modelType] || [];
paths.forEach((path) => {
this.addExtraFolderPathRow(modelType, path);
});
// Add empty row for new path if no paths exist
if (paths.length === 0) {
this.addExtraFolderPathRow(modelType, '', false);
}
});
}
addExtraFolderPathRow(modelType, path = '', shouldFocus = true) {
const container = document.getElementById(`extraFolderPaths-${modelType}`);
if (!container) return;
const row = document.createElement('div');
row.className = 'extra-folder-path-row mapping-row';
row.innerHTML = `
<div class="path-controls">
<input type="text" class="extra-folder-path-input"
placeholder="${translate('settings.extraFolderPaths.pathPlaceholder', {}, '/path/to/models')}" value="${path}"
onblur="settingsManager.updateExtraFolderPaths('${modelType}')"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
<button type="button" class="remove-path-btn"
onclick="this.parentElement.parentElement.remove(); settingsManager.updateExtraFolderPaths('${modelType}')"
title="${translate('common.actions.delete', {}, 'Delete')}">
<i class="fas fa-times"></i>
</button>
</div>
`;
container.appendChild(row);
// Focus the input if it's empty (new row)
if (!path && shouldFocus) {
const input = row.querySelector('.extra-folder-path-input');
if (input) {
setTimeout(() => input.focus(), 0);
}
}
}
async updateExtraFolderPaths(changedModelType) {
const extraFolderPaths = {};
// Collect paths for all model types
['loras', 'checkpoints', 'unet', 'embeddings'].forEach((modelType) => {
const container = document.getElementById(`extraFolderPaths-${modelType}`);
if (!container) return;
const inputs = container.querySelectorAll('.extra-folder-path-input');
const paths = [];
inputs.forEach((input) => {
const value = input.value.trim();
if (value) {
paths.push(value);
}
});
extraFolderPaths[modelType] = paths;
});
// Check if paths have actually changed
const currentPaths = state.global.settings.extra_folder_paths || {};
const pathsChanged = JSON.stringify(currentPaths) !== JSON.stringify(extraFolderPaths);
if (!pathsChanged) {
return;
}
// Update state
state.global.settings.extra_folder_paths = extraFolderPaths;
try {
// Save to backend - this triggers path validation
await this.saveSetting('extra_folder_paths', extraFolderPaths);
showToast('settings.extraFolderPaths.saveSuccess', {}, 'success');
// Add empty row if no valid paths exist for the changed type
const container = document.getElementById(`extraFolderPaths-${changedModelType}`);
if (container) {
const inputs = container.querySelectorAll('.extra-folder-path-input');
const hasEmptyRow = Array.from(inputs).some((input) => !input.value.trim());
if (!hasEmptyRow) {
this.addExtraFolderPathRow(changedModelType, '');
}
}
} catch (error) {
console.error('Failed to save extra folder paths:', error);
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
// Restore previous state on error
state.global.settings.extra_folder_paths = currentPaths;
this.loadExtraFolderPaths();
}
}
loadBaseModelMappings() {
const mappingsContainer = document.getElementById('baseModelMappingsContainer');
if (!mappingsContainer) return;
const mappings = state.global.settings.base_model_path_mappings || {};
// Clear existing mappings
mappingsContainer.innerHTML = '';
// Add existing mappings
Object.entries(mappings).forEach(([baseModel, pathValue]) => {
this.addMappingRow(baseModel, pathValue);
});
// Add empty row for new mappings if none exist
if (Object.keys(mappings).length === 0) {
this.addMappingRow('', '');
}
}
addMappingRow(baseModel = '', pathValue = '') {
const mappingsContainer = document.getElementById('baseModelMappingsContainer');
if (!mappingsContainer) return;
const row = document.createElement('div');
row.className = 'mapping-row';
const availableModels = getMappableBaseModelsDynamic().filter(model => {
const existingMappings = state.global.settings.base_model_path_mappings || {};
return !existingMappings.hasOwnProperty(model) || model === baseModel;
});
row.innerHTML = `
<div class="mapping-controls">
<select class="base-model-select">
<option value="">${translate('settings.downloadPathTemplates.selectBaseModel', {}, 'Select Base Model')}</option>
${availableModels.map(model =>
`<option value="${model}" ${model === baseModel ? 'selected' : ''}>${model}</option>`
).join('')}
</select>
<input type="text" class="path-value-input" placeholder="${translate('settings.downloadPathTemplates.customPathPlaceholder', {}, 'Custom path (e.g., flux)')}" value="${pathValue}">
<button type="button" class="remove-mapping-btn" title="${translate('settings.downloadPathTemplates.removeMapping', {}, 'Remove mapping')}">
<i class="fas fa-times"></i>
</button>
</div>
`;
// Add event listeners
const baseModelSelect = row.querySelector('.base-model-select');
const pathValueInput = row.querySelector('.path-value-input');
const removeBtn = row.querySelector('.remove-mapping-btn');
// Save on select change immediately
baseModelSelect.addEventListener('change', () => this.updateBaseModelMappings());
// Save on input blur or Enter key
pathValueInput.addEventListener('blur', () => this.updateBaseModelMappings());
pathValueInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.target.blur();
}
});
removeBtn.addEventListener('click', () => {
row.remove();
this.updateBaseModelMappings();
});
mappingsContainer.appendChild(row);
}
updateBaseModelMappings() {
const mappingsContainer = document.getElementById('baseModelMappingsContainer');
if (!mappingsContainer) return;
const rows = mappingsContainer.querySelectorAll('.mapping-row');
const newMappings = {};
let hasValidMapping = false;
rows.forEach(row => {
const baseModelSelect = row.querySelector('.base-model-select');
const pathValueInput = row.querySelector('.path-value-input');
const baseModel = baseModelSelect.value.trim();
const pathValue = pathValueInput.value.trim();
if (baseModel && pathValue) {
newMappings[baseModel] = pathValue;
hasValidMapping = true;
}
});
// Check if mappings have actually changed
const currentMappings = state.global.settings.base_model_path_mappings || {};
const mappingsChanged = JSON.stringify(currentMappings) !== JSON.stringify(newMappings);
if (mappingsChanged) {
// Update state and save
state.global.settings.base_model_path_mappings = newMappings;
this.saveBaseModelMappings();
}
// Add empty row if no valid mappings exist
const hasEmptyRow = Array.from(rows).some(row => {
const baseModelSelect = row.querySelector('.base-model-select');
const pathValueInput = row.querySelector('.path-value-input');
return !baseModelSelect.value && !pathValueInput.value;
});
if (!hasEmptyRow) {
this.addMappingRow('', '');
}
// Update available options in all selects
this.updateAvailableBaseModels();
}
updateAvailableBaseModels() {
const mappingsContainer = document.getElementById('baseModelMappingsContainer');
if (!mappingsContainer) return;
const existingMappings = state.global.settings.base_model_path_mappings || {};
const rows = mappingsContainer.querySelectorAll('.mapping-row');
rows.forEach(row => {
const select = row.querySelector('.base-model-select');
const currentValue = select.value;
// Get available models (not already mapped, except current)
const availableModels = getMappableBaseModelsDynamic().filter(model =>
!existingMappings.hasOwnProperty(model) || model === currentValue
);
// Rebuild options
select.innerHTML = `<option value="">${translate('settings.downloadPathTemplates.selectBaseModel', {}, 'Select Base Model')}</option>` +
availableModels.map(model =>
`<option value="${model}" ${model === currentValue ? 'selected' : ''}>${model}</option>`
).join('');
});
}
async saveBaseModelMappings() {
try {
// Save to backend using universal save method
await this.saveSetting('base_model_path_mappings', state.global.settings.base_model_path_mappings);
// Show success toast
const mappingCount = Object.keys(state.global.settings.base_model_path_mappings).length;
if (mappingCount > 0) {
showToast('toast.settings.mappingsUpdated', {
count: mappingCount,
plural: mappingCount !== 1 ? 's' : ''
}, 'success');
} else {
showToast('toast.settings.mappingsCleared', {}, 'success');
}
} catch (error) {
console.error('Error saving base model mappings:', error);
showToast('toast.settings.mappingSaveFailed', { message: error.message }, 'error');
}
}
loadDownloadPathTemplates() {
const templates = state.global.settings.download_path_templates || DEFAULT_PATH_TEMPLATES;
Object.keys(templates).forEach(modelType => {
this.loadTemplateForModelType(modelType, templates[modelType]);
});
}
loadTemplateForModelType(modelType, template) {
const presetSelect = document.getElementById(`${modelType}TemplatePreset`);
const customRow = document.getElementById(`${modelType}CustomRow`);
const customInput = document.getElementById(`${modelType}CustomTemplate`);
if (!presetSelect) return;
// Find matching preset
const matchingPreset = this.findMatchingPreset(template);
if (matchingPreset !== null) {
presetSelect.value = matchingPreset;
if (customRow) customRow.style.display = 'none';
} else {
// 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> ${translate('settings.downloadPathTemplates.validation.validFlat', {}, '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> ${translate('settings.downloadPathTemplates.validation.invalidChars', {}, 'Invalid characters detected')}`;
validationElement.classList.add('invalid');
return false;
}
// Check for double slashes
if (template.includes('//')) {
validationElement.innerHTML = `<i class="fas fa-times"></i> ${translate('settings.downloadPathTemplates.validation.doubleSlashes', {}, '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> ${translate('settings.downloadPathTemplates.validation.leadingTrailingSlash', {}, '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> ${translate('settings.downloadPathTemplates.validation.invalidPlaceholder', { placeholder: invalidPlaceholders[0] }, `Invalid placeholder: ${invalidPlaceholders[0]}`)}`;
validationElement.classList.add('invalid');
return false;
}
// Template is valid
validationElement.innerHTML = `<i class="fas fa-check"></i> ${translate('settings.downloadPathTemplates.validation.validTemplate', {}, '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 backend using universal save method
await this.saveSetting('download_path_templates', state.global.settings.download_path_templates);
showToast('toast.settings.downloadTemplatesUpdated', {}, 'success');
} catch (error) {
console.error('Error saving download path templates:', error);
showToast('toast.settings.downloadTemplatesFailed', { message: error.message }, 'error');
}
}
toggleSettings() {
if (this.isOpen) {
modalManager.closeModal('settingsModal');
} else {
modalManager.showModal('settingsModal');
}
this.isOpen = !this.isOpen;
}
async saveToggleSetting(elementId, settingKey) {
const element = document.getElementById(elementId);
if (!element) return;
const value = element.checked;
try {
await this.saveSetting(settingKey, value);
if (settingKey === 'proxy_enabled') {
const proxySettingsGroup = document.getElementById('proxySettingsGroup');
if (proxySettingsGroup) {
proxySettingsGroup.style.display = value ? 'block' : 'none';
}
}
// Refresh metadata archive status when enable setting changes
if (settingKey === 'enable_metadata_archive_db') {
await this.updateMetadataArchiveStatus();
}
if (settingKey === 'backup_auto_enabled') {
await this.updateBackupStatus();
}
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
// Apply frontend settings immediately
this.applyFrontendSettings();
// Trigger auto download setup/teardown when setting changes
if (settingKey === 'auto_download_example_images' && window.exampleImagesManager) {
if (value) {
window.exampleImagesManager.setupAutoDownload();
} else {
window.exampleImagesManager.clearAutoDownload();
}
}
if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content') {
this.reloadContent();
}
// Recalculate layout when compact mode changes
if (settingKey === 'compact_mode' && state.virtualScroller) {
state.virtualScroller.calculateLayout();
showToast('toast.settings.compactModeToggled', {
state: value ? 'toast.settings.compactModeEnabled' : 'toast.settings.compactModeDisabled'
}, 'success');
}
} catch (error) {
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
async saveSelectSetting(elementId, settingKey) {
const element = document.getElementById(elementId);
if (!element) return;
const value = settingKey === 'mature_blur_level'
? this.normalizeMatureBlurLevel(element.value)
: element.value;
try {
// Update frontend state with mapped keys
await this.saveSetting(settingKey, value);
// Apply frontend settings immediately
this.applyFrontendSettings();
// Recalculate layout when display density changes
if (settingKey === 'display_density' && state.virtualScroller) {
state.virtualScroller.calculateLayout();
let densityName = "Default";
if (value === 'medium') densityName = "Medium";
if (value === 'compact') densityName = "Compact";
showToast('toast.settings.displayDensitySet', { density: densityName }, 'success');
return;
}
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
if (
settingKey === 'model_name_display'
|| settingKey === 'model_card_footer_action'
|| settingKey === 'update_flag_strategy'
|| settingKey === 'mature_blur_level'
) {
this.reloadContent();
}
} catch (error) {
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
updateExampleImagesOpenSettingsVisibility() {
const openMode = state.global.settings.example_images_open_mode || 'system';
const localRootSetting = document.getElementById('exampleImagesLocalRootSetting');
const uriTemplateSetting = document.getElementById('exampleImagesUriTemplateSetting');
if (localRootSetting) {
localRootSetting.style.display = openMode === 'system' ? 'none' : 'block';
}
if (uriTemplateSetting) {
uriTemplateSetting.style.display = openMode === 'uri_template' ? 'block' : 'none';
}
}
async handleExampleImagesOpenModeChange() {
await this.saveSelectSetting('exampleImagesOpenMode', 'example_images_open_mode');
this.updateExampleImagesOpenSettingsVisibility();
}
async loadMetadataArchiveSettings() {
try {
// Load current settings from state
const enableMetadataArchiveCheckbox = document.getElementById('enableMetadataArchive');
if (enableMetadataArchiveCheckbox) {
enableMetadataArchiveCheckbox.checked = state.global.settings.enable_metadata_archive_db || false;
}
// Load status
await this.updateMetadataArchiveStatus();
} catch (error) {
console.error('Error loading metadata archive settings:', error);
}
}
async loadBackupSettings() {
const backupAutoEnabledCheckbox = document.getElementById('backupAutoEnabled');
if (backupAutoEnabledCheckbox) {
backupAutoEnabledCheckbox.checked = state.global.settings.backup_auto_enabled ?? true;
}
const backupRetentionCountInput = document.getElementById('backupRetentionCount');
if (backupRetentionCountInput) {
backupRetentionCountInput.value = state.global.settings.backup_retention_count ?? 5;
}
await this.updateBackupStatus();
}
async updateBackupStatus() {
try {
const response = await fetch('/api/lm/backup/status');
const data = await response.json();
const statusContainer = document.getElementById('backupStatus');
if (!statusContainer || !data.success) {
return;
}
const status = data.status || {};
const latestAutoSnapshot = status.latestAutoSnapshot;
const retentionCount = status.retentionCount ?? state.global.settings.backup_retention_count ?? 5;
const enabled = status.enabled ?? state.global.settings.backup_auto_enabled ?? true;
const backupDir = status.backupDir || '';
const backupLocationPath = document.getElementById('backupLocationPath');
if (backupLocationPath) {
backupLocationPath.textContent = backupDir;
backupLocationPath.title = backupDir;
}
const formatTimestamp = (timestamp) => {
if (!timestamp) {
return translate('common.status.unknown', {}, 'Unknown');
}
return new Date(timestamp * 1000).toLocaleString();
};
const renderSnapshotDetail = (snapshot) => {
if (!snapshot) {
return translate('settings.backup.noneAvailable', {}, 'No snapshots yet');
}
const size = typeof snapshot.size === 'number' ? ` (${this.formatFileSize(snapshot.size)})` : '';
return `${snapshot.name}${size}`;
};
statusContainer.innerHTML = `
<div class="backup-summary-grid">
<div class="backup-summary-card">
<div class="backup-summary-label">${translate('settings.backup.autoEnabled', {}, 'Automatic snapshots')}</div>
<div class="backup-summary-value status-${enabled ? 'enabled' : 'disabled'}">
${enabled ? translate('common.status.enabled') : translate('common.status.disabled')}
</div>
</div>
<div class="backup-summary-card">
<div class="backup-summary-label">${translate('settings.backup.retention', {}, 'Retention')}</div>
<div class="backup-summary-value">${retentionCount}</div>
</div>
<div class="backup-summary-card">
<div class="backup-summary-label">${translate('settings.backup.snapshotCount', {}, 'Saved snapshots')}</div>
<div class="backup-summary-value">${status.snapshotCount ?? 0}</div>
</div>
</div>
<div class="backup-status-list">
<div class="backup-status-row">
<div class="backup-status-label">${translate('settings.backup.latestAutoSnapshot', {}, 'Latest auto snapshot')}</div>
<div class="backup-status-content">
<div class="backup-status-primary">${formatTimestamp(latestAutoSnapshot?.mtime)}</div>
<div class="backup-status-secondary">${renderSnapshotDetail(latestAutoSnapshot)}</div>
</div>
</div>
</div>
`;
} catch (error) {
console.error('Error updating backup status:', error);
}
}
async exportBackup() {
try {
const response = await fetch('/api/lm/backup/export', {
method: 'POST',
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const blob = await response.blob();
const contentDisposition = response.headers.get('Content-Disposition') || '';
const match = contentDisposition.match(/filename="([^"]+)"/);
const filename = match?.[1] || `lora-manager-backup-${Date.now()}.zip`;
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
showToast('settings.backup.exportSuccess', {}, 'success');
} catch (error) {
console.error('Failed to export backup:', error);
showToast('settings.backup.exportFailed', { message: error.message }, 'error');
}
}
triggerBackupImport() {
const input = document.getElementById('backupImportInput');
input?.click();
}
async handleBackupImportFile(input) {
if (!(input instanceof HTMLInputElement)) {
return;
}
const file = input.files?.[0];
input.value = '';
if (!file) {
return;
}
if (!confirm(translate('settings.backup.importConfirm', {}, 'Import this backup and overwrite local user state?'))) {
return;
}
try {
const formData = new FormData();
formData.append('archive', file);
const response = await fetch('/api/lm/backup/import', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (!response.ok || data.success === false) {
throw new Error(data.error || `Request failed with status ${response.status}`);
}
showToast('settings.backup.importSuccess', {}, 'success');
await this.updateBackupStatus();
window.location.reload();
} catch (error) {
console.error('Failed to import backup:', error);
showToast('settings.backup.importFailed', { message: error.message }, 'error');
}
}
async updateMetadataArchiveStatus() {
try {
const response = await fetch('/api/lm/metadata-archive-status');
const data = await response.json();
const statusContainer = document.getElementById('metadataArchiveStatus');
if (statusContainer && data.success) {
const status = data;
const sizeText = status.databaseSize > 0 ? ` (${this.formatFileSize(status.databaseSize)})` : '';
statusContainer.innerHTML = `
<div class="archive-status-item">
<span class="archive-status-label">${translate('settings.metadataArchive.status')}:</span>
<span class="archive-status-value status-${status.isAvailable ? 'available' : 'unavailable'}">
${status.isAvailable ? translate('settings.metadataArchive.statusAvailable') : translate('settings.metadataArchive.statusUnavailable')}
${sizeText}
</span>
</div>
<div class="archive-status-item">
<span class="archive-status-label">${translate('settings.metadataArchive.enabled')}:</span>
<span class="archive-status-value status-${status.isEnabled ? 'enabled' : 'disabled'}">
${status.isEnabled ? translate('common.status.enabled') : translate('common.status.disabled')}
</span>
</div>
`;
// Update button states
const downloadBtn = document.getElementById('downloadMetadataArchiveBtn');
const removeBtn = document.getElementById('removeMetadataArchiveBtn');
if (downloadBtn) {
downloadBtn.disabled = status.isAvailable;
downloadBtn.textContent = status.isAvailable ?
translate('settings.metadataArchive.downloadedButton') :
translate('settings.metadataArchive.downloadButton');
}
if (removeBtn) {
removeBtn.disabled = !status.isAvailable;
}
}
} catch (error) {
console.error('Error updating metadata archive status:', error);
}
}
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
async downloadMetadataArchive() {
try {
const downloadBtn = document.getElementById('downloadMetadataArchiveBtn');
if (downloadBtn) {
downloadBtn.disabled = true;
downloadBtn.textContent = translate('settings.metadataArchive.downloadingButton');
}
// Show loading with enhanced progress
const progressUpdater = state.loadingManager.showEnhancedProgress(translate('settings.metadataArchive.preparing'));
// Set up WebSocket for progress updates
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const downloadId = `metadata_archive_${Date.now()}`;
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
let wsConnected = false;
let actualDownloadId = downloadId; // Will be updated when WebSocket confirms the ID
// Promise to wait for WebSocket connection and ID confirmation
const wsReady = new Promise((resolve) => {
ws.onopen = () => {
wsConnected = true;
console.log('Connected to metadata archive download progress WebSocket');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// Handle download ID confirmation
if (data.type === 'download_id') {
actualDownloadId = data.download_id;
console.log(`Connected to metadata archive download progress with ID: ${data.download_id}`);
resolve(data.download_id);
return;
}
// Handle metadata archive download progress
if (data.type === 'metadata_archive_download') {
const message = data.message || '';
// Update progress bar based on stage
let progressPercent = 0;
if (data.stage === 'download') {
// Extract percentage from message if available
const percentMatch = data.message.match(/(\d+\.?\d*)%/);
if (percentMatch) {
progressPercent = Math.min(parseFloat(percentMatch[1]), 90); // Cap at 90% for download
} else {
progressPercent = 0; // Default download progress
}
} else if (data.stage === 'extract') {
progressPercent = 95; // Near completion for extraction
}
// Update loading manager progress
progressUpdater.updateProgress(progressPercent, '', `${message}`);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
resolve(downloadId); // Fallback to original ID
};
// Timeout fallback
setTimeout(() => resolve(downloadId), 5000);
});
ws.onclose = () => {
console.log('WebSocket connection closed');
};
// Wait for WebSocket to be ready
await wsReady;
const response = await fetch(`/api/lm/download-metadata-archive?download_id=${encodeURIComponent(actualDownloadId)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
// Close WebSocket
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
if (data.success) {
// Complete progress
await progressUpdater.complete(translate('settings.metadataArchive.downloadComplete'));
showToast('settings.metadataArchive.downloadSuccess', 'success');
// Update settings using universal save method
await this.saveSetting('enable_metadata_archive_db', true);
// Update UI
const enableCheckbox = document.getElementById('enableMetadataArchive');
if (enableCheckbox) {
enableCheckbox.checked = true;
}
await this.updateMetadataArchiveStatus();
} else {
// Hide loading on error
state.loadingManager.hide();
showToast('settings.metadataArchive.downloadError' + ': ' + data.error, 'error');
}
} catch (error) {
console.error('Error downloading metadata archive:', error);
// Hide loading on error
state.loadingManager.hide();
showToast('settings.metadataArchive.downloadError' + ': ' + error.message, 'error');
} finally {
const downloadBtn = document.getElementById('downloadMetadataArchiveBtn');
if (downloadBtn) {
downloadBtn.disabled = false;
downloadBtn.textContent = translate('settings.metadataArchive.downloadButton');
}
}
}
async removeMetadataArchive() {
if (!confirm(translate('settings.metadataArchive.removeConfirm'))) {
return;
}
try {
const removeBtn = document.getElementById('removeMetadataArchiveBtn');
if (removeBtn) {
removeBtn.disabled = true;
removeBtn.textContent = translate('settings.metadataArchive.removingButton');
}
const response = await fetch('/api/lm/remove-metadata-archive', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
showToast('settings.metadataArchive.removeSuccess', 'success');
// Update settings using universal save method
await this.saveSetting('enable_metadata_archive_db', false);
// Update UI
const enableCheckbox = document.getElementById('enableMetadataArchive');
if (enableCheckbox) {
enableCheckbox.checked = false;
}
await this.updateMetadataArchiveStatus();
} else {
showToast('settings.metadataArchive.removeError' + ': ' + data.error, 'error');
}
} catch (error) {
console.error('Error removing metadata archive:', error);
showToast('settings.metadataArchive.removeError' + ': ' + error.message, 'error');
} finally {
const removeBtn = document.getElementById('removeMetadataArchiveBtn');
if (removeBtn) {
removeBtn.disabled = false;
removeBtn.textContent = translate('settings.metadataArchive.removeButton');
}
}
}
async saveAutoOrganizeExclusions() {
const input = document.getElementById('autoOrganizeExclusions');
const errorElement = document.getElementById('autoOrganizeExclusionsError');
if (!input) return;
const normalized = this.normalizePatternList(input.value);
if (input.value.trim() && normalized.length === 0) {
if (errorElement) {
errorElement.textContent = translate(
'settings.autoOrganizeExclusions.validation.noPatterns',
{},
'Enter at least one pattern separated by commas or semicolons.'
);
}
return;
}
const current = this.normalizePatternList(state.global.settings.auto_organize_exclusions);
if (normalized.join('|') === current.join('|')) {
if (errorElement) {
errorElement.textContent = '';
}
return;
}
try {
if (errorElement) {
errorElement.textContent = '';
}
await this.saveSetting('auto_organize_exclusions', normalized);
input.value = normalized.join(', ');
showToast(
'toast.settings.settingsUpdated',
{ setting: translate('settings.autoOrganizeExclusions.label') },
'success'
);
} catch (error) {
console.error('Failed to save auto-organize exclusions:', error);
if (errorElement) {
errorElement.textContent = translate(
'settings.autoOrganizeExclusions.validation.saveFailed',
{ message: error.message },
`Unable to save exclusions: ${error.message}`
);
}
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
renderDownloadSkipBaseModels() {
const container = document.getElementById('downloadSkipBaseModelsContainer');
const searchInput = document.getElementById('downloadSkipBaseModelsSearch');
const emptyState = document.getElementById('downloadSkipBaseModelsEmpty');
if (!container) {
return;
}
const selectedValues = this.normalizeDownloadSkipBaseModels(
state.global.settings.download_skip_base_models
);
const selected = new Set(selectedValues);
const options = this.getAvailableDownloadSkipBaseModels();
const query = (searchInput?.value || '').trim().toLowerCase();
const filteredOptions = query
? options.filter((baseModel) => baseModel.toLowerCase().includes(query))
: options;
container.innerHTML = filteredOptions.map((baseModel) => `
<label class="base-model-skip-option">
<input
type="checkbox"
name="downloadSkipBaseModel"
value="${baseModel}"
${selected.has(baseModel) ? 'checked' : ''}
>
<span>${baseModel}</span>
</label>
`).join('');
if (emptyState) {
emptyState.hidden = filteredOptions.length > 0;
}
this.renderDownloadSkipBaseModelsSummary(selectedValues);
}
renderDownloadSkipBaseModelsSummary(selectedValues = null) {
const summaryElement = document.getElementById('downloadSkipBaseModelsSummary');
if (!summaryElement) {
return;
}
const values = Array.isArray(selectedValues)
? selectedValues
: this.normalizeDownloadSkipBaseModels(state.global.settings.download_skip_base_models);
if (values.length === 0) {
summaryElement.textContent = translate(
'settings.downloadSkipBaseModels.summary.none',
{},
'None selected'
);
return;
}
if (values.length <= 2) {
summaryElement.textContent = values.join(', ');
return;
}
summaryElement.textContent = translate(
'settings.downloadSkipBaseModels.summary.count',
{ count: values.length },
`${values.length} selected`
);
}
setDownloadSkipBaseModelsPanelOpen(isOpen) {
const panel = document.getElementById('downloadSkipBaseModelsPanel');
const toggle = document.getElementById('downloadSkipBaseModelsToggle');
const toggleLabel = toggle?.querySelector('.base-model-skip-toggle-label');
if (!panel || !toggle) {
return;
}
panel.hidden = !isOpen;
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
if (toggleLabel) {
toggleLabel.textContent = isOpen
? translate('settings.downloadSkipBaseModels.actions.collapse', {}, 'Collapse')
: translate('settings.downloadSkipBaseModels.actions.edit', {}, 'Edit');
}
if (isOpen) {
const searchInput = document.getElementById('downloadSkipBaseModelsSearch');
searchInput?.focus();
}
}
toggleDownloadSkipBaseModelsPanel() {
const panel = document.getElementById('downloadSkipBaseModelsPanel');
if (!panel) {
return;
}
this.setDownloadSkipBaseModelsPanelOpen(panel.hidden);
}
async saveDownloadSkipBaseModels() {
const container = document.getElementById('downloadSkipBaseModelsContainer');
const errorElement = document.getElementById('downloadSkipBaseModelsError');
if (!container) return;
const selected = Array.from(
container.querySelectorAll('input[name="downloadSkipBaseModel"]:checked')
).map((input) => input.value);
const normalized = this.normalizeDownloadSkipBaseModels(selected);
const current = this.normalizeDownloadSkipBaseModels(state.global.settings.download_skip_base_models);
if (normalized.join('|') === current.join('|')) {
if (errorElement) {
errorElement.textContent = '';
}
return;
}
try {
if (errorElement) {
errorElement.textContent = '';
}
await this.saveSetting('download_skip_base_models', normalized);
this.renderDownloadSkipBaseModels();
showToast(
'toast.settings.settingsUpdated',
{ setting: translate('settings.downloadSkipBaseModels.label') },
'success'
);
} catch (error) {
console.error('Failed to save download skip base models:', error);
if (errorElement) {
errorElement.textContent = translate(
'settings.downloadSkipBaseModels.validation.saveFailed',
{ message: error.message },
`Unable to save excluded base models: ${error.message}`
);
}
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
async clearDownloadSkipBaseModels() {
const searchInput = document.getElementById('downloadSkipBaseModelsSearch');
if (searchInput) {
searchInput.value = '';
}
const current = this.normalizeDownloadSkipBaseModels(
state.global.settings.download_skip_base_models
);
if (current.length === 0) {
this.renderDownloadSkipBaseModels();
return;
}
try {
const errorElement = document.getElementById('downloadSkipBaseModelsError');
if (errorElement) {
errorElement.textContent = '';
}
await this.saveSetting('download_skip_base_models', []);
this.renderDownloadSkipBaseModels();
showToast(
'toast.settings.settingsUpdated',
{ setting: translate('settings.downloadSkipBaseModels.label') },
'success'
);
} catch (error) {
const errorElement = document.getElementById('downloadSkipBaseModelsError');
console.error('Failed to clear download skip base models:', error);
if (errorElement) {
errorElement.textContent = translate(
'settings.downloadSkipBaseModels.validation.saveFailed',
{ message: error.message },
`Unable to save excluded base models: ${error.message}`
);
}
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
async saveMetadataRefreshSkipPaths() {
const input = document.getElementById('metadataRefreshSkipPaths');
const errorElement = document.getElementById('metadataRefreshSkipPathsError');
if (!input) return;
const normalized = this.normalizePatternList(input.value);
if (input.value.trim() && normalized.length === 0) {
if (errorElement) {
errorElement.textContent = translate(
'settings.metadataRefreshSkipPaths.validation.noPaths',
{},
'Enter at least one path separated by commas.'
);
}
return;
}
const current = this.normalizePatternList(state.global.settings.metadata_refresh_skip_paths);
if (normalized.join('|') === current.join('|')) {
if (errorElement) {
errorElement.textContent = '';
}
return;
}
try {
if (errorElement) {
errorElement.textContent = '';
}
await this.saveSetting('metadata_refresh_skip_paths', normalized);
input.value = normalized.join(', ');
showToast(
'toast.settings.settingsUpdated',
{ setting: translate('settings.metadataRefreshSkipPaths.label') },
'success'
);
} catch (error) {
console.error('Failed to save metadata refresh skip paths:', error);
if (errorElement) {
errorElement.textContent = translate(
'settings.metadataRefreshSkipPaths.validation.saveFailed',
{ message: error.message },
`Unable to save skip paths: ${error.message}`
);
}
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
async saveInputSetting(elementId, settingKey) {
const element = document.getElementById(elementId);
if (!element) return;
const value = element.value.trim(); // Trim whitespace
const shouldShowLoading = settingKey === 'recipes_path';
try {
// Check if value has changed from existing value
const currentValue = state.global.settings[settingKey];
const normalizedCurrentValue = currentValue === undefined || currentValue === null
? ''
: String(currentValue).trim();
if (value === normalizedCurrentValue) {
return; // No change, exit early
}
if (shouldShowLoading) {
state.loadingManager?.showSimpleLoading(
translate('settings.folderSettings.recipesPathMigrating', {}, 'Migrating recipes...')
);
}
// For username and password, handle empty values specially
if ((settingKey === 'proxy_username' || settingKey === 'proxy_password') && value === '') {
// Remove from state instead of setting to empty string
delete state.global.settings[settingKey];
// Send delete flag to backend
const payload = {};
payload[settingKey] = '__DELETE__';
const response = await fetch('/api/lm/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error('Failed to delete setting');
}
} else {
// Use the universal save method
await this.saveSetting(settingKey, value);
}
if (shouldShowLoading) {
state.loadingManager?.hide();
}
if (settingKey === 'recipes_path') {
showToast('toast.settings.recipesPathUpdated', {}, 'success');
} else if (settingKey === 'backup_retention_count') {
await this.updateBackupStatus();
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
} else {
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
}
} catch (error) {
if (shouldShowLoading) {
state.loadingManager?.hide();
}
if (settingKey === 'recipes_path') {
showToast('toast.settings.recipesPathSaveFailed', { message: error.message }, 'error');
} else {
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
}
async saveLanguageSetting() {
const element = document.getElementById('languageSelect');
if (!element) return;
const selectedLanguage = element.value;
try {
// Use the universal save method for language (frontend-only setting)
await this.saveSetting('language', selectedLanguage);
// Reload the page to apply the new language
window.location.reload();
} catch (error) {
showToast('toast.settings.languageChangeFailed', { message: error.message }, 'error');
}
}
toggleInputVisibility(button) {
const input = button.parentElement.querySelector('input');
const icon = button.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.className = 'fas fa-eye-slash';
} else {
input.type = 'password';
icon.className = 'fas fa-eye';
}
}
confirmClearCache() {
// Show confirmation modal
modalManager.showModal('clearCacheModal');
}
async reloadContent() {
if (this.currentPage === 'loras') {
// Reload the loras without updating folders
await resetAndReload(false);
} else if (this.currentPage === 'recipes') {
// Reload the recipes without updating folders
await window.recipeManager.loadRecipes({ preserveScroll: true });
} else if (this.currentPage === 'checkpoints') {
// Reload the checkpoints without updating folders
await resetAndReload(false);
} else if (this.currentPage === 'embeddings') {
// Reload the embeddings without updating folders
await resetAndReload(false);
}
}
applyFrontendSettings() {
// Apply autoplay setting to existing videos in card previews
const autoplayOnHover = state.global.settings.autoplay_on_hover;
document.querySelectorAll('.card-preview video').forEach(video => {
configureModelCardVideo(video, autoplayOnHover);
});
// Apply display density class to grid
const grid = document.querySelector('.card-grid');
if (grid) {
const density = state.global.settings.display_density || 'default';
// Remove all density classes first
grid.classList.remove('default-density', 'medium-density', 'compact-density');
// Add the appropriate density class
grid.classList.add(`${density}-density`);
}
// Apply card info display setting
const cardInfoDisplay = state.global.settings.card_info_display || 'always';
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
// Apply show version on card setting
const showVersionOnCard = state.global.settings.show_version_on_card !== false;
document.body.classList.toggle('hide-card-version', !showVersionOnCard);
const shouldShowSidebar = state.global.settings.show_folder_sidebar !== false;
if (sidebarManager && typeof sidebarManager.setSidebarEnabled === 'function') {
sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
console.error('Failed to apply sidebar visibility setting:', error);
});
}
}
}
// Create singleton instance
export const settingsManager = new SettingsManager();