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} 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} */ 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 });