Add Markdown support

This commit is contained in:
dwrz
2026-07-09 14:47:43 +00:00
parent 98986a884a
commit e0eed6a935
13 changed files with 184 additions and 14 deletions

View 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;
}
}