// 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 { makeWindowDraggable } from './windowDrag.js'; import { clearDockSide } from './modalSnap.js'; import { sortModelIds } from './modelSort.js'; import { providerLogo } from './providers.js'; import { isAltGrEvent } from './platform.js'; let initialized = false; let modalEl = null; function el(id) { return document.getElementById(id); } 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 : ''; } /* ── Tab switching ── */ const ADMIN_TABS = new Set(['services', 'integrations', 'tools', 'users', 'system']); function initTabs() { modalEl.querySelectorAll('[data-settings-tab]').forEach(btn => { btn.addEventListener('click', () => { const tab = btn.dataset.settingsTab; // Lazy-init admin when first clicking an admin tab if (ADMIN_TABS.has(tab) && window.adminModule && typeof window.adminModule.open === 'function') { window.adminModule.open(tab); return; } modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab)); modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab)); // Mark when the Appearance tab is open so the modal can go // semi-transparent — lets the user see the rest of the UI react as // they flip toggles instead of having to close + reopen the modal. document.body.classList.toggle('settings-appearance-open', tab === 'appearance'); syncAppearanceOpacity(tab === 'appearance'); if (tab === 'ai') refreshAiModelEndpoints(); }); }); } /* ── Dragging ── */ function initDrag() { const header = modalEl.querySelector('.modal-header'); const content = modalEl.querySelector('.settings-modal-content'); if (!header || !content) return; // Skip interactive controls in the header (e.g. the opacity slider) so // grabbing them doesn't start a window-drag. makeWindowDraggable(modalEl, { content, header, skipSelector: 'button, input, select, .theme-opacity-wrap', enableDock: true, }); } function resetWindowPlacement() { const content = modalEl && modalEl.querySelector('.settings-modal-content'); if (!content) return; const hadLeft = modalEl.classList.contains('modal-left-docked'); const hadRight = modalEl.classList.contains('modal-right-docked'); modalEl.classList.remove('modal-left-docked', 'modal-right-docked'); if (hadLeft) clearDockSide('left', modalEl); if (hadRight) clearDockSide('right', modalEl); if (content._leftDockNavObs) { try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {} try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {} delete content._leftDockNavObs; } delete content._preDockSnapshot; delete content._dockSide; delete content._dockSuspended; delete content.dataset._tilePreSnap; delete content.dataset._tileZone; [ 'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform', 'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition', ].forEach(prop => content.style.removeProperty(prop)); } /* ── Delegated link: close Settings + open the Prompt (characters) modal ── */ function initOpenPromptModalLink() { document.addEventListener('click', async (e) => { const link = e.target.closest('[data-open-prompt-modal]'); if (!link) return; e.preventDefault(); // Close settings first so the prompt modal isn't stacked on top. if (modalEl && !modalEl.classList.contains('hidden')) close(); try { const m = await import('./presets.js'); const fn = m.openCustomPresetModal || (m.default && m.default.openCustomPresetModal); if (typeof fn === 'function') fn(); } catch (_) { const modal = document.getElementById('custom-preset-modal'); if (modal) modal.classList.remove('hidden'); } // Force the Persona tab (data-chartab="character") since the link's // whole purpose is editing personas — not landing on Inject by default. const personaTab = document.querySelector('#custom-preset-modal .preset-tab[data-chartab="character"]'); if (personaTab) personaTab.click(); }); } /* ── Close on backdrop / X ── */ function initClose() { modalEl.querySelector('.close-btn').addEventListener('click', close); modalEl.addEventListener('mousedown', e => { if (uiModule.isTouchInsideModal()) return; if (e.target === modalEl) close(); }); document.addEventListener('keydown', e => { if (e.key !== 'Escape' || !modalEl || modalEl.classList.contains('hidden')) return; // Bail when a transient popover inside the modal is open — Esc should // dismiss just that, not the whole modal. Same-document listeners fire // in registration order regardless of capture/bubble, so the popover's // own handler can't pre-empt ours; we have to opt out here. const popoverOpen = modalEl.querySelector( '#adm-epLocalMoreMenu, #adm-epApiMoreMenu, #adm-provider-menu, #search-provider-menu, [data-popover-open="1"]' ); if (popoverOpen && popoverOpen.style.display !== 'none' && !popoverOpen.classList.contains('hidden')) { return; } // If an integration edit/add form is open inside the modal, close // just that — don't dismiss the whole settings modal. (Pressing // ESC mid-edit and losing the modal was a fast-typing footgun.) const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form'); if (innerForm && innerForm.style.display !== 'none' && innerForm.children.length > 0) { e.preventDefault(); e.stopPropagation(); innerForm.style.display = 'none'; innerForm.innerHTML = ''; return; } e.preventDefault(); e.stopPropagation(); close(); }); } /* ── 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