Files
2026-02-13 15:03:37 +00:00

1373 lines
40 KiB
JavaScript

const STREAM_ENDPOINT = '/v1/chat/voice/stream';
const ICONS_URL = '/static/icons.svg';
const MODELS_ENDPOINT = '/v1/models';
const MODEL_KEY = 'odidere_model';
const STORAGE_KEY = 'odidere_history';
const VOICES_ENDPOINT = '/v1/voices';
const VOICE_KEY = 'odidere_voice';
/**
* Odidere is the main application class for the voice assistant UI.
* It manages audio recording, chat history, and communication with the API.
* Creates a new instance bound to the given document.
* @param {Object} options
* @param {Document} options.document
*/
class Odidere {
constructor({ document }) {
this.document = document;
// State
this.attachments = [];
this.audioChunks = [];
this.currentAudio = null;
this.currentAudioUrl = null;
this.currentController = null;
this.history = [];
this.isProcessing = false;
this.isRecording = false;
this.isMuted = false;
this.mediaRecorder = null;
// DOM Elements
this.$attach = document.getElementById('attach');
this.$attachments = document.getElementById('attachments');
this.$chat = document.getElementById('chat');
this.$fileInput = document.getElementById('file-input');
this.$model = document.getElementById('model');
this.$ptt = document.getElementById('ptt');
this.$reset = document.getElementById('reset');
this.$send = document.getElementById('send');
this.$textInput = document.getElementById('text-input');
this.$voice = document.getElementById('voice');
this.$mute = document.getElementById('mute');
// Templates
this.$tplAssistantMessage = document.getElementById(
'tpl-assistant-message',
);
this.$tplAttachmentChip = document.getElementById('tpl-attachment-chip');
this.$tplCollapsible = document.getElementById('tpl-collapsible');
this.$tplDebugRow = document.getElementById('tpl-debug-row');
this.$tplErrorMessage = document.getElementById('tpl-error-message');
this.$tplToolCall = document.getElementById('tpl-tool-call');
this.$tplUserMessage = document.getElementById('tpl-user-message');
this.#init();
}
// ====================
// PUBLIC API
// ====================
/**
* destroy releases all resources including media streams and audio.
*/
destroy() {
for (const track of this.mediaRecorder?.stream?.getTracks() ?? []) {
track.stop();
}
this.#stopCurrentAudio();
this.currentController?.abort();
}
/**
* reset clears all state including history, attachments, and pending
* requests.
*/
reset() {
this.#stopCurrentAudio();
if (this.currentController) {
this.currentController.abort();
this.currentController = null;
}
this.#setLoadingState(false);
this.history = [];
localStorage.removeItem(STORAGE_KEY);
this.$chat.innerHTML = '';
this.#clearAttachments();
this.$textInput.value = '';
this.$textInput.style.height = 'auto';
}
// ====================
// INITIALIZATION
// ====================
/**
* #init initializes the application.
*/
#init() {
this.#loadHistory();
this.#bindEvents();
this.#fetchVoices();
this.#fetchModels();
}
/**
* #bindEvents attaches all event listeners to DOM elements.
*/
#bindEvents() {
// PTT button: touch
this.$ptt.addEventListener('touchstart', this.#handlePttTouchStart, {
passive: false,
});
this.$ptt.addEventListener('touchend', this.#handlePttTouchEnd);
this.$ptt.addEventListener('touchcancel', this.#handlePttTouchEnd);
// Prevent context menu on long press.
this.$ptt.addEventListener('contextmenu', (e) => e.preventDefault());
// PTT button: mouse
this.$ptt.addEventListener('mousedown', this.#handlePttMouseDown);
this.$ptt.addEventListener('mouseup', this.#handlePttMouseUp);
this.$ptt.addEventListener('mouseleave', this.#handlePttMouseUp);
// Keyboard: spacebar PTT (outside inputs)
this.document.addEventListener('keydown', this.#handleKeyDown);
this.document.addEventListener('keyup', this.#handleKeyUp);
// Textarea: Enter to send, Shift+Enter for newline
this.$textInput.addEventListener('keydown', this.#handleTextareaKeyDown);
this.$textInput.addEventListener('input', this.#handleTextareaInput);
// Send button
this.$send.addEventListener('click', () => this.#submitText());
// Reset button
this.$reset.addEventListener('click', () => this.reset());
// File attachment
this.$attach.addEventListener('click', () => this.$fileInput.click());
this.$fileInput.addEventListener('change', (e) =>
this.#handleAttachments(e.target.files),
);
// Save selections on change.
this.$model.addEventListener('change', () => {
localStorage.setItem(MODEL_KEY, this.$model.value);
});
this.$voice.addEventListener('change', () => {
localStorage.setItem(VOICE_KEY, this.$voice.value);
});
// Mute button
this.$mute.addEventListener('click', () => this.#toggleMute());
}
/**
* #loadHistory loads chat history from localStorage and renders it.
*/
#loadHistory() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return;
this.history = JSON.parse(stored);
this.#renderMessages(this.history);
} catch (e) {
console.error('failed to load history:', e);
this.history = [];
}
}
// ====================
// EVENT HANDLERS
// ====================
/**
* #handlePttTouchStart handles touch start on the PTT button.
* @param {TouchEvent} event
*/
#handlePttTouchStart = (event) => {
event.preventDefault();
event.stopPropagation();
this.#startRecording();
};
/**
* #handlePttTouchEnd handles touch end on the PTT button.
* @param {TouchEvent} event
*/
#handlePttTouchEnd = (event) => {
event.stopPropagation();
this.#stopRecording();
};
/**
* #handlePttMouseDown handles mouse down on the PTT button.
* Ignores events that originate from touch to avoid double-firing.
* @param {MouseEvent} event
*/
#handlePttMouseDown = (event) => {
if (event.sourceCapabilities?.firesTouchEvents) return;
event.preventDefault();
event.stopPropagation();
this.#startRecording();
};
/**
* #handlePttMouseUp handles mouse up on the PTT button.
* @param {MouseEvent} event
*/
#handlePttMouseUp = (event) => {
if (event.sourceCapabilities?.firesTouchEvents) return;
event.stopPropagation();
this.#stopRecording();
};
/**
* #handleKeyDown handles keydown events for spacebar PTT.
* Only triggers when focus is not in an input element.
* @param {KeyboardEvent} event
*/
#handleKeyDown = (event) => {
if (event.code !== 'Space') return;
if (event.repeat) return;
const isInput =
event.target.tagName === 'INPUT' ||
event.target.tagName === 'TEXTAREA' ||
event.target.tagName === 'SELECT';
if (isInput) return;
event.preventDefault();
this.#startRecording();
};
/**
* #handleKeyUp handles keyup events for spacebar PTT.
* @param {KeyboardEvent} event
*/
#handleKeyUp = (event) => {
if (event.code !== 'Space') return;
const isInput =
event.target.tagName === 'INPUT' ||
event.target.tagName === 'TEXTAREA' ||
event.target.tagName === 'SELECT';
if (isInput) return;
event.preventDefault();
this.#stopRecording();
};
/**
* #handleTextareaKeyDown handles Enter key to submit text.
* Shift+Enter inserts a newline instead.
* @param {KeyboardEvent} event
*/
#handleTextareaKeyDown = (event) => {
if (event.code !== 'Enter') return;
if (event.shiftKey) return;
event.preventDefault();
this.#submitText();
};
/**
* #handleTextareaInput adjusts textarea height to fit content.
*/
#handleTextareaInput = () => {
const textarea = this.$textInput;
textarea.style.height = 'auto';
const computed = getComputedStyle(textarea);
const maxHeight = parseFloat(computed.maxHeight);
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
};
/**
* #handleAttachments adds files to the attachment list.
* Duplicates are ignored based on name and size.
* @param {FileList} files
*/
#handleAttachments(files) {
for (const file of files) {
const isDuplicate = this.attachments.some(
(a) =>
a.name === file.name &&
a.size === file.size &&
a.lastModified === file.lastModified,
);
if (isDuplicate) continue;
this.attachments.push(file);
}
this.#renderAttachments();
this.$fileInput.value = '';
}
// ====================
// AUDIO RECORDING
// ====================
/**
* #startRecording begins audio recording if not already recording or
* processing.
* Initializes the MediaRecorder on first use.
*/
async #startRecording() {
if (this.isRecording) return;
if (this.isProcessing) return;
this.#stopCurrentAudio();
// Initialize MediaRecorder on first use
if (!this.mediaRecorder) {
const success = await this.#initMediaRecorder();
if (!success) return;
}
this.audioChunks = [];
this.mediaRecorder.start();
this.#setRecordingState(true);
}
/**
* #stopRecording stops the current audio recording.
*/
#stopRecording() {
if (!this.isRecording) return;
if (!this.mediaRecorder) return;
this.mediaRecorder.stop();
this.#setRecordingState(false);
}
/**
* #initMediaRecorder initializes the MediaRecorder with microphone access.
* Prefers opus codec for better compression if available.
* @returns {Promise<boolean>} true if successful
*/
async #initMediaRecorder() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
this.mediaRecorder = new MediaRecorder(stream, { mimeType });
this.mediaRecorder.addEventListener('dataavailable', (event) => {
if (event.data.size > 0) {
this.audioChunks.push(event.data);
}
});
this.mediaRecorder.addEventListener('stop', async () => {
if (this.audioChunks.length === 0) return;
const audioBlob = new Blob(this.audioChunks, {
type: this.mediaRecorder.mimeType,
});
this.audioChunks = [];
// Capture and clear text input to send with audio.
const text = this.$textInput.value.trim();
this.$textInput.value = '';
this.$textInput.style.height = 'auto';
await this.#sendRequest({ audio: audioBlob, text });
});
this.mediaRecorder.addEventListener('error', (event) => {
console.error('MediaRecorder error:', event.error);
this.#setRecordingState(false);
this.audioChunks = [];
this.#renderError(
`Recording error: ${event.error?.message || 'Unknown error'}`,
);
});
return true;
} catch (e) {
console.error('failed to initialize media recorder:', e);
this.#renderError(`Microphone access denied: ${e.message}`);
return false;
}
}
// ====================
// AUDIO PLAYBACK
// ====================
/**
* #playAudio decodes and plays base64-encoded WAV audio.
* @param {string} base64Audio
*/
async #playAudio(base64Audio) {
try {
this.#stopCurrentAudio();
const audioData = Uint8Array.from(atob(base64Audio), (c) =>
c.charCodeAt(0),
);
const audioBlob = new Blob([audioData], { type: 'audio/wav' });
const audioUrl = URL.createObjectURL(audioBlob);
this.currentAudioUrl = audioUrl;
this.currentAudio = new Audio(audioUrl);
this.currentAudio.muted = !!this.isMuted;
await new Promise((resolve, reject) => {
this.currentAudio.addEventListener(
'ended',
() => {
this.#cleanupAudio();
resolve();
},
{ once: true },
);
this.currentAudio.addEventListener(
'error',
(e) => {
this.#cleanupAudio();
reject(e);
},
{ once: true },
);
this.currentAudio.play().catch(reject);
});
} catch (e) {
console.error('failed to play audio:', e);
this.#cleanupAudio();
}
}
/**
* #stopCurrentAudio stops and cleans up any playing audio.
*/
#stopCurrentAudio() {
if (this.currentAudio) {
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
}
this.#cleanupAudio();
}
/**
* #cleanupAudio revokes the object URL and clears audio references.
*/
#cleanupAudio() {
if (this.currentAudioUrl) {
URL.revokeObjectURL(this.currentAudioUrl);
this.currentAudioUrl = null;
}
this.currentAudio = null;
}
// ====================
// API: FETCH
// ====================
/**
* #fetchModels fetches available models from the API and populates selectors.
*/
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.models, data.default_model);
} catch (e) {
console.error('failed to fetch models:', e);
this.#populateModelsFallback();
}
}
/**
* #fetchVoices fetches available voices from the API and populates
* selectors.
*/
async #fetchVoices() {
try {
const res = await fetch(VOICES_ENDPOINT);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
// Filter out legacy v0 voices.
const filtered = data.voices.filter((v) => !v.includes('_v0'));
this.#populateVoiceSelect(filtered);
} 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);
}
}
// ====================
// API: CHAT
// ====================
/**
* #submitText submits the current text input and attachments.
*/
async #submitText() {
const text = this.$textInput.value.trim();
const hasContent = text || this.attachments.length > 0;
if (!hasContent) return;
if (this.isProcessing) return;
this.#stopCurrentAudio();
this.$textInput.value = '';
this.$textInput.style.height = 'auto';
await this.#sendRequest({ text });
}
/**
* #sendRequest sends a chat request to the streaming SSE endpoint.
*
* For text-only input, renders the user message immediately for
* responsiveness.
* For audio input, waits for the server's transcription event before
* rendering the user message.
*
* Messages are rendered incrementally as SSE events arrive:
* - user (transcription), assistant (tool calls), tool (results),
* and a final assistant message with synthesized audio.
*
* @param {Object} options
* @param {Blob} [options.audio] - Recorded audio blob
* @param {string} [options.text] - Text input
*/
async #sendRequest({ audio = null, text = '' }) {
this.#setLoadingState(true);
this.currentController = new AbortController();
try {
// Build the user message with text and attachments.
const userMessage = await this.#buildUserMessage(text, this.attachments);
const hasContent = text || this.attachments.length > 0 || audio;
if (!hasContent) {
this.#setLoadingState(false);
return;
}
// Build messages array: history + current user message.
const messages = [
...this.history.map(({ id, meta, ...msg }) => msg),
userMessage,
];
const payload = {
messages,
voice: this.$voice.value,
model: this.$model.value,
};
if (audio) {
payload.audio = await this.#toBase64(audio);
}
// Clear attachments after building payload (before async operations).
this.#clearAttachments();
// For text-only requests (no audio), add to history and render
// immediately.
if (!audio) {
this.#appendHistory([userMessage]);
this.#renderMessages([userMessage]);
}
const res = await fetch(STREAM_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: this.currentController.signal,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`server error ${res.status}: ${err}`);
}
// Parse SSE stream from response body.
const reader = res.body.getReader();
const decoder = new TextDecoder();
let sseBuffer = '';
// Stash assistant messages with tool_calls until their results arrive.
let pendingTools = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
sseBuffer += decoder.decode(value, { stream: true });
// Process complete SSE events (separated by double newlines).
const parts = sseBuffer.split('\n\n');
sseBuffer = parts.pop();
for (const part of parts) {
if (!part.trim()) continue;
// Extract data from SSE event lines.
let data = '';
for (const line of part.split('\n')) {
if (line.startsWith('data: ')) {
data += line.slice(6);
}
}
if (!data) continue;
let event;
try {
event = JSON.parse(data);
} catch {
console.error('failed to parse SSE data:', data);
continue;
}
// Handle errors sent mid-stream.
if (event.error) {
this.#renderError(event.error);
continue;
}
const message = event.message;
// Handle user message from server (audio transcription).
if (message.role === 'user' && event.transcription) {
const displayParts = [];
if (text) displayParts.push(text);
if (event.transcription) displayParts.push(event.transcription);
const displayContent = displayParts.join('\n\n');
let historyContent;
if (typeof userMessage.content === 'string') {
historyContent = displayContent;
} else {
historyContent = [...userMessage.content];
if (event.transcription) {
historyContent.push({
type: 'text',
text: event.transcription,
});
}
}
const audioUserMessage = {
id: userMessage.id,
role: 'user',
content: historyContent,
meta: { language: event.detected_language },
};
this.#appendHistory([audioUserMessage]);
this.#renderMessages([audioUserMessage]);
continue;
}
// Stash assistant messages with tool calls; don't render yet.
if (
message.role === 'assistant' &&
message.tool_calls?.length > 0 &&
!message.content
) {
pendingTools = {
assistant: message,
results: [],
expected: message.tool_calls.length,
};
// Add to history (server needs it) but don't render.
this.#appendHistory([message]);
continue;
}
// Collect tool results and render once all have arrived.
if (message.role === 'tool' && pendingTools.assistant) {
pendingTools.results.push(message);
// Add to history (server needs it) but don't render yet.
this.#appendHistory([message]);
// Once all tool results are in, render the combined message.
if (pendingTools.results.length >= pendingTools.expected) {
this.#renderMessages([
pendingTools.assistant,
...pendingTools.results,
]);
pendingTools = null;
}
continue;
}
// Regular assistant message (final reply with content).
const meta = {};
if (event.voice) meta.voice = event.voice;
this.#appendHistory([message], meta);
this.#renderMessages([message], meta);
// For the final assistant message with audio, play it.
if (event.audio) {
this.#setLoadingState(false);
await this.#playAudio(event.audio);
}
}
}
this.#setLoadingState(false);
} catch (e) {
if (e.name === 'AbortError') {
console.debug('request aborted');
return;
}
console.error('failed to send request:', e);
this.#renderError(`Error: ${e.message}`);
this.#setLoadingState(false);
} finally {
this.currentController = null;
}
}
/**
* #buildUserMessage builds a user message object for the API.
* Text files are included as text, images as base64 data URLs.
* Returns either a simple string content or multipart array format
* depending on whether images are present.
* @param {string} text
* @param {File[]} attachments
* @returns {Promise<{id: string, role: 'user', content: string | Array}>}
*/
async #buildUserMessage(text, attachments) {
const textParts = [];
const imageParts = [];
// Process attachments.
// Images become data URLs, text files become inline text.
for (const file of attachments) {
if (file.type.startsWith('image/')) {
const base64 = await this.#toBase64(file);
const dataUrl = `data:${file.type};base64,${base64}`;
imageParts.push({
type: 'image_url',
image_url: { url: dataUrl },
});
} else {
const content = await file.text();
textParts.push(`=== File: ${file.name} ===\n\n${content}`);
}
}
// Add user's message text after files.
if (text) {
textParts.push(text);
}
const combinedText = textParts.join('\n\n');
// If no images, return simple string content.
if (imageParts.length === 0) {
return {
id: crypto.randomUUID(),
role: 'user',
content: combinedText || '',
};
}
// If images present, use multipart array format.
const parts = [];
if (combinedText) {
parts.push({ type: 'text', text: combinedText });
}
parts.push(...imageParts);
return {
id: crypto.randomUUID(),
role: 'user',
content: parts,
};
}
// ====================
// STATE & HISTORY
// ====================
/**
* #appendHistory adds multiple messages to history.
* Only the last message receives the metadata.
* @param {Object[]} messages
* @param {Object} [meta]
*/
#appendHistory(messages, meta = {}) {
for (let i = 0; i < messages.length; i++) {
const msg = { ...messages[i] };
// Generate ID if not present.
if (!msg.id) {
msg.id = crypto.randomUUID();
}
// Attach metadata only to the final message.
if (i === messages.length - 1) {
msg.meta = meta;
}
this.history.push(msg);
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.history));
} catch (e) {
console.error('failed to save history:', e);
}
}
/**
* #setLoadingState updates UI to reflect loading state.
* @param {boolean} loading
*/
#setLoadingState(loading) {
this.isProcessing = loading;
this.$send.classList.toggle('loading', loading);
this.$send.disabled = loading;
this.$ptt.disabled = loading;
}
/**
* #setRecordingState updates UI to reflect recording state.
* @param {boolean} recording
*/
#setRecordingState(recording) {
this.isRecording = recording;
this.$ptt.classList.toggle('recording', recording);
this.$ptt.setAttribute('aria-pressed', String(recording));
}
/**
* #toggleMute toggles mute state and updates button label.
*/
#toggleMute() {
this.isMuted = !this.isMuted;
if (this.$mute) {
const iconName = this.isMuted ? 'volume-off' : 'volume';
this.$mute.replaceChildren(this.#icon(iconName));
this.$mute.classList.toggle('footer__toolbar-btn--muted', this.isMuted);
this.$mute.setAttribute('aria-label', this.isMuted ? 'Unmute' : 'Mute');
}
// Apply mute state to any currently playing audio.
if (this.currentAudio) {
this.currentAudio.muted = !!this.isMuted;
}
}
// ====================
// RENDER: SELECTS
// ====================
/**
* #populateModels populates the model selector with available options.
* @param {Array<{id: string}>} models
* @param {string} defaultModel
*/
#populateModels(models, defaultModel) {
this.$model.innerHTML = '';
for (const m of models) {
const $opt = this.document.createElement('option');
$opt.value = m.id;
$opt.textContent = m.id;
this.$model.appendChild($opt);
}
this.#loadModel(defaultModel);
}
/**
* #populateModelsFallback shows an error state when models fail to load.
*/
#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);
}
/**
* #loadModel restores the model selection from localStorage or uses the
* default.
* @param {string} defaultModel
*/
#loadModel(defaultModel) {
const stored = localStorage.getItem(MODEL_KEY);
const escaped = stored ? CSS.escape(stored) : null;
// Try stored value first, then default, then first available option.
let selectedValue;
if (escaped && this.$model.querySelector(`option[value="${escaped}"]`)) {
selectedValue = stored;
} else if (defaultModel) {
selectedValue = defaultModel;
} else if (this.$model.options.length > 0) {
selectedValue = this.$model.options[0].value;
}
if (selectedValue) {
this.$model.value = selectedValue;
}
}
/**
* #populateVoiceSelect populates voice selector grouped by language.
* Voice IDs follow the pattern: {lang}{gender}_{name} (e.g., "af_bella").
* @param {string[]} voices
*/
#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 (language + gender)
const groups = {};
for (const voice of voices) {
const prefix = voice.substring(0, 2);
if (!groups[prefix]) groups[prefix] = [];
groups[prefix].push(voice);
}
// Clear and rebuild selector.
this.$voice.innerHTML = '';
// Add "Auto" option for automatic language detection.
const $auto = this.document.createElement('option');
$auto.value = '';
$auto.textContent = '🌐 Auto';
this.$voice.appendChild($auto);
// Sort prefixes: by language code first, then by gender.
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]);
});
// Create optgroups for each language/gender combination.
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()) {
// Extract voice name from ID (e.g., "af_bella" -> "Bella").
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 restores the voice selection from localStorage.
*/
#loadVoice() {
const stored = localStorage.getItem(VOICE_KEY);
if (stored === null) {
this.$voice.value = '';
return;
}
// Empty string is valid ("Auto").
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;
}
// ====================
// RENDER: MESSAGES
// ====================
/**
* #renderMessages renders messages to the chat container.
* Messages already in the DOM (matched by data-id) are skipped.
* Tool results are matched to their corresponding tool calls.
* @param {Object[]} messages
* @param {Object} [meta] - Default metadata for messages without their own
*/
#renderMessages(messages, meta = {}) {
// Build tool result map for matching tool calls to their results.
const toolResultMap = new Map();
for (const msg of messages) {
if (msg.role === 'tool' && msg.tool_call_id) {
toolResultMap.set(msg.tool_call_id, msg);
}
}
for (const msg of messages) {
// Skip if already rendered.
if (msg.id && this.$chat.querySelector(`[data-id="${msg.id}"]`)) {
continue;
}
// Render user message.
if (msg.role === 'user') {
this.#renderUserMessage(msg, msg.meta ?? meta);
continue;
}
// Tool call -- already processed with the result map.
if (msg.role !== 'assistant') continue;
// Skip assistant messages with no displayable content.
const hasContent =
msg.content || msg.reasoning_content || msg.tool_calls?.length;
if (!hasContent) continue;
// Otherwise, render assistant message.
const toolResults = (msg.tool_calls ?? [])
.map((tc) => toolResultMap.get(tc.id))
.filter(Boolean);
this.#renderAssistantMessage(msg, msg.meta ?? meta, toolResults);
}
}
/**
* #renderUserMessage renders a user message to the chat.
* @param {Object} message
* @param {Object} [meta]
*/
#renderUserMessage(message, meta = null) {
const content = (() => {
if (typeof message.content === 'string') {
return message.content;
}
if (Array.isArray(message.content)) {
return message.content
.filter((p) => p.type === 'text')
.map((p) => p.text)
.join('\n');
}
return '';
})();
const $msg = this.$tplUserMessage.content.cloneNode(true).firstElementChild;
if (message.id) $msg.dataset.id = message.id;
$msg.querySelector('.message__content').textContent = content;
// Populate debug panel.
const $dl = $msg.querySelector('.message__debug-list');
if (meta?.language) this.#appendDebugRow($dl, 'Language', meta.language);
if (meta?.voice) this.#appendDebugRow($dl, 'Voice', meta.voice);
if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', '');
// Bind action buttons.
const $inspect = $msg.querySelector('[data-action="inspect"]');
$inspect.addEventListener('click', () => {
const isOpen = $msg.classList.toggle('message--debug-open');
$inspect.setAttribute('aria-expanded', String(isOpen));
});
const $copy = $msg.querySelector('[data-action="copy"]');
$copy.addEventListener('click', () =>
this.#copyToClipboard($copy, content),
);
this.$chat.appendChild($msg);
this.$chat.scrollTop = this.$chat.scrollHeight;
}
/**
* #renderAssistantMessage renders an assistant message with optional
* reasoning, tool calls, and their results.
* @param {Object} message
* @param {string} [message.content]
* @param {string} [message.reasoning_content]
* @param {Array} [message.tool_calls]
* @param {Object} [meta]
* @param {Object[]} [toolResults]
*/
#renderAssistantMessage(message, meta = null, toolResults = []) {
const $msg =
this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
if (message.id) $msg.dataset.id = message.id;
const $body = $msg.querySelector('.message__body');
const $content = $msg.querySelector('.message__content');
// Reasoning block (collapsible, shows LLM's chain-of-thought)
if (message.reasoning_content) {
const $reasoning = this.#createCollapsibleBlock(
'reasoning',
'Reasoning',
message.reasoning_content,
);
$body.insertBefore($reasoning, $content);
}
// Tool call blocks (collapsible, shows function name, args, and result)
if (message.tool_calls?.length > 0) {
for (const toolCall of message.tool_calls) {
const result = toolResults.find((r) => r.tool_call_id === toolCall.id);
const $toolBlock = this.#createToolCallBlock(toolCall, result);
$body.insertBefore($toolBlock, $content);
}
}
// Main content
if (message.content) {
$content.textContent = message.content;
} else {
$content.remove();
}
// Populate debug panel.
const $dl = $msg.querySelector('.message__debug-list');
if (meta?.voice) this.#appendDebugRow($dl, 'Voice', meta.voice);
if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', '');
// Bind action buttons.
const $inspect = $msg.querySelector('[data-action="inspect"]');
$inspect.addEventListener('click', () => {
const isOpen = $msg.classList.toggle('message--debug-open');
$inspect.setAttribute('aria-expanded', String(isOpen));
});
// Assemble text from all available content.
const copyParts = [];
if (message.reasoning_content) copyParts.push(message.reasoning_content);
for (const tc of message.tool_calls ?? []) {
const name = tc.function?.name || 'unknown';
const args = tc.function?.arguments || '{}';
copyParts.push(`${name}(${args})`);
}
if (message.content) copyParts.push(message.content);
const copyText = copyParts.join('\n\n');
const $copy = $msg.querySelector('[data-action="copy"]');
$copy.addEventListener('click', () =>
this.#copyToClipboard($copy, copyText),
);
this.$chat.appendChild($msg);
this.$chat.scrollTop = this.$chat.scrollHeight;
}
/**
* #renderError renders an error message in the chat.
* @param {string} message
*/
#renderError(message) {
const $msg =
this.$tplErrorMessage.content.cloneNode(true).firstElementChild;
$msg.querySelector('.message__content').textContent = message;
this.$chat.appendChild($msg);
this.$chat.scrollTop = this.$chat.scrollHeight;
}
// ====================
// RENDER: ATTACHMENTS
// ====================
/**
* #renderAttachments renders the attachment chips in the footer.
*/
#renderAttachments() {
this.$attachments.innerHTML = '';
for (let i = 0; i < this.attachments.length; i++) {
const file = this.attachments[i];
const $chip =
this.$tplAttachmentChip.content.cloneNode(true).firstElementChild;
const $name = $chip.querySelector('.compose__attachment-name');
$name.textContent = file.name;
$name.title = file.name;
const $remove = $chip.querySelector('.compose__attachment-remove');
$remove.setAttribute('aria-label', `Remove ${file.name}`);
$remove.addEventListener('click', () => this.#removeAttachment(i));
this.$attachments.appendChild($chip);
}
}
/**
* #removeAttachment removes the attachment at the given index.
* @param {number} index
*/
#removeAttachment(index) {
this.attachments.splice(index, 1);
this.#renderAttachments();
}
/**
* #clearAttachments removes all attachments.
*/
#clearAttachments() {
this.attachments = [];
this.#renderAttachments();
}
// ====================
// RENDER: COMPONENTS
// ====================
/**
* #createCollapsibleBlock creates a collapsible details element.
* @param {string} type - CSS modifier for styling
* @param {string} label - Summary text
* @param {string} content - Content to display when expanded
* @returns {HTMLDetailsElement}
*/
#createCollapsibleBlock(type, label, content) {
const $details =
this.$tplCollapsible.content.cloneNode(true).firstElementChild;
$details.classList.add(`collapsible--${type}`);
const $summary = $details.querySelector('.collapsible__summary');
$summary.insertBefore(this.#icon(type), $summary.firstChild);
$details.querySelector('.collapsible__label').textContent = label;
$details.querySelector('pre').textContent = content;
return $details;
}
/**
* #createToolCallBlock creates a collapsible block for a tool call.
* Shows the tool name, arguments, and optionally the result.
* @param {Object} toolCall
* @param {Object} [result]
* @returns {HTMLDetailsElement}
*/
#createToolCallBlock(toolCall, result = null) {
const $details =
this.$tplToolCall.content.cloneNode(true).firstElementChild;
$details.querySelector('.collapsible__label').textContent =
toolCall.function?.name || 'Tool Call';
// Arguments section
const $args = $details.querySelector('[data-args]');
try {
const args = JSON.parse(toolCall.function?.arguments || '{}');
$args.textContent = JSON.stringify(args, null, 2);
} catch {
$args.textContent = toolCall.function?.arguments || '';
}
// Output section (only if result is available)
if (result) {
const $outputSection = $details.querySelector(
'.collapsible__section--output',
);
$outputSection.hidden = false;
const $output = $details.querySelector('[data-output]');
try {
const output = JSON.parse(result.content || '{}');
$output.textContent = JSON.stringify(output, null, 2);
} catch {
$output.textContent = result.content || '';
}
}
return $details;
}
/**
* #appendDebugRow appends a label/value pair to a debug list.
* @param {HTMLDListElement} $dl
* @param {string} label
* @param {string} value
*/
#appendDebugRow($dl, label, value) {
const $row = this.$tplDebugRow.content.cloneNode(true);
$row.querySelector('dt').textContent = label;
$row.querySelector('dd').textContent = value;
$dl.appendChild($row);
}
// ====================
// UTILITIES
// ====================
/**
* #icon creates an SVG icon element referencing the icon sprite.
* @param {string} name
* @returns {SVGSVGElement}
*/
#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;
}
/**
* #toBase64 converts a Blob to a base64-encoded string.
* @param {Blob} blob
* @returns {Promise<string>}
*/
async #toBase64(blob) {
const buffer = await blob.arrayBuffer();
return new Uint8Array(buffer).toBase64();
}
/**
* #copyToClipboard copies text and shows a brief success indicator.
* @param {HTMLButtonElement} $button - The button to update with feedback
* @param {string} text - The text to copy
*/
async #copyToClipboard($button, text) {
try {
await navigator.clipboard.writeText(text);
$button.replaceChildren(this.#icon('check'));
$button.classList.add('message__action-btn--success');
setTimeout(() => {
$button.replaceChildren(this.#icon('copy'));
$button.classList.remove('message__action-btn--success');
}, 1500);
} catch (e) {
console.error('failed to copy:', e);
}
}
}
// Initialize
new Odidere({ document });