diff --git a/internal/service/static/conversation.js b/internal/service/static/conversation.js new file mode 100644 index 0000000..6b44674 --- /dev/null +++ b/internal/service/static/conversation.js @@ -0,0 +1,273 @@ +/** + * ConversationTree is a pure data model for the conversation message tree. + * It owns the message map, tree structure (parent/children), and localStorage + * persistence. No DOM, no rendering. + */ + +const STORAGE_KEY = 'odidere_history'; + +/** + * extractText extracts plain text from a message content field. + * Content can be a string or an array of content-part objects. + * @param {string|Array} content + * @returns {string} + */ +export function extractText(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .filter((p) => p.type === 'text') + .map((p) => p.text) + .join('\n'); + } + return ''; +} + +export class ConversationTree { + constructor() { + this.messagesMap = new Map(); + this.leafId = null; + this.rootId = null; + } + + /** + * get returns a message by ID. + * @param {string} id + * @returns {Object|undefined} + */ + get(id) { + return this.messagesMap.get(id); + } + + /** + * 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; + } + + /** @returns {number} number of messages in the tree */ + get size() { + return this.messagesMap.size; + } + + /** + * append 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. Returns the same messages, now enriched. + * @param {Object[]} messages + * @param {Object} [meta] + * @returns {Object[]} the same message objects, now enriched + */ + append(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.save(); + return messages; + } + + /** + * delete 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 + */ + delete(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. + 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. + 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. + if (idsToDelete.has(this.leafId)) { + if (parentId) { + this.updateLeafId(parentId); + } else if (childrenToReparent.length > 0) { + this.updateLeafId(childrenToReparent[0]); + } else { + this.leafId = null; + } + } + + this.save(); + } + + /** + * updateLeafId walks the tree from a starting node to find the deepest + * leaf by following the first child at each step. + * @param {string} startId + */ + updateLeafId(startId) { + let current = startId; + while (current) { + const msg = this.messagesMap.get(current); + if (!msg?.children || msg.children.length === 0) break; + current = msg.children[0]; + } + this.leafId = current; + } + + /** + * load reads chat history from localStorage and populates the map. + */ + load() { + 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; + } catch (e) { + console.error('failed to load history:', e); + this.messagesMap.clear(); + this.leafId = null; + this.rootId = null; + } + } + + /** + * save persists the tree to localStorage. + */ + save() { + 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); + } + } + + /** + * clear wipes the map and localStorage. + */ + clear() { + this.messagesMap.clear(); + this.leafId = null; + this.rootId = null; + localStorage.removeItem(STORAGE_KEY); + } +} diff --git a/internal/service/static/edit.js b/internal/service/static/edit.js new file mode 100644 index 0000000..3a8740a --- /dev/null +++ b/internal/service/static/edit.js @@ -0,0 +1,213 @@ +/** + * EditModal opens a modal for editing a message (text, reasoning, or + * tool call). Reads from and writes to the conversation tree. Returns + * a Promise so the App knows when to re-render. + */ + +import { extractText } from './conversation.js'; + +export class EditModal { + /** + * @param {Object} options + * @param {Document} options.document + * @param {import('./conversation.js').ConversationTree} options.conversation + */ + constructor({ document, conversation }) { + this.document = document; + this.conversation = conversation; + } + + /** + * open creates and shows the edit modal for a message. + * @param {string} messageId + * @param {string} [field] - Optional: 'reasoning' or 'tool' + * @param {string} [toolCallId] - Optional, for tool edits + * @returns {Promise} true if saved, false if cancelled + */ + open(messageId, field = null, toolCallId = null) { + const msg = this.conversation.get(messageId); + if (!msg) return Promise.resolve(false); + + return new Promise((resolve) => { + const isToolEdit = field === 'tool'; + const tplId = isToolEdit ? 'tpl-edit-tool-modal' : 'tpl-edit-modal'; + const $tpl = this.document.getElementById(tplId); + if (!$tpl) return resolve(false); + + const $modal = $tpl.content.cloneNode(true).firstElementChild; + const $overlay = this.document.createElement('div'); + $overlay.className = 'modal-overlay'; + $overlay.appendChild($modal); + + if (!isToolEdit) { + $modal.querySelector('h2').textContent = + field === 'reasoning' ? 'Edit Reasoning' : 'Edit Message'; + } + + const saveBtn = $modal.querySelector('#edit-save'); + const closeBtn = $modal.querySelector('.modal__close'); + + // Populate textarea(s). + if (isToolEdit && toolCallId) { + this._populateToolEdit(msg, toolCallId, $modal); + } else if (field === 'reasoning') { + const textarea = $modal.querySelector('#edit-textarea'); + textarea.value = msg.reasoning_content || ''; + } else { + const textarea = $modal.querySelector('#edit-textarea'); + textarea.value = extractText(msg.content); + } + + const close = () => { + $overlay.remove(); + this.document.removeEventListener('keydown', handleEscape); + resolve(false); + }; + + const save = () => { + const saved = this._save(messageId, $modal, field, toolCallId, saveBtn); + if (saved) { + $overlay.remove(); + this.document.removeEventListener('keydown', handleEscape); + resolve(true); + } + }; + + closeBtn.addEventListener('click', close); + saveBtn.addEventListener('click', save); + + $overlay.addEventListener('click', (e) => { + if (e.target === $overlay) close(); + }); + + const handleEscape = (e) => { + if (e.key === 'Escape') { + close(); + } + }; + this.document.addEventListener('keydown', handleEscape); + + this.document.body.appendChild($overlay); + + const firstTextarea = $modal.querySelector('textarea'); + if (firstTextarea) firstTextarea.focus(); + }); + } + + // --- Private --- + + _populateToolEdit(msg, toolCallId, $modal) { + const toolCall = msg.tool_calls?.find((tc) => tc.id === toolCallId); + if (!toolCall) return; + + const $callTextarea = $modal.querySelector('#edit-tool-call'); + const $resultTextarea = $modal.querySelector('#edit-tool-result'); + + try { + const args = JSON.parse(toolCall.function?.arguments || '{}'); + $callTextarea.value = JSON.stringify( + { name: toolCall.function?.name, arguments: args }, + null, + 2, + ); + } catch { + $callTextarea.value = JSON.stringify( + { + name: toolCall.function?.name, + arguments: toolCall.function?.arguments || '', + }, + null, + 2, + ); + } + + for (const [, m] of this.conversation.messagesMap) { + if (m.role === 'tool' && m.tool_call_id === toolCallId) { + $resultTextarea.value = extractText(m.content) || ''; + break; + } + } + } + + _save(messageId, $modal, field, toolCallId, saveBtn) { + const msg = this.conversation.get(messageId); + if (!msg) return false; + + if (field === 'tool' && toolCallId) { + return this._saveToolEdit(msg, toolCallId, $modal, saveBtn); + } + + if (field === 'reasoning') { + const textarea = $modal.querySelector('#edit-textarea'); + msg.reasoning_content = textarea.value; + this.conversation.save(); + return true; + } + + // Regular message edit. + const textarea = $modal.querySelector('#edit-textarea'); + const newText = textarea.value; + if (Array.isArray(msg.content)) { + const textPart = msg.content.find((p) => p.type === 'text'); + if (textPart) { + textPart.text = newText; + } else { + msg.content.unshift({ type: 'text', text: newText }); + } + } else { + msg.content = newText; + } + this.conversation.save(); + return true; + } + + _saveToolEdit(msg, toolCallId, $modal, saveBtn) { + const toolCall = msg.tool_calls?.find((tc) => tc.id === toolCallId); + if (!toolCall) return false; + + const $callTextarea = $modal.querySelector('#edit-tool-call'); + const $resultTextarea = $modal.querySelector('#edit-tool-result'); + + let parsedCall; + try { + parsedCall = JSON.parse($callTextarea.value); + } catch { + $callTextarea.classList.add('modal__textarea--error'); + saveBtn.classList.add('modal__save--error'); + setTimeout(() => { + saveBtn.classList.remove('modal__save--error'); + }, 500); + return false; + } + + if (parsedCall.name) { + toolCall.function.name = parsedCall.name; + } + if (parsedCall.arguments !== undefined) { + toolCall.function.arguments = + typeof parsedCall.arguments === 'string' + ? parsedCall.arguments + : JSON.stringify(parsedCall.arguments); + } + + for (const [, m] of this.conversation.messagesMap) { + if (m.role === 'tool' && m.tool_call_id === toolCallId) { + const newText = $resultTextarea.value; + if (Array.isArray(m.content)) { + const textPart = m.content.find((p) => p.type === 'text'); + if (textPart) { + textPart.text = newText; + } else { + m.content.unshift({ type: 'text', text: newText }); + } + } else { + m.content = newText; + } + break; + } + } + + this.conversation.save(); + return true; + } +} diff --git a/internal/service/static/icons.svg b/internal/service/static/icons.svg index 584fe48..94c1a2b 100644 --- a/internal/service/static/icons.svg +++ b/internal/service/static/icons.svg @@ -128,4 +128,10 @@ + + + + + + diff --git a/internal/service/static/main.css b/internal/service/static/main.css index 656be96..6018b83 100644 --- a/internal/service/static/main.css +++ b/internal/service/static/main.css @@ -127,104 +127,6 @@ body { border-radius: 50%; animation: spin 0.8s linear infinite; } - -/* ==================== */ -/* Collapsible */ -/* ==================== */ - -.collapsible { - border-bottom: 1px dashed var(--color-border-light); -} - -.collapsible:last-child { - border-bottom: none; -} - -.collapsible__content { - padding: var(--s-2) var(--s-1); -} - -.collapsible__summary::before { - content: "▶"; - font-size: 0.625em; - color: var(--color-text-muted); - transition: transform 0.15s ease; -} - -.collapsible[open] .collapsible__summary::before { - transform: rotate(90deg); -} - -.collapsible__content > pre { - margin: 0; - font-family: var(--font-mono); - font-size: var(--s-1); - white-space: pre-wrap; - word-break: break-word; -} - -.collapsible__label { - font-size: var(--s-1); - color: var(--color-text-muted); -} - -.collapsible__pre { - margin: 0; - padding: var(--s-2); - font-family: var(--font-mono); - font-size: var(--s-1); - background: var(--color-bg); - border-radius: var(--radius); - overflow-x: auto; - white-space: pre-wrap; - word-break: break-word; -} - -.collapsible__section { - margin-bottom: var(--s-2); -} - -.collapsible__section:last-child { - margin-bottom: 0; -} - -.collapsible__section-label { - font-size: var(--s-2); - font-weight: 500; - color: var(--color-text-muted); - margin-bottom: 0.25rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.collapsible__summary { - display: flex; - align-items: center; - gap: var(--s-2); - padding: var(--s-2) var(--s-1); - cursor: pointer; - user-select: none; - list-style: none; -} - -.collapsible__summary::-webkit-details-marker { - display: none; -} - -.collapsible__summary .icon { - width: var(--icon-size); - height: var(--icon-size); - color: var(--color-text-muted); -} - -.collapsible--reasoning .collapsible__summary .icon { - color: var(--color-purple); -} - -.collapsible--tool .collapsible__summary .icon { - color: var(--color-orange); -} - /* ==================== */ /* Compose */ /* ==================== */ @@ -1104,6 +1006,41 @@ body { outline: none; } +/* Collapsible sections within the tool-call edit modal only. + The textarea inside keeps its own border and focus styles so it looks + identical to the bare textarea in single-section modals. */ +.modal__collapsible + .modal__collapsible { + margin-top: var(--s-1); +} + +.modal__collapsible-summary { + display: flex; + align-items: center; + gap: var(--s-2); + padding: 0 0 var(--s-1) 0; + cursor: pointer; + user-select: none; + list-style: none; + font-size: var(--s-1); + font-weight: 500; + color: var(--color-text-muted); +} + +.modal__collapsible-summary::-webkit-details-marker { + display: none; +} + +.modal__collapsible-summary::before { + content: "▶"; + font-size: 0.625em; + color: var(--color-text-muted); + transition: transform 0.15s ease; +} + +.modal__collapsible[open] > .modal__collapsible-summary::before { + transform: rotate(90deg); +} + .modal__textarea:focus { border-color: var(--color-primary); } @@ -1182,3 +1119,89 @@ body { font-size: var(--s0); } } + +/* ==================== */ +/* Reasoning & Tool Bubbles */ +/* ==================== */ + +.message--reasoning .message__icon { + color: var(--color-primary); +} + +.message--reasoning .message__icon--reasoning { + color: var(--color-purple); +} + +.message--tool .message__icon { + color: var(--color-primary); +} + +.message--tool .message__icon--tool { + color: var(--color-orange); +} + +.message__collapsible { + border-bottom: none; +} + +.message__collapsible-summary { + display: flex; + align-items: center; + gap: var(--s-2); + padding: var(--s-2) var(--s-1); + cursor: pointer; + user-select: none; + list-style: none; +} + +.message__collapsible-summary::-webkit-details-marker { + display: none; +} + +.message__collapsible-summary::before { + content: "▶"; + font-size: 0.625em; + color: var(--color-text-muted); + transition: transform 0.15s ease; +} + +.message__collapsible[open] > .message__collapsible-summary::before { + transform: rotate(90deg); +} + +.message__collapsible-label { + font-size: var(--s-1); + color: var(--color-text-muted); +} + +/* .message__content inherits white-space: pre-wrap for plain text messages + (to preserve newlines from LLM output). For tool messages, the content is + structured HTML with newlines between child elements; pre-wrap renders those + as visible blank lines, causing large vertical gaps. Reset to normal. */ +.message--tool .message__content { + white-space: normal; + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.message__tool-section-label { + font-size: var(--s-2); + font-weight: 500; + color: var(--color-text-muted); + margin: 0 0 var(--s-2); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.message__tool-pre { + margin: 0; + padding: var(--s-2); + font-family: var(--font-mono); + font-size: var(--s-1); + background: var(--color-bg); + border-radius: var(--radius); + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} diff --git a/internal/service/static/main.js b/internal/service/static/main.js index 033f5e2..0f2628e 100644 --- a/internal/service/static/main.js +++ b/internal/service/static/main.js @@ -1,59 +1,66 @@ -const ICONS_URL = '/static/icons.svg'; -const MODELS_ENDPOINT = '/v1/models'; -const MODEL_KEY = 'odidere_model'; -const PROVIDER_KEY = 'odidere_provider'; -const STORAGE_KEY = 'odidere_history'; +/** + * Odidere is the composition root and orchestrator. It creates all + * subsystems, wires them together, owns DOM refs, event delegation, + * and runTurn (the streaming orchestration formerly known as sendRequest). + */ + +import { ConversationTree, extractText } from './conversation.js'; +import { EditModal } from './edit.js'; +import { Renderer } from './render.js'; +import { Settings } from './settings.js'; +import { STT, VOICE_MAP } from './stt.js'; +import { TTSPlayer } from './tts.js'; +import { WakeLock } from './wakelock.js'; + const STREAM_ENDPOINT = '/v1/chat/voice/stream'; +const SYSTEM_MESSAGE_KEY = 'odidere_system_message'; +const ICONS_URL = '/static/icons.svg'; + const StreamEventDelta = 'delta'; const StreamEventReasoningDelta = 'reasoning_delta'; const StreamEventDone = 'done'; const StreamEventMessage = 'message'; -const SYSTEM_MESSAGE_KEY = 'odidere_system_message'; -const VOICE_KEY = 'odidere_voice'; -/** - * Content part type constants for OpenAI Chat Completions content parts. - * @see https://platform.openai.com/docs/api-reference/chat/create - */ const ContentTypeImageURL = 'image_url'; const ContentTypeText = 'text'; -const _ContentTypeFile = 'file'; -const _ContentTypeInputAudio = 'input_audio'; -const _ContentTypeRefusal = 'refusal'; -/** - * VOICE_MAP maps whisper language names to default Kokoro voices. - * Used for auto-selecting a voice when the selector is set to "Auto". - */ -const VOICE_MAP = { - chinese: 'zf_xiaobei', - english: 'af_heart', - french: 'ff_siwis', - hindi: 'hf_alpha', - italian: 'if_sara', - japanese: 'jf_alpha', - korean: 'kf_sarah', - portuguese: 'pf_dora', - spanish: 'ef_dora', -}; - -/** - * 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; + // --- Create subsystems --- + this.conversation = new ConversationTree(); + this.stt = new STT({ + url: document.querySelector('meta[name="stt-url"]')?.content || '', + }); + this.tts = new TTSPlayer({ + url: document.querySelector('meta[name="tts-url"]')?.content || '', + defaultVoice: + document.querySelector('meta[name="tts-default-voice"]')?.content || '', + }); + this.settings = new Settings({ + document, + ttsURL: document.querySelector('meta[name="tts-url"]')?.content || '', + }); + this.renderer = new Renderer({ + document, + conversation: this.conversation, + }); + this.editModal = new EditModal({ + document, + conversation: this.conversation, + }); + this.wakeLock = new WakeLock(); + + // --- Wire TTS events --- + this.tts.addEventListener('playbackstart', (e) => + this.renderer.setPlayButton(e.detail.messageId, true), + ); + this.tts.addEventListener('playbackend', (e) => + this.renderer.setPlayButton(e.detail.messageId, false), + ); + + // --- State flags --- this.isProcessing = false; this.isRecording = false; this.isTranscribing = false; @@ -61,248 +68,137 @@ class Odidere { this.isResetPending = false; this.isScrollPending = false; this.isStopPending = false; - this.locationData = null; - this.mediaRecorder = null; - this.wakeLock = null; this.pendingDeleteId = null; - // Conversation state - this.messagesMap = new Map(); - this.leafId = null; - this.rootId = null; - - // Context tracking - this.modelContextSizes = new Map(); // model -> context_size + this.currentController = null; this.totalTokens = 0; - // 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; + // Attachments & location + this.attachments = []; + this.locationData = null; - // STT state - this.sttURL = document.querySelector('meta[name="stt-url"]')?.content || ''; - - // 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 + // --- DOM refs --- this.$attach = document.getElementById('attach'); - this.$attachments = document.getElementById('attachments'); - this.$chat = document.getElementById('chat'); - this.$chatLoading = document.getElementById('chat-loading'); + this.$context = document.getElementById('context'); this.$fileInput = document.getElementById('file-input'); this.$location = document.getElementById('location'); - this.$model = document.getElementById('model'); + this.$mute = document.getElementById('mute'); 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'); - this.$context = document.getElementById('context'); this.$transcribe = document.getElementById('transcribe'); - - // 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'); + this.$compose = document.querySelector('.compose'); - // 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(); + this.init(); } // ==================== - // PUBLIC API + // LIFECYCLE // ==================== - /** - * destroy releases all resources including media streams and audio. - */ - destroy() { - for (const track of this.mediaRecorder?.stream?.getTracks() ?? []) { - track.stop(); + + init() { + this.conversation.load(); + if (this.conversation.size > 0) { + this.renderer.renderMessages(this.conversation.getActivePath()); } - this.#stopCurrentAudio(); - this.#stopTTS(); - this.currentController?.abort(); - this.#releaseWakeLock(); + this.bindEvents(); + this.settings.fetchVoices(); + this.settings.fetchModels(); + this.updateSendButtonState(); + this._initVisualViewport(); } - /** - * reset clears all state including history, attachments, and pending - * requests. - */ - reset() { - this.#stopCurrentAudio(); - this.#stopTTS(); + destroy() { + this.stt.destroy(); + this.tts.stop(); + this.currentController?.abort(); + this.wakeLock.release(); + } + reset() { + this.tts.stop(); if (this.currentController) { this.currentController.abort(); this.currentController = null; } - - this.#setLoadingState(false); + 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.querySelectorAll(':scope > .message').forEach((el) => { - el.remove(); - }); - this.#clearAttachments(); - this.#clearLocation(); + this.conversation.clear(); + this.renderer.setAutoScroll(true); + this.renderer.clearChat(); + this.clearAttachments(); + this.clearLocation(); this.$textInput.value = ''; this.$textInput.style.height = 'auto'; this.totalTokens = 0; - this.#updateContext(); - this.#updateSendButtonState(); + this.updateContext(); + this.updateSendButtonState(); } // ==================== - // INITIALIZATION + // EVENT BINDING // ==================== - /** - * #init initializes the application. - */ - #init() { - this.#loadHistory(); - this.#bindEvents(); - this.#fetchVoices(); - this.#fetchModels(); - this.#initWakeLock(); - this.#initVisualViewport(); - } - /** - * #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. + bindEvents() { + // --- Delegated click for all data-action elements --- + this.document.addEventListener('click', (e) => this._handleClick(e)); + + // --- Press-and-hold for PTT and transcribe --- + this.$compose.addEventListener('mousedown', (e) => + this._handlePressStart(e), + ); + this.$compose.addEventListener('touchstart', (e) => + this._handlePressStart(e), + ); + this.document.addEventListener('mouseup', (e) => this._handlePressEnd(e)); + this.document.addEventListener('touchend', (e) => this._handlePressEnd(e)); + this.document.addEventListener('touchcancel', (e) => + this._handlePressEnd(e), + ); + + // Prevent context menu on long press for PTT and transcribe. 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)); - - // Location toggle. - this.$location.addEventListener('click', () => - this.#handleLocationToggle(), - ); - - // File attachment. - this.$attach.addEventListener('click', () => this.$fileInput.click()); - this.$fileInput.addEventListener('change', (e) => - this.#handleAttachments(e.target.files), - ); - - // Transcribe button: hold to record, release to transcribe and populate textarea. - this.$transcribe.addEventListener( - 'touchstart', - this.#handleTranscribeTouchStart, - { - passive: false, - }, - ); - this.$transcribe.addEventListener( - 'touchend', - this.#handleTranscribeTouchEnd, - ); - this.$transcribe.addEventListener( - 'touchcancel', - this.#handleTranscribeTouchEnd, - ); - // Prevent context menu on long press. this.$transcribe.addEventListener('contextmenu', (e) => e.preventDefault()); - this.$transcribe.addEventListener( - 'mousedown', - this.#handleTranscribeMouseDown, + // --- Keyboard: spacebar PTT --- + this.document.addEventListener('keydown', (e) => this._handleKeyDown(e)); + this.document.addEventListener('keyup', (e) => this._handleKeyUp(e)); + + // --- Textarea --- + this.$textInput.addEventListener('keydown', (e) => + this._handleTextareaKeyDown(e), ); - this.$transcribe.addEventListener('mouseup', this.#handleTranscribeMouseUp); - this.$transcribe.addEventListener( - 'mouseleave', - this.#handleTranscribeMouseUp, + this.$textInput.addEventListener('input', () => + this._handleTextareaInput(), ); - // Save selections on change. - this.$model.addEventListener('change', () => { - const $opt = this.$model.options[this.$model.selectedIndex]; - localStorage.setItem(PROVIDER_KEY, $opt.dataset.provider); - localStorage.setItem(MODEL_KEY, $opt.dataset.model); - }); - this.$voice.addEventListener('change', () => { - localStorage.setItem(VOICE_KEY, this.$voice.value); - }); - // Mute button - this.$mute.addEventListener('click', () => this.#toggleMute()); + // --- File input --- + this.$attach.addEventListener('click', () => this.$fileInput.click()); + this.$fileInput.addEventListener('change', (e) => + this.handleAttachments(e.target.files), + ); - // Scroll button: update icon as user scrolls - this.$chat.addEventListener( + // --- Chat scroll --- + this.renderer.$chat.addEventListener( 'scroll', () => { - this.#updateScrollIcon(); - this.#handleChatScroll(); + this.renderer.updateScrollIcon(); + this.renderer.handleChatScroll(); }, { passive: true }, ); - this.#updateScrollIcon(); + this.renderer.updateScrollIcon(); - // Settings modal - this.$settings.addEventListener('click', () => this.openSettings()); - this.$settingsClose.addEventListener('click', () => this.closeSettings()); + // --- Settings modal --- + this.$settings.addEventListener('click', () => this.settings.open()); + this.$settingsClose.addEventListener('click', () => this.settings.close()); this.$settingsOverlay.addEventListener('click', (e) => { - if (e.target === this.$settingsOverlay) this.closeSettings(); + if (e.target === this.$settingsOverlay) this.settings.close(); }); this.document.addEventListener('keydown', (e) => { if ( @@ -310,1637 +206,382 @@ class Odidere { this.$settingsOverlay.classList.contains('open') ) { e.preventDefault(); - this.closeSettings(); + this.settings.close(); } }); this.$settingsTabs.forEach(($tab) => { - $tab.addEventListener('click', () => this.#switchTab($tab.dataset.tab)); + $tab.addEventListener('click', () => + this.settings.switchTab($tab.dataset.tab), + ); }); this.$saveSystemMessage.addEventListener('click', () => - this.#saveSystemMessage(), + this.settings.saveSystemMessage(), ); - // Wake lock: reacquire on visibility change + // --- Model/voice change persistence --- + this.settings.$model.addEventListener('change', () => + this.settings.saveModelSelection(), + ); + this.settings.$voice.addEventListener('change', () => + this.settings.saveVoiceSelection(), + ); + + // --- Wake lock: reacquire on visibility change --- this.document.addEventListener('visibilitychange', () => - this.#handleVisibilityChange(), + this.wakeLock.handleVisibilityChange( + this.isRecording || this.isProcessing, + ), ); } // ==================== - // CONVERSATION TREE + // CLICK DELEGATION // ==================== - /** - * #loadHistory loads chat history from localStorage and renders it. - */ - #loadHistory() { - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (!stored) return; + _handleClick(event) { + const $target = event.target.closest('[data-action]'); + const action = $target?.dataset.action; - 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; + // Click outside any action button: cancel all pending states. + if (!action) { + this._cancelAllPending(); + return; } + + // Cancel pending states for other actions. + if (action !== 'reset') this._cancelResetPending(); + if (action !== 'send') this._cancelStopPending(); + if (action !== 'scroll') this._cancelScrollPending(); + if (action !== 'delete') this._cancelDeletePending(); + + this.handleAction(action, event, $target); } - /** - * #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(); + handleAction(action, event, $target) { + switch (action) { + case 'send': + this._handleSendClick(); + break; + case 'reset': + this._handleResetClick(); + break; + case 'scroll': + this._handleScrollClick(); + break; + case 'mute': + this.toggleMute(); + break; + case 'settings': + this.settings.open(); + break; + case 'attach': + // Handled by direct listener on $attach. + break; + case 'location': + this.handleLocationToggle(); + break; + case 'inspect': { + const $msg = $target.closest('.message'); + const isOpen = $msg.classList.toggle('message--debug-open'); + $target.setAttribute('aria-expanded', String(isOpen)); + break; } - - // Attach metadata only to the final message. - if (i === messages.length - 1) { - msg.meta = meta; + case 'copy': { + const $msg = $target.closest('.message'); + const text = $msg.dataset.copyText || ''; + this.renderer.copyToClipboard($target, text); + break; } - - // Link to the tree. - msg.parent = prevId; - msg.children = []; - - if (prevId) { - const parent = this.messagesMap.get(prevId); - if (parent) { - parent.children.push(msg.id); + case 'play': { + const $msg = $target.closest('.message'); + const messageId = $msg.dataset.id; + const text = $msg.querySelector('.message__content').textContent; + const voice = $msg.dataset.voice || ''; + if (this.tts.currentMessageId === messageId) { + this.tts.stop(); + } else { + this.tts.stop(); + this.tts.enqueue(text, voice, messageId); } + break; + } + case 'edit': { + const $msg = $target.closest('.message'); + const messageId = $msg.dataset.messageId || $msg.dataset.id; + const field = $msg.dataset.field || null; + const toolCallId = $msg.dataset.toolCallId || null; + this._openEdit(messageId, field, toolCallId); + break; + } + case 'delete': { + const $msg = $target.closest('.message'); + // Error messages have no data-id: just remove from DOM. + if (!$msg.dataset.id && !$msg.dataset.messageId) { + $msg.remove(); + break; + } + const messageId = $msg.dataset.messageId || $msg.dataset.id; + const field = $msg.dataset.field || null; + const toolCallId = $msg.dataset.toolCallId || null; + this._handleDelete(event, messageId, field, toolCallId, $target); + break; + } + } + } + + // ==================== + // PRESS-AND-HOLD + // ==================== + + _handlePressStart(event) { + const $target = event.target.closest('[data-action]'); + if (!$target) return; + const action = $target.dataset.action; + + if (action === 'ptt') { + if (event.type === 'touchstart') { + event.preventDefault(); + event.stopPropagation(); + } else if (event.sourceCapabilities?.firesTouchEvents) { + return; } else { - // First message in the conversation. - this.rootId = msg.id; + event.preventDefault(); + event.stopPropagation(); } - - 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]); + this.handleSendButtonPress(); + } else if (action === 'transcribe') { + if (event.type === 'touchstart') { + event.preventDefault(); + event.stopPropagation(); + } else if (event.sourceCapabilities?.firesTouchEvents) { + return; } else { - this.leafId = null; + event.preventDefault(); + event.stopPropagation(); } + this.handleTranscribePress(); } - - // Remove the DOM element(s). - for (const deleteId of idsToDelete) { - const $el = this.$chat.querySelector(`[data-id="${deleteId}"]`); - if ($el) $el.remove(); - } - - // Token count is no longer accurate after deletion; - // it will be refreshed on the next LLM response. - this.totalTokens = 0; - this.#updateContext(); - - 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]; + _handlePressEnd(_event) { + if (this.isRecording) { + this.handleSendButtonRelease(); + } else if (this.isTranscribing) { + this.handleTranscribeRelease(); } - this.leafId = current; } // ==================== - // EVENT HANDLERS + // STT 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(); - }; + async handleSendButtonPress() { + if (this.isRecording || this.isTranscribing || this.isProcessing) return; + this.tts.stop(); + this.isRecording = true; + this.setRecordingState(true); + try { + await this.stt.acquire(); + // User may have released while acquire was pending (e.g. during + // the iOS permission dialog). Bail before starting. + if (!this.isRecording) return; + this.stt.start(); + } catch (e) { + if (!this.isRecording) return; + this.isRecording = false; + this.setRecordingState(false); + this.renderer.renderError(`Microphone access denied: ${e.message}`); + } + } - /** - * #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(); - }; + async handleSendButtonRelease() { + if (!this.isRecording) return; + // Clear the flag immediately so that a pending acquire() in the + // press handler bails out instead of starting the recorder. + this.isRecording = false; + this.setRecordingState(false); + try { + const { text: transcription, detectedLanguage } = await this.stt.stop(); + if (!transcription) return; + const voice = this.resolveVoice(detectedLanguage); + const existing = this.$textInput.value.trim(); + this.$textInput.value = ''; + this.$textInput.style.height = 'auto'; + const combined = existing + ? `${existing}\n${transcription}` + : transcription; + await this.runTurn({ text: combined, detectedLanguage, voice }); + } catch (e) { + console.error('transcription failed:', e); + this.renderer.renderError(`Transcription failed: ${e.message}`); + this.setLoadingState(false); + } + } - /** - * #handlePttMouseUp handles mouse up on the PTT button. - * @param {MouseEvent} event - */ - #handlePttMouseUp = (event) => { - if (event.sourceCapabilities?.firesTouchEvents) return; - event.stopPropagation(); - this.#stopRecording(); - }; + async handleTranscribePress() { + if (this.isRecording || this.isTranscribing || this.isProcessing) return; + this.tts.stop(); + this.isTranscribing = true; + this.$transcribe.classList.add('compose__action-btn--recording'); + try { + await this.stt.acquire(); + if (!this.isTranscribing) return; + this.stt.start(); + } catch (e) { + if (!this.isTranscribing) return; + this.isTranscribing = false; + this.$transcribe.classList.remove('compose__action-btn--recording'); + this.renderer.renderError(`Microphone access denied: ${e.message}`); + } + } - /** - * #handleKeyDown handles keydown events for spacebar PTT. - * Only triggers when focus is not in an input element. - * @param {KeyboardEvent} event - */ - #handleKeyDown = (event) => { + async handleTranscribeRelease() { + if (!this.isTranscribing) return; + this.isTranscribing = false; + this.$transcribe.classList.remove('compose__action-btn--recording'); + try { + const { text: transcription } = await this.stt.stop(); + if (transcription) { + const existing = this.$textInput.value; + if (existing.trim()) { + this.$textInput.value = `${existing}\n${transcription}`; + } else { + this.$textInput.value = transcription; + } + this._handleTextareaInput(); + } + this.$textInput.focus(); + } catch (e) { + console.error('transcription failed:', e); + this.renderer.renderError(`Transcription failed: ${e.message}`); + } + } + + // ==================== + // KEYBOARD + // ==================== + + _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(); - }; + this.handleSendButtonPress(); + } - /** - * #handleKeyUp handles keyup events for spacebar PTT. - * @param {KeyboardEvent} event - */ - #handleKeyUp = (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(); - }; + this.handleSendButtonRelease(); + } - /** - * #handleTextareaKeyDown handles Enter key to submit text. - * Shift+Enter inserts a newline instead. - * @param {KeyboardEvent} event - */ - #handleTextareaKeyDown = (event) => { + _handleTextareaKeyDown(event) { if (event.code !== 'Enter') return; if (event.shiftKey) return; - event.preventDefault(); - this.#submitText(); - }; + this._submitText(); + } - /** - * #handleLocationToggle toggles GPS location inclusion. - * When enabled, requests the user's current position via the Geolocation API. - * If denied or unavailable, silently leaves the button off. - */ - #handleLocationToggle = () => { - if (this.locationData) { - // Turn off. - this.locationData = null; - this.$location.classList.remove( - 'compose__action-btn--location', - 'active', - ); - this.#updateSendButtonState(); - return; - } - - if (!navigator.geolocation) { - return; - } - - navigator.geolocation.getCurrentPosition( - (position) => { - this.locationData = position; - this.$location.classList.add('compose__action-btn--location', 'active'); - this.#updateSendButtonState(); - }, - // On error, leave the button off; user must retry manually. - () => {}, - ); - }; - - /** - * #handleTextareaInput adjusts textarea height to fit content. - */ - #handleTextareaInput = () => { + _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(); + this.updateSendButtonState(); } // ==================== - // EDIT MODAL + // CONVERSATION ORCHESTRATION // ==================== - /** - * #handleEditClick opens the edit modal for the given message. - * @param {MouseEvent} event - * @param {string} messageId - */ - #handleEditClick(event, messageId) { - event.stopPropagation(); - this.#openEditModal(messageId); - } - - /** - * #openEditModal creates and shows the edit modal for a message. - * @param {string} messageId - */ - #openEditModal(messageId) { - const msg = this.messagesMap.get(messageId); - if (!msg) return; - - // Build modal DOM. - const $overlay = this.document.createElement('div'); - $overlay.className = 'modal-overlay'; - $overlay.innerHTML = ` - - `; - - // Determine if this message should be edited as JSON. - const isJson = this.#shouldShowJson(msg); - const textarea = $overlay.querySelector('#edit-textarea'); - const saveBtn = $overlay.querySelector('#edit-save'); - const closeBtn = $overlay.querySelector('.modal__close'); - - // Populate textarea. - if (isJson) { - textarea.value = JSON.stringify( - this.#extractEditableFields(msg), - null, - 2, - ); - } else { - textarea.value = this.#extractText(msg.content); - } - - // Adjust textarea size for JSON. - if (isJson) { - textarea.rows = 20; - } - - // Bind events. - closeBtn.addEventListener('click', () => this.#closeEditModal()); - saveBtn.addEventListener('click', () => - this.#saveEdit(messageId, textarea, isJson, saveBtn), - ); - - // Close on overlay click (outside modal). - $overlay.addEventListener('click', (e) => { - if (e.target === $overlay) this.#closeEditModal(); - }); - - // Close on Escape. - const handleEscape = (e) => { - if (e.key === 'Escape') { - this.#closeEditModal(); - this.document.removeEventListener('keydown', handleEscape); - } - }; - this.document.addEventListener('keydown', handleEscape); - - this.document.body.appendChild($overlay); - textarea.focus(); - } - - /** - * #closeEditModal removes the modal from the DOM. - */ - #closeEditModal() { - const $overlay = this.document.querySelector('.modal-overlay'); - if ($overlay) $overlay.remove(); - } - - /** - * #shouldShowJson determines if a message should be edited as JSON. - * Assistant messages with reasoning or tool calls are shown as JSON. - * @param {Object} message - * @returns {boolean} - */ - #shouldShowJson(message) { - if (message.role !== 'assistant') return false; - if (message.reasoning_content) return true; - if (message.tool_calls?.length > 0) return true; - return false; - } - - /** - * #extractEditableFields returns the editable fields of a message, - * stripping tree-structural fields (id, parent, children, meta). - * @param {Object} message - * @returns {Object} - */ - #extractEditableFields(message) { - const { id, parent, children, meta, ...editable } = message; - - if (message.role === 'assistant' && message.tool_calls?.length > 0) { - const toolCallIds = new Set(message.tool_calls.map((tc) => tc.id)); - const toolResults = []; - for (const result of this.messagesMap.values()) { - if (result.role === 'tool' && toolCallIds.has(result.tool_call_id)) { - toolResults.push(this.#extractEditableFields(result)); - } - } - if (toolResults.length > 0) { - editable.tool_results = toolResults; - } - } - - return editable; - } - - /** - * #saveEdit validates and applies the edited content to a message. - * @param {string} messageId - * @param {HTMLTextAreaElement} textarea - * @param {boolean} isJson - * @param {HTMLButtonElement} saveBtn - */ - #saveEdit(messageId, textarea, isJson, saveBtn) { - const msg = this.messagesMap.get(messageId); - if (!msg) return; - - if (isJson) { - // JSON mode: parse and validate. - let parsed; - try { - parsed = JSON.parse(textarea.value); - } catch (_e) { - // Invalid JSON: show error, do nothing else. - textarea.classList.add('modal__textarea--error'); - saveBtn.classList.add('modal__save--error'); - setTimeout(() => { - saveBtn.classList.remove('modal__save--error'); - }, 500); - return; - } - - // Apply parsed fields to the message. - // Preserve structural fields (id, parent, children, meta). - // tool_results is a synthetic editable field for hidden tool result - // messages that are displayed inside assistant tool-call blocks. - for (const key of Object.keys(parsed)) { - if ( - ['id', 'parent', 'children', 'meta', 'tool_results'].includes(key) - ) { - continue; - } - msg[key] = parsed[key]; - } - - if (msg.role === 'assistant' && Array.isArray(parsed.tool_results)) { - const toolCallIds = new Set((msg.tool_calls ?? []).map((tc) => tc.id)); - const existingResults = []; - for (const result of this.messagesMap.values()) { - if (result.role === 'tool' && toolCallIds.has(result.tool_call_id)) { - existingResults.push(result); - } - } - - parsed.tool_results.forEach((parsedResult, index) => { - const result = - existingResults.find( - (r) => r.tool_call_id === parsedResult?.tool_call_id, - ) ?? existingResults[index]; - if (!result || !parsedResult || typeof parsedResult !== 'object') { - return; - } - for (const key of Object.keys(parsedResult)) { - if (['id', 'parent', 'children', 'meta'].includes(key)) continue; - result[key] = parsedResult[key]; - } - }); - } - } else { - // Plain text mode: update content. - const newText = textarea.value; - if (Array.isArray(msg.content)) { - // Multipart: update first text part or create one. - const textPart = msg.content.find((p) => p.type === ContentTypeText); - if (textPart) { - textPart.text = newText; - } else { - msg.content.unshift({ type: ContentTypeText, text: newText }); - } - } else { - msg.content = newText; - } - } - - // Persist. - this.#saveToStorage(); - - // Re-render the message in place. - this.#reRenderMessage(msg); - - // Close modal. - this.#closeEditModal(); - } - - /** - * #reRenderMessage replaces the DOM element for a message. - * @param {Object} message - */ - #reRenderMessage(message) { - const $old = this.$chat.querySelector(`[data-id="${message.id}"]`); - if (!$old) return; - - if (message.role === 'user') { - // Render user message. - const $msg = - this.$tplUserMessage.content.cloneNode(true).firstElementChild; - if (message.id) $msg.dataset.id = message.id; - - const content = this.#extractText(message.content); - $msg.querySelector('.message__content').textContent = content; - - // Populate debug panel. - const $dl = $msg.querySelector('.message__debug-list'); - if (message.meta?.language) - this.#appendDebugRow($dl, 'Language', message.meta.language); - if (message.meta?.voice) - this.#appendDebugRow($dl, 'Voice', message.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), - ); - - const $edit = $msg.querySelector('[data-action="edit"]'); - $edit.addEventListener('click', (e) => - this.#handleEditClick(e, message.id), - ); - - $old.replaceWith($msg); - } else if (message.role === 'assistant') { - // Render assistant message. - 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. - if (message.reasoning_content) { - const $reasoning = this.#createCollapsibleBlock( - 'reasoning', - 'Reasoning', - message.reasoning_content, - ); - $body.insertBefore($reasoning, $content); - } - - // Tool call blocks. - if (message.tool_calls?.length > 0) { - const toolResultMap = new Map(); - const toolCallIds = new Set(message.tool_calls.map((tc) => tc.id)); - for (const result of this.messagesMap.values()) { - if (result.role === 'tool' && toolCallIds.has(result.tool_call_id)) { - toolResultMap.set(result.tool_call_id, result); - } - } - for (const toolCall of message.tool_calls) { - const $toolBlock = this.#createToolCallBlock( - toolCall, - toolResultMap.get(toolCall.id), - ); - $body.insertBefore($toolBlock, $content); - } - } - - // Main content. - if (this.#extractText(message.content)) { - $content.textContent = this.#extractText(message.content); - } else { - $content.remove(); - } - - // Populate debug panel. - const $dl = $msg.querySelector('.message__debug-list'); - if (message.meta?.voice) - this.#appendDebugRow($dl, 'Voice', message.meta.voice); - if (message.meta?.usage) { - const u = message.meta.usage; - this.#appendDebugRow($dl, 'Prompt tokens', String(u.prompt_tokens)); - this.#appendDebugRow( - $dl, - 'Completion tokens', - String(u.completion_tokens), - ); - this.#appendDebugRow($dl, 'Total tokens', String(u.total_tokens)); - } - 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. - const $play = $msg.querySelector('[data-action="play"]'); - if (this.#extractText(message.content)) { - const voice = message.meta?.voice || this.$voice.value || ''; - $play.addEventListener('click', (e) => - this.#handlePlayClick( - e, - message.id, - this.#extractText(message.content), - voice, - ), - ); - } else { - $play.remove(); - } - - // Copy button. - const $copy = $msg.querySelector('[data-action="copy"]'); - $copy.addEventListener('click', () => { - 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 (this.#extractText(message.content)) - copyParts.push(this.#extractText(message.content)); - this.#copyToClipboard($copy, copyParts.join('\n\n')); - }); - - const $delete = $msg.querySelector('[data-action="delete"]'); - $delete.addEventListener('click', (e) => - this.#handleDeleteClick(e, message.id), - ); - - const $edit = $msg.querySelector('[data-action="edit"]'); - $edit.addEventListener('click', (e) => - this.#handleEditClick(e, message.id), - ); - - $old.replaceWith($msg); - } - } - - /** - * #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); - } - - // ==================== - // STT: FRONTEND TRANSCRIPTION - // ==================== - /** - * #transcribeAudio sends an audio blob to whisper-server directly from - * the browser and returns the transcription result. - * @param {Blob} audioBlob - * @returns {Promise<{text: string, detectedLanguage: string}>} - */ - async #transcribeAudio(audioBlob) { - if (!this.sttURL) { - throw new Error('STT URL not configured'); - } - - const formData = new FormData(); - formData.append('file', audioBlob, 'audio.webm'); - formData.append('response_format', 'verbose_json'); - - const res = await fetch(`${this.sttURL}/inference`, { - method: 'POST', - body: formData, - }); - - if (!res.ok) { - const errText = await res.text().catch(() => ''); - throw new Error(`STT error ${res.status}: ${errText}`); - } - - const data = await res.json(); - const text = (data.text || '').trim(); - const detectedLanguage = data.detected_language || data.language || ''; - return { text, detectedLanguage }; - } - - /** - * #handleTranscribeTouchStart handles touch start on the transcribe button. - * Begins recording for transcription (hold-to-record, like PTT). - * @param {TouchEvent} event - */ - #handleTranscribeTouchStart = (event) => { - event.preventDefault(); - event.stopPropagation(); - this.#startTranscribeRecording(); - }; - - /** - * #handleTranscribeTouchEnd handles touch end on the transcribe button. - * Stops recording; the shared stop handler will populate the textarea. - * @param {TouchEvent} event - */ - #handleTranscribeTouchEnd = (event) => { - event.stopPropagation(); - this.#stopTranscribeRecording(); - }; - - /** - * #handleTranscribeMouseDown handles mouse down on the transcribe button. - * Ignores events that originate from touch to avoid double-firing. - * @param {MouseEvent} event - */ - #handleTranscribeMouseDown = (event) => { - if (event.sourceCapabilities?.firesTouchEvents) return; - event.preventDefault(); - event.stopPropagation(); - this.#startTranscribeRecording(); - }; - - /** - * #handleTranscribeMouseUp handles mouse up on the transcribe button. - * @param {MouseEvent} event - */ - #handleTranscribeMouseUp = (event) => { - if (event.sourceCapabilities?.firesTouchEvents) return; - event.stopPropagation(); - this.#stopTranscribeRecording(); - }; - - /** - * #startTranscribeRecording begins recording for transcription mode. - * Unlike PTT, this populates the textarea on stop rather than sending. - */ - async #startTranscribeRecording() { - if (this.isTranscribing) return; - if (this.isRecording) return; - if (this.isProcessing) return; - - this.#stopCurrentAudio(); - this.#stopTTS(); - - // Initialize MediaRecorder on first use (shared with PTT). - if (!this.mediaRecorder) { - const success = await this.#initMediaRecorder(); - if (!success) return; - } - - this.audioChunks = []; - this.mediaRecorder.start(); - this.isTranscribing = true; - this.$transcribe.classList.add('compose__action-btn--recording'); - } - - /** - * #stopTranscribeRecording stops the media recorder for transcription mode. - * The shared 'stop' event handler (set up in #initMediaRecorder) will - * fire and clear UI state since this.isTranscribing is still true. - */ - #stopTranscribeRecording() { - if (!this.isTranscribing) return; - if (!this.mediaRecorder) return; - - // Keep isTranscribing=true so the stop handler knows to populate textarea. - // The stop handler is responsible for clearing the recording class, - // so we do not remove it here. - this.mediaRecorder.stop(); - } - - // ==================== - // 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.isTranscribing) 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); - } - - /** - * #stopRecording stops the current audio recording. - */ - #stopRecording() { - if (!this.isRecording) return; - if (!this.mediaRecorder) return; - - // Do not clear recording state here. The async 'stop' event handler - // is responsible for clearing it (#setRecordingState(false)) after - // the audio blob has been assembled. - this.mediaRecorder.stop(); - } - - /** - * #initMediaRecorder initializes the MediaRecorder with microphone access. - * Prefers opus codec for better compression if available. - * The stop handler branches on this.isTranscribing: when true it populates - * the textarea; when false (PTT flow) it sends the request. - * @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; - - // Capture the mode synchronously before any async work. - // The 'stop' event fires asynchronously, so this.isTranscribing - // may have changed by the time we await below. - const wasTranscribing = this.isTranscribing; - - const audioBlob = new Blob(this.audioChunks, { - type: this.mediaRecorder.mimeType, - }); - this.audioChunks = []; - - // Clear the flag now that the stop event has fired. - if (wasTranscribing) { - this.isTranscribing = false; - this.$transcribe.classList.remove('compose__action-btn--recording'); - } else { - this.#setRecordingState(false); - } - - // Transcribe the audio. - try { - const { text: transcription, detectedLanguage } = - await this.#transcribeAudio(audioBlob); - - // Resolve the effective voice for this message. - // If the selector is "Auto", pick a default for the detected language. - // This does NOT change the dropdown - the voice is per-message only. - let voice = this.$voice.value; - if (!voice && detectedLanguage) { - voice = VOICE_MAP[detectedLanguage] || ''; - } - - // Branch: transcribe-only (populate textarea) vs PTT (send). - if (wasTranscribing) { - // Transcribe mode: populate textarea, do not send. - this.$transcribe.classList.add('compose__action-btn--transcribing'); - try { - if (transcription) { - const existing = this.$textInput.value; - if (existing.trim()) { - this.$textInput.value = `${existing}\n${transcription}`; - } else { - this.$textInput.value = transcription; - } - this.#handleTextareaInput(); - } - } finally { - this.$transcribe.classList.remove( - 'compose__action-btn--transcribing', - ); - this.$textInput.focus(); - } - } else { - // PTT mode: capture text, clear input, send request. - const text = this.$textInput.value.trim(); - this.$textInput.value = ''; - this.$textInput.style.height = 'auto'; - - const combined = text ? `${text}\n${transcription}` : transcription; - await this.#sendRequest({ - text: combined, - detectedLanguage, - voice, - }); - } - } catch (e) { - console.error('transcription failed:', e); - this.#renderError(`Transcription failed: ${e.message}`); - if (!wasTranscribing) { - this.#setLoadingState(false); - } - } - }); - - this.mediaRecorder.addEventListener('error', (event) => { - console.error('MediaRecorder error:', event.error); - if (this.isTranscribing) { - this.isTranscribing = false; - this.$transcribe.classList.remove('compose__action-btn--recording'); - } else { - 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; - if (this.isRecording || this.isTranscribing) 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. - * The response now includes models with availability status and provider grouping. - */ - async #fetchModels() { - try { - const res = await fetch(MODELS_ENDPOINT); - if (!res.ok) throw new Error(`${res.status}`); - const data = await res.json(); - this.#populateModels( - data.providers, - data.default_provider, - data.default_model, - ); - } catch (e) { - console.error('failed to fetch models:', e); - this.#populateModelsFallback(); - } - } - - /** - * #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() { + async _submitText() { const text = this.$textInput.value.trim(); if (this.isProcessing) return; - - this.#stopCurrentAudio(); - this.#stopTTS(); + this.tts.stop(); this.$textInput.value = ''; this.$textInput.style.height = 'auto'; - - await this.#sendRequest({ text, voice: this.$voice.value }); + await this.runTurn({ text, voice: this.settings.getVoice() }); } /** - * #sendRequest sends a chat request to the streaming SSE endpoint. - * - * For text-only input, renders the user message immediately for - * responsiveness. - * - * Messages are rendered incrementally as SSE events arrive: - * - user, assistant (tool calls), tool (results), - * and a final assistant message with content. - * + * runTurn sends a chat request to the streaming SSE endpoint. * @param {Object} options - * @param {string} [options.text] - Text input - * @param {string} [options.detectedLanguage] - Detected language from STT - * @param {string} [options.voice] - Resolved voice for this message (from auto-selection) + * @param {string} [options.text] + * @param {string} [options.detectedLanguage] + * @param {string} [options.voice] */ - async #sendRequest({ text = '', detectedLanguage = '', voice }) { - this.#setLoadingState(true); - // Re-enable auto-scroll when the user sends a new message. - this.isAutoScrollEnabled = true; + async runTurn({ text = '', detectedLanguage = '', voice } = {}) { + this.setLoadingState(true); + this.renderer.setAutoScroll(true); this.currentController = new AbortController(); - let streamingMessage = null; + let streamingBubbles = { + assistantId: null, + message: null, + reasoning: null, + text: null, + }; let done = false; + let pendingTools = null; + + const streamingMeta = (usage, timings) => { + const meta = {}; + if (voice) meta.voice = voice; + if (usage) meta.usage = usage; + if (timings) meta.timings = timings; + return meta; + }; try { const hasNewInput = text || this.attachments.length > 0 || this.locationData; - // Build messages array from active path + current user message. - const activePath = this.getActivePath(); + const activePath = this.conversation.getActivePath(); let messages; let userMessage; if (hasNewInput) { - userMessage = await this.#buildUserMessage(text, this.attachments); + 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 $opt = this.$model.options[this.$model.selectedIndex]; - const payload = { - messages, - provider: $opt?.dataset.provider, - model: $opt?.dataset.model, - }; + const { provider, model } = this.settings.getModel(); + const payload = { messages, provider, model }; const systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY); if (systemMessage) { payload.system_message = systemMessage; } - // Clear attachments and location after building payload (before async operations). - this.#clearAttachments(); - this.#clearLocation(); + // Clear attachments and location after building payload. + this.clearAttachments(); + this.clearLocation(); - // For text-only requests with new input, add to history and render - // immediately. Skip when continuing conversation (no new user message). + // For text-only requests with new input, add to history and + // render immediately. if (hasNewInput) { const meta = {}; if (detectedLanguage) meta.language = detectedLanguage; if (voice) meta.voice = voice; - const appended = this.#appendHistory([userMessage], meta); - this.#renderMessages(appended, meta); + const appended = this.conversation.append([userMessage], meta); + this.renderer.renderMessages(appended, meta); } const res = await fetch(STREAM_ENDPOINT, { @@ -1953,185 +594,112 @@ class Odidere { 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; - const streamingMeta = (usage, timings) => { - const meta = {}; - if (voice) meta.voice = voice; - if (usage) meta.usage = usage; - if (timings) meta.timings = timings; - return meta; - }; + for await (const { eventType, event } of this._parseSSEStream(res)) { + if (event.error) { + throw new Error(event.error); + } - const mergeStreamingMessage = (message) => { - const target = streamingMessage.message; - const id = target.id; - const parent = target.parent; - const children = target.children; - const meta = target.meta; - - Object.assign(target, message); - target.id = id; - target.parent = parent; - target.children = children; - target.meta = meta; - - this.messagesMap.set(id, target); - this.#saveToStorage(); - return target; - }; - - 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 event type and data from SSE event lines. - let eventType = StreamEventMessage; - let data = ''; - for (const line of part.split('\n')) { - if (line.startsWith('event: ')) { - eventType = line.slice(7); - } - 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; + if ( + eventType === StreamEventDelta || + eventType === StreamEventReasoningDelta + ) { + if (!streamingBubbles.message) { + streamingBubbles.assistantId = crypto.randomUUID(); + streamingBubbles.message = { + id: streamingBubbles.assistantId, + role: 'assistant', + content: [{ type: ContentTypeText, text: '' }], + }; } - // Handle errors sent mid-stream. - if (event.error) { - throw new Error(event.error); - } - - if ( - eventType === StreamEventDelta || - eventType === StreamEventReasoningDelta - ) { - if (!streamingMessage) { - const message = { - role: 'assistant', - content: [{ type: ContentTypeText, text: '' }], - }; - const meta = streamingMeta(event.usage, event.timings); - const appended = this.#appendHistory([message], meta); - const $el = this.#renderStreamingAssistantMessage( - appended[0], - meta, + if (eventType === StreamEventReasoningDelta) { + if (!streamingBubbles.reasoning) { + const $el = this.renderer.createStreamingReasoning( + streamingBubbles.assistantId, + streamingMeta(event.usage, event.timings), ); - streamingMessage = { message: appended[0], $el }; + streamingBubbles.reasoning = { $el }; + streamingBubbles.message.reasoning_content = ''; } + streamingBubbles.message.reasoning_content += + event.reasoning_delta || ''; + this.renderer.appendReasoningDelta( + streamingBubbles.reasoning.$el, + event.reasoning_delta || '', + ); + } else { + if (!streamingBubbles.text) { + const $el = this.renderer.createStreamingText( + streamingBubbles.message, + streamingMeta(event.usage, event.timings), + ); + streamingBubbles.text = { $el }; + } + streamingBubbles.message.content[0].text += event.delta || ''; + this.renderer.appendDelta( + streamingBubbles.text.$el, + event.delta || '', + ); + } + continue; + } - if (eventType === StreamEventDelta) { - streamingMessage.message.content[0].text += event.delta || ''; - this.#appendStreamingDelta( - streamingMessage.$el, - event.delta || '', - ); - } else { - streamingMessage.message.reasoning_content = `${ - streamingMessage.message.reasoning_content || '' - }${event.reasoning_delta || ''}`; - this.#appendStreamingReasoningDelta( - streamingMessage.$el, - event.reasoning_delta || '', - ); - } - continue; + const message = event.message; + if (!message) continue; + + if (eventType === StreamEventDone) { + if (message.role !== 'assistant') continue; + + if (event.usage?.total_tokens) { + this.totalTokens = event.usage.total_tokens; + this.updateContext(); } - const message = event.message; - if (!message) continue; + const meta = streamingMeta(event.usage, event.timings); + let finalMessage = message; - if (eventType === StreamEventDone) { - if (message.role !== 'assistant') continue; + if (streamingBubbles.message) { + finalMessage.id = streamingBubbles.assistantId; + const appended = this.conversation.append([finalMessage], meta); + finalMessage = appended[0]; - // Track cumulative token usage. - if (event.usage?.total_tokens) { - this.totalTokens = event.usage.total_tokens; - this.#updateContext(); - } - - const meta = streamingMeta(event.usage, event.timings); - let finalMessage = message; - let $streaming = null; - if (streamingMessage) { - finalMessage = mergeStreamingMessage(message); - $streaming = streamingMessage.$el; - streamingMessage = null; - } - - // Stash assistant messages with tool calls; render once all - // tool results have arrived so the output is visible in the UI. - if (finalMessage.tool_calls?.length > 0) { - if (!$streaming) { - const appended = this.#appendHistory([finalMessage], meta); - finalMessage = appended[0]; - } - if ($streaming) { - $streaming = this.#finalizeStreamingAssistantMessage( - $streaming, - finalMessage, - meta, - ); - } - pendingTools = { - assistant: finalMessage, - results: [], - expected: finalMessage.tool_calls.length, - $el: $streaming, - }; - - // Enqueue TTS for content even when tool calls are present, - // so the LLM's spoken text before tool execution is played. - if (this.#extractText(finalMessage.content)) { - this.#enqueueTTS( - this.#extractText(finalMessage.content), - voice || '', - finalMessage.id, - ); - } - continue; - } - - // Regular assistant message (final reply with content). - if ($streaming) { - this.#finalizeStreamingAssistantMessage( - $streaming, + if (streamingBubbles.reasoning) { + this.renderer.finalizeStreamingReasoning( + streamingBubbles.reasoning.$el, finalMessage, meta, ); - } else { - const appended = this.#appendHistory([finalMessage], meta); - finalMessage = appended[0]; - this.#renderMessages(appended, meta); } + if (streamingBubbles.text && !finalMessage.tool_calls?.length) { + this.renderer.finalizeStreamingText( + streamingBubbles.text.$el, + finalMessage, + meta, + ); + } + } - // Enqueue TTS for the final assistant message with content. - if (this.#extractText(finalMessage.content)) { - this.#enqueueTTS( - this.#extractText(finalMessage.content), + if (finalMessage.tool_calls?.length > 0) { + if (!streamingBubbles.message) { + const appended = this.conversation.append([finalMessage], meta); + finalMessage = appended[0]; + } + pendingTools = { + assistant: finalMessage, + results: [], + expected: finalMessage.tool_calls.length, + $el: streamingBubbles.text?.$el || null, + }; + streamingBubbles = { + assistantId: null, + message: null, + reasoning: null, + text: null, + }; + if (extractText(finalMessage.content)) { + this.tts.enqueue( + extractText(finalMessage.content), voice || '', finalMessage.id, ); @@ -2139,86 +707,160 @@ class Odidere { 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]); + if (!streamingBubbles.message) { + const appended = this.conversation.append([finalMessage], meta); + finalMessage = appended[0]; + this.renderer.renderMessages(appended, meta); + } + streamingBubbles = { + assistantId: null, + message: null, + reasoning: null, + text: null, + }; + if (extractText(finalMessage.content)) { + this.tts.enqueue( + extractText(finalMessage.content), + voice || '', + finalMessage.id, + ); + } + continue; + } - // Once all tool results are in, render the combined message. - if (pendingTools.results.length >= pendingTools.expected) { - if (pendingTools.$el) { - this.#finalizeStreamingAssistantMessage( - pendingTools.$el, - pendingTools.assistant, - pendingTools.assistant.meta, - pendingTools.results, - ); - } else { - this.#renderMessages([ - pendingTools.assistant, - ...pendingTools.results, - ]); - } - pendingTools = null; + // Collect tool results. + if (message.role === 'tool' && pendingTools && pendingTools.assistant) { + const appended = this.conversation.append([message]); + pendingTools.results.push(appended[0]); + + if (pendingTools.results.length >= pendingTools.expected) { + if (pendingTools.$el) { + this.renderer.finalizeStreamingText( + pendingTools.$el, + pendingTools.assistant, + pendingTools.assistant.meta, + ); } + this.renderer.renderMessages([ + pendingTools.assistant, + ...pendingTools.results, + ]); + pendingTools = null; } } } done = true; - this.#setLoadingState(false); + this.setLoadingState(false); } catch (e) { if (e.name !== 'AbortError') { console.error('failed to send request:', e); - this.#renderError(`Error: ${e.message}`); + this.renderer.renderError(`Error: ${e.message}`); } - this.#setLoadingState(false); + this.setLoadingState(false); } finally { this.currentController = null; - if (!done && streamingMessage) { - const $msg = streamingMessage.$el; - const $deleteBtn = document.createElement('button'); - $deleteBtn.type = 'button'; - $deleteBtn.className = - 'message__action-btn message__action-btn--delete'; - $deleteBtn.dataset.action = 'delete'; - $deleteBtn.setAttribute('aria-label', 'Delete message'); - $deleteBtn.innerHTML = ``; - $deleteBtn.addEventListener('click', (e) => - this.#handleDeleteClick(e, streamingMessage.message.id), - ); - $msg - .querySelector('.message__actions-buttons') - .insertBefore( - $deleteBtn, - $msg.querySelector('.message__actions-buttons').firstChild, + + // Persist a streaming message interrupted by stop or error. + if (!done && streamingBubbles.message) { + const meta = streamingMeta(null, null); + const finalMessage = streamingBubbles.message; + const hasContent = !!extractText(finalMessage.content); + const hasToolCalls = finalMessage.tool_calls?.length > 0; + + if (hasContent || hasToolCalls) { + const appended = this.conversation.append([finalMessage], meta); + if (streamingBubbles.reasoning) { + this.renderer.finalizeStreamingReasoning( + streamingBubbles.reasoning.$el, + appended[0], + meta, + ); + } + if (streamingBubbles.text) { + this.renderer.finalizeStreamingText( + streamingBubbles.text.$el, + appended[0], + meta, + ); + } + } else { + streamingBubbles.reasoning?.$el?.remove(); + streamingBubbles.text?.$el?.remove(); + } + } + + // Finalize pending tool calls interrupted by stop or error. + if (!done && pendingTools) { + if (pendingTools.$el) { + this.renderer.finalizeStreamingText( + pendingTools.$el, + pendingTools.assistant, + pendingTools.assistant.meta ?? {}, ); + } + this.renderer.renderMessages([ + pendingTools.assistant, + ...pendingTools.results, + ]); + pendingTools = 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) { + // ==================== + // SSE PARSING + // ==================== + + async *_parseSSEStream(response) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split('\n\n'); + buffer = parts.pop(); + + for (const part of parts) { + if (!part.trim()) continue; + + let eventType = StreamEventMessage; + let data = ''; + for (const line of part.split('\n')) { + if (line.startsWith('event: ')) eventType = line.slice(7); + 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; + } + + yield { eventType, event }; + } + } + } + + // ==================== + // BUILD USER MESSAGE + // ==================== + + 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 buffer = await file.arrayBuffer(); + const base64 = new Uint8Array(buffer).toBase64(); const dataURL = `data:${file.type};base64,${base64}`; imageParts.push({ type: ContentTypeImageURL, @@ -2230,20 +872,16 @@ class Odidere { } } - // Add user's message text after files. if (text) { textParts.push(text); } - // Include GPS location if enabled (appended at end). if (this.locationData) { - const locationText = JSON.stringify(this.locationData, null, 2); - textParts.push(locationText); + textParts.push(JSON.stringify(this.locationData, null, 2)); } const combinedText = textParts.join('\n\n'); - // If no images, return simple string content. if (imageParts.length === 0) { return { id: crypto.randomUUID(), @@ -2252,7 +890,6 @@ class Odidere { }; } - // If images present, use multipart array format. const parts = []; if (combinedText) { parts.push({ type: ContentTypeText, text: combinedText }); @@ -2266,19 +903,27 @@ class Odidere { }; } + // ==================== + // VOICE RESOLUTION + // ==================== + + resolveVoice(detectedLanguage) { + let voice = this.settings.getVoice(); + if (!voice && detectedLanguage) { + voice = VOICE_MAP[detectedLanguage] || ''; + } + return voice; + } + // ==================== // 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() { + + updateSendButtonState() { const hasText = this.$textInput.value.trim().length > 0; const hasAttachments = this.attachments.length > 0; const hasLocation = this.locationData !== null; - const hasHistory = this.messagesMap.size > 0; + const hasHistory = this.conversation.size > 0; const enabled = hasText || hasAttachments || hasLocation || hasHistory; this.$send.disabled = !enabled; this.$send.setAttribute( @@ -2287,253 +932,49 @@ class Odidere { ); } - /** - * #setLoadingState updates UI to reflect loading state. - * @param {boolean} loading - */ - #setLoadingState(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. - // The spinner is a permanent last child of #chat; messages are inserted before it. - this.$chatLoading.hidden = !loading; - // Keep the send button enabled during loading so it can act as a stop button. + this.renderer.$chatLoading.hidden = !loading; this.$ptt.disabled = loading; this.$transcribe.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(); + this.wakeLock.acquire(); } else { - this.#releaseWakeLock(); + this.wakeLock.release(); } } - /** - * #setRecordingState updates UI to reflect recording state. - * @param {boolean} recording - */ - #setRecordingState(recording) { - this.isRecording = recording; + setRecordingState(recording) { this.$ptt.classList.toggle('recording', recording); this.$ptt.setAttribute('aria-pressed', String(recording)); } - /** - * #toggleMute toggles mute state and updates button label. - */ - #toggleMute() { + 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; - } + 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'); + this.tts.setMuted(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 grouped by provider. - * Models are displayed with availability indicators: - * - Available: normal selectable option - * - Not found: red strikethrough, disabled - * - Provider error: grayed out, disabled - * @param {Object>} providers - * @param {string} defaultProvider - * @param {string} defaultModel - */ - #populateModels(providers, defaultProvider, defaultModel) { - this.$model.innerHTML = ''; - this.modelContextSizes.clear(); - - // Sort provider names. - const sortedProviders = Object.keys(providers).sort(); - - // Create optgroups for each provider. - for (const provider of sortedProviders) { - const $optgroup = this.document.createElement('optgroup'); - $optgroup.label = provider; - - // Sort models within provider. - for (const m of providers[provider].sort((a, b) => - a.model.localeCompare(b.model), - )) { - const $opt = this.document.createElement('option'); - $opt.value = m.model; - $opt.dataset.provider = provider; - $opt.dataset.model = m.model; - if (m.context_size) { - this.modelContextSizes.set(m.model, m.context_size); - } - - switch (m.status) { - case 'available': - $opt.textContent = m.model; - break; - case 'not_found': - $opt.textContent = `~~${m.model}~~ (not found)`; - $opt.disabled = true; - $opt.classList.add('model-unavailable-not-found'); - break; - case 'provider_error': - $opt.textContent = `${m.model} (provider error)`; - $opt.disabled = true; - $opt.classList.add('model-unavailable-error'); - break; - } - - $optgroup.appendChild($opt); - } - - this.$model.appendChild($optgroup); - } - - this.#loadModel(defaultProvider, defaultModel); - } - - /** - * #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} defaultProvider - * @param {string} defaultModel - */ - #loadModel(defaultProvider, defaultModel) { - const storedProvider = localStorage.getItem(PROVIDER_KEY); - const storedModel = localStorage.getItem(MODEL_KEY); - - // Try stored value first, then default, then first available option. - let $opt; - if (storedProvider && storedModel) { - $opt = this.$model.querySelector( - `option[data-provider="${CSS.escape(storedProvider)}"][data-model="${CSS.escape(storedModel)}"]`, - ); - } - if (!$opt && defaultProvider && defaultModel) { - $opt = this.$model.querySelector( - `option[data-provider="${CSS.escape(defaultProvider)}"][data-model="${CSS.escape(defaultModel)}"]`, - ); - } - if (!$opt && this.$model.options.length > 0) { - // Find first available (non-disabled) option. - for (const opt of this.$model.options) { - if (!opt.disabled) { - $opt = opt; - break; - } - } - } - - if ($opt) { - this.$model.value = $opt.value; - } - } - - /** - * #updateContext updates the context usage indicator in the toolbar. - * Shows percentage if context_size is known, token count if only - * usage data is available, or hides the indicator otherwise. - */ - #updateContext() { + updateContext() { if (!this.$context) return; + const { model } = this.settings.getModel(); + const contextSize = model ? this.settings.getModelContextSize(model) : null; - const model = this.$model?.value; - const contextSize = model ? this.modelContextSizes.get(model) : null; - - // Hide if no data. if (this.totalTokens === 0) { this.$context.hidden = true; return; } this.$context.hidden = false; - // Remove severity classes. this.$context.classList.remove( 'footer__toolbar-context--warning', 'footer__toolbar-context--critical', @@ -2554,817 +995,265 @@ class Odidere { this.$context.classList.add('footer__toolbar-context--warning'); } } else { - // No context_size known; show token count. this.$context.textContent = tokenLabel; this.$context.title = ''; } } - /** - * #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 + // ATTACHMENTS & LOCATION // ==================== - /** - * #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 === ContentTypeText) - .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), - ); - - const $edit = $msg.querySelector('[data-action="edit"]'); - $edit.addEventListener('click', (e) => - this.#handleEditClick(e, message.id), - ); - - this.$chat.insertBefore($msg, this.$chatLoading); - this.#scrollToBottom(); - } - - /** - * #renderStreamingAssistantMessage renders a placeholder assistant message - * that receives streamed text deltas until the completion is done. - * @param {Object} message - * @param {Object} [meta] - * @returns {HTMLElement} - */ - #renderStreamingAssistantMessage(message, meta = null) { - const $msg = - this.$tplAssistantMessage.content.cloneNode(true).firstElementChild; - if (message.id) $msg.dataset.id = message.id; - $msg.classList.add('message--streaming'); - - // 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', ''); - - // Disable actions until the full message has arrived. - const $inspect = $msg.querySelector('[data-action="inspect"]'); - $inspect.addEventListener('click', () => { - const isOpen = $msg.classList.toggle('message--debug-open'); - $inspect.setAttribute('aria-expanded', String(isOpen)); - }); - $msg.querySelector('[data-action="play"]').remove(); - $msg.querySelector('[data-action="copy"]').remove(); - $msg.querySelector('[data-action="delete"]').remove(); - - this.$chat.insertBefore($msg, this.$chatLoading); - this.#scrollToBottom(); - return $msg; - } - - /** - * #appendStreamingDelta appends text to a streaming assistant message. - * @param {HTMLElement} $msg - * @param {string} delta - */ - #appendStreamingDelta($msg, delta) { - const $content = $msg.querySelector('.message__content'); - $content.textContent += delta; - this.#scrollToBottom(); - } - - /** - * #appendStreamingReasoningDelta appends reasoning text to a streaming - * assistant message. - * @param {HTMLElement} $msg - * @param {string} delta - */ - #appendStreamingReasoningDelta($msg, delta) { - let $reasoning = $msg.querySelector('.collapsible--reasoning'); - if (!$reasoning) { - $reasoning = this.#createCollapsibleBlock('reasoning', 'Reasoning', ''); - const $content = $msg.querySelector('.message__content'); - $msg.querySelector('.message__body').insertBefore($reasoning, $content); - } - - $reasoning.querySelector('pre').textContent += delta; - this.#scrollToBottom(); - } - - /** - * #finalizeStreamingAssistantMessage replaces a streaming placeholder with - * a fully bound assistant message. - * @param {HTMLElement} $streaming - * @param {Object} message - * @param {Object} [meta] - * @param {Object[]} [toolResults] - * @returns {HTMLElement|null} - */ - #finalizeStreamingAssistantMessage( - $streaming, - message, - meta = null, - toolResults = [], - ) { - if (!$streaming) return null; - - 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, + 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, ); - $body.insertBefore($reasoning, $content); + if (isDuplicate) continue; + this.attachments.push(file); } - - // 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 (this.#extractText(message.content)) { - $content.textContent = this.#extractText(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 (meta?.usage) { - const u = meta.usage; - this.#appendDebugRow($dl, 'Prompt tokens', String(u.prompt_tokens)); - this.#appendDebugRow( - $dl, - 'Completion tokens', - String(u.completion_tokens), - ); - this.#appendDebugRow($dl, 'Total tokens', String(u.total_tokens)); - if (u.completion_tokens_details?.reasoning_tokens > 0) - this.#appendDebugRow( - $dl, - 'Reasoning tokens', - String(u.completion_tokens_details.reasoning_tokens), - ); - if (u.prompt_tokens_details?.cached_tokens > 0) - this.#appendDebugRow( - $dl, - 'Cached tokens', - String(u.prompt_tokens_details.cached_tokens), - ); - } - if (meta?.timings) { - const t = meta.timings; - if (t.prompt_ms > 0) - this.#appendDebugRow( - $dl, - 'Prompt eval', - t.prompt_ms.toFixed(1) + - ' ms (' + - t.prompt_per_second.toFixed(0) + - ' tok/s)', - ); - if (t.predicted_ms > 0) - this.#appendDebugRow( - $dl, - 'Predicted eval', - t.predicted_ms.toFixed(1) + - ' ms (' + - t.predicted_per_second.toFixed(0) + - ' tok/s)', - ); - } - 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 (this.#extractText(message.content)) { - const voice = meta?.voice || this.$voice.value || ''; - $play.addEventListener('click', (e) => - this.#handlePlayClick( - e, - message.id, - this.#extractText(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 (this.#extractText(message.content)) - copyParts.push(this.#extractText(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), - ); - - const $edit = $msg.querySelector('[data-action="edit"]'); - $edit.addEventListener('click', (e) => - this.#handleEditClick(e, message.id), - ); - - $streaming.replaceWith($msg); - this.#scrollToBottom(); - return $msg; + this._renderAttachments(); + this.updateSendButtonState(); + this.$fileInput.value = ''; } - /** - * #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 (this.#extractText(message.content)) { - $content.textContent = this.#extractText(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 (meta?.usage) { - const u = meta.usage; - this.#appendDebugRow($dl, 'Prompt tokens', String(u.prompt_tokens)); - this.#appendDebugRow( - $dl, - 'Completion tokens', - String(u.completion_tokens), - ); - this.#appendDebugRow($dl, 'Total tokens', String(u.total_tokens)); - if (u.completion_tokens_details?.reasoning_tokens > 0) - this.#appendDebugRow( - $dl, - 'Reasoning tokens', - String(u.completion_tokens_details.reasoning_tokens), - ); - if (u.prompt_tokens_details?.cached_tokens > 0) - this.#appendDebugRow( - $dl, - 'Cached tokens', - String(u.prompt_tokens_details.cached_tokens), - ); - } - if (meta?.timings) { - const t = meta.timings; - if (t.prompt_ms > 0) - this.#appendDebugRow( - $dl, - 'Prompt eval', - t.prompt_ms.toFixed(1) + - ' ms (' + - t.prompt_per_second.toFixed(0) + - ' tok/s)', - ); - if (t.predicted_ms > 0) - this.#appendDebugRow( - $dl, - 'Predicted eval', - t.predicted_ms.toFixed(1) + - ' ms (' + - t.predicted_per_second.toFixed(0) + - ' tok/s)', - ); - } - 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 (this.#extractText(message.content)) { - const voice = meta?.voice || this.$voice.value || ''; - $play.addEventListener('click', (e) => - this.#handlePlayClick( - e, - message.id, - this.#extractText(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 (this.#extractText(message.content)) - copyParts.push(this.#extractText(message.content)); - const copyText = copyParts.join('\n\n'); - - const $copy = $msg.querySelector('[data-action="copy"]'); - $copy.addEventListener('click', () => - this.#copyToClipboard($copy, copyText), + _renderAttachments() { + this.renderer.renderAttachments(this.attachments, (i) => + this._removeAttachment(i), ); - - const $delete = $msg.querySelector('[data-action="delete"]'); - $delete.addEventListener('click', (e) => - this.#handleDeleteClick(e, message.id), - ); - - const $edit = $msg.querySelector('[data-action="edit"]'); - $edit.addEventListener('click', (e) => - this.#handleEditClick(e, message.id), - ); - - this.$chat.insertBefore($msg, this.$chatLoading); - 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; - - const $delete = $msg.querySelector('[data-action="delete"]'); - if ($delete) { - $delete.addEventListener('click', () => $msg.remove()); - } - - this.$chat.insertBefore($msg, this.$chatLoading); - 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) { + _removeAttachment(index) { this.attachments.splice(index, 1); - this.#renderAttachments(); - this.#updateSendButtonState(); + this._renderAttachments(); + this.updateSendButtonState(); } - /** - * #clearLocation clears location data and resets the button state. - */ - #clearLocation() { + clearAttachments() { + this.attachments = []; + this._renderAttachments(); + } + + handleLocationToggle() { + if (this.locationData) { + this.clearLocation(); + this.updateSendButtonState(); + return; + } + if (!navigator.geolocation) return; + navigator.geolocation.getCurrentPosition( + (position) => { + this.locationData = position; + this.$location.classList.add('compose__action-btn--location', 'active'); + this.updateSendButtonState(); + }, + () => {}, + ); + } + + clearLocation() { this.locationData = null; this.$location.classList.remove('compose__action-btn--location', 'active'); } - /** - * #clearAttachments removes all attachments. - */ - #clearAttachments() { - this.attachments = []; - this.#renderAttachments(); + // ==================== + // TWO-PRESS SAFETY PATTERNS + // ==================== + + _handleSendClick() { + if (this.$send.classList.contains('loading')) { + this._handleStopClick(); + } else { + this._submitText(); + } } - // ==================== - // 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; + _handleStopClick() { + if (this.isStopPending) { + this.isStopPending = false; + this.$send.classList.remove('pending'); + this.stopConversation(); + } else { + this.isStopPending = true; + this.$send.classList.add('pending'); + } } - /** - * #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; + _cancelStopPending() { + if (!this.isStopPending) return; + this.isStopPending = false; + this.$send.classList.remove('pending'); + } - $details.querySelector('.collapsible__label').textContent = - toolCall.function?.name || 'Tool Call'; + _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'); + } + } - // 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 || ''; + _cancelResetPending() { + if (!this.isResetPending) return; + this.isResetPending = false; + this.$reset.classList.remove('footer__toolbar-btn--pending'); + } + + _handleScrollClick() { + if (this.isScrollPending) { + this.isScrollPending = false; + this.$scroll.classList.remove('footer__toolbar-btn--pending'); + const $chat = this.renderer.$chat; + if (this.renderer.isAtBottom()) { + $chat.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + $chat.scrollTo({ top: $chat.scrollHeight, behavior: 'smooth' }); + } + this.renderer.updateScrollIcon(); + } else { + this.isScrollPending = true; + this.$scroll.classList.add('footer__toolbar-btn--pending'); + } + } + + _cancelScrollPending() { + if (!this.isScrollPending) return; + this.isScrollPending = false; + this.$scroll.classList.remove('footer__toolbar-btn--pending'); + } + + _handleDelete(event, messageId, field, toolCallId, $btn) { + event.stopPropagation(); + const deleteKey = field + ? `${messageId}:${field}:${toolCallId || ''}` + : messageId; + + if (this.pendingDeleteId === deleteKey) { + this.pendingDeleteId = null; + $btn.classList.remove('pending'); + this._deleteField(messageId, field, toolCallId); + } else { + this._cancelDeletePending(); + this.pendingDeleteId = deleteKey; + $btn.classList.add('pending'); + } + } + + _deleteField(messageId, field, toolCallId) { + if (!field) { + this.conversation.delete(messageId); + this.renderer.reRenderActivePath(); + this.totalTokens = 0; + this.updateContext(); + this.updateSendButtonState(); + return; } - // Output section (only if result is available) - if (result) { - const $outputSection = $details.querySelector( - '.collapsible__section--output', - ); - $outputSection.hidden = false; + const msg = this.conversation.get(messageId); + if (msg?.role !== 'assistant') return; - const $output = $details.querySelector('[data-output]'); - try { - const output = JSON.parse(this.#extractText(result.content) || '{}'); - $output.textContent = JSON.stringify(output, null, 2); - } catch { - $output.textContent = this.#extractText(result.content) || ''; + if (field === 'reasoning') { + msg.reasoning_content = ''; + } else if (field === 'text') { + msg.content = ''; + } else if (field === 'tool' && toolCallId) { + const idx = msg.tool_calls?.findIndex((tc) => tc.id === toolCallId); + if (idx !== -1) { + msg.tool_calls.splice(idx, 1); + } + for (const [id, m] of this.conversation.messagesMap) { + if (m.role === 'tool' && m.tool_call_id === toolCallId) { + this.conversation.delete(id); + break; + } } } - 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); - } - - /** - * #extractText extracts plain text from a message content field. - * Content can be a string or an array of ContentPart objects. - * @param {string|Array} content - * @returns {string} - */ - #extractText(content) { - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - return content - .filter((p) => p.type === ContentTypeText) - .map((p) => p.text) - .join('\n'); - } - return ''; - } - - // ==================== - // 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); + if ( + !extractText(msg.content) && + !msg.reasoning_content && + (!msg.tool_calls || msg.tool_calls.length === 0) + ) { + this.conversation.delete(messageId); + this.renderer.reRenderActivePath(); + this.totalTokens = 0; + this.updateContext(); + this.updateSendButtonState(); + } else { + this.renderer.reRenderActivePath(); + this.conversation.save(); } } - // ==================== - // 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'); + _cancelDeletePending() { + if (!this.pendingDeleteId) return; + const deleteKey = this.pendingDeleteId; + const parts = deleteKey.split(':'); + const messageId = parts[0]; + const field = parts[1] || null; + const toolCallId = parts[2] || null; + + let $msg; + if (field === 'reasoning') { + $msg = this.renderer.$chat.querySelector( + `[data-id="${messageId}/reasoning"]`, + ); + } else if (field === 'tool' && toolCallId) { + $msg = this.renderer.$chat.querySelector( + `[data-tool-call-id="${toolCallId}"]`, + ); + } else { + $msg = this.renderer.$chat.querySelector(`[data-id="${messageId}"]`); } + + if ($msg) { + const $btn = $msg.querySelector('[data-action="delete"]'); + if ($btn) $btn.classList.remove('pending'); + } + this.pendingDeleteId = null; } - /** - * #initVisualViewport handles pinch-zoom on mobile by keeping the - * container height in sync with the visual viewport. Without this, - * the bottom toolbar vanishes off-screen when the user zooms in. - */ - #initVisualViewport() { + _cancelAllPending() { + this._cancelResetPending(); + this._cancelStopPending(); + this._cancelScrollPending(); + this._cancelDeletePending(); + } + + // ==================== + // EDIT + // ==================== + + async _openEdit(messageId, field, toolCallId) { + const saved = await this.editModal.open(messageId, field, toolCallId); + if (saved) this.renderer.reRenderActivePath(); + } + + // ==================== + // STOP CONVERSATION + // ==================== + + stopConversation() { + this.tts.stop(); + if (this.currentController) { + this.currentController.abort(); + this.currentController = null; + } + this.setLoadingState(false); + } + + // ==================== + // VISUAL VIEWPORT + // ==================== + + _initVisualViewport() { if (!window.visualViewport) return; - const container = this.document.querySelector('.container'); if (!container) return; @@ -3379,45 +1268,25 @@ class Odidere { handler(); } - /** - * #acquireWakeLock requests a screen wake lock. Idempotent. - */ - async #acquireWakeLock() { - if (this.wakeLock) return; - if (!('wakeLock' in navigator)) return; + // ==================== + // ICONS + // ==================== - 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(); + _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; } } -// Initialize +// Bootstrap new Odidere({ document }); diff --git a/internal/service/static/render.js b/internal/service/static/render.js new file mode 100644 index 0000000..377bc01 --- /dev/null +++ b/internal/service/static/render.js @@ -0,0 +1,685 @@ +/** + * Renderer creates and updates message DOM elements from templates. + * Handles streaming bubbles, attachments, and play-button state. + * Takes the conversation tree as a dependency. + */ + +import { extractText } from './conversation.js'; + +const ICONS_URL = '/static/icons.svg'; + +export class Renderer { + /** + * @param {Object} options + * @param {Document} options.document + * @param {import('./conversation.js').ConversationTree} options.conversation + */ + constructor({ document, conversation }) { + this.document = document; + this.conversation = conversation; + + // DOM Elements + this.$chat = document.getElementById('chat'); + this.$chatLoading = document.getElementById('chat-loading'); + this.$attachments = document.getElementById('attachments'); + this.$scroll = document.getElementById('scroll'); + this.$voice = document.getElementById('voice'); + + // Auto-scroll: enabled by default, disabled when user scrolls up. + this.isAutoScrollEnabled = true; + this._lastScrollTop = 0; + + // Templates + this.$tplAssistantMessage = document.getElementById( + 'tpl-assistant-message', + ); + this.$tplAttachmentChip = document.getElementById('tpl-attachment-chip'); + this.$tplDebugRow = document.getElementById('tpl-debug-row'); + this.$tplErrorMessage = document.getElementById('tpl-error-message'); + this.$tplUserMessage = document.getElementById('tpl-user-message'); + this.$tplReasoningMessage = document.getElementById( + 'tpl-reasoning-message', + ); + this.$tplToolMessage = document.getElementById('tpl-tool-message'); + } + + // --- Full / incremental render --- + + /** + * renderMessages renders messages to the chat container. + * Messages already in the DOM (matched by data-id) are skipped. + * @param {Object[]} messages + * @param {Object} [meta] + */ + renderMessages(messages, meta = {}) { + 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) { + if (msg.role === 'user') { + if (msg.id && this.$chat.querySelector(`[data-id="${msg.id}"]`)) { + continue; + } + this._renderUserMessage(msg, msg.meta ?? meta); + continue; + } + if (msg.role !== 'assistant') continue; + + const hasContent = + msg.content || msg.reasoning_content || msg.tool_calls?.length; + if (!hasContent) continue; + + const assistantId = msg.id; + const bubbleMeta = msg.meta ?? meta; + + if (msg.reasoning_content) { + const reasoningId = `${assistantId}/reasoning`; + if (!this.$chat.querySelector(`[data-id="${reasoningId}"]`)) { + this._renderReasoningMessage(msg, bubbleMeta); + } + } + + const textContent = extractText(msg.content); + if (textContent) { + if ( + !assistantId || + !this.$chat.querySelector(`[data-id="${assistantId}"]`) + ) { + this._renderAssistantMessage(msg, bubbleMeta); + } + } + + if (msg.tool_calls?.length > 0) { + for (const toolCall of msg.tool_calls) { + const result = toolResultMap.get(toolCall.id); + const toolBubbleId = result?.id || toolCall.id; + if (!this.$chat.querySelector(`[data-id="${toolBubbleId}"]`)) { + this._renderToolMessage(msg, toolCall, result, bubbleMeta); + } + } + } + } + } + + /** + * reRenderActivePath re-renders the entire active conversation path. + */ + reRenderActivePath() { + const $loading = this.$chatLoading; + this.$chat.innerHTML = ''; + this.$chat.appendChild($loading); + this._renderTree(this.conversation.rootId); + this.scrollToBottom(); + } + + /** + * reRenderMessage replaces the DOM element(s) for a message. + * @param {Object} message + */ + reRenderMessage(message) { + if (message.role === 'user') { + const $old = this.$chat.querySelector(`[data-id="${message.id}"]`); + if (!$old) return; + const $msg = + this.$tplUserMessage.content.cloneNode(true).firstElementChild; + if (message.id) $msg.dataset.id = message.id; + + const content = extractText(message.content); + $msg.querySelector('.message__content').textContent = content; + + const $dl = $msg.querySelector('.message__debug-list'); + if (message.meta?.language) + this._appendDebugRow($dl, 'Language', message.meta.language); + if (message.meta?.voice) + this._appendDebugRow($dl, 'Voice', message.meta.voice); + if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', ''); + + $old.replaceWith($msg); + } else if (message.role === 'assistant') { + const $textBubble = this.$chat.querySelector(`[data-id="${message.id}"]`); + const $reasoningBubble = this.$chat.querySelector( + `[data-id="${message.id}/reasoning"]`, + ); + const $toolBubbles = this.$chat.querySelectorAll( + `[data-parent-id="${message.id}"]`, + ); + + let $reference = $reasoningBubble || $textBubble; + if (!$reference && $toolBubbles.length > 0) { + $reference = $toolBubbles[0]; + } + if (!$reference) return; + + const $toRemove = []; + if ($reasoningBubble) $toRemove.push($reasoningBubble); + if ($textBubble) $toRemove.push($textBubble); + for (const $tool of $toolBubbles) { + $toRemove.push($tool); + } + + const toolResultMap = new Map(); + if (message.tool_calls?.length > 0) { + const toolCallIds = new Set(message.tool_calls.map((tc) => tc.id)); + for (const result of this.conversation.messagesMap.values()) { + if (result.role === 'tool' && toolCallIds.has(result.tool_call_id)) { + toolResultMap.set(result.tool_call_id, result); + } + } + } + + const bubbleMeta = message.meta ?? {}; + + for (const $el of $toRemove) { + $el.remove(); + } + + if (message.reasoning_content) { + this._renderReasoningMessage(message, bubbleMeta); + } + + const textContent = extractText(message.content); + if (textContent) { + this._renderAssistantMessage(message, bubbleMeta); + } + + if (message.tool_calls?.length > 0) { + for (const toolCall of message.tool_calls) { + const result = toolResultMap.get(toolCall.id); + this._renderToolMessage(message, toolCall, result, bubbleMeta); + } + } + } + } + + // --- Streaming bubbles --- + + /** + * createStreamingReasoning creates a streaming reasoning bubble. + * @param {string} assistantId + * @param {Object} [meta] + * @returns {HTMLElement} + */ + createStreamingReasoning(assistantId, meta = null) { + const $msg = + this.$tplReasoningMessage.content.cloneNode(true).firstElementChild; + $msg.dataset.id = `${assistantId}/reasoning`; + $msg.dataset.parentId = assistantId; + $msg.dataset.field = 'reasoning'; + $msg.classList.add('message--streaming'); + + $msg.querySelector('.message__collapsible').open = true; + + 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', ''); + + // Remove action buttons during streaming. + $msg.querySelector('[data-action="delete"]').remove(); + $msg.querySelector('[data-action="edit"]').remove(); + $msg.querySelector('[data-action="copy"]').remove(); + + this.$chat.insertBefore($msg, this.$chatLoading); + this.scrollToBottom(); + return $msg; + } + + /** + * createStreamingText creates a streaming text content bubble. + * @param {Object} message + * @param {Object} [meta] + * @returns {HTMLElement} + */ + createStreamingText(message, meta = null) { + const $msg = + this.$tplAssistantMessage.content.cloneNode(true).firstElementChild; + if (message.id) $msg.dataset.id = message.id; + $msg.classList.add('message--streaming'); + + 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', ''); + + // Remove action buttons during streaming. + $msg.querySelector('[data-action="play"]').remove(); + $msg.querySelector('[data-action="copy"]').remove(); + $msg.querySelector('[data-action="delete"]').remove(); + $msg.querySelector('[data-action="edit"]').remove(); + + this.$chat.insertBefore($msg, this.$chatLoading); + this.scrollToBottom(); + return $msg; + } + + /** + * appendDelta appends text to a streaming assistant message. + * @param {HTMLElement} $msg + * @param {string} delta + */ + appendDelta($msg, delta) { + const $content = $msg.querySelector('.message__content'); + $content.textContent += delta; + this.scrollToBottom(); + } + + /** + * appendReasoningDelta appends reasoning text to a streaming bubble. + * @param {HTMLElement} $msg + * @param {string} delta + */ + appendReasoningDelta($msg, delta) { + const $content = $msg.querySelector('.message__content'); + $content.textContent += delta; + this.scrollToBottom(); + } + + /** + * finalizeStreamingReasoning replaces a streaming reasoning bubble + * with a fully bound one. + * @param {HTMLElement} $streaming + * @param {Object} message + * @param {Object} [meta] + * @returns {HTMLElement|null} + */ + finalizeStreamingReasoning($streaming, message, meta = null) { + if (!$streaming) return null; + + const $msg = + this.$tplReasoningMessage.content.cloneNode(true).firstElementChild; + $msg.dataset.id = `${message.id}/reasoning`; + $msg.dataset.parentId = message.id; + $msg.dataset.field = 'reasoning'; + $msg.dataset.messageId = message.id; + + $msg.querySelector('.message__content').textContent = + message.reasoning_content || ''; + $msg.querySelector('.message__collapsible').open = false; + + 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', ''); + + $msg.dataset.copyText = message.reasoning_content || ''; + + $streaming.replaceWith($msg); + this.scrollToBottom(); + return $msg; + } + + /** + * finalizeStreamingText replaces a streaming text bubble with a + * fully bound one. + * @param {HTMLElement} $streaming + * @param {Object} message + * @param {Object} [meta] + * @returns {HTMLElement|null} + */ + finalizeStreamingText($streaming, message, meta = null) { + if (!$streaming) return null; + + const $msg = + this.$tplAssistantMessage.content.cloneNode(true).firstElementChild; + if (message.id) $msg.dataset.id = message.id; + + const $content = $msg.querySelector('.message__content'); + const text = extractText(message.content); + if (text) { + $content.textContent = text; + } else { + $content.remove(); + } + + const $dl = $msg.querySelector('.message__debug-list'); + this._populateDebugPanel($dl, meta); + if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', ''); + + const voice = meta?.voice || this.$voice.value || ''; + $msg.dataset.voice = voice; + $msg.dataset.copyText = text || ''; + + $streaming.replaceWith($msg); + this.scrollToBottom(); + return $msg; + } + + // --- Attachments --- + + /** + * renderAttachments renders the attachment chips in the footer. + * @param {File[]} attachments + * @param {number} attachments.length + * @param {Function} onRemove - Called with the index to remove. + */ + renderAttachments(attachments, onRemove) { + this.$attachments.innerHTML = ''; + + for (let i = 0; i < attachments.length; i++) { + const file = 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', () => onRemove(i)); + + this.$attachments.appendChild($chip); + } + } + + // --- Play button --- + + /** + * setPlayButton updates the play/stop button icon for a message. + * @param {string} messageId + * @param {boolean} playing + */ + setPlayButton(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'); + } + } + + // --- Error --- + + /** + * 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.insertBefore($msg, this.$chatLoading); + this.scrollToBottom(); + } + + // --- Scroll --- + + scrollToBottom() { + if (this.isAutoScrollEnabled) { + this.$chat.scrollTop = this.$chat.scrollHeight; + } + } + + isAtBottom() { + const { scrollTop, scrollHeight, clientHeight } = this.$chat; + return scrollHeight - clientHeight - scrollTop < 10; + } + + updateScrollIcon() { + const atBottom = this.isAtBottom(); + this.$scroll.classList.toggle('scrolled-up', !atBottom); + this.$scroll.setAttribute( + 'aria-label', + atBottom ? 'Scroll to top' : 'Scroll to bottom', + ); + } + + handleChatScroll() { + const { scrollTop, scrollHeight, clientHeight } = this.$chat; + const distanceFromBottom = scrollHeight - clientHeight - scrollTop; + const isAtBottom = distanceFromBottom < 10; + + if (scrollTop < this._lastScrollTop && !isAtBottom) { + this.isAutoScrollEnabled = false; + } else if (isAtBottom) { + this.isAutoScrollEnabled = true; + } + + this._lastScrollTop = scrollTop; + } + + setAutoScroll(enabled) { + this.isAutoScrollEnabled = enabled; + } + + clearChat() { + const $loading = this.$chatLoading; + this.$chat.innerHTML = ''; + this.$chat.appendChild($loading); + } + + // --- Private --- + + _renderTree(messageId) { + const messages = []; + this._collectMessages(messageId, messages); + this.renderMessages(messages); + } + + _collectMessages(messageId, messages) { + if (!messageId) return; + const msg = this.conversation.get(messageId); + if (!msg) return; + messages.push(msg); + if (msg.children) { + for (const childId of msg.children) { + this._collectMessages(childId, messages); + } + } + } + + _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; + $msg.dataset.copyText = content; + + 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', ''); + + this.$chat.insertBefore($msg, this.$chatLoading); + this.scrollToBottom(); + } + + _renderAssistantMessage(message, meta = null) { + const $msg = + this.$tplAssistantMessage.content.cloneNode(true).firstElementChild; + if (message.id) $msg.dataset.id = message.id; + + const $content = $msg.querySelector('.message__content'); + const text = extractText(message.content); + if (text) { + $content.textContent = text; + } else { + $content.remove(); + } + + const $dl = $msg.querySelector('.message__debug-list'); + this._populateDebugPanel($dl, meta); + if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', ''); + + const voice = meta?.voice || this.$voice.value || ''; + $msg.dataset.voice = voice; + $msg.dataset.copyText = text || ''; + + this.$chat.insertBefore($msg, this.$chatLoading); + this.scrollToBottom(); + } + + _renderReasoningMessage(message, meta = null) { + const $msg = + this.$tplReasoningMessage.content.cloneNode(true).firstElementChild; + $msg.dataset.id = `${message.id}/reasoning`; + $msg.dataset.parentId = message.id; + $msg.dataset.field = 'reasoning'; + $msg.dataset.messageId = message.id; + + $msg.querySelector('.message__content').textContent = + message.reasoning_content || ''; + $msg.dataset.copyText = message.reasoning_content || ''; + + 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', ''); + + this.$chat.insertBefore($msg, this.$chatLoading); + this.scrollToBottom(); + } + + _renderToolMessage(assistantMessage, toolCall, result = null, meta = null) { + const $msg = this.$tplToolMessage.content.cloneNode(true).firstElementChild; + const toolBubbleId = result?.id || toolCall.id; + $msg.dataset.id = toolBubbleId; + $msg.dataset.parentId = assistantMessage.id; + $msg.dataset.field = 'tool'; + $msg.dataset.messageId = assistantMessage.id; + $msg.dataset.toolCallId = toolCall.id; + + $msg.querySelector('.message__collapsible-label').textContent = + toolCall.function?.name || 'Tool Call'; + + const $args = $msg.querySelector('[data-args]'); + try { + const args = JSON.parse(toolCall.function?.arguments || '{}'); + $args.textContent = JSON.stringify(args, null, 2); + } catch { + $args.textContent = toolCall.function?.arguments || ''; + } + + if (result) { + const $outputSection = $msg.querySelector( + '.message__tool-section--output', + ); + $outputSection.hidden = false; + + const $output = $msg.querySelector('[data-output]'); + try { + const output = JSON.parse(extractText(result.content) || '{}'); + $output.textContent = JSON.stringify(output, null, 2); + } catch { + $output.textContent = extractText(result.content) || ''; + } + } + + // Set copy text. + const copyParts = []; + const name = toolCall.function?.name || 'unknown'; + const args = toolCall.function?.arguments || '{}'; + copyParts.push(`${name}(${args})`); + if (result) { + copyParts.push(extractText(result.content) || ''); + } + $msg.dataset.copyText = copyParts.join('\n\n'); + + 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', ''); + + this.$chat.insertBefore($msg, this.$chatLoading); + this.scrollToBottom(); + } + + _populateDebugPanel($dl, meta) { + if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice); + if (meta?.usage) { + const u = meta.usage; + this._appendDebugRow($dl, 'Prompt tokens', String(u.prompt_tokens)); + this._appendDebugRow( + $dl, + 'Completion tokens', + String(u.completion_tokens), + ); + this._appendDebugRow($dl, 'Total tokens', String(u.total_tokens)); + if (u.completion_tokens_details?.reasoning_tokens > 0) + this._appendDebugRow( + $dl, + 'Reasoning tokens', + String(u.completion_tokens_details.reasoning_tokens), + ); + if (u.prompt_tokens_details?.cached_tokens > 0) + this._appendDebugRow( + $dl, + 'Cached tokens', + String(u.prompt_tokens_details.cached_tokens), + ); + } + if (meta?.timings) { + const t = meta.timings; + if (t.prompt_ms > 0) + this._appendDebugRow( + $dl, + 'Prompt eval', + `${t.prompt_ms.toFixed(1)} ms (${t.prompt_per_second.toFixed(0)} tok/s)`, + ); + if (t.predicted_ms > 0) + this._appendDebugRow( + $dl, + 'Predicted eval', + `${t.predicted_ms.toFixed(1)} ms (${t.predicted_per_second.toFixed(0)} tok/s)`, + ); + } + } + + _appendDebugRow($dl, label, value) { + const $row = this.$tplDebugRow.content.cloneNode(true); + $row.querySelector('dt').textContent = label; + $row.querySelector('dd').textContent = value; + $dl.appendChild($row); + } + + _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; + } + + copyToClipboard($button, text) { + navigator.clipboard.writeText(text).then( + () => { + $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); + }, + (e) => console.error('failed to copy:', e), + ); + } +} diff --git a/internal/service/static/settings.js b/internal/service/static/settings.js new file mode 100644 index 0000000..31e2eae --- /dev/null +++ b/internal/service/static/settings.js @@ -0,0 +1,350 @@ +/** + * Settings owns the settings modal DOM, model/voice selects, and system + * message. Fetches models and voices from the API, populates selects, + * and persists selections to localStorage. + */ + +const MODELS_ENDPOINT = '/v1/models'; +const MODEL_KEY = 'odidere_model'; +const PROVIDER_KEY = 'odidere_provider'; +const VOICE_KEY = 'odidere_voice'; +const SYSTEM_MESSAGE_KEY = 'odidere_system_message'; +const ICONS_URL = '/static/icons.svg'; + +export class Settings { + /** + * @param {Object} options + * @param {Document} options.document + * @param {string} options.ttsURL - kokoro-fastapi base URL for voice fetching + */ + constructor({ document, ttsURL }) { + this.document = document; + this.ttsURL = ttsURL; + this.modelContextSizes = new Map(); + + // DOM Elements + this.$model = document.getElementById('model'); + this.$voice = document.getElementById('voice'); + this.$settings = document.getElementById('settings'); + this.$settingsOverlay = document.getElementById('settings-overlay'); + this.$settingsClose = document.getElementById('settings-close'); + this.$settingsTabs = document.querySelectorAll('.settings-tab'); + this.$settingsPanels = document.querySelectorAll('.settings-tab-panel'); + this.$systemMessageInput = document.getElementById('system-message-input'); + this.$saveSystemMessage = document.getElementById('save-system-message'); + } + + // --- Modal --- + + open() { + this.$settingsOverlay.classList.add('open'); + this._loadSystemMessage(); + this.switchTab('model-voice'); + this.$settingsClose.focus(); + } + + close() { + this.$settingsOverlay.classList.remove('open'); + this.$settings.focus(); + } + + switchTab(name) { + this.$settingsTabs.forEach(($tab) => { + const isActive = $tab.dataset.tab === name; + $tab.classList.toggle('active', isActive); + $tab.setAttribute('aria-selected', String(isActive)); + }); + this.$settingsPanels.forEach(($panel) => { + const isActive = $panel.dataset.tabPanel === name; + $panel.classList.toggle('active', isActive); + $panel.hidden = !isActive; + }); + } + + // --- Model select --- + + async fetchModels() { + try { + const res = await fetch(MODELS_ENDPOINT); + if (!res.ok) throw new Error(`${res.status}`); + const data = await res.json(); + this.populateModels( + data.providers, + data.default_provider, + data.default_model, + ); + } catch (e) { + console.error('failed to fetch models:', e); + this._populateModelsFallback(); + } + } + + populateModels(providers, defaultProvider, defaultModel) { + this.$model.innerHTML = ''; + this.modelContextSizes.clear(); + + const sortedProviders = Object.keys(providers).sort(); + + for (const provider of sortedProviders) { + const $optgroup = this.document.createElement('optgroup'); + $optgroup.label = provider; + + for (const m of providers[provider].sort((a, b) => + a.model.localeCompare(b.model), + )) { + const $opt = this.document.createElement('option'); + $opt.value = m.model; + $opt.dataset.provider = provider; + $opt.dataset.model = m.model; + if (m.context_size) { + this.modelContextSizes.set(m.model, m.context_size); + } + + switch (m.status) { + case 'available': + $opt.textContent = m.model; + break; + case 'not_found': + $opt.textContent = `~~${m.model}~~ (not found)`; + $opt.disabled = true; + $opt.classList.add('model-unavailable-not-found'); + break; + case 'provider_error': + $opt.textContent = `${m.model} (provider error)`; + $opt.disabled = true; + $opt.classList.add('model-unavailable-error'); + break; + } + + $optgroup.appendChild($opt); + } + + this.$model.appendChild($optgroup); + } + + this.loadModel(defaultProvider, defaultModel); + } + + loadModel(defaultProvider, defaultModel) { + const storedProvider = localStorage.getItem(PROVIDER_KEY); + const storedModel = localStorage.getItem(MODEL_KEY); + + let $opt; + if (storedProvider && storedModel) { + $opt = this.$model.querySelector( + `option[data-provider="${CSS.escape(storedProvider)}"][data-model="${CSS.escape(storedModel)}"]`, + ); + } + if (!$opt && defaultProvider && defaultModel) { + $opt = this.$model.querySelector( + `option[data-provider="${CSS.escape(defaultProvider)}"][data-model="${CSS.escape(defaultModel)}"]`, + ); + } + if (!$opt && this.$model.options.length > 0) { + for (const opt of this.$model.options) { + if (!opt.disabled) { + $opt = opt; + break; + } + } + } + + if ($opt) { + this.$model.value = $opt.value; + } + } + + getModel() { + const $opt = this.$model.options[this.$model.selectedIndex]; + return { + provider: $opt?.dataset.provider, + model: $opt?.dataset.model, + }; + } + + getModelContextSize(model) { + return this.modelContextSizes.get(model) ?? null; + } + + // --- Voice select --- + + async fetchVoices() { + try { + const res = await fetch(`${this.ttsURL}/v1/audio/voices`); + if (!res.ok) throw new Error(`${res.status}`); + const data = await res.json(); + + // Filter out legacy v0 voices. + const filtered = data.voices.filter((v) => { + const id = typeof v === 'string' ? v : v.id; + return !id.includes('_v0'); + }); + const voiceIds = filtered.map((v) => (typeof v === 'string' ? v : v.id)); + this.populateVoiceSelect(voiceIds); + } catch (e) { + console.error('failed to fetch voices:', e); + this.$voice.innerHTML = ''; + + const $opt = this.document.createElement('option'); + $opt.value = ''; + $opt.disabled = true; + $opt.selected = true; + $opt.textContent = 'Failed to load'; + this.$voice.appendChild($opt); + } + } + + populateVoiceSelect(voices) { + const langLabels = { + af: '🇺🇸 American English (F)', + am: '🇺🇸 American English (M)', + bf: '🇬🇧 British English (F)', + bm: '🇬🇧 British English (M)', + jf: '🇯🇵 Japanese (F)', + jm: '🇯🇵 Japanese (M)', + zf: '🇨🇳 Mandarin Chinese (F)', + zm: '🇨🇳 Mandarin Chinese (M)', + ef: '🇪🇸 Spanish (F)', + em: '🇪🇸 Spanish (M)', + ff: '🇫🇷 French (F)', + fm: '🇫🇷 French (M)', + hf: '🇮🇳 Hindi (F)', + hm: '🇮🇳 Hindi (M)', + if: '🇮🇹 Italian (F)', + im: '🇮🇹 Italian (M)', + pf: '🇧🇷 Brazilian Portuguese (F)', + pm: '🇧🇷 Brazilian Portuguese (M)', + kf: '🇰🇷 Korean (F)', + km: '🇰🇷 Korean (M)', + }; + + // Group voices by their two-character prefix. + const groups = {}; + for (const voice of voices) { + const prefix = voice.substring(0, 2); + if (!groups[prefix]) groups[prefix] = []; + groups[prefix].push(voice); + } + + this.$voice.innerHTML = ''; + + // Add "Auto" option. + const $auto = this.document.createElement('option'); + $auto.value = ''; + $auto.textContent = '🌐 Auto'; + this.$voice.appendChild($auto); + + const sortedPrefixes = Object.keys(groups).sort((a, b) => { + if (a[0] !== b[0]) return a[0].localeCompare(b[0]); + return a[1].localeCompare(b[1]); + }); + + for (const prefix of sortedPrefixes) { + const label = langLabels[prefix] || prefix.toUpperCase(); + + const $optgroup = this.document.createElement('optgroup'); + $optgroup.label = label; + + for (const voice of groups[prefix].sort()) { + const name = voice.substring(3).replace(/_/g, ' '); + const displayName = name.charAt(0).toUpperCase() + name.slice(1); + + const $opt = this.document.createElement('option'); + $opt.value = voice; + $opt.textContent = displayName; + $optgroup.appendChild($opt); + } + + this.$voice.appendChild($optgroup); + } + + this.loadVoice(); + } + + loadVoice() { + const stored = localStorage.getItem(VOICE_KEY); + + if (stored === null) { + this.$voice.value = ''; + return; + } + + const escaped = stored === '' ? '' : CSS.escape(stored); + const exists = + stored === '' || this.$voice.querySelector(`option[value="${escaped}"]`); + + if (!exists) { + this.$voice.value = ''; + return; + } + + this.$voice.value = stored; + } + + getVoice() { + return this.$voice.value; + } + + // --- System message --- + + loadSystemMessage() { + const stored = localStorage.getItem(SYSTEM_MESSAGE_KEY); + this.$systemMessageInput.value = stored || ''; + } + + saveSystemMessage() { + const value = this.$systemMessageInput.value; + localStorage.setItem(SYSTEM_MESSAGE_KEY, value); + + // Brief visual feedback on the save button. + this.$saveSystemMessage.replaceChildren(this._icon('check')); + this.$saveSystemMessage.classList.add('settings-save-btn--success'); + + setTimeout(() => { + this.$saveSystemMessage.textContent = 'Save'; + this.$saveSystemMessage.classList.remove('settings-save-btn--success'); + }, 1500); + } + + // --- Model/voice change persistence --- + + saveModelSelection() { + const $opt = this.$model.options[this.$model.selectedIndex]; + localStorage.setItem(PROVIDER_KEY, $opt.dataset.provider); + localStorage.setItem(MODEL_KEY, $opt.dataset.model); + } + + saveVoiceSelection() { + localStorage.setItem(VOICE_KEY, this.$voice.value); + } + + // --- Private --- + + _populateModelsFallback() { + this.$model.innerHTML = ''; + + const $opt = this.document.createElement('option'); + $opt.value = ''; + $opt.disabled = true; + $opt.selected = true; + $opt.textContent = 'Failed to load'; + this.$model.appendChild($opt); + } + + _icon(name) { + const svg = this.document.createElementNS( + 'http://www.w3.org/2000/svg', + 'svg', + ); + svg.setAttribute('class', 'icon'); + + const use = this.document.createElementNS( + 'http://www.w3.org/2000/svg', + 'use', + ); + use.setAttribute('href', `${ICONS_URL}#${name}`); + + svg.appendChild(use); + return svg; + } +} diff --git a/internal/service/static/stt.js b/internal/service/static/stt.js new file mode 100644 index 0000000..81492a5 --- /dev/null +++ b/internal/service/static/stt.js @@ -0,0 +1,207 @@ +/** + * STT records audio via a MediaRecorder, posts it to whisper-server, + * and returns the transcription. No concept of modes — the App + * decides what to do with the result. + * + * Recording lifecycle: acquire() requests microphone access (async, + * may prompt) and creates a fresh MediaRecorder; start() begins + * capturing (synchronous); stop() resolves the audio blob and + * releases the stream. A fresh stream is acquired per recording and + * torn down on every stop() so iOS/WebKit never reuses a capture + * track that TTS playback or an audio-session interruption has + * quietly killed. + */ + +/** + * VOICE_MAP maps whisper language names to default Kokoro voices. + * Used for auto-selecting a voice when the selector is set to "Auto". + */ +export const VOICE_MAP = { + chinese: 'zf_xiaobei', + english: 'af_heart', + french: 'ff_siwis', + hindi: 'hf_alpha', + italian: 'if_sara', + japanese: 'jf_alpha', + korean: 'kf_sarah', + portuguese: 'pf_dora', + spanish: 'ef_dora', +}; + +export class STT { + /** + * @param {Object} options + * @param {string} options.url - whisper-server base URL + */ + constructor({ url }) { + this.url = url; + this.mediaRecorder = null; + this.audioChunks = []; + this._ext = 'webm'; + } + + /** + * acquire requests microphone access and creates a fresh + * MediaRecorder. Always starts clean so iOS never reuses a dead + * capture track. Must be called before start. + */ + async acquire() { + this._release(); + + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + }); + + const [track] = stream.getAudioTracks(); + if (track?.readyState !== 'live') { + for (const t of stream.getTracks()) t.stop(); + throw new Error('microphone unavailable'); + } + + let mimeType = null; + for (const candidate of [ + 'audio/webm;codecs=opus', + 'audio/webm', + 'audio/mp4;codecs=mp4a.40.2', + 'audio/mp4', + ]) { + if (MediaRecorder.isTypeSupported?.(candidate)) { + mimeType = candidate; + break; + } + } + this._ext = mimeType?.includes('mp4') ? 'm4a' : 'webm'; + this.mediaRecorder = mimeType + ? new MediaRecorder(stream, { mimeType }) + : new MediaRecorder(stream); + + this.audioChunks = []; + this.mediaRecorder.addEventListener('dataavailable', (event) => { + if (event.data.size > 0) { + this.audioChunks.push(event.data); + } + }); + } + + /** + * start begins recording. Requires acquire() to have completed. + */ + start() { + this.audioChunks = []; + this.mediaRecorder.start(); + } + + /** + * stop stops recording, transcribes the audio, and returns the result. + * @returns {Promise<{text: string, detectedLanguage: string}>} + */ + async stop() { + const audioBlob = await this._stopRecording(); + if (audioBlob.size === 0) { + return { text: '', detectedLanguage: '' }; + } + return this._transcribe(audioBlob); + } + + /** @returns {boolean} */ + get isRecording() { + return this.mediaRecorder?.state === 'recording'; + } + + /** + * destroy stops tracks and releases the media stream. Called on page + * teardown. + */ + destroy() { + this._release(); + } + + /** + * _stopRecording stops the MediaRecorder and resolves with the + * assembled audio blob, releasing the stream. Returns an empty blob + * if there is no active recording, so it is safe to call before + * start has run (e.g. if the user released while acquire was still + * pending). + * @returns {Promise} + * @private + */ + _stopRecording() { + return new Promise((resolve, reject) => { + const mr = this.mediaRecorder; + if (!mr || mr.state === 'inactive') { + this._release(); + resolve(new Blob([])); + return; + } + + mr.addEventListener( + 'stop', + () => { + const type = mr.mimeType; + const blob = new Blob(this.audioChunks, { type }); + if (type?.includes('mp4') || type?.includes('m4a')) { + this._ext = 'm4a'; + } + this._release(); + resolve(blob); + }, + { once: true }, + ); + + mr.addEventListener( + 'error', + (event) => { + this._release(); + reject(event.error); + }, + { once: true }, + ); + + mr.stop(); + }); + } + + /** + * _release stops all tracks and clears recorder state. + * @private + */ + _release() { + for (const track of this.mediaRecorder?.stream?.getTracks() ?? []) { + track.stop(); + } + this.mediaRecorder = null; + this.audioChunks = []; + } + + /** + * _transcribe sends an audio blob to whisper-server and returns the + * transcription result. + * @param {Blob} audioBlob + * @returns {Promise<{text: string, detectedLanguage: string}>} + * @private + */ + async _transcribe(audioBlob) { + if (!this.url) { + throw new Error('STT URL not configured'); + } + + const formData = new FormData(); + formData.append('file', audioBlob, `audio.${this._ext}`); + formData.append('response_format', 'verbose_json'); + + const res = await fetch(`${this.url}/inference`, { + method: 'POST', + body: formData, + }); + + if (!res.ok) { + const errText = await res.text().catch(() => ''); + throw new Error(`STT error ${res.status}: ${errText}`); + } + + const data = await res.json(); + const text = (data.text || '').trim(); + const detectedLanguage = data.detected_language || data.language || ''; + return { text, detectedLanguage }; + } +} diff --git a/internal/service/static/tts.js b/internal/service/static/tts.js new file mode 100644 index 0000000..0309433 --- /dev/null +++ b/internal/service/static/tts.js @@ -0,0 +1,200 @@ +/** + * TTSPlayer synthesizes text via kokoro-fastapi, manages a playback + * queue, and plays audio. Extends EventTarget for playback state + * notifications. + * + * Events dispatched: + * 'playbackstart' { detail: { messageId } } + * 'playbackend' { detail: { messageId } } + * 'stopped' (no detail) + */ +export class TTSPlayer extends EventTarget { + /** + * @param {Object} options + * @param {string} options.url - kokoro-fastapi base URL + * @param {string} options.defaultVoice + */ + constructor({ url, defaultVoice }) { + super(); + this.url = url; + this.defaultVoice = defaultVoice; + this.playQueue = []; + this._playing = false; + this._currentMessageId = null; + this.currentAudio = null; + this.currentAudioURL = null; + this._muted = false; + } + + /** + * enqueue adds a message to the TTS playback queue and starts + * playback if not already playing. + * @param {string} text + * @param {string} voice + * @param {string} messageId + */ + enqueue(text, voice, messageId) { + if (!text || !this.url) return; + this.playQueue.push({ text, voice, messageId }); + if (!this._playing) { + this._processQueue(); + } + } + + /** + * stop stops current audio playback, clears the queue, and resets. + */ + stop() { + this._stopCurrentAudio(); + this.playQueue = []; + this._playing = false; + + if (this.currentMessageId) { + this.dispatchEvent( + new CustomEvent('playbackend', { + detail: { messageId: this.currentMessageId }, + }), + ); + } + this._currentMessageId = null; + this.dispatchEvent(new CustomEvent('stopped')); + } + + /** + * setMuted updates the mute state for current and future playback. + * @param {boolean} muted + */ + setMuted(muted) { + this._muted = muted; + if (this.currentAudio) { + this.currentAudio.muted = muted; + } + } + + /** @returns {boolean} */ + get isPlaying() { + return this._playing; + } + + /** @returns {string|null} */ + get currentMessageId() { + return this._currentMessageId; + } + + // Private implementation + + /** + * _processQueue pops the next item and synthesizes/plays it. + * @private + */ + async _processQueue() { + if (this.playQueue.length === 0) { + this._playing = false; + this._currentMessageId = null; + return; + } + + this._playing = true; + const item = this.playQueue.shift(); + this._currentMessageId = item.messageId; + + this.dispatchEvent( + new CustomEvent('playbackstart', { + detail: { messageId: item.messageId }, + }), + ); + + await this._synthesizeAndPlay(item.text, item.voice); + + this.dispatchEvent( + new CustomEvent('playbackend', { + detail: { messageId: item.messageId }, + }), + ); + + // Move to next item in queue. + this._processQueue(); + } + + /** + * _synthesizeAndPlay calls kokoro-fastapi to synthesize text and + * plays the resulting audio. + * @param {string} text + * @param {string} voice + * @private + */ + async _synthesizeAndPlay(text, voice) { + try { + this._stopCurrentAudio(); + + const res = await fetch(`${this.url}/v1/audio/speech`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'tts-1', + input: text, + voice: voice || this.defaultVoice, + 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._muted; + + 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(); + } + } + + /** + * _stopCurrentAudio stops and cleans up any playing audio. + * @private + */ + _stopCurrentAudio() { + if (this.currentAudio) { + this.currentAudio.pause(); + this.currentAudio.currentTime = 0; + } + this._cleanupAudio(); + } + + /** + * _cleanupAudio revokes the object URL and clears audio references. + * @private + */ + _cleanupAudio() { + if (this.currentAudioURL) { + URL.revokeObjectURL(this.currentAudioURL); + this.currentAudioURL = null; + } + this.currentAudio = null; + } +} diff --git a/internal/service/static/wakelock.js b/internal/service/static/wakelock.js new file mode 100644 index 0000000..55bc17d --- /dev/null +++ b/internal/service/static/wakelock.js @@ -0,0 +1,49 @@ +/** + * WakeLock manages the screen wake lock to prevent the display from + * dimming during active use (recording or processing). + */ +export class WakeLock { + constructor() { + this.wakeLock = null; + } + + /** + * acquire requests a screen wake lock. Idempotent. + */ + async acquire() { + 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); + } + } + + /** + * release releases the active wake lock. Idempotent. + */ + async release() { + 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. + * @param {boolean} isActive - Whether the app is recording or processing. + */ + async handleVisibilityChange(isActive) { + if (document.visibilityState !== 'visible') return; + if (!isActive) return; + await this.acquire(); + } +} diff --git a/internal/service/templates/static/body.gohtml b/internal/service/templates/static/body.gohtml index 9902bb4..7fd95ea 100644 --- a/internal/service/templates/static/body.gohtml +++ b/internal/service/templates/static/body.gohtml @@ -3,5 +3,6 @@ {{ template "main" . }} {{ template "templates" . }} {{ template "modal/settings" . }} + {{ template "modal/edit" . }} {{ end }} diff --git a/internal/service/templates/static/footer/compose.gohtml b/internal/service/templates/static/footer/compose.gohtml index 363d1a6..81c2dc8 100644 --- a/internal/service/templates/static/footer/compose.gohtml +++ b/internal/service/templates/static/footer/compose.gohtml @@ -14,6 +14,7 @@ type="button" class="compose__action-btn" id="attach" + data-action="attach" aria-label="Attach files" > @@ -22,6 +23,7 @@ type="button" class="compose__action-btn" id="location" + data-action="location" aria-label="Include location" title="Include GPS location" > @@ -34,6 +36,7 @@ type="button" class="compose__action-btn" id="transcribe" + data-action="transcribe" aria-label="Transcribe to text" title="Record and transcribe (does not send)" > @@ -43,6 +46,7 @@ type="button" class="compose__action-btn compose__action-btn--record" id="ptt" + data-action="ptt" aria-label="Push to talk" > @@ -51,6 +55,7 @@ type="button" class="compose__action-btn compose__action-btn--send" id="send" + data-action="send" aria-label="Send message" > diff --git a/internal/service/templates/static/footer/toolbar.gohtml b/internal/service/templates/static/footer/toolbar.gohtml index 582bbc6..ac95e5a 100644 --- a/internal/service/templates/static/footer/toolbar.gohtml +++ b/internal/service/templates/static/footer/toolbar.gohtml @@ -4,6 +4,7 @@ type="button" class="footer__toolbar-btn" id="settings" + data-action="settings" aria-label="Settings" > @@ -14,6 +15,7 @@ type="button" class="footer__toolbar-btn" id="reset" + data-action="reset" aria-label="Reset conversation" > @@ -22,6 +24,7 @@ type="button" class="footer__toolbar-btn" id="scroll" + data-action="scroll" aria-label="Scroll to top" > @@ -31,6 +34,7 @@ type="button" class="footer__toolbar-btn" id="mute" + data-action="mute" aria-label="Mute" > diff --git a/internal/service/templates/static/head.gohtml b/internal/service/templates/static/head.gohtml index 764e5b2..fe2bcfd 100644 --- a/internal/service/templates/static/head.gohtml +++ b/internal/service/templates/static/head.gohtml @@ -9,6 +9,6 @@ Odidere - + {{ end }} diff --git a/internal/service/templates/static/modal/edit.gohtml b/internal/service/templates/static/modal/edit.gohtml new file mode 100644 index 0000000..e71e8d6 --- /dev/null +++ b/internal/service/templates/static/modal/edit.gohtml @@ -0,0 +1,42 @@ +{{ define "modal/edit" }} + + + +{{ end }} diff --git a/internal/service/templates/static/templates.gohtml b/internal/service/templates/static/templates.gohtml index bd58a2a..baf6562 100644 --- a/internal/service/templates/static/templates.gohtml +++ b/internal/service/templates/static/templates.gohtml @@ -51,7 +51,13 @@ - - - - - + + + + {{ end }}