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.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.folderSettings.otherSubTypes', {}, 'Enabled 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 `
${label}
`;
}).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 = `
${label}
${translate('common.actions.add', {}, 'Add')}
`;
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 = `
${translate('settings.downloadPathTemplates.selectBaseModel', {}, 'Select Base Model')}
${availableModels.map(model =>
`${model} `
).join('')}
`;
// 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 = `${translate('settings.downloadPathTemplates.selectBaseModel', {}, 'Select Base Model')} ` +
availableModels.map(model =>
`${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) => `
${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();