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 SYSTEM_MESSAGE_KEY = 'odidere_system_message'; 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.isProcessing = false; this.isRecording = false; this.isMuted = false; this.isResetPending = false; this.isScrollPending = false; this.isStopPending = false; this.mediaRecorder = null; this.wakeLock = null; this.pendingDeleteId = null; // Conversation state this.messagesMap = new Map(); this.leafId = null; this.rootId = null; // TTS state this.ttsUrl = document.querySelector('meta[name="tts-url"]')?.content || ''; this.ttsDefaultVoice = document.querySelector('meta[name="tts-default-voice"]')?.content || ''; this.playQueue = []; this.isPlaying = false; this.currentMessageId = null; // Auto-scroll: enabled by default, disabled when user scrolls up, // re-enabled when they scroll back to bottom or send a new message. this.isAutoScrollEnabled = true; this._lastScrollTop = 0; // DOM Elements this.$attach = document.getElementById('attach'); this.$attachments = document.getElementById('attachments'); this.$chat = document.getElementById('chat'); this.$chatLoading = document.getElementById('chat-loading'); this.$fileInput = document.getElementById('file-input'); this.$model = document.getElementById('model'); this.$ptt = document.getElementById('ptt'); this.$reset = document.getElementById('reset'); this.$scroll = document.getElementById('scroll'); this.$send = document.getElementById('send'); this.$textInput = document.getElementById('text-input'); this.$voice = document.getElementById('voice'); this.$mute = document.getElementById('mute'); // Settings modal 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'); // 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.#stopTTS(); this.currentController?.abort(); this.#releaseWakeLock(); } /** * reset clears all state including history, attachments, and pending * requests. */ reset() { this.#stopCurrentAudio(); this.#stopTTS(); if (this.currentController) { this.currentController.abort(); this.currentController = null; } this.#setLoadingState(false); this.pendingDeleteId = null; this.messagesMap.clear(); this.leafId = null; this.rootId = null; this.isAutoScrollEnabled = true; this._lastScrollTop = 0; localStorage.removeItem(STORAGE_KEY); this.$chat.innerHTML = ''; this.#clearAttachments(); this.$textInput.value = ''; this.$textInput.style.height = 'auto'; this.#updateSendButtonState(); } // ==================== // INITIALIZATION // ==================== /** * #init initializes the application. */ #init() { this.#loadHistory(); this.#bindEvents(); this.#fetchVoices(); this.#fetchModels(); this.#initWakeLock(); } /** * #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: when loading, acts as stop button with two-press safety this.$send.addEventListener('click', () => this.#handleSendClick()); this.document.addEventListener('click', (e) => this.#handleStopCancel(e)); // Reset button: two-press safety this.$reset.addEventListener('click', () => this.#handleResetClick()); this.document.addEventListener('click', (e) => this.#handleResetCancel(e)); // Scroll button: two-press safety, swaps between scroll-to-bottom and // scroll-to-top. this.$scroll.addEventListener('click', () => this.#handleScrollClick()); this.document.addEventListener('click', (e) => this.#handleScrollCancel(e)); this.document.addEventListener('click', (e) => this.#handleDeleteCancel(e)); // 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()); // Scroll button: update icon as user scrolls this.$chat.addEventListener( 'scroll', () => { this.#updateScrollIcon(); this.#handleChatScroll(); }, { passive: true }, ); this.#updateScrollIcon(); // Settings modal this.$settings.addEventListener('click', () => this.openSettings()); this.$settingsClose.addEventListener('click', () => this.closeSettings()); this.$settingsOverlay.addEventListener('click', (e) => { if (e.target === this.$settingsOverlay) this.closeSettings(); }); this.document.addEventListener('keydown', (e) => { if ( e.key === 'Escape' && this.$settingsOverlay.classList.contains('open') ) { e.preventDefault(); this.closeSettings(); } }); this.$settingsTabs.forEach(($tab) => { $tab.addEventListener('click', () => this.#switchTab($tab.dataset.tab)); }); this.$saveSystemMessage.addEventListener('click', () => this.#saveSystemMessage(), ); // Wake lock: reacquire on visibility change this.document.addEventListener('visibilitychange', () => this.#handleVisibilityChange(), ); } // ==================== // CONVERSATION TREE // ==================== /** * #loadHistory loads chat history from localStorage and renders it. */ #loadHistory() { try { const stored = localStorage.getItem(STORAGE_KEY); if (!stored) return; const parsed = JSON.parse(stored); // Ensure messages have parent/children fields (defensive). for (const msg of parsed.messages) { if (msg.parent === undefined) msg.parent = null; if (msg.children === undefined) msg.children = []; } this.messagesMap = new Map(parsed.messages.map((m) => [m.id, m])); this.leafId = parsed.leafId; this.rootId = parsed.rootId; // Render the active path. const activePath = this.getActivePath(); this.#renderMessages(activePath); this.#updateSendButtonState(); } catch (e) { console.error('failed to load history:', e); this.messagesMap.clear(); this.leafId = null; this.rootId = null; } } /** * #saveToStorage persists the tree to localStorage. */ #saveToStorage() { const messages = Array.from(this.messagesMap.values()); const data = { messages, leafId: this.leafId, rootId: this.rootId, }; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch (e) { console.error('failed to save history:', e); } } /** * getActivePath resolves the ordered array of messages from root to leaf * by walking parent pointers from leafId. * @returns {Object[]} ordered messages */ getActivePath() { if (!this.leafId) return []; const path = []; let current = this.leafId; while (current) { const msg = this.messagesMap.get(current); if (!msg) break; path.push(msg); current = msg.parent; } path.reverse(); return path; } /** * #appendHistory adds messages to the tree. Each new message is a child * of the previous one (or the current leaf for the first). * Only the last message receives the metadata. * Mutates the passed-in message objects in place (adds id, parent, children, * meta) and stores the same references in messagesMap. Callers should use * the returned messages for rendering to ensure the delete handler captures * the correct IDs. * @param {Object[]} messages * @param {Object} [meta] * @returns {Object[]} the same message objects, now enriched */ #appendHistory(messages, meta = {}) { let prevId = this.leafId; 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; } // Link to the tree. msg.parent = prevId; msg.children = []; if (prevId) { const parent = this.messagesMap.get(prevId); if (parent) { parent.children.push(msg.id); } } else { // First message in the conversation. this.rootId = msg.id; } this.messagesMap.set(msg.id, msg); this.leafId = msg.id; prevId = msg.id; } this.#saveToStorage(); this.#updateSendButtonState(); return messages; } /** * deleteMessage removes a message from the tree and reparents its * children to the grandparent. Also handles tool call / tool result * pairing. When deleting the root, the first child becomes the new root * and any remaining children are reparented under it. * @param {string} messageId */ deleteMessage(messageId) { const msg = this.messagesMap.get(messageId); if (!msg) return; const parentId = msg.parent; // null if this is the root const parent = parentId ? this.messagesMap.get(parentId) : null; // Identify tool result messages that belong to this assistant message. const idsToDelete = new Set([messageId]); if (msg.role === 'assistant' && msg.tool_calls?.length > 0) { const toolCallIds = new Set(msg.tool_calls.map((tc) => tc.id)); for (const [id, m] of this.messagesMap) { if (m.role === 'tool' && toolCallIds.has(m.tool_call_id)) { idsToDelete.add(id); } } } // Collect all children that need reparenting. const childrenToReparent = []; for (const deleteId of idsToDelete) { const delMsg = this.messagesMap.get(deleteId); if (delMsg?.children) { childrenToReparent.push(...delMsg.children); } } // Remove deleted messages from the map. for (const deleteId of idsToDelete) { this.messagesMap.delete(deleteId); } // Remove deleted IDs from parent's children. if (parent) { parent.children = parent.children.filter((c) => !idsToDelete.has(c)); } // Reparent children of deleted messages to the grandparent. // When deleting the root, the first child becomes the new root and the // remaining children are reparented under it. if (parent) { for (const childId of childrenToReparent) { const child = this.messagesMap.get(childId); if (child) { child.parent = parentId; if (!parent.children.includes(childId)) { parent.children.push(childId); } } } } else if (childrenToReparent.length > 0) { // Root deletion: first child becomes the new root, rest become its children. const newRootId = childrenToReparent[0]; const newRoot = this.messagesMap.get(newRootId); if (newRoot) { newRoot.parent = null; for (let i = 1; i < childrenToReparent.length; i++) { const childId = childrenToReparent[i]; const child = this.messagesMap.get(childId); if (child) { child.parent = newRootId; if (!newRoot.children.includes(childId)) { newRoot.children.push(childId); } } } } this.rootId = newRootId; } else { this.rootId = null; } // Update leafId if it was deleted or was a descendant of a deleted message. if (idsToDelete.has(this.leafId)) { if (parentId) { this.#updateLeafId(parentId); } else if (childrenToReparent.length > 0) { this.#updateLeafId(childrenToReparent[0]); } else { this.leafId = null; } } // Remove the DOM element(s). for (const deleteId of idsToDelete) { const $el = this.$chat.querySelector(`[data-id="${deleteId}"]`); if ($el) $el.remove(); } this.#saveToStorage(); this.#updateSendButtonState(); } /** * #updateLeafId walks the tree from a starting node to find the deepest * leaf by following the first child at each step. Used when the active * leaf is deleted and a new one must be chosen. With branching, this * picks the first branch; future work may track which branch was active. * @param {string} startId */ #updateLeafId(startId) { let current = startId; while (current) { const msg = this.messagesMap.get(current); if (!msg?.children || msg.children.length === 0) break; // Follow the first child. With branching, this picks the first branch. current = msg.children[0]; } this.leafId = current; } // ==================== // 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`; this.#updateSendButtonState(); }; /** * #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.#updateSendButtonState(); this.$fileInput.value = ''; } /** * #handleResetClick toggles the pending state on first press, * executes the reset on second press. */ #handleResetClick() { if (this.isResetPending) { this.isResetPending = false; this.$reset.classList.remove('footer__toolbar-btn--pending'); this.reset(); } else { this.isResetPending = true; this.$reset.classList.add('footer__toolbar-btn--pending'); } } /** * #handleResetCancel clears the pending reset state when the user * clicks anywhere outside the reset button. * @param {MouseEvent} event */ #handleResetCancel(event) { if (!this.isResetPending) return; if (this.$reset.contains(event.target)) return; this.isResetPending = false; this.$reset.classList.remove('footer__toolbar-btn--pending'); } /** * #handleScrollClick toggles the pending scroll state on first press, * executes the scroll on second press. At bottom → scroll to top, * elsewhere → scroll to bottom. */ #handleScrollClick() { if (this.isScrollPending) { this.isScrollPending = false; this.$scroll.classList.remove('footer__toolbar-btn--pending'); if (this.#isAtBottom()) { this.$chat.scrollTo({ top: 0, behavior: 'smooth' }); } else { this.$chat.scrollTo({ top: this.$chat.scrollHeight, behavior: 'smooth', }); } this.#updateScrollIcon(); } else { this.isScrollPending = true; this.$scroll.classList.add('footer__toolbar-btn--pending'); } } /** * #handleScrollCancel clears the pending scroll state when the user * clicks anywhere outside the scroll button. * @param {MouseEvent} event */ #handleScrollCancel(event) { if (!this.isScrollPending) return; if (this.$scroll.contains(event.target)) return; this.isScrollPending = false; this.$scroll.classList.remove('footer__toolbar-btn--pending'); } /** * #handleChatScroll detects whether the user scrolled up or down and * toggles auto-scroll accordingly. Scrolling up disables it; scrolling * back to the bottom re-enables it. */ #handleChatScroll() { const { scrollTop, scrollHeight, clientHeight } = this.$chat; const distanceFromBottom = scrollHeight - clientHeight - scrollTop; const isAtBottom = distanceFromBottom < 10; if (scrollTop < this._lastScrollTop && !isAtBottom) { // User scrolled up and is not at the bottom → disable auto-scroll. this.isAutoScrollEnabled = false; } else if (isAtBottom) { // User scrolled back to the bottom → re-enable auto-scroll. this.isAutoScrollEnabled = true; } this._lastScrollTop = scrollTop; } /** * #isAtBottom checks if the chat is scrolled within 10px of the bottom. * @returns {boolean} */ #isAtBottom() { const { scrollTop, scrollHeight, clientHeight } = this.$chat; return scrollHeight - clientHeight - scrollTop < 10; } /** * #updateScrollIcon toggles a CSS class to show the appropriate arrow * and updates the aria-label. No DOM nodes are created on scroll. */ #updateScrollIcon() { const atBottom = this.#isAtBottom(); this.$scroll.classList.toggle('scrolled-up', !atBottom); this.$scroll.setAttribute( 'aria-label', atBottom ? 'Scroll to top' : 'Scroll to bottom', ); } /** * #scrollToBottom scrolls the chat to the bottom if auto-scroll is * enabled (i.e., the user is following along and hasn't scrolled up). */ #scrollToBottom() { if (this.isAutoScrollEnabled) { this.$chat.scrollTop = this.$chat.scrollHeight; } } /** * #handleSendClick routes to either send or stop based on loading state. */ #handleSendClick() { if (this.$send.classList.contains('loading')) { this.#handleStopClick(); } else { this.#submitText(); } } /** * #handleStopClick toggles the pending stop state on first press, * aborts the current request on second press. */ #handleStopClick() { if (this.isStopPending) { this.isStopPending = false; this.$send.classList.remove('pending'); this.#stopConversation(); } else { this.isStopPending = true; this.$send.classList.add('pending'); } } /** * #handleStopCancel clears the pending stop state when the user * clicks anywhere outside the send button. * @param {MouseEvent} event */ #handleStopCancel(event) { if (!this.isStopPending) return; if (this.$send.contains(event.target)) return; this.isStopPending = false; this.$send.classList.remove('pending'); } /** * #handleDeleteClick toggles the pending delete state on first press * (yellow highlight), executes the delete on second press. Clicking * anywhere outside the button cancels the pending state. * @param {MouseEvent} event * @param {string} messageId */ #handleDeleteClick(event, messageId) { event.stopPropagation(); const $btn = event.currentTarget; if (this.pendingDeleteId === messageId) { this.pendingDeleteId = null; $btn.classList.remove('pending'); this.deleteMessage(messageId); } else { // Clear any previously pending delete button. this.#clearPendingDelete(); this.pendingDeleteId = messageId; $btn.classList.add('pending'); } } /** * #clearPendingDelete clears the pending delete state and removes the * yellow highlight from the delete button. */ #clearPendingDelete() { if (!this.pendingDeleteId) return; const $msg = this.$chat.querySelector( `[data-id="${this.pendingDeleteId}"]`, ); if ($msg) { const $btn = $msg.querySelector('[data-action="delete"]'); if ($btn) $btn.classList.remove('pending'); } this.pendingDeleteId = null; } /** * #handleDeleteCancel clears the pending delete state when the user * clicks anywhere outside the delete button. * @param {MouseEvent} event */ #handleDeleteCancel(event) { if (!this.pendingDeleteId) return; const $btn = event.target.closest('[data-action="delete"]'); if ($btn) return; this.#clearPendingDelete(); } /** * #stopConversation aborts the current streaming request and audio. */ #stopConversation() { this.#stopCurrentAudio(); this.#stopTTS(); if (this.currentController) { this.currentController.abort(); this.currentController = null; } this.#setLoadingState(false); } // ==================== // 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(); this.#stopTTS(); // Initialize MediaRecorder on first use if (!this.mediaRecorder) { const success = await this.#initMediaRecorder(); if (!success) return; } this.audioChunks = []; this.mediaRecorder.start(); this.#setRecordingState(true); this.#acquireWakeLock(); } /** * #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; } } // ==================== // TTS PLAYBACK // ==================== /** * #enqueueTTS adds a message to the TTS playback queue and starts * playback if not already playing. * @param {string} text * @param {string} voice * @param {string} messageId */ #enqueueTTS(text, voice, messageId) { if (!text || !this.ttsUrl) return; this.playQueue.push({ text, voice, messageId }); if (!this.isPlaying) { this.#processQueue(); } } /** * #processQueue pops the next item from the TTS queue and synthesizes it. */ async #processQueue() { if (this.playQueue.length === 0) { this.isPlaying = false; this.currentMessageId = null; return; } this.isPlaying = true; const item = this.playQueue.shift(); this.currentMessageId = item.messageId; // Update the play button on the message to show stop state. this.#setPlayButtonState(item.messageId, true); await this.#synthesizeAndPlay(item.text, item.voice, item.messageId); // Reset this message's button back to play state before moving on. this.#setPlayButtonState(item.messageId, false); // Move to next item in queue. this.#processQueue(); } /** * #stopTTS stops current audio playback, clears the queue, and resets UI. */ #stopTTS() { this.#stopCurrentAudio(); this.playQueue = []; this.isPlaying = false; // Reset all play buttons. if (this.currentMessageId) { this.#setPlayButtonState(this.currentMessageId, false); } this.currentMessageId = null; } /** * #synthesizeAndPlay calls kokoro-fastapi to synthesize text and plays * the resulting audio. On error, logs and returns without blocking the queue. * @param {string} text * @param {string} voice * @param {string} _messageId */ async #synthesizeAndPlay(text, voice, _messageId) { try { this.#stopCurrentAudio(); const res = await fetch(`${this.ttsUrl}/v1/audio/speech`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'tts-1', input: text, voice: voice || this.$voice.value || this.ttsDefaultVoice, response_format: 'wav', }), }); if (!res.ok) { throw new Error(`TTS error ${res.status}`); } const audioBlob = await res.blob(); 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('TTS synthesis failed:', e); this.#cleanupAudio(); } } /** * #setPlayButtonState updates the play/stop button icon and state for a * given message. * @param {string} messageId * @param {boolean} playing */ #setPlayButtonState(messageId, playing) { const $msg = this.$chat.querySelector(`[data-id="${messageId}"]`); if (!$msg) return; const $btn = $msg.querySelector('[data-action="play"]'); if (!$btn) return; if (playing) { $btn.classList.add('playing'); $btn.innerHTML = ``; $btn.setAttribute('aria-label', 'Stop'); } else { $btn.classList.remove('playing'); $btn.innerHTML = ``; $btn.setAttribute('aria-label', 'Play'); } } /** * #handlePlayClick handles the play/stop button on an assistant message. * If not playing: stop any current audio, enqueue this message, start playing. * If currently playing this message: stop playback, clear remaining queue, reset. * @param {MouseEvent} event * @param {string} messageId * @param {string} text * @param {string} voice */ #handlePlayClick(event, messageId, text, voice) { event.stopPropagation(); if (this.currentMessageId === messageId) { // Currently playing this message: stop everything. this.#stopTTS(); } else { // Not playing this message: stop current playback, enqueue this one. this.#stopTTS(); this.#enqueueTTS(text, voice, messageId); } } // ==================== // AUDIO PLAYBACK // ==================== /** * #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(`${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); } } // ==================== // API: CHAT // ==================== /** * #submitText submits the current text input and attachments. */ async #submitText() { const text = this.$textInput.value.trim(); if (this.isProcessing) return; this.#stopCurrentAudio(); this.#stopTTS(); 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 content. * * @param {Object} options * @param {Blob} [options.audio] - Recorded audio blob * @param {string} [options.text] - Text input */ async #sendRequest({ audio = null, text = '' }) { this.#setLoadingState(true); // Re-enable auto-scroll when the user sends a new message. this.isAutoScrollEnabled = true; this.currentController = new AbortController(); try { const hasNewInput = text || this.attachments.length > 0 || audio; // Build messages array from active path + current user message. const activePath = this.getActivePath(); let messages; let userMessage; if (hasNewInput) { userMessage = await this.#buildUserMessage(text, this.attachments); messages = [ ...activePath.map(({ parent, children, id, meta, ...msg }) => msg), userMessage, ]; } else { // Continue conversation: send existing history without a new user message. messages = activePath.map( ({ parent, children, id, meta, ...msg }) => msg, ); } const payload = { messages, voice: this.$voice.value, model: this.$model.value, }; const systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY); if (systemMessage) { payload.system_message = systemMessage; } if (audio) { payload.audio = await this.#toBase64(audio); } // Clear attachments after building payload (before async operations). this.#clearAttachments(); // For text-only requests with new input, add to history and render // immediately. Skip when continuing conversation (no new user message). if (!audio && hasNewInput) { const appended = this.#appendHistory([userMessage]); this.#renderMessages(appended); } 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 ) { const appended = this.#appendHistory([message]); pendingTools = { assistant: appended[0], results: [], expected: message.tool_calls.length, }; continue; } // Collect tool results and render once all have arrived. if ( message.role === 'tool' && pendingTools && pendingTools.assistant ) { const appended = this.#appendHistory([message]); pendingTools.results.push(appended[0]); // 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; const appended = this.#appendHistory([message], meta); this.#renderMessages(appended, meta); // Enqueue TTS for the final assistant message with content. if (message.content) { this.#enqueueTTS( message.content, event.voice || this.$voice.value || '', message.id, ); } } } 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 // ==================== /** * #updateSendButtonState enables the send button when there is text, * attachments, or at least one message in the conversation history, * and disables it otherwise. */ #updateSendButtonState() { const hasText = this.$textInput.value.trim().length > 0; const hasAttachments = this.attachments.length > 0; const hasHistory = this.messagesMap.size > 0; const enabled = hasText || hasAttachments || hasHistory; this.$send.disabled = !enabled; this.$send.setAttribute( 'aria-label', enabled ? 'Send message' : 'Start a conversation to enable send', ); } /** * #setLoadingState updates UI to reflect loading state. * @param {boolean} loading */ #setLoadingState(loading) { this.isProcessing = loading; this.$send.classList.toggle('loading', loading); this.$send.setAttribute('aria-label', loading ? 'Stop' : 'Send message'); // Show/hide the spinner in the chat area. // Positioning is handled by #renderMessages to ensure it's always at the end. this.$chatLoading.hidden = !loading; // Keep the send button enabled during loading so it can act as a stop button. this.$ptt.disabled = loading; // Clear any pending stop state when loading ends. if (!loading && this.isStopPending) { this.isStopPending = false; this.$send.classList.remove('pending'); } if (loading) { this.#acquireWakeLock(); } else { this.#releaseWakeLock(); } } /** * #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; } } /** * openSettings opens the settings modal and loads current values. */ openSettings() { this.$settingsOverlay.classList.add('open'); this.#loadSystemMessage(); this.#switchTab('model-voice'); // Focus the close button for accessibility. this.$settingsClose.focus(); } /** * closeSettings closes the settings modal and restores focus. */ closeSettings() { this.$settingsOverlay.classList.remove('open'); this.$settings.focus(); } /** * #switchTab switches the active tab in the settings modal. * @param {string} tabName */ #switchTab(tabName) { this.$settingsTabs.forEach(($tab) => { const isActive = $tab.dataset.tab === tabName; $tab.classList.toggle('active', isActive); $tab.setAttribute('aria-selected', String(isActive)); }); this.$settingsPanels.forEach(($panel) => { const isActive = $panel.dataset.tabPanel === tabName; $panel.classList.toggle('active', isActive); $panel.hidden = !isActive; }); } /** * #saveSystemMessage saves the textarea value to localStorage. */ #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); } /** * #loadSystemMessage reads from localStorage and populates the textarea. */ #loadSystemMessage() { const stored = localStorage.getItem(SYSTEM_MESSAGE_KEY); this.$systemMessageInput.value = stored || ''; } // ==================== // 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); } // Ensure the loading spinner is always at the end of the chat. if (!this.$chatLoading.hidden) { this.$chat.appendChild(this.$chatLoading); } } /** * #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), ); const $delete = $msg.querySelector('[data-action="delete"]'); $delete.addEventListener('click', (e) => this.#handleDeleteClick(e, message.id), ); this.$chat.appendChild($msg); this.#scrollToBottom(); } /** * #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)); }); // Play button: only for messages with non-empty content. const $play = $msg.querySelector('[data-action="play"]'); if (message.content) { const voice = meta?.voice || this.$voice.value || ''; $play.addEventListener('click', (e) => this.#handlePlayClick(e, message.id, message.content, voice), ); } else { $play.remove(); } // 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), ); const $delete = $msg.querySelector('[data-action="delete"]'); $delete.addEventListener('click', (e) => this.#handleDeleteClick(e, message.id), ); this.$chat.appendChild($msg); this.#scrollToBottom(); } /** * #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.#scrollToBottom(); } // ==================== // 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(); this.#updateSendButtonState(); } /** * #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); } } // ==================== // WAKE LOCK // ==================== /** * #initWakeLock performs feature detection for the Screen Wake Lock API. */ #initWakeLock() { if (!('wakeLock' in navigator)) { console.debug('Screen Wake Lock API not supported'); } } /** * #acquireWakeLock requests a screen wake lock. Idempotent. */ async #acquireWakeLock() { if (this.wakeLock) return; if (!('wakeLock' in navigator)) return; try { this.wakeLock = await navigator.wakeLock.request('screen'); } catch (e) { console.debug('wake lock acquire failed:', e); } } /** * #releaseWakeLock releases the active wake lock. Idempotent. */ async #releaseWakeLock() { if (!this.wakeLock) return; try { await this.wakeLock.release(); } catch (e) { console.debug('wake lock release failed:', e); } this.wakeLock = null; } /** * #handleVisibilityChange reacquires the wake lock when the tab becomes * visible, if the app is currently active (recording or processing). */ #handleVisibilityChange() { if (this.document.visibilityState !== 'visible') return; if (!this.isRecording && !this.isProcessing) return; this.#acquireWakeLock(); } } // Initialize new Odidere({ document });