351 lines
9.9 KiB
JavaScript
351 lines
9.9 KiB
JavaScript
|
|
/**
|
||
|
|
* 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;
|
||
|
|
}
|
||
|
|
}
|