/** * 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 MAX_TURNS_KEY = 'odidere_max_turns'; const MUTE_KEY = 'odidere_mute'; const ICONS_URL = '/static/icons.svg'; const StreamEventDelta = 'delta'; const StreamEventReasoningDelta = 'reasoning_delta'; const StreamEventDone = 'done'; const StreamEventMessage = 'message'; const ContentTypeImageURL = 'image_url'; const ContentTypeText = 'text'; // GeolocationOptions: coarse accuracy is sufficient for chat context. // 30s cache avoids redundant GPS requests on repeated taps. const GeolocationOptions = { enableHighAccuracy: false, timeout: 20_000, maximumAge: 30_000, }; class Odidere { constructor({ document }) { this.document = document; // --- 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; this.isMuted = false; this.isResetPending = false; this.isStopPending = false; this.pendingDeleteId = null; // Promise for the currently in-flight runTurn, so reset() can await it. this._currentRun = null; this.currentController = null; this.totalTokens = 0; this.systemMessage = ''; // Attachments & location this.attachments = []; this.locationData = null; // --- DOM refs --- this.$attach = document.getElementById('attach'); this.$context = document.getElementById('context'); this.$fileInput = document.getElementById('file-input'); this.$location = document.getElementById('location'); this.$mute = document.getElementById('mute'); this.$ptt = document.getElementById('ptt'); this.$reset = document.getElementById('reset'); this.$send = document.getElementById('send'); this.$textInput = document.getElementById('text-input'); this.$transcribe = document.getElementById('transcribe'); this.$settings = document.getElementById('settings'); this.$settingsOverlay = document.getElementById('settings-overlay'); this.$settingsClose = document.getElementById('settings-close'); this.$settingsTabs = document.querySelectorAll('.settings-tab'); this.$saveSystemMessage = document.getElementById('save-system-message'); this.$composeSettingsOverlay = document.getElementById( 'compose-settings-overlay', ); this.$composeSettingsClose = document.getElementById( 'compose-settings-close', ); this.$maxTurnsInput = document.getElementById('max-turns-input'); this.$compose = document.querySelector('.compose'); this.init(); } // ==================== // LIFECYCLE // ==================== init() { this.conversation.load(); this.systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY) || ''; this.renderer.renderSystemMessage(this.systemMessage); if (this.conversation.size > 0) { this.renderer.renderMessages(this.conversation.getActivePath()); } this.bindEvents(); this.settings.fetchVoices(); this.settings.fetchModels(); this.updateSendButtonState(); // Restore mute state const storedMute = localStorage.getItem(MUTE_KEY); if (storedMute === 'true') { this.isMuted = true; this.tts.setMuted(true); this.$mute.replaceChildren(this._icon('volume-off')); this.$mute.classList.add('footer__toolbar-btn--muted'); this.$mute.setAttribute('aria-label', 'Unmute'); } this._initVisualViewport(); } destroy() { this.stt.destroy(); this.tts.stop(); this.currentController?.abort(); this.wakeLock.release(); } /** * Reset stops any in-flight turn, waits for it to settle, then * clears the conversation, DOM, attachments, location, and input. * The abort and await pattern ensures message streaming in runTurn completes * before conversation.clear(). */ async reset() { this.tts.stop(); if (this.currentController) { this.currentController.abort(); this.currentController = null; } this.setLoadingState(false); // Wait for the current run to settle before clearing. if (this._currentRun) { try { await this._currentRun; } catch {} } this.pendingDeleteId = null; this.conversation.clear(); this.renderer.setAutoScroll(false); this.renderer.clearChat(); this.systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY) || ''; this.renderer.renderSystemMessage(this.systemMessage); this.clearAttachments(); this.clearLocation(); this.$textInput.value = ''; this.$textInput.style.height = 'auto'; this.totalTokens = 0; this.updateContext(); this.updateSendButtonState(); } // ==================== // EVENT BINDING // ==================== 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()); this.$transcribe.addEventListener('contextmenu', (e) => e.preventDefault()); // --- 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.$textInput.addEventListener('input', () => this._handleTextareaInput(), ); // --- File input --- this.$attach.addEventListener('click', () => this.$fileInput.click()); this.$fileInput.addEventListener('change', (e) => this.handleAttachments(e.target.files), ); // --- Chat scroll --- this.renderer.$chat.addEventListener( 'scroll', () => this.renderer.handleChatScroll(), { passive: true }, ); this.renderer.updateFollowIndicator(); // --- 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.settings.close(); }); this.document.addEventListener('keydown', (e) => { if ( e.key === 'Escape' && this.$settingsOverlay.classList.contains('open') ) { e.preventDefault(); this.settings.close(); } }); this.$settingsTabs.forEach(($tab) => { $tab.addEventListener('click', () => this.settings.switchTab($tab.dataset.tab), ); }); this.$saveSystemMessage.addEventListener('click', () => this.settings.saveSystemMessage(), ); // --- Compose settings --- this.$composeSettingsClose.addEventListener('click', () => this._closeComposeSettings(), ); this.$composeSettingsOverlay.addEventListener('click', (e) => { if (e.target === this.$composeSettingsOverlay) this._closeComposeSettings(); }); this.$maxTurnsInput.addEventListener('input', () => { this._setMaxTurns(this.$maxTurnsInput.value); }); // --- 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.wakeLock.handleVisibilityChange( this.isRecording || this.isProcessing, ), ); } // ==================== // CLICK DELEGATION // ==================== _handleClick(event) { const $target = event.target.closest('[data-action]'); const action = $target?.dataset.action; // 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 !== 'delete') this._cancelDeletePending(); this.handleAction(action, event, $target); } handleAction(action, event, $target) { switch (action) { case 'send': this._handleSendClick(); break; case 'reset': this._handleResetClick(); break; case 'scroll-up': this._handleScrollUp(); break; case 'scroll-down': this._handleScrollDown(); break; case 'mute': this.toggleMute(); break; case 'settings': this.settings.open(); break; case 'compose-settings': this._openComposeSettings(); 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; } case 'copy': { const $msg = $target.closest('.message'); const text = $msg.dataset.copyText || ''; this.renderer.copyToClipboard($target, text); break; } 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 { event.preventDefault(); event.stopPropagation(); } this.handleSendButtonPress(); } else if (action === 'transcribe') { if (event.type === 'touchstart') { event.preventDefault(); event.stopPropagation(); } else if (event.sourceCapabilities?.firesTouchEvents) { return; } else { event.preventDefault(); event.stopPropagation(); } this.handleTranscribePress(); } } _handlePressEnd(_event) { if (this.isRecording) { this.handleSendButtonRelease(); } else if (this.isTranscribing) { this.handleTranscribeRelease(); } } // ==================== // STT HANDLERS // ==================== 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}`); } } 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); } } 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}`); } } 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.handleSendButtonPress(); } _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.handleSendButtonRelease(); } _handleTextareaKeyDown(event) { if (event.code !== 'Enter') return; if (event.shiftKey) return; event.preventDefault(); this._submitText(); } _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(); } // ==================== // CONVERSATION ORCHESTRATION // ==================== async _submitText() { const text = this.$textInput.value.trim(); if (this.isProcessing) return; this.tts.stop(); this.$textInput.value = ''; this.$textInput.style.height = 'auto'; await this.runTurn({ text, voice: this.settings.getVoice() }); } /** * Build and send a chat request, then consume the streaming SSE response. * * The body executes inside an async IIFE stored as this._currentRun so * reset() can abort the stream and await the IIFE before clearing state. * On interruption (stop or reset), the catch block persists any partially- * received assistant message or pending tool calls; on reset, clear() runs * afterward, so the persisted data is immediately discarded. * * @param {Object} options * @param {string} [options.text] * @param {string} [options.detectedLanguage] * @param {string} [options.voice] */ async runTurn({ text = '', detectedLanguage = '', voice } = {}) { this.setLoadingState(true); // Follow state is sticky across turns; runTurn does not reset it. It turns // off only when the user scrolls up or presses the up arrow. Off at load. this.currentController = new AbortController(); this._currentRun = (async () => { let streamingBubbles = { assistantId: null, message: null, reasoning: null, text: null, }; 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; const activePath = this.conversation.getActivePath(); let messages; let userMessage; if (hasNewInput) { userMessage = await this._buildUserMessage(text, this.attachments); messages = [ ...activePath.map(({ parent, children, id, meta, ...msg }) => msg), userMessage, ]; } else { messages = activePath.map( ({ parent, children, id, meta, ...msg }) => msg, ); } const { provider, model } = this.settings.getModel(); const payload = { messages, provider, model }; if (this.systemMessage) { payload.system_message = this.systemMessage; } const maxTurns = this._getMaxTurns(); if (maxTurns > 0) { payload.max_iterations = maxTurns; } // Clear attachments and location after building payload. this.clearAttachments(); this.clearLocation(); // 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.conversation.append([userMessage], meta); this.renderer.renderMessages(appended, meta); // When not following, anchor the new user message to the top so the // reply grows below it. When following, let streaming pin the bottom. if (this.renderer.isAutoScrollEnabled) { this.renderer.scrollToBottom(); } else { this.renderer.scrollToLastUserMessage(); } } const res = await fetch(STREAM_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: this.currentController.signal, }); const requestId = res.headers.get('X-Request-ID') || ''; if (!res.ok) { const err = await res.text(); this.renderer.renderError( `server error ${res.status}: ${err}`, requestId, ); return; } for await (const { eventType, event } of this._parseSSEStream(res)) { if (event.error) { this.renderer.renderError(event.error, requestId); return; } if ( eventType === StreamEventDelta || eventType === StreamEventReasoningDelta ) { if (!streamingBubbles.message) { streamingBubbles.assistantId = crypto.randomUUID(); streamingBubbles.message = { id: streamingBubbles.assistantId, role: 'assistant', content: [{ type: ContentTypeText, text: '' }], }; } if (eventType === StreamEventReasoningDelta) { if (!streamingBubbles.reasoning) { const $el = this.renderer.createStreamingReasoning( streamingBubbles.assistantId, streamingMeta(event.usage, event.timings), ); 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; } 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 meta = streamingMeta(event.usage, event.timings); let finalMessage = message; if (streamingBubbles.message) { finalMessage.id = streamingBubbles.assistantId; const appended = this.conversation.append([finalMessage], meta); finalMessage = appended[0]; if (streamingBubbles.reasoning) { this.renderer.finalizeStreamingReasoning( streamingBubbles.reasoning.$el, finalMessage, meta, ); } if (streamingBubbles.text && !finalMessage.tool_calls?.length) { this.renderer.finalizeStreamingText( streamingBubbles.text.$el, finalMessage, meta, ); } } 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, ); } continue; } 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; } // 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; } } } this.setLoadingState(false); } catch (e) { if (e.name !== 'AbortError') { console.error('failed to send request:', e); this.renderer.renderError(`Error: ${e.message}`); } this.setLoadingState(false); // Persist a streaming message interrupted by stop or error. if (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 (pendingTools) { if (pendingTools.$el) { this.renderer.finalizeStreamingText( pendingTools.$el, pendingTools.assistant, pendingTools.assistant.meta ?? {}, ); } this.renderer.renderMessages([ pendingTools.assistant, ...pendingTools.results, ]); pendingTools = null; } } finally { this.currentController = null; } })(); try { await this._currentRun; } finally { this._currentRun = null; } } // ==================== // 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 = []; for (const file of attachments) { if (file.type.startsWith('image/')) { const buffer = await file.arrayBuffer(); const base64 = new Uint8Array(buffer).toBase64(); const dataURL = `data:${file.type};base64,${base64}`; imageParts.push({ type: ContentTypeImageURL, image_url: { url: dataURL }, }); } else { const content = await file.text(); textParts.push(`=== File: ${file.name} ===\n\n${content}`); } } if (text) { textParts.push(text); } if (this.locationData) { textParts.push(JSON.stringify(this.locationData, null, 2)); } const combinedText = textParts.join('\n\n'); if (imageParts.length === 0) { return { id: crypto.randomUUID(), role: 'user', content: combinedText || '', }; } const parts = []; if (combinedText) { parts.push({ type: ContentTypeText, text: combinedText }); } parts.push(...imageParts); return { id: crypto.randomUUID(), role: 'user', content: parts, }; } // ==================== // VOICE RESOLUTION // ==================== resolveVoice(detectedLanguage) { let voice = this.settings.getVoice(); if (!voice && detectedLanguage) { voice = VOICE_MAP[detectedLanguage] || ''; } return voice; } // ==================== // STATE // ==================== updateSendButtonState() { const hasText = this.$textInput.value.trim().length > 0; const hasAttachments = this.attachments.length > 0; const hasLocation = this.locationData !== null; const hasHistory = this.conversation.size > 0; const enabled = hasText || hasAttachments || hasLocation || hasHistory; this.$send.disabled = !enabled; this.$send.setAttribute( 'aria-label', enabled ? 'Send message' : 'Start a conversation to enable send', ); } setLoadingState(loading) { this.isProcessing = loading; this.$send.classList.toggle('loading', loading); this.$send.setAttribute('aria-label', loading ? 'Stop' : 'Send message'); this.renderer.$chatLoading.hidden = !loading; this.$ptt.disabled = loading; this.$transcribe.disabled = loading; if (!loading && this.isStopPending) { this.isStopPending = false; this.$send.classList.remove('pending'); } // While loading, the send button acts as the stop control and must stay // clickable regardless of the normal enable/disable logic. When loading // ends, restore the standard state. if (loading) { this.$send.disabled = false; } else { this.updateSendButtonState(); } if (loading) { this.wakeLock.acquire(); } else { this.wakeLock.release(); } } setRecordingState(recording) { this.$ptt.classList.toggle('recording', recording); this.$ptt.setAttribute('aria-pressed', String(recording)); } toggleMute() { this.isMuted = !this.isMuted; localStorage.setItem(MUTE_KEY, String(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); } updateContext() { if (!this.$context) return; const { model } = this.settings.getModel(); const contextSize = model ? this.settings.getModelContextSize(model) : null; if (this.totalTokens === 0) { this.$context.hidden = true; return; } this.$context.hidden = false; this.$context.classList.remove( 'footer__toolbar-context--warning', 'footer__toolbar-context--critical', ); const tokenLabel = this.totalTokens >= 1000 ? `${(this.totalTokens / 1000).toFixed(1)}k tokens` : `${this.totalTokens} tokens`; if (contextSize && contextSize > 0) { const pct = Math.round((this.totalTokens / contextSize) * 100); this.$context.textContent = `${pct}%`; this.$context.title = tokenLabel; if (pct >= 90) { this.$context.classList.add('footer__toolbar-context--critical'); } else if (pct >= 70) { this.$context.classList.add('footer__toolbar-context--warning'); } } else { this.$context.textContent = tokenLabel; this.$context.title = ''; } } // ==================== // ATTACHMENTS & LOCATION // ==================== 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 = ''; } _renderAttachments() { this.renderer.renderAttachments(this.attachments, (i) => this._removeAttachment(i), ); } _removeAttachment(index) { this.attachments.splice(index, 1); this._renderAttachments(); this.updateSendButtonState(); } 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(); }, () => {}, GeolocationOptions, ); } clearLocation() { this.locationData = null; this.$location.classList.remove('compose__action-btn--location', 'active'); } // ==================== // COMPOSE SETTINGS // ==================== _openComposeSettings() { this.$maxTurnsInput.value = this._getMaxTurns(); this.$composeSettingsOverlay.classList.add('open'); this.$composeSettingsClose.focus(); } _closeComposeSettings() { this.$composeSettingsOverlay.classList.remove('open'); this.$composeSettings.focus(); } _getMaxTurns() { const stored = localStorage.getItem(MAX_TURNS_KEY); return stored ? Number(stored) : 0; } _setMaxTurns(value) { localStorage.setItem(MAX_TURNS_KEY, String(value)); } // ==================== // TWO-PRESS SAFETY PATTERNS // ==================== _handleSendClick() { if (this.$send.classList.contains('loading')) { this._handleStopClick(); } else { this._submitText(); } } _handleStopClick() { if (this.isStopPending) { this.isStopPending = false; this.$send.classList.remove('pending'); this.stopConversation(); } else { this.isStopPending = true; this.$send.classList.add('pending'); } } _cancelStopPending() { if (!this.isStopPending) return; this.isStopPending = false; this.$send.classList.remove('pending'); } _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'); } } _cancelResetPending() { if (!this.isResetPending) return; this.isResetPending = false; this.$reset.classList.remove('footer__toolbar-btn--pending'); } _handleScrollUp() { const $chat = this.renderer.$chat; this.renderer.setAutoScroll(false); $chat.scrollTop = 0; } _handleScrollDown() { const $chat = this.renderer.$chat; // Set scrollTop first so the queued scroll event sees bottom position // and does not disable following. $chat.scrollTop = $chat.scrollHeight; this.renderer.setAutoScroll(true); } _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; } const msg = this.conversation.get(messageId); if (msg?.role !== 'assistant') return; 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; } } } 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(); } } _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; this._currentRun = null; } _cancelAllPending() { this._cancelResetPending(); this._cancelStopPending(); this._cancelDeletePending(); } // ==================== // EDIT // ==================== async _openEdit(messageId, field, toolCallId) { if (messageId === 'system') { const saved = await this._openSystemEdit(); if (saved) { this.renderer.reRenderSystemMessage(this.systemMessage); } return; } const saved = await this.editModal.open(messageId, field, toolCallId); if (saved) this.renderer.reRenderActivePath(); } async _openSystemEdit() { return new Promise((resolve) => { const $tpl = this.document.getElementById('tpl-edit-modal'); 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); $modal.querySelector('h2').textContent = 'Edit System Message'; const textarea = $modal.querySelector('#edit-textarea'); textarea.value = this.systemMessage || ''; const saveBtn = $modal.querySelector('#edit-save'); const closeBtn = $modal.querySelector('.modal__close'); const close = () => { $overlay.remove(); this.document.removeEventListener('keydown', handleEscape); resolve(false); }; const save = () => { const newText = textarea.value.trim(); const settingsDefault = localStorage.getItem(SYSTEM_MESSAGE_KEY) || ''; this.systemMessage = newText || settingsDefault; $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); textarea.focus(); }); } // ==================== // 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; const handler = () => { const h = visualViewport.height; if (h > 0) { container.style.height = `${h}px`; } }; visualViewport.addEventListener('resize', handler); handler(); } // ==================== // ICONS // ==================== _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; } } // Bootstrap new Odidere({ document });