import { modalManager } from './ModalManager.js'; import { showToast } from '../utils/uiHelpers.js'; import { state, createDefaultSettings } from '../state/index.js'; import { resetAndReload, getModelApiClient } from '../api/modelApiFactory.js'; import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES, FILENAME_TEMPLATE_PLACEHOLDERS, DEFAULT_FILENAME_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 { directoryPickerModal } from '../components/DirectoryPickerModal.js'; const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']); const PATH_VALIDATION_ERROR_I18N = { path_not_found: { key: 'settings.pathValidation.pathNotFound', fallback: 'Path does not exist' }, not_a_directory: { key: 'settings.pathValidation.notADirectory', fallback: 'Not a directory' }, not_readable: { key: 'settings.pathValidation.notReadable', fallback: 'Path is not readable' }, not_writable: { key: 'settings.pathValidation.notWritable', fallback: 'Path is not writable' }, }; // Other-model sub_type -> i18n label key, mirroring the checkbox list in // templates/components/modals/settings/library.html. const OTHER_SUB_TYPE_LABEL_KEYS = { vae: 'settings.folderSettings.subTypeVae', upscaler: 'settings.folderSettings.subTypeUpscaler', text_encoder: 'settings.folderSettings.subTypeTextEncoder', clip_vision: 'settings.folderSettings.subTypeClipVision', controlnet: 'settings.folderSettings.subTypeControlnet', }; // Singular filename-template model type -> plural API model type (MODEL_TYPES) const FILENAME_TEMPLATE_MODEL_TYPES = { lora: 'loras', checkpoint: 'checkpoints', embedding: 'embeddings', }; export class SettingsManager { constructor() { this.initialized = false; this.isOpen = false; this.initializationPromise = null; this.availableLibraries = {}; this.activeLibrary = ''; this.registeredStartupBannerIds = new Set(); this.modelPathsSectionInitialized = false; this.modelPathsDirty = false; // 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(); this.setupModelPathsSection(); } 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 }; let filenameTemplates = backendSettings?.download_filename_templates; if (typeof filenameTemplates === 'string') { try { const parsed = JSON.parse(filenameTemplates); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { filenameTemplates = parsed; } } catch (parseError) { console.warn('Failed to parse download_filename_templates string from backend, using defaults'); filenameTemplates = null; } } if (!filenameTemplates || typeof filenameTemplates !== 'object' || Array.isArray(filenameTemplates)) { filenameTemplates = {}; } merged.download_filename_templates = { ...DEFAULT_FILENAME_TEMPLATES, ...filenameTemplates }; 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-model-paths-settings': modalManager.showModal('settingsModal'); document.querySelector('.settings-nav-item[data-section="modelPaths"]')?.click(); 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 (settingKey === 'lora_syntax_format') { try { localStorage.setItem('lm:lora-syntax-format-changed', Date.now().toString()); } catch (_) { } } 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'; if (this.isOpen) { this.loadSettingsToUI(); } else { // Reset API key edit mode on close this.cancelEditApiKey(true); // Clear proxy password on close const proxyPasswordInput = document.getElementById('proxyPassword'); if (proxyPasswordInput) proxyPasswordInput.value = ''; } } }); }); 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(); } }); } }); ['lora', 'checkpoint', 'embedding'].forEach(modelType => { const filenameInput = document.getElementById(`${modelType}FilenameTemplate`); if (filenameInput) { filenameInput.addEventListener('input', (e) => { const template = e.target.value; settingsManager.validateFilenameTemplate(modelType, template); settingsManager.updateFilenamePreview(modelType, template); }); filenameInput.addEventListener('blur', (e) => { const template = e.target.value; if (settingsManager.validateFilenameTemplate(modelType, template)) { settingsManager.updateFilenameTemplate(modelType, template); } }); filenameInput.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; // Query live instead of using the captured NodeLists: the // standalone Model Paths section is added after this // initializer runs, and a stale snapshot would leave it // active forever. document.querySelectorAll('.settings-section').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 document.querySelectorAll('.settings-nav-item').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 = `

${translate('settings.search.noResults', { query }, `No settings found matching "${query}"`)}

`; 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 _fetchProviderModelsAsync() { try { const resp = await fetch('/api/lm/llm/provider-models'); if (!resp.ok) return; const data = await resp.json(); if (data.success && data.models) { this._providerModels = data.models; // Refresh model combobox if the settings modal is still open. // Skip when provider is Ollama — it fetches its own live list // from the local Ollama API and we must not overwrite it. const llmProviderSelect = document.getElementById('llmProvider'); const provider = llmProviderSelect ? llmProviderSelect.value : 'openai'; if (this._llmModelCombobox && provider !== 'ollama') { this._llmModelCombobox.updatePresets(this._providerModels[provider] || []); } } } catch (_) { // Silently ignore — models stay empty until next modal open } } 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 ); } // Set card blur amount slider const cardBlurAmountInput = document.getElementById('cardBlurAmount'); const cardBlurValue = state.global.settings.card_blur_amount ?? 8; if (cardBlurAmountInput) { cardBlurAmountInput.value = cardBlurValue; cardBlurAmountInput.style.setProperty('--range-fill', (cardBlurValue / 20 * 100) + '%'); } const cardBlurAmountValue = document.getElementById('cardBlurAmountValue'); if (cardBlurAmountValue) { cardBlurAmountValue.textContent = `${cardBlurValue}px`; } const usePortableCheckbox = document.getElementById('usePortableSettings'); if (usePortableCheckbox) { usePortableCheckbox.checked = !!state.global.settings.use_portable_settings; } // Update API key status display (do NOT pre-fill the input) this.updateApiKeyStatus(); this.updateLlmApiKeyStatus(); // ── AI Provider settings ────────────────────────────────────── // Load provider presets from the JSON script tag embedded in the template this._providerPresets = {}; this._providerModels = {}; const presetsScript = document.getElementById('llmProviderPresets'); if (presetsScript) { try { this._providerPresets = JSON.parse(presetsScript.textContent); } catch (_) { this._providerPresets = {}; } } const modelsScript = document.getElementById('llmProviderModels'); if (modelsScript) { try { this._providerModels = JSON.parse(modelsScript.textContent); } catch (_) { this._providerModels = {}; } } // If the embedded provider models is empty (server did not block on // the remote catalog during page render), fetch asynchronously. if (!this._providerModels || Object.keys(this._providerModels).length === 0) { this._fetchProviderModelsAsync(); } const llmProviderSelect = document.getElementById('llmProvider'); if (llmProviderSelect) { llmProviderSelect.value = state.global.settings.llm_provider || 'openai'; } // Destroy previous combobox instances before creating new ones, // since loadSettingsToUI() runs on every modal open. if (this._llmApiBaseCombobox) { this._llmApiBaseCombobox.destroy(); } if (this._llmModelCombobox) { this._llmModelCombobox.destroy(); } const llmApiBaseInput = document.getElementById('llmApiBase'); if (llmApiBaseInput) { llmApiBaseInput.value = state.global.settings.llm_api_base || ''; const presetUrls = Object.values(this._providerPresets) .map(p => p.api_base) .filter(Boolean); if (typeof Combobox !== 'undefined') { this._llmApiBaseCombobox = new Combobox(llmApiBaseInput, { presets: presetUrls, placeholder: 'https://api.openai.com/v1', }); } } // Helper to update model Combobox presets from catalog / Ollama API const llmModelInput = document.getElementById('llmModel'); this._llmModelCombobox = null; if (llmModelInput) { llmModelInput.value = state.global.settings.llm_model || ''; } if (llmModelInput && typeof Combobox !== 'undefined') { const currentProvider = llmProviderSelect ? llmProviderSelect.value : 'openai'; const fallbackModels = currentProvider === 'ollama' ? [] : (this._providerModels[currentProvider] || []); this._llmModelCombobox = new Combobox(llmModelInput, { presets: fallbackModels, placeholder: translate('settings.aiProvider.modelPlaceholder', {}, 'Select a model...'), onSelect: (value) => { state.global.settings.llm_model = value; this.saveSetting('llm_model', value) .then(() => showToast('toast.settings.settingsUpdated', { setting: 'model' }, 'success')) .catch(() => {}); }, }); } const _loadModelPresets = async (provider) => { if (!this._llmModelCombobox) return; if (provider === 'ollama') { try { const apiBase = document.getElementById('llmApiBase')?.value?.trim() || 'http://localhost:11434/v1'; const resp = await fetch(`/api/lm/llm/models?provider=ollama&api_base=${encodeURIComponent(apiBase)}`); if (resp.ok) { const data = await resp.json(); if (data.success && Array.isArray(data.models)) { this._llmModelCombobox.updatePresets(data.models); return; } } } catch (_) {} this._llmModelCombobox.updatePresets([]); } else { this._llmModelCombobox.updatePresets(this._providerModels[provider] || []); } }; _loadModelPresets(llmProviderSelect ? llmProviderSelect.value : 'openai'); // Provider change → auto-fill API Base URL + update model presets if (llmProviderSelect) { llmProviderSelect.addEventListener('change', () => { const provider = llmProviderSelect.value; const preset = this._providerPresets[provider]; if (preset) { if (llmApiBaseInput && preset.api_base) { llmApiBaseInput.value = preset.api_base; if (this._llmApiBaseCombobox) { this._llmApiBaseCombobox.setValue(preset.api_base); } llmApiBaseInput.dispatchEvent(new Event('blur')); } } _loadModelPresets(provider); }); } 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 recipes layout setting (segmented control active state) this.updateRecipesLayoutControls(state.global.settings.recipes_layout || 'grid'); // Set card info display setting const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay'); if (cardInfoDisplaySelect) { cardInfoDisplaySelect.value = state.global.settings.card_info_display || 'always'; } // 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 group by model const groupByModelCheckbox = document.getElementById('groupByModel'); if (groupByModelCheckbox) { groupByModelCheckbox.checked = !!state.global.settings.group_by_model; } // Set sticky controls const stickyControlsCheckbox = document.getElementById('stickyControls'); if (stickyControlsCheckbox) { stickyControlsCheckbox.checked = !!state.global.settings.sticky_controls; } // Set model name display setting const modelNameDisplaySelect = document.getElementById('modelNameDisplay'); if (modelNameDisplaySelect) { modelNameDisplaySelect.value = state.global.settings.model_name_display || 'model_name'; } const versionGroupingSelect = document.getElementById('versionGrouping'); if (versionGroupingSelect) { versionGroupingSelect.value = state.global.settings.version_grouping || '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; } // Set hide paid updates setting const hidePaidUpdatesCheckbox = document.getElementById('hidePaidUpdates'); if (hidePaidUpdatesCheckbox) { hidePaidUpdatesCheckbox.checked = state.global.settings.hide_paid_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 download filename templates this.loadFilenameTemplates(); // 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 default other-model roots (per sub_type) await this.loadOtherRoots(); this.updateOtherModelsControls(); // Load extra folder paths this.loadExtraFolderPaths(); // Load standalone model library paths (no-op in plugin mode) this.loadModelPaths(); // Load language setting const languageSelect = document.getElementById('languageSelect'); if (languageSelect) { const currentLanguage = state.global.settings.language || 'en'; languageSelect.value = currentLanguage; } this.loadDownloadBackendSettings(); this.loadProxySettings(); // Set license icon style const useNewLicenseIconsCheckbox = document.getElementById('useNewLicenseIcons'); if (useNewLicenseIconsCheckbox) { useNewLicenseIconsCheckbox.checked = state.global.settings.use_new_license_icons !== false; } // Directory browse buttons + advisory path validation (idempotent, // safe to call on every modal open). this.attachPathField('recipesPath', { onAfterSelect: () => this.saveInputSetting('recipesPath', 'recipes_path'), }); this.attachPathField('exampleImagesPath', { onAfterSelect: (pickedPath) => { // Mirror ExampleImagesManager's blur-save flow. window.exampleImagesManager?.updateDownloadButtonState?.(pickedPath.trim() !== ''); this.saveSetting('example_images_path', pickedPath) .then(() => showToast('toast.exampleImages.pathUpdated', {}, 'success')) .catch((error) => showToast('toast.exampleImages.pathUpdateFailed', { message: error.message }, 'error')); }, }); this.attachPathField('exampleImagesLocalRoot', { onAfterSelect: () => this.saveInputSetting('exampleImagesLocalRoot', 'example_images_local_root'), }); } 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; } showNoRootsPlaceholder(select) { select.innerHTML = ''; const option = document.createElement('option'); option.value = ''; option.textContent = translate('settings.folderSettings.noDefault', {}, 'No Default'); select.appendChild(option); select.disabled = true; } async loadLoraRoots() { const defaultLoraRootSelect = document.getElementById('defaultLoraRoot'); if (!defaultLoraRootSelect) return; try { // 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) { this.showNoRootsPlaceholder(defaultLoraRootSelect); return; } defaultLoraRootSelect.innerHTML = ''; defaultLoraRootSelect.disabled = false; // 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); this.showNoRootsPlaceholder(defaultLoraRootSelect); showToast('toast.settings.loraRootsFailed', { message: error.message }, 'error'); } } async loadCheckpointRoots() { const defaultCheckpointRootSelect = document.getElementById('defaultCheckpointRoot'); if (!defaultCheckpointRootSelect) return; try { // 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) { this.showNoRootsPlaceholder(defaultCheckpointRootSelect); return; } defaultCheckpointRootSelect.innerHTML = ''; defaultCheckpointRootSelect.disabled = false; // 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); this.showNoRootsPlaceholder(defaultCheckpointRootSelect); showToast('toast.settings.checkpointRootsFailed', { message: error.message }, 'error'); } } async loadUnetRoots() { const defaultUnetRootSelect = document.getElementById('defaultUnetRoot'); if (!defaultUnetRootSelect) return; try { // 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) { this.showNoRootsPlaceholder(defaultUnetRootSelect); return; } defaultUnetRootSelect.innerHTML = ''; defaultUnetRootSelect.disabled = false; // 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); this.showNoRootsPlaceholder(defaultUnetRootSelect); showToast('toast.settings.unetRootsFailed', { message: error.message }, 'error'); } } async loadOtherRoots() { const selects = document.querySelectorAll('select[data-other-root-subtype]'); if (!selects.length) return; try { // Fetch other-model roots grouped by sub_type const response = await fetch('/api/lm/other/roots_by_subtype'); if (!response.ok) { throw new Error('Failed to fetch other model roots'); } const data = await response.json(); const groupedRoots = data.roots_by_subtype || {}; const defaultRoots = state.global.settings.default_other_roots || {}; selects.forEach((select) => { const subType = select.dataset.otherRootSubtype; const roots = groupedRoots[subType] || []; if (!roots.length) { this.showNoRootsPlaceholder(select); return; } select.innerHTML = ''; select.disabled = false; // Add options for each root roots.forEach(root => { const option = document.createElement('option'); option.value = root; option.textContent = root; select.appendChild(option); }); const defaultRoot = defaultRoots[subType] || ''; select.value = roots.includes(defaultRoot) ? defaultRoot : roots[0]; }); } catch (error) { console.error('Error loading other model roots:', error); selects.forEach((select) => this.showNoRootsPlaceholder(select)); showToast('toast.settings.otherRootsFailed', { message: error.message }, 'error'); } } async loadEmbeddingRoots() { const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot'); if (!defaultEmbeddingRootSelect) return; try { // 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) { this.showNoRootsPlaceholder(defaultEmbeddingRootSelect); return; } defaultEmbeddingRootSelect.innerHTML = ''; defaultEmbeddingRootSelect.disabled = false; // 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); this.showNoRootsPlaceholder(defaultEmbeddingRootSelect); 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 = `
`; 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); } } } clearExtraFolderPathError(input) { input.classList.remove('has-error'); const row = input.closest('.extra-folder-path-row'); if (row) { const errEl = row.querySelector('.extra-folder-path-error'); if (errEl) { errEl.classList.remove('visible'); errEl.textContent = ''; } } } _clearAllExtraFolderPathErrors() { document.querySelectorAll('.extra-folder-path-input.has-error').forEach((input) => { input.classList.remove('has-error'); }); document.querySelectorAll('.extra-folder-path-error.visible').forEach((el) => { el.classList.remove('visible'); el.textContent = ''; }); } _markExtraFolderPathsError(modelType, overlappingPaths, showMessage = false) { const container = document.getElementById(`extraFolderPaths-${modelType}`); if (!container) return; const inputs = container.querySelectorAll('.extra-folder-path-input'); inputs.forEach((input) => { const val = input.value.trim(); if (val && overlappingPaths.includes(val)) { input.classList.add('has-error'); if (showMessage) { const row = input.closest('.extra-folder-path-row'); if (row) { const errEl = row.querySelector('.extra-folder-path-error'); if (errEl) { errEl.textContent = translate('settings.extraFolderPaths.validation.checkpointUnetOverlapInline', {}, 'This path is also used for a different model type. Use separate folders for checkpoints and diffusion models.'); errEl.classList.add('visible'); } } } } }); } removeExtraFolderPathRow(btn, modelType) { const row = btn.closest('.extra-folder-path-row'); if (row) { row.remove(); this.updateExtraFolderPaths(modelType); } } async updateExtraFolderPaths(changedModelType) { // Clear previous errors this._clearAllExtraFolderPathErrors(); 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; }); // Client-side pre-check: checkpoints and unet must not share the same path. // Normalise paths to reduce false negatives vs the backend's realpath + normcase. const normalise = (p) => p.replace(/[/\\]+$/, '').toLowerCase(); const ckptSet = new Set((extraFolderPaths.checkpoints || []).map(normalise)); const unetSet = new Set((extraFolderPaths.unet || []).map(normalise)); const ckptOverlap = (extraFolderPaths.checkpoints || []).filter(p => p && unetSet.has(normalise(p))); const unetOverlap = (extraFolderPaths.unet || []).filter(p => p && ckptSet.has(normalise(p))); const hasOverlap = ckptOverlap.length > 0 || unetOverlap.length > 0; if (hasOverlap) { // Error message only on the side the user just edited. // The other side gets red border only (passive conflict indicator). if (changedModelType === 'checkpoints') { this._markExtraFolderPathsError('checkpoints', ckptOverlap, true); this._markExtraFolderPathsError('unet', unetOverlap, false); } else if (changedModelType === 'unet') { this._markExtraFolderPathsError('unet', unetOverlap, true); this._markExtraFolderPathsError('checkpoints', ckptOverlap, false); } else { // Pre-existing conflict from direct config edit — mark both without messages this._markExtraFolderPathsError('checkpoints', ckptOverlap, false); this._markExtraFolderPathsError('unet', unetOverlap, false); } return; } // 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(); } } // --- Standalone Model Paths section -------------------------------------- // The section only exists in standalone mode, where primary folder_paths // are read from settings.json instead of the ComfyUI host. Editors are // rendered from the backend-provided folder_path_schema so new model // categories appear automatically. setupModelPathsSection() { if (this.modelPathsSectionInitialized) return; if (!state.global.settings.standalone_mode) return; const navGroup = document.querySelector('.settings-nav-list .settings-nav-group'); const settingsForm = document.querySelector('.settings-form'); if (!navGroup || !settingsForm) return; const navButton = document.createElement('button'); navButton.type = 'button'; navButton.className = 'settings-nav-item'; navButton.dataset.section = 'modelPaths'; navButton.textContent = translate('settings.nav.modelPaths', {}, 'Model Paths'); const section = document.createElement('div'); section.className = 'settings-section'; section.id = 'section-modelPaths'; section.dataset.section = 'modelPaths'; section.innerHTML = `

${translate('settings.modelPaths.title', {}, 'Model Library Paths')}

${translate('settings.modelPaths.description', {}, 'Root folders LoRA Manager scans for your models. Changes take effect after restarting the server.')}
${translate('settings.modelPaths.pendingRestartNotice', {}, 'Path changes saved. Restart LoRA Manager for them to take effect.')}

${translate('settings.modelPaths.coreTypes', {}, 'Core Model Types')}

${translate('settings.modelPaths.otherTypes', {}, 'Other Model Types')}

${this._buildModelPathSubTypeCheckboxes()}
${translate('settings.modelPaths.otherTypesDisabledHint', {}, 'No other model types are enabled. Turn on the types you need above to configure their folders.')}
`; // The static nav items were bound by initializeNavigation() before the // backend sync completed, so this dynamically added item carries its // own handler with the same show-section behavior. navButton.addEventListener('click', () => { document.querySelectorAll('.settings-section').forEach((s) => s.classList.remove('active')); section.classList.add('active'); document.querySelectorAll('.settings-nav-item').forEach((n) => n.classList.remove('active')); navButton.classList.add('active'); }); navGroup.appendChild(navButton); settingsForm.appendChild(section); this.modelPathsSectionInitialized = true; } _buildModelPathSubTypeCheckboxes() { const schema = state.global.settings.folder_path_schema || []; const subTypes = []; schema.forEach((entry) => { if (entry.category === 'other' && entry.sub_type && !subTypes.includes(entry.sub_type)) { subTypes.push(entry.sub_type); } }); return subTypes.map((subType) => { const labelKey = OTHER_SUB_TYPE_LABEL_KEYS[subType]; const label = labelKey ? translate(labelKey, {}, subType) : subType; return ` `; }).join(''); } /** * Master toggle inside the standalone Model Paths section. Edits the same * enable_other_models key as the Library tab control and keeps both in * sync, then re-renders the other-model path editors in place. */ async handleModelPathsEnableOtherModels() { const toggle = document.getElementById('modelPathsEnableOtherModels'); if (!toggle) return; const enabled = toggle.checked; const previous = !!state.global.settings.enable_other_models; try { await this.saveSetting('enable_other_models', enabled); // Mirror the Library tab flow: refresh its controls and roots, // then re-render this section's editors immediately. this.updateOtherModelsControls(); await this.loadOtherRoots(); this.updateOtherModelsControls(); this.updateOtherModelsNavVisibility(enabled); this.removeOtherModelsAnnouncement(enabled); this.loadModelPaths(); showToast('toast.settings.settingsUpdated', { setting: 'enable other models' }, 'success'); } catch (error) { toggle.checked = previous; state.global.settings.enable_other_models = previous; showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error'); } } /** * Sub-type checkboxes inside the standalone Model Paths section. Saves the * same enabled_other_sub_types allow-list as the Library tab checkboxes * (which use data-other-subtype-toggle, so the two never mix) and * re-renders the editors without requiring a modal reopen. */ async handleModelPathsSubTypeToggles() { const values = Array.from(document.querySelectorAll('[data-model-paths-subtype]')) .filter((input) => input.checked) .map((input) => input.value); const previous = state.global.settings.enabled_other_sub_types; try { await this.saveSetting('enabled_other_sub_types', values); this.updateOtherModelsControls(); await this.loadOtherRoots(); this.updateOtherModelsControls(); this.loadModelPaths(); showToast('toast.settings.settingsUpdated', { setting: 'other model types' }, 'success'); } catch (error) { state.global.settings.enabled_other_sub_types = previous; this.loadModelPaths(); showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error'); } } loadModelPaths() { if (!state.global.settings.standalone_mode) return; const coreHost = document.getElementById('modelPathsCoreTypes'); const otherHost = document.getElementById('modelPathsOtherTypes'); if (!coreHost || !otherHost) return; coreHost.innerHTML = ''; otherHost.innerHTML = ''; const schema = state.global.settings.folder_path_schema || []; const otherModelsEnabled = state.global.settings.enable_other_models === true; const enabledSubTypes = new Set(state.global.settings.enabled_other_sub_types || []); // Keep the inline enable controls in sync with the current settings. const masterToggle = document.getElementById('modelPathsEnableOtherModels'); if (masterToggle) { masterToggle.checked = otherModelsEnabled; } document.querySelectorAll('[data-model-paths-subtype]').forEach((input) => { input.checked = enabledSubTypes.has(input.value); input.disabled = !otherModelsEnabled; }); const subTypeToggles = document.getElementById('modelPathsSubTypeToggles'); if (subTypeToggles) { subTypeToggles.classList.toggle('is-disabled', !otherModelsEnabled); } let otherCount = 0; schema.forEach((entry) => { if (entry.category === 'core') { this._buildModelPathTypeGroup(coreHost, entry); } else if (otherModelsEnabled && entry.sub_type && enabledSubTypes.has(entry.sub_type)) { otherCount++; this._buildModelPathTypeGroup(otherHost, entry); } }); const emptyHint = document.getElementById('modelPathsOtherEmpty'); if (emptyHint) { emptyHint.style.display = otherCount === 0 ? 'block' : 'none'; } const folderPaths = state.global.settings.folder_paths || {}; schema.forEach((entry) => { const container = document.getElementById(`modelFolderPaths-${entry.key}`); if (!container) return; container.innerHTML = ''; const paths = folderPaths[entry.key] || []; paths.forEach((path) => { this.addModelFolderPathRow(entry.key, path); }); // No trailing empty row on load: an unconfigured type shows just // its Add button, and removing a row never resurrects an empty one. }); } _buildModelPathTypeGroup(host, entry) { const item = document.createElement('div'); item.className = 'setting-item'; const label = translate(`settings.modelPaths.folderKeys.${entry.key}`, {}, entry.key); item.innerHTML = `
`; host.appendChild(item); } addModelFolderPathRow(key, path = '', shouldFocus = true) { const container = document.getElementById(`modelFolderPaths-${key}`); if (!container) return; const row = document.createElement('div'); row.className = 'extra-folder-path-row mapping-row'; row.innerHTML = `
`; container.appendChild(row); if (!path && shouldFocus) { const input = row.querySelector('.extra-folder-path-input'); if (input) { setTimeout(() => input.focus(), 0); } } } clearModelFolderPathError(input) { input.classList.remove('has-error'); const row = input.closest('.extra-folder-path-row'); if (row) { const errEl = row.querySelector('.extra-folder-path-error'); if (errEl) { errEl.classList.remove('visible'); errEl.textContent = ''; } } } _clearAllModelFolderPathErrors() { const section = document.getElementById('section-modelPaths'); if (!section) return; section.querySelectorAll('.extra-folder-path-input.has-error').forEach((input) => { input.classList.remove('has-error'); }); section.querySelectorAll('.extra-folder-path-error.visible').forEach((el) => { el.classList.remove('visible'); el.textContent = ''; }); } _markModelFolderPathsError(key, overlappingPaths, showMessage = false) { const container = document.getElementById(`modelFolderPaths-${key}`); if (!container) return; const inputs = container.querySelectorAll('.extra-folder-path-input'); inputs.forEach((input) => { const val = input.value.trim(); if (val && overlappingPaths.includes(val)) { input.classList.add('has-error'); if (showMessage) { const row = input.closest('.extra-folder-path-row'); if (row) { const errEl = row.querySelector('.extra-folder-path-error'); if (errEl) { errEl.textContent = translate('settings.extraFolderPaths.validation.checkpointUnetOverlapInline', {}, 'This path is also used for a different model type. Use separate folders for checkpoints and diffusion models.'); errEl.classList.add('visible'); } } } } }); } removeModelFolderPathRow(btn, key) { const row = btn.closest('.extra-folder-path-row'); if (row) { row.remove(); this.updateModelFolderPaths(key, { fromRemoval: true }); } } async updateModelFolderPaths(changedKey, { fromRemoval = false } = {}) { this._clearAllModelFolderPathErrors(); const folderPaths = {}; const section = document.getElementById('section-modelPaths'); if (!section) return; section.querySelectorAll('.extra-folder-paths-container[id^="modelFolderPaths-"]').forEach((container) => { const key = container.id.slice('modelFolderPaths-'.length); const paths = []; container.querySelectorAll('.extra-folder-path-input').forEach((input) => { const value = input.value.trim(); if (value) { paths.push(value); } }); folderPaths[key] = paths; }); // Client-side pre-check: checkpoints and unet must not share the same path. const normalise = (p) => p.replace(/[/\\]+$/, '').toLowerCase(); const ckptSet = new Set((folderPaths.checkpoints || []).map(normalise)); const unetSet = new Set((folderPaths.unet || []).map(normalise)); const ckptOverlap = (folderPaths.checkpoints || []).filter(p => p && unetSet.has(normalise(p))); const unetOverlap = (folderPaths.unet || []).filter(p => p && ckptSet.has(normalise(p))); const hasOverlap = ckptOverlap.length > 0 || unetOverlap.length > 0; if (hasOverlap) { if (changedKey === 'checkpoints') { this._markModelFolderPathsError('checkpoints', ckptOverlap, true); this._markModelFolderPathsError('unet', unetOverlap, false); } else if (changedKey === 'unet') { this._markModelFolderPathsError('unet', unetOverlap, true); this._markModelFolderPathsError('checkpoints', ckptOverlap, false); } else { this._markModelFolderPathsError('checkpoints', ckptOverlap, false); this._markModelFolderPathsError('unet', unetOverlap, false); } return; } const currentPaths = state.global.settings.folder_paths || {}; const pathsChanged = JSON.stringify(currentPaths) !== JSON.stringify(folderPaths); if (!pathsChanged) { return; } state.global.settings.folder_paths = folderPaths; try { await this.saveSetting('folder_paths', folderPaths); // The "model folders need setup" startup banner is obsolete once // at least one folder path is configured. const hasAnyPath = Object.values(folderPaths).some(value => { const list = Array.isArray(value) ? value : [value]; return list.some(path => typeof path === 'string' && path.trim()); }); if (hasAnyPath) { bannerService.removeBannerElement('startup-missing-model-paths'); } this._markModelPathsDirty(); showToast('settings.modelPaths.saveSuccessRestart', {}, 'success'); // Keep the continuous-add flow: after the user fills the trailing // empty row, append a fresh one — but never after a removal. const container = document.getElementById(`modelFolderPaths-${changedKey}`); if (container && !fromRemoval) { const inputs = container.querySelectorAll('.extra-folder-path-input'); const hasEmptyRow = Array.from(inputs).some((input) => !input.value.trim()); if (!hasEmptyRow) { this.addModelFolderPathRow(changedKey, ''); } } } catch (error) { console.error('Failed to save folder paths:', error); showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error'); state.global.settings.folder_paths = currentPaths; this.loadModelPaths(); } } /** * Surface a persistent "restart required" cue after folder_paths changes: * a dot on the Model Paths nav item, an inline notice in the section, and * a global banner. The banner id is unique per change because dismissed * banner ids persist across restarts — reusing one would mute future * reminders. The whole state clears on the next page load (i.e. after the * restart the user was asked to do). */ _markModelPathsDirty() { if (this.modelPathsDirty) return; this.modelPathsDirty = true; document.querySelector('.settings-nav-item[data-section="modelPaths"]') ?.classList.add('has-pending-restart'); document.getElementById('modelPathsRestartNotice')?.classList.add('visible'); const bannerId = `model-paths-restart-${Date.now()}`; bannerService.registerBanner(bannerId, { id: bannerId, title: translate('settings.modelPaths.pendingRestartBannerTitle', {}, 'Restart required to apply path changes'), content: translate('settings.modelPaths.pendingRestartBannerMessage', {}, 'Model library paths were updated. Restart the LoRA Manager server to scan the new folders.'), dismissible: true, // Above startup warnings (60), below startup errors (90): a pending // restart is the most actionable state and should preempt prompts. priority: 80, }); } 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 = `
`; // 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 = `` + availableModels.map(model => `` ).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 = ` ${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 = ` ${translate('settings.downloadPathTemplates.validation.invalidChars', {}, 'Invalid characters detected')}`; validationElement.classList.add('invalid'); return false; } // Check for double slashes if (template.includes('//')) { validationElement.innerHTML = ` ${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 = ` ${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 = ` ${translate('settings.downloadPathTemplates.validation.invalidPlaceholder', { placeholder: invalidPlaceholders[0] }, `Invalid placeholder: ${invalidPlaceholders[0]}`)}`; validationElement.classList.add('invalid'); return false; } // Template is valid validationElement.innerHTML = ` ${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'); } } loadFilenameTemplates() { const templates = state.global.settings.download_filename_templates || DEFAULT_FILENAME_TEMPLATES; ['lora', 'checkpoint', 'embedding'].forEach(modelType => { const input = document.getElementById(`${modelType}FilenameTemplate`); if (!input) return; const template = templates[modelType] || ''; input.value = template; this.validateFilenameTemplate(modelType, template); this.updateFilenamePreview(modelType, template); }); } validateFilenameTemplate(modelType, template) { const validationElement = document.getElementById(`${modelType}FilenameValidation`); if (!validationElement) return true; // Reset validation state validationElement.innerHTML = ''; validationElement.className = 'template-validation'; if (!template) { validationElement.innerHTML = ` ${translate('settings.filenameTemplates.validation.restoreOriginal', {}, 'Valid (empty template restores original filenames)')}`; validationElement.classList.add('valid'); return true; } // A filename stem cannot contain path separators or OS-illegal characters const invalidChars = /[/\\<>:"|?*]/; if (invalidChars.test(template)) { validationElement.innerHTML = ` ${translate('settings.filenameTemplates.validation.invalidChars', {}, 'Invalid characters detected (a filename cannot contain / \\ < > : " | ? *)')}`; 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 => !FILENAME_TEMPLATE_PLACEHOLDERS.includes(match) ); if (invalidPlaceholders.length > 0) { validationElement.innerHTML = ` ${translate('settings.filenameTemplates.validation.invalidPlaceholder', { placeholder: invalidPlaceholders[0] }, `Invalid placeholder: ${invalidPlaceholders[0]}`)}`; validationElement.classList.add('invalid'); return false; } // Template is valid validationElement.innerHTML = ` ${translate('settings.filenameTemplates.validation.validTemplate', {}, 'Valid template')}`; validationElement.classList.add('valid'); return true; } updateFilenameTemplate(modelType, template) { if (!this.validateFilenameTemplate(modelType, template)) { return; // Don't save invalid templates } // Update state if (!state.global.settings.download_filename_templates) { state.global.settings.download_filename_templates = { ...DEFAULT_FILENAME_TEMPLATES }; } state.global.settings.download_filename_templates[modelType] = template; // Update preview this.updateFilenamePreview(modelType, template); // Save settings this.saveFilenameTemplates(); } updateFilenamePreview(modelType, template) { const previewElement = document.getElementById(`${modelType}FilenamePreview`); if (!previewElement) return; if (!template) { // Empty template restores the recorded original filename previewElement.textContent = 'V1.safetensors'; } else { const exampleStem = template .replaceAll('{model_name}', 'model-name') .replaceAll('{version_name}', 'v3') .replaceAll('{base_model}', 'Flux.1 D') .replaceAll('{author}', 'authorname') .replaceAll('{first_tag}', 'style') .replaceAll('{hash_short}', 'a1b2c3d4e5') .replaceAll('{original_name}', 'V1'); previewElement.textContent = `${exampleStem}.safetensors`; } previewElement.style.display = 'block'; } async saveFilenameTemplates() { try { // Save to backend using universal save method await this.saveSetting('download_filename_templates', state.global.settings.download_filename_templates); showToast('toast.settings.filenameTemplatesUpdated', {}, 'success'); } catch (error) { console.error('Error saving download filename templates:', error); showToast('toast.settings.filenameTemplatesFailed', { message: error.message }, 'error'); } } /** * Confirm the bulk apply/revert via a dedicated modal. Self-managed (NOT * through ModalManager): it stacks above the settings modal, and * ModalManager's "close current modal on open" behavior would kill the * settings modal underneath. Resolves true when the user confirms. */ confirmFilenameTemplateApply(isRevert) { const modalElement = document.getElementById('filenameTemplateConfirmModal'); if (!modalElement) { return Promise.resolve(true); } const titleElement = modalElement.querySelector('[data-role="title"]'); if (titleElement) { titleElement.textContent = isRevert ? translate('modals.filenameTemplateConfirm.titleRevert', {}, 'Restore original filenames?') : translate('modals.filenameTemplateConfirm.titleApply', {}, 'Apply filename template to library?'); } const messageElement = modalElement.querySelector('[data-role="message"]'); if (messageElement) { messageElement.textContent = isRevert ? translate('settings.filenameTemplates.confirmRevert', {}, 'Restore the recorded original filename for all previously renamed files of this model type? This changes the relative path seen by ComfyUI loaders. Files without a recorded original filename are skipped.') : translate('settings.filenameTemplates.confirmApply', {}, 'Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model\'s metadata.'); } const confirmButton = modalElement.querySelector('[data-action="confirm-filename-template"]'); const cancelButton = modalElement.querySelector('[data-action="cancel-filename-template"]'); if (!confirmButton || !cancelButton) { return Promise.resolve(true); } confirmButton.textContent = isRevert ? translate('modals.filenameTemplateConfirm.revertButton', {}, 'Restore Original Filenames') : translate('settings.filenameTemplates.applyButton', {}, 'Apply to Library Now'); return new Promise((resolve) => { let resolved = false; const cleanup = () => { confirmButton.removeEventListener('click', handleConfirm); cancelButton.removeEventListener('click', handleCancel); document.removeEventListener('keydown', handleEscape, true); }; const finalize = (proceed) => { if (resolved) { return; } resolved = true; cleanup(); modalElement.classList.remove('show'); // Keep body.modal-open: the settings modal underneath is still open. resolve(proceed); }; const handleConfirm = (event) => { event.preventDefault(); finalize(true); }; const handleCancel = (event) => { event.preventDefault(); finalize(false); }; // Capture phase + stopPropagation so ESC never reaches the // settings modal's own ESC handler underneath. const handleEscape = (event) => { if (event.key === 'Escape') { event.stopPropagation(); finalize(false); } }; confirmButton.addEventListener('click', handleConfirm); cancelButton.addEventListener('click', handleCancel); document.addEventListener('keydown', handleEscape, true); modalElement.classList.add('show'); cancelButton.focus(); }); } async applyFilenameTemplate(modelType) { const template = state.global.settings.download_filename_templates?.[modelType] || ''; // An empty template reverts renamed models to their recorded original // filename instead of rendering a template. const confirmed = await this.confirmFilenameTemplateApply(!template); if (!confirmed) { return; } try { const apiClient = getModelApiClient(FILENAME_TEMPLATE_MODEL_TYPES[modelType]); await apiClient.applyFilenameTemplate(); resetAndReload(true); } catch (error) { // The API client already surfaced a toast with the failure reason console.error('Error applying filename template:', 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(); } if (settingKey === 'enable_other_models') { // Roots only exist while the feature is on, so re-fetch them // after the backend rebuilt the other-model root set. this.updateOtherModelsControls(); await this.loadOtherRoots(); this.updateOtherModelsControls(); this.updateOtherModelsNavVisibility(value); this.removeOtherModelsAnnouncement(value); } 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' || settingKey === 'group_by_model') { // Save/restore sort preference when toggling group_by_model if (settingKey === 'group_by_model' && window.pageControls?.onGroupByModelToggled) { window.pageControls.onGroupByModelToggled(value); } 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 { // Recipes layout has its own shared entry point used by both the // settings modal segmented control and the recipes page toolbar toggle if (settingKey === 'recipes_layout') { return this.saveRecipesLayout(element.value); } // 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 === 'version_grouping' || settingKey === 'mature_blur_level' ) { this.reloadContent(); } } catch (error) { showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error'); } } /** * Save one sub_type entry of the default_other_roots dict setting * (read-modify-write: the backend stores the whole mapping). */ async saveOtherRootSetting(subType, value) { try { const defaultRoots = { ...(state.global.settings.default_other_roots || {}) }; if (value) { defaultRoots[subType] = value; } else { delete defaultRoots[subType]; } await this.saveSetting('default_other_roots', defaultRoots); showToast('toast.settings.settingsUpdated', { setting: `default ${subType} root` }, 'success'); } catch (error) { showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error'); } } /** * Reflect the opt-in Other Models state in the settings UI: the master * toggle gates every sub_type checkbox, and a switched-off sub_type has * its default-root select disabled. Never force-enables a select (the * no-roots placeholder owns that state). */ updateOtherModelsControls() { const enableOtherModels = !!state.global.settings.enable_other_models; const enabledSubTypes = new Set( state.global.settings.enabled_other_sub_types || ['vae', 'upscaler', 'text_encoder'] ); const masterToggle = document.getElementById('enableOtherModels'); if (masterToggle) { masterToggle.checked = enableOtherModels; } document.querySelectorAll('[data-other-subtype-toggle]').forEach((input) => { input.checked = enabledSubTypes.has(input.value); input.disabled = !enableOtherModels; }); const container = document.getElementById('otherSubTypeToggles'); if (container) { container.classList.toggle('is-disabled', !enableOtherModels); } document.querySelectorAll('select[data-other-root-subtype]').forEach((select) => { const subType = select.dataset.otherRootSubtype; if (!enableOtherModels || !enabledSubTypes.has(subType)) { select.disabled = true; } }); } /** * Persist the whole enabled_other_sub_types list (the backend stores an * allow-list) and refresh the per-sub_type default-root selects. */ async saveEnabledOtherSubTypes() { const values = Array.from( document.querySelectorAll('[data-other-subtype-toggle]') ) .filter((input) => input.checked) .map((input) => input.value); try { await this.saveSetting('enabled_other_sub_types', values); this.updateOtherModelsControls(); await this.loadOtherRoots(); this.updateOtherModelsControls(); showToast('toast.settings.settingsUpdated', { setting: 'other model types' }, 'success'); } catch (error) { showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error'); } } /** * Show or hide the Other Models nav entry. The nav is server-rendered, so * toggling the class here keeps it in sync when the switch is flipped from * the settings modal (no reload needed). */ updateOtherModelsNavVisibility(enabled) { const navItem = document.getElementById('otherNavItem'); if (navItem) { navItem.classList.toggle('nav-item--hidden', !enabled); } } /** * Drop the Other Models announcement banner once the feature is on. */ removeOtherModelsAnnouncement(enabled) { if (!enabled) { return; } bannerService.removeOtherModelsAnnouncement(); } /** * Save the recipes page layout (grid | masonry) and rebuild the scroller. * Shared entry point for the settings modal segmented control and the * recipes page toolbar toggle; both stay in sync via * updateRecipesLayoutControls(). */ async saveRecipesLayout(value) { if (value !== 'grid' && value !== 'masonry') { return; } // Update frontend state with mapped keys await this.saveSetting('recipes_layout', value); // Apply frontend settings immediately this.applyFrontendSettings(); // Dispatch layout change event; the scroller instance is about to be rebuilt, // so calculateLayout() must NOT run on the old instance here window.dispatchEvent(new CustomEvent('lm:recipes-layout-changed')); this.updateRecipesLayoutControls(value); } /** * Sync the active state of every recipes layout control * (settings modal segmented control and recipes page toolbar toggle). */ updateRecipesLayoutControls(value) { document.querySelectorAll('[data-recipes-layout]').forEach((control) => { const active = control.dataset.recipesLayout === value; control.classList.toggle('active', active); if (control.hasAttribute('aria-pressed')) { control.setAttribute('aria-pressed', String(active)); } if (control.hasAttribute('aria-checked')) { control.setAttribute('aria-checked', String(active)); } }); } async saveRangeSetting(elementId, displayId, settingKey) { const element = document.getElementById(elementId); if (!element) return; const value = parseInt(element.value, 10); try { await this.saveSetting(settingKey, value); this.applyFrontendSettings(); // Update the displayed value next to the slider const displayEl = document.getElementById(displayId); if (displayEl) { displayEl.textContent = `${value}px`; } const max = parseInt(element.max, 10) || 20; element.style.setProperty('--range-fill', (value / max * 100) + '%'); showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success'); } 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; } const enableCivarchiveApiCheckbox = document.getElementById('enableCivarchiveApi'); if (enableCivarchiveApiCheckbox) { enableCivarchiveApiCheckbox.checked = state.global.settings.enable_civarchive_api ?? true; } const metadataProviderOrderSelect = document.getElementById('metadataProviderOrder'); if (metadataProviderOrderSelect) { metadataProviderOrderSelect.value = state.global.settings.metadata_provider_order || 'civitai_archive_sqlite'; } // 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 = `
${translate('settings.backup.autoEnabled', {}, 'Automatic snapshots')}
${enabled ? translate('common.status.enabled') : translate('common.status.disabled')}
${translate('settings.backup.retention', {}, 'Retention')}
${retentionCount}
${translate('settings.backup.snapshotCount', {}, 'Saved snapshots')}
${status.snapshotCount ?? 0}
${translate('settings.backup.latestAutoSnapshot', {}, 'Latest auto snapshot')}
${formatTimestamp(latestAutoSnapshot?.mtime)}
${renderSnapshotDetail(latestAutoSnapshot)}
`; } 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 = `
${translate('settings.metadataArchive.status')}: ${status.isAvailable ? translate('settings.metadataArchive.statusAvailable') : translate('settings.metadataArchive.statusUnavailable')} ${sizeText}
${translate('settings.metadataArchive.enabled')}: ${status.isEnabled ? translate('common.status.enabled') : translate('common.status.disabled')}
`; // 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) => ` `).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'); } } // ── Directory picker + live path validation ───────────────────────── // Validation is advisory only: it never blocks or alters save flows. attachPathField(inputId, { expect = 'directory', onAfterSelect } = {}) { const input = document.getElementById(inputId); if (!input) { console.warn(`SettingsManager.attachPathField: #${inputId} not found`); return; } if (input.dataset.pathFieldAttached === '1') return; input.dataset.pathFieldAttached = '1'; const browseBtn = document.createElement('button'); browseBtn.type = 'button'; browseBtn.className = 'browse-path-btn inset'; browseBtn.title = translate('settings.directoryPicker.title', {}, 'Browse Folders'); browseBtn.innerHTML = ''; // Inset layout: the button is absolutely positioned inside the right // edge of the input, so the row keeps its original single-control look. const parent = input.parentElement; let wrapper; let statusHost; if (parent && parent.classList.contains('path-control')) { // e.g. #exampleImagesPath sits beside a Download button: wrap only // the input so the button insets into it and Download stays beside it. wrapper = document.createElement('div'); wrapper.className = 'text-input-wrapper'; parent.insertBefore(wrapper, input); wrapper.appendChild(input); statusHost = parent; } else { // .text-input-wrapper provided by the setting_input macro wrapper = parent; statusHost = parent; } wrapper.appendChild(browseBtn); input.classList.add('has-inset-browse'); const statusEl = document.createElement('div'); statusEl.className = 'path-validation'; statusHost.appendChild(statusEl); input._pathFieldConfig = { expect, onAfterSelect, statusEl }; browseBtn.addEventListener('click', () => this.browseForPath(inputId)); input.addEventListener('blur', () => { clearTimeout(input._pathValidationTimer); this.validatePath(input, statusEl, expect); }); input.addEventListener('input', () => { clearTimeout(input._pathValidationTimer); input._pathValidationTimer = setTimeout(() => { this.validatePath(input, statusEl, expect); }, 500); }); } async validatePath(input, statusEl, expect = 'directory') { const value = input.value.trim(); const seq = input._pathValidationSeq = (input._pathValidationSeq || 0) + 1; if (!value) { this._clearPathStatus(statusEl); return; } let data; try { const response = await fetch('/api/lm/validate-path', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: value, expect }), }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } data = await response.json(); } catch (error) { if (seq === input._pathValidationSeq) { this._clearPathStatus(statusEl); } return; } // Ignore responses overtaken by newer input or validation runs. if (seq !== input._pathValidationSeq || input.value.trim() !== value) { return; } if (data.success && !data.error_code) { statusEl.innerHTML = `${translate('settings.pathValidation.valid', {}, 'Path is valid')}`; statusEl.classList.add('visible', 'valid'); } else { const message = this._getPathValidationMessage(data); statusEl.innerHTML = ''; statusEl.querySelector('span').textContent = message; statusEl.classList.add('visible'); statusEl.classList.remove('valid'); } } _getPathValidationMessage(data) { const entry = PATH_VALIDATION_ERROR_I18N[data?.error_code]; if (entry) { return translate(entry.key, {}, entry.fallback); } return data?.error || translate('settings.pathValidation.pathNotFound', {}, 'Path does not exist'); } _clearPathStatus(statusEl) { statusEl.classList.remove('visible', 'valid'); statusEl.textContent = ''; } browseForPath(inputId, { onAfterSelect } = {}) { const input = document.getElementById(inputId); if (!input) return; const config = input._pathFieldConfig || {}; const afterSelect = onAfterSelect || config.onAfterSelect; directoryPickerModal.open({ initialPath: input.value.trim(), onSelect: (pickedPath) => { input.value = pickedPath; if (config.statusEl) { this.validatePath(input, config.statusEl, config.expect || 'directory'); } if (typeof afterSelect === 'function') { afterSelect(pickedPath); } }, }); } // Browse variant for the dynamic extra/model folder path rows: picking a // folder routes through the row's existing validation + save logic. browseForPathRow(btn, key, isModelPath = false) { const row = btn.closest('.extra-folder-path-row'); const input = row ? row.querySelector('.extra-folder-path-input') : null; if (!input) return; directoryPickerModal.open({ initialPath: input.value.trim(), onSelect: (pickedPath) => { input.value = pickedPath; if (isModelPath) { this.updateModelFolderPaths(key); } else { this.updateExtraFolderPaths(key); } }, }); } 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'); } } // ── CivitAI API Key management ────────────────────────────── updateApiKeyStatus() { const hasKey = !!(state.global.settings.civitai_api_key_set || state.global.settings.civitai_api_key); const statusEl = document.getElementById('civitaiApiKeyStatus'); const statusText = document.getElementById('civitaiApiKeyStatusText'); const actionBtn = document.getElementById('civitaiApiKeyActionBtn'); if (!statusText || !actionBtn) return; if (hasKey) { statusText.classList.remove('api-key-status--unconfigured'); statusText.classList.add('api-key-status--configured'); statusText.innerHTML = ' ' + translate('settings.civitaiApiKeyConfigured', {}, 'Configured'); actionBtn.textContent = translate('common.actions.change', {}, 'Change'); } else { statusText.classList.remove('api-key-status--configured'); statusText.classList.add('api-key-status--unconfigured'); statusText.innerHTML = ' ' + translate('settings.civitaiApiKeyNotConfigured', {}, 'Not configured'); actionBtn.textContent = translate('settings.civitaiApiKeySet', {}, 'Set up'); } } updateLlmApiKeyStatus() { const hasKey = !!(state.global.settings.llm_api_key_set || state.global.settings.llm_api_key); const statusText = document.getElementById('llmApiKeyStatusText'); const actionBtn = document.getElementById('llmApiKeyActionBtn'); if (!statusText || !actionBtn) return; if (hasKey) { statusText.classList.remove('api-key-status--unconfigured'); statusText.classList.add('api-key-status--configured'); statusText.innerHTML = ' ' + translate('settings.aiProvider.apiKeyConfigured', {}, 'Configured'); actionBtn.textContent = translate('common.actions.change', {}, 'Change'); } else { statusText.classList.remove('api-key-status--configured'); statusText.classList.add('api-key-status--unconfigured'); statusText.innerHTML = ' ' + translate('settings.aiProvider.apiKeyNotSet', {}, 'Not set'); actionBtn.textContent = translate('settings.aiProvider.apiKeySet', {}, 'Set up'); } } editApiKey(settingsKey = 'civitai_api_key', inputId = 'civitaiApiKey') { const statusId = inputId + 'Status'; const editId = inputId + 'Edit'; const statusEl = document.getElementById(statusId); if (statusEl) statusEl.classList.add('is-hidden'); const editContainer = document.getElementById(editId); if (editContainer) editContainer.classList.remove('is-hidden'); // Focus the input const input = document.getElementById(inputId); if (input) { input.value = ''; // Never pre-fill the secret setTimeout(() => input.focus(), 50); } } cancelEditApiKey(silent, inputId = 'civitaiApiKey') { const editId = inputId + 'Edit'; const statusId = inputId + 'Status'; const editContainer = document.getElementById(editId); if (editContainer) editContainer.classList.add('is-hidden'); const statusContainer = document.getElementById(statusId); if (statusContainer) statusContainer.classList.remove('is-hidden'); // Clear any typed value const input = document.getElementById(inputId); if (input) input.value = ''; if (!silent) { if (inputId === 'civitaiApiKey') { this.updateApiKeyStatus(); } } } async saveApiKey(settingsKey = 'civitai_api_key', inputId = 'civitaiApiKey') { const input = document.getElementById(inputId); if (!input) return; const value = input.value.trim(); try { await this.saveSetting(settingsKey, value); const labelName = settingsKey === 'civitai_api_key' ? 'CivitAI API Key' : 'LLM API Key'; showToast('toast.settings.settingsUpdated', { setting: labelName }, 'success'); } catch (error) { showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error'); return; } // Update the in-memory flag so the UI reflects the change if (settingsKey === 'civitai_api_key') { state.global.settings.civitai_api_key_set = !!value; } this.cancelEditApiKey(true, inputId); if (inputId === 'civitaiApiKey') { this.updateApiKeyStatus(); } } toggleInputVisibility(button) { const input = button.parentElement.querySelector('input'); if (!input) return; const icon = button.querySelector('i'); if (input.dataset.mask === 'css') { // CSS-masked input (CivitAI API key) — toggle class, not type input.classList.toggle('api-key-masked'); if (icon) { icon.className = input.classList.contains('api-key-masked') ? 'fas fa-eye' : 'fas fa-eye-slash'; } } else if (input.type === 'password') { input.type = 'text'; if (icon) icon.className = 'fas fa-eye-slash'; } else { input.type = 'password'; if (icon) 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(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); } else if (this.currentPage === 'other') { // Reload the other models without updating folders await resetAndReload(false); } } applyFrontendSettings() { // Apply card blur amount to CSS custom property const cardBlurAmount = state.global.settings.card_blur_amount ?? 8; document.documentElement.style.setProperty('--card-blur-amount', `${cardBlurAmount}px`); // 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); // Apply license icon style const useNewLicenseIcons = state.global.settings.use_new_license_icons !== false; document.body.classList.toggle('use-new-license-icons', useNewLicenseIcons); // Apply group-by-model mode const groupByModel = !!state.global.settings.group_by_model; document.body.classList.toggle('group-by-model', groupByModel); // Apply sticky controls mode (keeps the action bar visible while scrolling) const stickyControls = !!state.global.settings.sticky_controls; document.body.classList.toggle('sticky-controls', stickyControls); } } // Create singleton instance export const settingsManager = new SettingsManager();