Add Markdown support
This commit is contained in:
350
internal/service/static/js/settings.js
Normal file
350
internal/service/static/js/settings.js
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user