// static/js/settings.js — Settings panel module (ES6) // User-facing preferences: AI models, search, appearance import uiModule from './ui.js'; import searchModule from './search.js'; import { byId } from './settings/dom.js'; import { getSettingsRegistryIssues, isAdminManagedSettingsTab, } from './settings/registry.js'; import { bindSettingsSearch } from './settings/search.js'; import { bindSettingsSidebar } from './settings/sidebar.js'; import { activateSettingsPanel, getActiveSettingsTab, bindSettingsNavigation, } from './settings/navigation.js'; import { bindSettingsDrag, bindSettingsClose, bindOpenPromptModalLink, showSettingsModal, hideSettingsModal, } from './settings/lifecycle.js'; import { sortModelIds } from './modelSort.js'; import { providerLogo } from './providers.js'; import { isAltGrEvent } from './platform.js'; import { bindMenuDismiss } from './escMenuStack.js'; import { invalidateSettings } from './appConfig.js'; let initialized = false; let modalEl = null; let _authPolicy = { password_min_length: 8 }; /** * POST a settings patch, then drop the shared snapshot in appConfig.js. * * Every write in this file goes through here so no save path can forget the * invalidation — a stale settings object served for the rest of the session is * a worse bug than the duplicate fetches the cache removes. The invalidation is * in a `finally` because a request that throws on the way back may still have * been applied server-side. * * Reads in this file deliberately stay direct fetches: this panel is the writer * and edits what it reads, so it must see the authoritative state, not a cache. */ async function _postSettings(body) { try { return await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } finally { invalidateSettings(); } } const el = byId; function esc(s) { return uiModule.esc(s); } function safeRasterDataUrl(raw) { const value = String(raw || '').trim(); return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : ''; } /* ── Settings shell coordination ── */ function onSettingsPanelActivated(tab) { // Appearance keeps its existing transparent preview behavior. document.body.classList.toggle('settings-appearance-open', tab === 'appearance'); syncAppearanceOpacity(tab === 'appearance'); // AI endpoints are intentionally refreshed only when entering the AI panel. if (tab === 'ai') refreshAiModelEndpoints(); } function openAdminSettingsTab(tab) { if (window.adminModule && typeof window.adminModule.open === 'function') { window.adminModule.open(tab); return true; } return false; } /* ── Appearance-tab opacity slider ── Mirrors the Theme customizer's slider: fades the settings modal's background (and inner cards) via color-mix so the user can watch the rest of the UI react to toggles, while keeping text/controls crisp (no element opacity). Only shown/active on the Appearance tab. */ const _SETTINGS_PEEK = 55; // % opacity when the Peek toggle is on function _applySettingsOpacity(on) { const content = modalEl && modalEl.querySelector('.settings-modal-content, .modal-content'); if (!content) return; const cards = content.querySelectorAll('.admin-card'); if (on) { const bgMix = `color-mix(in srgb, var(--bg) ${_SETTINGS_PEEK}%, transparent)`; const panelMix = `color-mix(in srgb, var(--panel) ${_SETTINGS_PEEK}%, transparent)`; content.style.setProperty('background', bgMix, 'important'); content.style.setProperty('backdrop-filter', 'none', 'important'); content.style.setProperty('-webkit-backdrop-filter', 'none', 'important'); cards.forEach(c => { c.style.setProperty('background', panelMix, 'important'); c.style.setProperty('backdrop-filter', 'none', 'important'); c.style.setProperty('-webkit-backdrop-filter', 'none', 'important'); }); } else { content.style.removeProperty('background'); content.style.removeProperty('backdrop-filter'); content.style.removeProperty('-webkit-backdrop-filter'); cards.forEach(c => { c.style.removeProperty('background'); c.style.removeProperty('backdrop-filter'); c.style.removeProperty('-webkit-backdrop-filter'); }); } } // Show/hide the Peek toggle for the Appearance tab and apply or clear the fade. function syncAppearanceOpacity(active) { const toggle = el('settings-opacity-wrap'); if (toggle) toggle.classList.toggle('hidden', !active); if (active) { _applySettingsOpacity(toggle ? toggle.classList.contains('active') : false); } else { _applySettingsOpacity(false); // clear the fade off the Appearance tab } } function initOpacityToggle() { const toggle = el('settings-opacity-wrap'); if (!toggle || toggle.dataset.bound === '1') return; toggle.dataset.bound = '1'; toggle.addEventListener('click', () => { const on = !toggle.classList.contains('active'); toggle.classList.toggle('active', on); toggle.setAttribute('aria-pressed', on ? 'true' : 'false'); _applySettingsOpacity(on); }); } /* ═══════════════════════════════════════════ AI TAB ═══════════════════════════════════════════ */ const _aiEndpointRefreshers = new Set(); let _aiEndpointRefreshInFlight = null; async function _fetchModelEndpoints() { const epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' }); const endpoints = await epRes.json(); return Array.isArray(endpoints) ? endpoints : []; } function _endpointLabel(ep) { return ep.name + (ep.online ? '' : ' (offline)'); } function _fillEndpointSelect(selectEl, endpoints, selected, keepBlank) { if (!selectEl) return; const previous = selected !== undefined ? selected : selectEl.value; const blankText = keepBlank && selectEl.options[0] && selectEl.options[0].value === '' ? selectEl.options[0].textContent : null; while (selectEl.options.length) selectEl.remove(0); if (blankText !== null) { const blank = document.createElement('option'); blank.value = ''; blank.textContent = blankText; selectEl.appendChild(blank); } (endpoints || []).forEach(function(ep) { if (!ep.is_enabled) return; const opt = document.createElement('option'); opt.value = ep.id; opt.textContent = _endpointLabel(ep); selectEl.appendChild(opt); }); if (previous && Array.from(selectEl.options).some(function(o) { return o.value === previous; })) { selectEl.value = previous; } else if (blankText !== null) { selectEl.value = ''; } _syncEndpointLogo(selectEl); } // Mirror the selected model's provider logo into a sibling