/** * Settings owns the settings modal DOM, model/voice selects, and system * message. Fetches models and voices from the API, populates selects, * and persists selections to localStorage. */ const MODELS_ENDPOINT = '/v1/models'; const MODEL_KEY = 'odidere_model'; const PROVIDER_KEY = 'odidere_provider'; const VOICE_KEY = 'odidere_voice'; const SYSTEM_MESSAGE_KEY = 'odidere_system_message'; const ICONS_URL = '/static/icons.svg'; export class Settings { /** * @param {Object} options * @param {Document} options.document * @param {string} options.ttsURL - kokoro-fastapi base URL for voice fetching */ constructor({ document, ttsURL }) { this.document = document; this.ttsURL = ttsURL; this.modelContextSizes = new Map(); // DOM Elements this.$model = document.getElementById('model'); this.$voice = document.getElementById('voice'); this.$settings = document.getElementById('settings'); this.$settingsOverlay = document.getElementById('settings-overlay'); this.$settingsClose = document.getElementById('settings-close'); this.$settingsTabs = document.querySelectorAll('.settings-tab'); this.$settingsPanels = document.querySelectorAll('.settings-tab-panel'); this.$systemMessageInput = document.getElementById('system-message-input'); this.$saveSystemMessage = document.getElementById('save-system-message'); } // --- Modal --- open() { this.$settingsOverlay.classList.add('open'); this._loadSystemMessage(); this.switchTab('model-voice'); this.$settingsClose.focus(); } close() { this.$settingsOverlay.classList.remove('open'); this.$settings.focus(); } switchTab(name) { this.$settingsTabs.forEach(($tab) => { const isActive = $tab.dataset.tab === name; $tab.classList.toggle('active', isActive); $tab.setAttribute('aria-selected', String(isActive)); }); this.$settingsPanels.forEach(($panel) => { const isActive = $panel.dataset.tabPanel === name; $panel.classList.toggle('active', isActive); $panel.hidden = !isActive; }); } // --- Model select --- async fetchModels() { try { const res = await fetch(MODELS_ENDPOINT); if (!res.ok) throw new Error(`${res.status}`); const data = await res.json(); this.populateModels( data.providers, data.default_provider, data.default_model, ); } catch (e) { console.error('failed to fetch models:', e); this._populateModelsFallback(); } } populateModels(providers, defaultProvider, defaultModel) { this.$model.innerHTML = ''; this.modelContextSizes.clear(); const sortedProviders = Object.keys(providers).sort(); for (const provider of sortedProviders) { const $optgroup = this.document.createElement('optgroup'); $optgroup.label = provider; for (const m of providers[provider].sort((a, b) => a.model.localeCompare(b.model), )) { const $opt = this.document.createElement('option'); $opt.value = m.model; $opt.dataset.provider = provider; $opt.dataset.model = m.model; if (m.context_size) { this.modelContextSizes.set(m.model, m.context_size); } switch (m.status) { case 'available': $opt.textContent = m.model; break; case 'not_found': $opt.textContent = `~~${m.model}~~ (not found)`; $opt.disabled = true; $opt.classList.add('model-unavailable-not-found'); break; case 'provider_error': $opt.textContent = `${m.model} (provider error)`; $opt.disabled = true; $opt.classList.add('model-unavailable-error'); break; } $optgroup.appendChild($opt); } this.$model.appendChild($optgroup); } this.loadModel(defaultProvider, defaultModel); } loadModel(defaultProvider, defaultModel) { const storedProvider = localStorage.getItem(PROVIDER_KEY); const storedModel = localStorage.getItem(MODEL_KEY); let $opt; if (storedProvider && storedModel) { $opt = this.$model.querySelector( `option[data-provider="${CSS.escape(storedProvider)}"][data-model="${CSS.escape(storedModel)}"]`, ); } if (!$opt && defaultProvider && defaultModel) { $opt = this.$model.querySelector( `option[data-provider="${CSS.escape(defaultProvider)}"][data-model="${CSS.escape(defaultModel)}"]`, ); } if (!$opt && this.$model.options.length > 0) { for (const opt of this.$model.options) { if (!opt.disabled) { $opt = opt; break; } } } if ($opt) { this.$model.value = $opt.value; } } getModel() { const $opt = this.$model.options[this.$model.selectedIndex]; return { provider: $opt?.dataset.provider, model: $opt?.dataset.model, }; } getModelContextSize(model) { return this.modelContextSizes.get(model) ?? null; } // --- Voice select --- async fetchVoices() { try { const res = await fetch(`${this.ttsURL}/v1/audio/voices`); if (!res.ok) throw new Error(`${res.status}`); const data = await res.json(); // Filter out legacy v0 voices. const filtered = data.voices.filter((v) => { const id = typeof v === 'string' ? v : v.id; return !id.includes('_v0'); }); const voiceIds = filtered.map((v) => (typeof v === 'string' ? v : v.id)); this.populateVoiceSelect(voiceIds); } catch (e) { console.error('failed to fetch voices:', e); this.$voice.innerHTML = ''; const $opt = this.document.createElement('option'); $opt.value = ''; $opt.disabled = true; $opt.selected = true; $opt.textContent = 'Failed to load'; this.$voice.appendChild($opt); } } populateVoiceSelect(voices) { const langLabels = { af: '🇺🇸 American English (F)', am: '🇺🇸 American English (M)', bf: '🇬🇧 British English (F)', bm: '🇬🇧 British English (M)', jf: '🇯🇵 Japanese (F)', jm: '🇯🇵 Japanese (M)', zf: '🇨🇳 Mandarin Chinese (F)', zm: '🇨🇳 Mandarin Chinese (M)', ef: '🇪🇸 Spanish (F)', em: '🇪🇸 Spanish (M)', ff: '🇫🇷 French (F)', fm: '🇫🇷 French (M)', hf: '🇮🇳 Hindi (F)', hm: '🇮🇳 Hindi (M)', if: '🇮🇹 Italian (F)', im: '🇮🇹 Italian (M)', pf: '🇧🇷 Brazilian Portuguese (F)', pm: '🇧🇷 Brazilian Portuguese (M)', kf: '🇰🇷 Korean (F)', km: '🇰🇷 Korean (M)', }; // Group voices by their two-character prefix. const groups = {}; for (const voice of voices) { const prefix = voice.substring(0, 2); if (!groups[prefix]) groups[prefix] = []; groups[prefix].push(voice); } this.$voice.innerHTML = ''; // Add "Auto" option. const $auto = this.document.createElement('option'); $auto.value = ''; $auto.textContent = '🌐 Auto'; this.$voice.appendChild($auto); const sortedPrefixes = Object.keys(groups).sort((a, b) => { if (a[0] !== b[0]) return a[0].localeCompare(b[0]); return a[1].localeCompare(b[1]); }); for (const prefix of sortedPrefixes) { const label = langLabels[prefix] || prefix.toUpperCase(); const $optgroup = this.document.createElement('optgroup'); $optgroup.label = label; for (const voice of groups[prefix].sort()) { const name = voice.substring(3).replace(/_/g, ' '); const displayName = name.charAt(0).toUpperCase() + name.slice(1); const $opt = this.document.createElement('option'); $opt.value = voice; $opt.textContent = displayName; $optgroup.appendChild($opt); } this.$voice.appendChild($optgroup); } this.loadVoice(); } loadVoice() { const stored = localStorage.getItem(VOICE_KEY); if (stored === null) { this.$voice.value = ''; return; } const escaped = stored === '' ? '' : CSS.escape(stored); const exists = stored === '' || this.$voice.querySelector(`option[value="${escaped}"]`); if (!exists) { this.$voice.value = ''; return; } this.$voice.value = stored; } getVoice() { return this.$voice.value; } // --- System message --- loadSystemMessage() { const stored = localStorage.getItem(SYSTEM_MESSAGE_KEY); this.$systemMessageInput.value = stored || ''; } saveSystemMessage() { const value = this.$systemMessageInput.value; localStorage.setItem(SYSTEM_MESSAGE_KEY, value); // Brief visual feedback on the save button. this.$saveSystemMessage.replaceChildren(this._icon('check')); this.$saveSystemMessage.classList.add('settings-save-btn--success'); setTimeout(() => { this.$saveSystemMessage.textContent = 'Save'; this.$saveSystemMessage.classList.remove('settings-save-btn--success'); }, 1500); } // --- Model/voice change persistence --- saveModelSelection() { const $opt = this.$model.options[this.$model.selectedIndex]; localStorage.setItem(PROVIDER_KEY, $opt.dataset.provider); localStorage.setItem(MODEL_KEY, $opt.dataset.model); } saveVoiceSelection() { localStorage.setItem(VOICE_KEY, this.$voice.value); } // --- Private --- _populateModelsFallback() { this.$model.innerHTML = ''; const $opt = this.document.createElement('option'); $opt.value = ''; $opt.disabled = true; $opt.selected = true; $opt.textContent = 'Failed to load'; this.$model.appendChild($opt); } _icon(name) { const svg = this.document.createElementNS( 'http://www.w3.org/2000/svg', 'svg', ); svg.setAttribute('class', 'icon'); const use = this.document.createElementNS( 'http://www.w3.org/2000/svg', 'use', ); use.setAttribute('href', `${ICONS_URL}#${name}`); svg.appendChild(use); return svg; } }