/** * Odidere is the composition root. It owns the in-memory state: the * selected conversation, the cache of its saved messages, and the * unfinished streamed output. It wires the other modules together: * api.js sends every request, storage.js holds the local cache and * settings, ui.js renders and reports user actions, and the voice * modules (stt.js, tts.js, wakelock.js) record, speak, and keep the * screen on. * * Two event streams feed the same handler: the response of the * inference request, and the subscription used while idle. Both * deliver saved messages, streaming fragments, and a terminal done * event. Saved rows enter the cache and localStorage; streamed * fragments build drafts, which render live and are discarded when * their stream ends without a save. */ import { Api, ApiError } from './api.js'; import { Storage } from './storage.js'; import { STT, VOICE_MAP } from './stt.js'; import { TTSPlayer } from './tts.js'; import { conversationLabel, extractText, UI } from './ui.js'; import { WakeLock } from './wakelock.js'; // The tool result saved when the user denies a tool call. A denied // call with no result makes every later run refuse the whole batch // with 'will not retry'; the note gives the model an answer. const DENIAL_NOTE = 'The user denied this tool call.'; // Coarse accuracy is sufficient for chat context. The 30 s cache // avoids a fresh GPS fix on repeated taps. const GEOLOCATION_OPTIONS = { enableHighAccuracy: false, timeout: 20_000, maximumAge: 30_000, }; class Odidere { constructor({ document }) { this.api = new Api(); this.storage = new Storage(); this.ui = new UI({ document, handlers: { onComposeInput: () => this.updateSendEnabled(), onComposeSettings: () => this.ui.openComposeSettings({ maxIterations: this.storage.getMaxIterations(), reasoningBudget: this.storage.getReasoningBudget(), }), onComposeSettingsChange: ({ maxIterations, reasoningBudget }) => { this.storage.setMaxIterations(maxIterations); this.storage.setReasoningBudget( Number.isFinite(reasoningBudget) ? reasoningBudget : null, ); }, onComposeSubmit: () => this.submit(), onModelChange: ({ provider, model }) => { this.storage.setModelSelection({ provider, model }); this.ui.updateContext( this.totalTokens, this.ui.getSelectedModel().contextSize, ); }, onVoiceChange: (voice) => { this.voice = voice; this.storage.setVoice(voice); }, onSettingsOpen: () => this.ui.setSystemMessageDraft(this.storage.getSystemMessage()), onSaveSystemMessage: (text) => this.storage.setSystemMessage(text), onConversationsOpen: () => { this.conversationQuery = ''; this.loadConversations(); }, onConversationSearch: (query) => { this.conversationQuery = query; this.filterConversations(query); }, onSelectConversation: (id) => this.switchConversation(id), onRenameConversation: (id) => this.renameConversation(id), onDeleteConversation: (id) => this.deleteConversation(id), onNewConversation: () => this.newConversation(), onPushToTalkStart: () => this.handlePushToTalkStart(), onPushToTalkEnd: () => this.handlePushToTalkEnd(), onTranscribeStart: () => this.handleTranscribeStart(), onTranscribeEnd: () => this.handleTranscribeEnd(), onAttachFiles: (files) => this.handleAttachments(files), onLocationToggle: () => this.toggleLocation(), onToggleMute: () => this.toggleMute(), onPlay: (id) => this.play(id), onEdit: (target) => this.editTarget(target), onDelete: (target) => this.deleteTarget(target), onApprove: (execution) => this.approve(execution), onDeny: (execution) => this.deny(execution), onReset: () => this.reset(), onStop: () => this.stop(), }, }); 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.wakeLock = new WakeLock(); // The playback buttons follow the TTS queue. this.tts.addEventListener('playbackstart', (event) => this.ui.setPlayButton(event.detail.messageId, true), ); this.tts.addEventListener('playbackend', (event) => this.ui.setPlayButton(event.detail.messageId, false), ); // --- In-memory state --- this.conversationId = null; // The rows the conversation picker shows, fetched on open. this.conversations = []; // The picker's current search text. A rename or delete keeps // it, so the list re-renders with the same filter. this.conversationQuery = ''; this.messages = new Map(); this.drafts = new Map(); this.busy = false; this.attachments = []; this.locationData = null; this.totalTokens = 0; this.voice = this.storage.getVoice(); this.lastVoice = ''; this.isRecording = false; this.isTranscribing = false; this.isMuted = this.storage.getMuted(); // One controller per open stream. The sync controller is // aborted whenever a run starts; the run controller is aborted // only by reset, which deletes the conversation. this.runController = null; this.syncController = null; // The promise of the run in flight, so reset can await it. this.currentRun = null; this.init().catch((error) => this.ui.renderError(`Startup failed: ${error.message}`), ); } // ==================== // LIFECYCLE // ==================== async init() { const cached = this.storage.loadConversation(); if (cached) { this.conversationId = cached.id; for (const row of cached.rows) this.messages.set(row.id, row); this.ui.renderConversation(this.orderedRows()); } else { this.ui.renderSystemDefault(this.storage.getSystemMessage()); } if (this.isMuted) this.tts.setMuted(true); this.ui.setMuted(this.isMuted); this.updateSendEnabled(); this.loadModels(); this.loadVoices(); this.bindLifecycle(); if (this.conversationId) await this.sync(); } /** Stop every activity. Called on page teardown. */ destroy() { this.stt.destroy(); this.tts.stop(); this.runController?.abort(); this.syncController?.abort(); this.wakeLock.release(); } /** React to connectivity and focus, which end or miss streams. */ bindLifecycle() { document.addEventListener('visibilitychange', () => { this.wakeLock.handleVisibilityChange(this.isRecording || this.busy); if (!document.hidden) this.sync(); }); window.addEventListener('online', () => this.sync()); } // ==================== // STATE // ==================== /** * orderedRows returns the cached rows in the order they were * saved. The server sends rows in saved order on every stream, * and the map keeps that order through updates. * @returns {Array} */ orderedRows() { return [...this.messages.values()]; } /** * isOrdered reports whether the cached rows are still in saved * order. A row another client inserted or deleted can shift the * sequences of rows the sync diff does not re-send, because their * updated_at did not change; the check catches that. */ isOrdered() { let previous = -Infinity; for (const row of this.messages.values()) { if (row.sequence <= previous) return false; previous = row.sequence; } return true; } /** * cacheRow stores one saved row and persists the cache. voice * tags the row for the play button; it is client state, not part * of the saved message. * @param {Object} row - a message row from a stream or a mutation. * @param {string} [voice] * @param {boolean} [persist] - set false to persist once, after a * batch of rows. */ cacheRow(row, voice = '', persist = true) { const previous = this.messages.get(row.id); this.messages.set(row.id, { id: row.id, role: row.role, sequence: row.sequence, updated_at: row.updated_at, content: row.content, metadata: row.metadata ?? null, voice: voice || previous?.voice || '', }); if (persist) this.persistCache(); } persistCache() { if (this.conversationId) { this.storage.saveConversation(this.conversationId, this.orderedRows()); } } /** * resultsFor returns the saved tool results of one assistant * message: the tool rows between it and the next assistant * message whose calls they answer. * @param {Object} row - an assistant message row. * @returns {Array} */ resultsFor(row) { if (row.role !== 'assistant') return []; const callIds = new Set( (row.content?.tool_calls ?? []).map((call) => call.id), ); const results = []; let after = false; for (const candidate of this.orderedRows()) { if (candidate.id === row.id) { after = true; continue; } if (!after) continue; if (candidate.role === 'assistant') break; if ( candidate.role === 'tool' && callIds.has(candidate.content?.tool_call_id) ) { results.push(candidate); } } return results; } /** * findResultRow returns the saved result of one call, or null. * @param {Object} row - the assistant message row. * @param {string} callId */ findResultRow(row, callId) { for (const candidate of this.resultsFor(row)) { if (candidate.content?.tool_call_id === callId) return candidate; } return null; } /** * findParentRow returns the assistant message row that asked for * one tool result, or null when no cached assistant has the call. * @param {Object} resultRow - a tool result row. */ findParentRow(resultRow) { const callId = resultRow.content?.tool_call_id; for (const row of this.orderedRows()) { if ( row.role === 'assistant' && (row.content?.tool_calls ?? []).some((call) => call.id === callId) ) { return row; } } return null; } // ==================== // EVENT HANDLING // ==================== /** * handleEvent processes one stream event. It returns true after * a done event, which ends a stream. * * voice tags assistant messages of this run for the play button. * It is empty for events of another client's run, whose voice is * not known here. * * @param {string} type * @param {Object} data * @param {string} [voice] * @returns {boolean} true when the stream is finished. */ handleEvent(type, data, voice = '') { switch (type) { case 'message_saved': { const row = data.message; const existed = this.messages.has(row.id); this.cacheRow(row, voice); this.applySavedRow(row, existed, voice); this.trackUsage(row.metadata?.usage); return false; } case 'message_deleted': { const row = this.messages.get(data.message_id); this.messages.delete(data.message_id); this.persistCache(); if (!row) return false; if (row.role === 'tool') { this.ui.clearToolOutput(row); } else { this.ui.removeMessageBubbles(data.message_id); } return false; } case 'assistant_update': { const draft = this.applyChunk(data.message_id, data.chunk); this.ui.renderDraft(draft); this.trackUsage(data.chunk?.usage); return false; } case 'done': // An error here ended the run; the saved rows it produced // are already in the cache. if (data.error) this.ui.renderError(data.error); return true; default: return false; } } /** * applySavedRow renders one saved row. A row that updates an * existing one re-renders the whole chat, because the bubble for * the old content is already on screen. * @param {Object} row * @param {boolean} existed - whether the row was already cached. * @param {string} voice - the voice of this run, '' when unknown. */ applySavedRow(row, existed, voice = '') { const wasDraft = this.drafts.delete(row.id); if (row.role === 'tool') { this.ui.attachToolResult( this.findParentRow(row), row.content?.tool_call_id, row, ); return; } if (existed || wasDraft) { if (wasDraft) { this.ui.replaceDraftWithSaved(row.id, row, this.resultsFor(row)); } else { this.ui.renderConversation(this.orderedRows()); } } else { this.ui.renderMessages([row, ...this.resultsFor(row)]); if (row.role === 'user') { // Anchor the new user message, so the reply grows below // it; while following, stay at the bottom instead. if (this.ui.isAutoScrollEnabled) { this.ui.scrollToBottom(); } else { this.ui.scrollToLastUserMessage(); } } } if (row.role === 'assistant') { const text = extractText(row.content?.content); const cached = this.messages.get(row.id); if (text) { this.tts.enqueue(text, cached?.voice || voice, row.id); } } } /** * applyChunk accumulates one streaming fragment into the draft of * its assistant message and returns the draft. Choices and * tool-call indices belong to stream assembly; saved rows are * ordered by the server. * @param {string} draftId - the message ID the server allocated * before the first fragment. * @param {Object} chunk - a provider stream chunk. */ applyChunk(draftId, chunk) { let draft = this.drafts.get(draftId); if (!draft) { draft = { id: draftId, reasoning: '', text: '', refusal: '', toolCalls: [], }; this.drafts.set(draftId, draft); } for (const choice of chunk?.choices ?? []) { const delta = choice.delta ?? {}; draft.text += delta.content ?? ''; draft.refusal += delta.refusal ?? ''; // Providers spell the reasoning field two ways; both carry // fragments of the same stream. draft.reasoning += (delta.reasoning_content ?? '') + (delta.reasoning ?? ''); for (const fragment of delta.tool_calls ?? []) { const index = fragment.index ?? 0; let call = draft.toolCalls[index]; if (!call) { call = { id: '', type: 'function', function: { name: '', arguments: '' }, }; draft.toolCalls[index] = call; } if (fragment.id) call.id = fragment.id; if (fragment.type) call.type = fragment.type; call.function.name += fragment.function?.name ?? ''; call.function.arguments += fragment.function?.arguments ?? ''; } } return draft; } /** Discard every draft; none of them was saved. */ clearDrafts() { for (const id of this.drafts.keys()) this.ui.removeDraft(id); this.drafts.clear(); } /** * trackUsage keeps the newest reported token total. Streaming * usage reports replace earlier ones; they are not increments. * @param {Object} [usage] */ trackUsage(usage) { if (usage?.total_tokens == null) return; this.totalTokens = usage.total_tokens; this.ui.updateContext( this.totalTokens, this.ui.getSelectedModel().contextSize, ); } // ==================== // SEND // ==================== /** * submit sends the compose text. Called by the send button and * the Enter key. */ async submit() { if (this.busy || this.isRecording || this.isTranscribing) return; const text = this.ui.takeComposeText(); await this.send({ text, voice: this.resolveVoice() }); } /** * send starts one run. With input, the run saves it first; with * none, it continues the saved history, which is how a run * resumes after an approval or denial. * * The compose text is taken before the request and restored when * the request fails before a stream opens, so nothing the user * typed is lost. Attachments and location stay until then too. * * @param {Object} [options] * @param {string} [options.text] - new user input, may be empty. * @param {string} [options.voice] - voice for the replies. */ async send({ text = '', voice = this.voice } = {}) { if (this.busy) return; const hasInput = Boolean(text) || this.attachments.length > 0 || this.locationData !== null; if (!hasInput && this.messages.size === 0) return; const run = this.run({ text, voice }); this.currentRun = run; try { await run; } finally { if (this.currentRun === run) this.currentRun = null; } } /** * run performs one inference request and consumes its stream. * Only send starts a run; it awaits this promise. */ async run({ text, voice }) { this.tts.stop(); this.lastVoice = voice; // End the idle subscription; the run's stream takes over. this.syncController?.abort(); let message; const hasInput = Boolean(text) || this.attachments.length > 0 || this.locationData !== null; if (hasInput) { try { message = await this.buildMessage(text); } catch (error) { this.ui.appendComposeText(text); this.ui.renderError(`Failed to build message: ${error.message}`); return; } } if (!this.conversationId) { try { const conversation = await this.api.createConversation({ systemMessage: this.storage.getSystemMessage() || undefined, }); this.conversationId = conversation.id; this.persistCache(); } catch (error) { this.ui.appendComposeText(text); this.ui.renderError(`Failed to create conversation: ${error.message}`); return; } } const controller = new AbortController(); this.runController = controller; this.busy = true; this.ui.setBusy(true); this.ui.setPendingApprovals([]); // Keep the screen on while the run streams. this.wakeLock.acquire(); // Consume the input once the server accepted the request: an // earlier failure keeps it for a retry. let consumed = false; const consumeInput = () => { if (consumed) return; consumed = true; this.attachments = []; this.locationData = null; this.renderAttachments(); this.ui.setLocationActive(false); this.updateSendEnabled(); }; try { const body = { stream: true }; if (message) body.message = message; const { provider, model } = this.ui.getSelectedModel(); if (provider) body.provider = provider; if (model) body.model = model; const maxIterations = this.storage.getMaxIterations(); if (maxIterations > 0) body.max_iterations = maxIterations; const budget = this.storage.getReasoningBudget(); if (budget !== null) body.thinking_budget_tokens = budget; for await (const { type, data } of this.api.inference( this.conversationId, body, controller.signal, )) { consumeInput(); if (this.handleEvent(type, data, voice)) break; } } catch (error) { if (error instanceof ApiError && error.status === 409) { if (hasInput) { this.ui.renderError( 'This conversation is already working in another session.', ); this.ui.appendComposeText(text); } } else if (error.name !== 'AbortError') { console.error('inference failed:', error); this.ui.renderError(`Inference failed: ${error.message}`); this.ui.appendComposeText(text); } // An AbortError ends delivery only; reset deleted the // conversation and will clear the local state. } finally { if (this.runController === controller) this.runController = null; this.clearDrafts(); this.busy = false; this.ui.setBusy(false); this.updateSendEnabled(); this.wakeLock.release(); if (this.conversationId && !controller.signal.aborted) { await this.sync(); } } } /** * buildMessage builds the user message from text, attachments, * and location. Text files are appended to the text; images * become data-URL parts. * @param {string} text */ async buildMessage(text) { const textParts = []; const imageParts = []; for (const file of this.attachments) { if (file.type.startsWith('image/')) { const buffer = await file.arrayBuffer(); const base64 = new Uint8Array(buffer).toBase64(); imageParts.push({ type: 'image_url', image_url: { url: `data:${file.type};base64,${base64}` }, }); } 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 combined = textParts.join('\n\n'); if (imageParts.length === 0) { return { role: 'user', content: combined }; } const parts = []; if (combined) parts.push({ type: 'text', text: combined }); parts.push(...imageParts); return { role: 'user', content: parts }; } /** * resolveVoice picks the voice for replies: the selection, or the * default voice of the transcribed language when the selection * is automatic. * @param {string} [detectedLanguage] */ resolveVoice(detectedLanguage = '') { if (this.voice) return this.voice; return detectedLanguage ? (VOICE_MAP[detectedLanguage] ?? '') : ''; } // ==================== // STOP AND RESET // ==================== /** * stop asks the server to cancel the ongoing inference. The open * stream keeps delivering events until done, so the busy state * clears in the run's finally block. */ async stop() { if (!this.busy || !this.conversationId) return; try { await this.api.stopConversation(this.conversationId); } catch (error) { this.ui.renderError(`Stop was not confirmed: ${error.message}`); } } /** * reset deletes the selected conversation permanently, with all * its messages and tool execution records, and starts fresh. The * two-press confirmation in the UI is the only guard. */ async reset() { this.tts.stop(); const id = this.conversationId; await this.detachConversation(); if (id) { try { await this.api.deleteConversation(id); } catch (error) { // The conversation survives on the server, but the local // selection is still cleared. this.ui.renderError(`Failed to delete conversation: ${error.message}`); } } this.clearLocalConversation(); } /** * clearLocalConversation drops every local trace of the selected * conversation: the cached messages, the drafts, the storage, and * the chat view. It sends no request. reset and the picker's * delete call it once the conversation is gone on the server. */ clearLocalConversation() { this.messages.clear(); this.drafts.clear(); this.storage.clearConversation(); this.totalTokens = 0; this.lastVoice = ''; this.ui.setAutoScroll(false); this.ui.clearChat(); this.ui.setPendingApprovals([]); this.ui.renderSystemDefault(this.storage.getSystemMessage()); this.ui.updateContext(0, null); this.updateSendEnabled(); } /** * detachConversation unselects the conversation, aborts any open * sync or inference stream, and waits for an in-flight run to * finish its cleanup. * * It does not stop the run on the server; it only ends local * stream delivery. * * conversationId is cleared before the aborts so that the ending * streams do not persist the cache, open a new sync stream, or * fetch approvals for the old conversation. Awaiting currentRun * lets run's finally block clear busy and drafts before the caller * clears local state or loads another conversation. */ async detachConversation() { this.conversationId = null; this.syncController?.abort(); this.runController?.abort(); if (this.currentRun) { try { await this.currentRun; } catch {} } } // ==================== // CONVERSATIONS // ==================== /** * loadConversations fetches the list for the picker and renders * it. The server sends the rows most recently updated first, so * the picker shows them in that order without sorting. */ async loadConversations() { try { this.conversations = await this.api.listConversations(); } catch (error) { this.ui.renderError(`Failed to list conversations: ${error.message}`); this.ui.closeConversations(); return; } this.ui.renderConversationList(this.conversations, { currentId: this.conversationId, }); } /** * filterConversations renders the conversations whose label * matches the search text. The label is the title, or the * creation date when the conversation has none. * @param {string} query */ filterConversations(query) { const needle = query.trim().toLowerCase(); const rows = needle ? this.conversations.filter((conversation) => conversationLabel(conversation).toLowerCase().includes(needle), ) : this.conversations; this.ui.renderConversationList(rows, { currentId: this.conversationId, }); } /** * switchConversation selects another conversation. The current * one stays on the server; only the local view changes. * * The target is read first, so a conversation that another client * deleted does not replace the one in view. * * A run in progress is detached, not stopped: the server keeps an * accepted inference running after the client disconnects, and a * later switch back re-attaches to it and replays what it saved. * * @param {string} id */ async switchConversation(id) { this.ui.closeConversations(); if (id === this.conversationId) return; try { await this.api.getConversation(id); } catch (error) { this.ui.renderError(`Failed to open conversation: ${error.message}`); return; } this.tts.stop(); // End delivery of the current streams. An accepted inference // keeps running on the server; see the comment above. this.syncController?.abort(); if (this.busy) { this.runController?.abort(); if (this.currentRun) { try { await this.currentRun; } catch {} } } // Drop the local state of the old selection. resync reads and // renders the whole history of the new selection and caches it. this.messages.clear(); this.clearDrafts(); this.totalTokens = 0; this.lastVoice = ''; this.storage.clearConversation(); this.ui.setAutoScroll(false); this.ui.clearChat(); this.ui.setPendingApprovals([]); this.ui.updateContext(0, null); this.conversationId = id; await this.resync(); this.updateSendEnabled(); } /** * renameConversation asks the user for a new title, saves it, and * re-renders the picker with the current search filter. The saved * row comes from the server, so the list shows exactly what was * stored. * @param {string} id */ async renameConversation(id) { const row = this.conversations.find((c) => c.id === id); if (!row) return; const title = await this.ui.editConversationTitle(row.title ?? ''); if (title == null) return; try { const saved = await this.api.renameConversation(id, title); row.title = saved.title; } catch (error) { this.ui.renderError(`Failed to rename conversation: ${error.message}`); return; } this.filterConversations(this.conversationQuery); } /** * deleteConversation removes one conversation from the server and * from the picker. The two-press confirmation in the UI is the * only guard. Deleting the conversation in view also clears the * local state, like reset does. * @param {string} id */ async deleteConversation(id) { try { await this.api.deleteConversation(id); } catch (error) { this.ui.renderError(`Failed to delete conversation: ${error.message}`); return; } this.conversations = this.conversations.filter((c) => c.id !== id); if (id === this.conversationId) { // The server-side delete cancels an ongoing inference. this.tts.stop(); await this.detachConversation(); this.clearLocalConversation(); } this.filterConversations(this.conversationQuery); } /** * newConversation closes the picker and clears the view for a * new conversation. It sends no request: send() creates the * conversation when the next message goes out, so an abandoned * click leaves no empty conversation on the server. A run in * flight on the old conversation keeps running there. */ async newConversation() { this.ui.closeConversations(); if (!this.conversationId) return; this.tts.stop(); await this.detachConversation(); this.clearLocalConversation(); } // ==================== // SYNC // ==================== /** * sync subscribes with the cached rows as its manifest. The * server sends what changed since they were saved, then the * events of a run in progress, ending with done. Without a run * in progress, the stream ends after the diff: the cache is then * in step with the saved history until the next change. * * Re-subscribing is the reconnect path: it replays what a lost * stream missed and re-attaches to a run still in progress. */ async sync() { if (!this.conversationId || this.busy) return; const controller = new AbortController(); this.syncController?.abort(); this.syncController = controller; try { const manifest = this.orderedRows().map(({ id, updated_at }) => ({ id, updated_at, })); for await (const { type, data } of this.api.subscribe( this.conversationId, manifest, controller.signal, )) { if (this.handleEvent(type, data)) break; } if (!this.isOrdered()) await this.resync(); } catch (error) { if (error instanceof ApiError && error.status === 404) { this.ui.renderError('This conversation was deleted.'); await this.clearSelection(); return; } if (error.name !== 'AbortError') { // Keep the cache; the next sync retries. console.error('sync failed:', error); } } finally { if (this.syncController === controller) this.syncController = null; } if (this.conversationId && !this.busy) { await this.updateApprovals(); } } /** * resync replaces the whole cache from the saved history. A * structural change can shift rows the sync diff does not * re-send, because their updated_at did not change; a full read * restores the saved order. */ async resync() { if (!this.conversationId || this.busy) return; const controller = new AbortController(); this.syncController?.abort(); this.syncController = controller; this.messages.clear(); this.drafts.clear(); try { for await (const { type, data } of this.api.subscribe( this.conversationId, [], controller.signal, )) { if (type === 'message_saved') { this.cacheRow(data.message, '', false); } else if (this.handleEvent(type, data)) { break; } } } catch (error) { if (error.name !== 'AbortError') { console.error('resync failed:', error); } } finally { if (this.syncController === controller) this.syncController = null; this.persistCache(); } this.ui.renderConversation(this.orderedRows()); if (this.conversationId && !this.busy) { await this.updateApprovals(); } } /** * clearSelection drops the local selection without a server * request. Used when the server reports the conversation gone. */ async clearSelection() { this.messages.clear(); this.drafts.clear(); this.storage.clearConversation(); this.conversationId = null; this.totalTokens = 0; this.ui.setAutoScroll(false); this.ui.clearChat(); this.ui.setPendingApprovals([]); this.ui.renderSystemDefault(this.storage.getSystemMessage()); this.ui.updateContext(0, null); this.updateSendEnabled(); } // ==================== // TOOL APPROVALS // ==================== /** * updateApprovals shows the approve and deny buttons on the tool * calls of this conversation that wait for a decision. Other * conversations, such as job runs, are not shown here. */ async updateApprovals() { if (!this.conversationId) { this.ui.setPendingApprovals([]); return; } try { const pending = await this.api.listPendingExecutions(); // Map each row to the fields the UI matches on; the rows // arrive in the server's snake_case. this.ui.setPendingApprovals( pending .filter((execution) => this.messages.has(execution.message_id)) .map((execution) => ({ executionId: execution.id, messageId: execution.message_id, toolCallId: execution.tool_call_id, })), ); } catch (error) { console.error('failed to list pending executions:', error); } } /** * approve records an approval and continues the run without new * input; the next inference executes the approved call. * @param {{executionId: string}} execution */ async approve({ executionId }) { if (this.busy || !this.conversationId) return; try { await this.api.decideExecution(executionId, 'approved'); } catch (error) { this.ui.renderError(`Approval failed: ${error.message}`); await this.updateApprovals(); return; } await this.send({ text: '', voice: this.lastVoice }); } /** * deny records a denial and answers the call with a denial note, * so the conversation can continue. The decision alone would * leave the call without a result, and every later run would * refuse the batch with 'will not retry'. The note inserts a * message, so the cache is replaced from the saved order before * the run continues. * @param {{executionId: string, messageId: string, toolCallId: string}} execution */ async deny({ executionId, messageId, toolCallId }) { if (this.busy || !this.conversationId) return; const assistant = this.messages.get(messageId); if (!assistant) return; try { await this.api.decideExecution(executionId, 'denied'); } catch (error) { this.ui.renderError(`Denial failed: ${error.message}`); await this.updateApprovals(); return; } // Hide the buttons at once; a second press races the requests. this.ui.setPendingApprovals( this.ui.pendingApprovals.filter( (execution) => execution.executionId !== executionId, ), ); try { await this.api.insertMessage(this.conversationId, { message: { role: 'tool', tool_call_id: toolCallId, content: DENIAL_NOTE, }, message_id: messageId, updated_at: assistant.updated_at, }); } catch (error) { this.reportMutationError(error); return; } await this.resync(); await this.send({ text: '', voice: this.lastVoice }); } // ==================== // EDITS // ==================== /** * editTarget edits one bubble: a message's text or reasoning, a * tool call, or the system message. The system bubble of a saved * conversation edits the saved system row; the placeholder shown * without a conversation edits the default used to create the * next one. * @param {{id: string, field: string | null, toolCallId: string | null}} target */ async editTarget({ id, field, toolCallId }) { if (id === 'system') { const text = await this.ui.editText({ title: 'Edit System Message', value: this.storage.getSystemMessage(), }); if (text === null) return; const trimmed = text.trim(); this.storage.setSystemMessage(trimmed); this.ui.renderSystemDefault(trimmed); return; } const row = this.messages.get(id); if (!row) return; if (field === 'tool' && toolCallId) { await this.editToolCall(row, toolCallId); } else if (field === 'reasoning') { await this.editText(row, 'reasoning'); } else { await this.editText(row, 'text'); } } /** * editText edits the text or the reasoning of one saved message * and sends the whole updated message, with the row's updated_at * as the revision check. * @param {Object} row * @param {'text' | 'reasoning'} kind */ async editText(row, kind) { const message = structuredClone(row.content ?? { role: row.role }); const value = kind === 'reasoning' ? message.reasoning_content || '' : extractText(message.content); const edited = await this.ui.editText({ title: kind === 'reasoning' ? 'Edit Reasoning' : 'Edit Message', value, }); if (edited === null) return; if (kind === 'reasoning') { message.reasoning = ''; message.reasoning_content = edited; } else if (typeof message.content === 'string') { message.content = edited; } else if (Array.isArray(message.content)) { const part = message.content.find((p) => p.type === 'text'); if (part) { part.text = edited; } else { message.content.unshift({ type: 'text', text: edited }); } } else { message.content = edited; } await this.putMessage(row.id, { message, updated_at: row.updated_at }); } /** * editToolCall edits the name and arguments of one call, and the * text of its result when it has one. The call is updated first: * both requests check revisions of the assistant message, and * the call update changes its updated_at. * @param {Object} row - the assistant message row. * @param {string} callId */ async editToolCall(row, callId) { const call = row.content?.tool_calls?.find((c) => c.id === callId); if (!call) return; const resultRow = this.findResultRow(row, callId); const edited = await this.ui.editToolCall({ name: call.function?.name ?? '', arguments: call.function?.arguments ?? '', result: resultRow ? extractText(resultRow.content?.content) : '', }); if (edited === null) return; let assistant = row; if ( edited.name !== call.function?.name || edited.arguments !== call.function?.arguments ) { try { const saved = await this.api.updateToolCall( this.conversationId, row.id, callId, { call: { id: callId, type: 'function', function: { name: edited.name, arguments: edited.arguments }, }, updated_at: row.updated_at, }, ); this.cacheRow(saved, row.voice); assistant = this.messages.get(saved.id); } catch (error) { this.reportMutationError(error); return; } } if (!resultRow && edited.result) { // A new result is an inserted message; replace the cache // from the saved order before the display renders it. try { await this.api.insertMessage(this.conversationId, { message: { role: 'tool', tool_call_id: callId, content: edited.result, }, message_id: assistant.id, updated_at: assistant.updated_at, }); } catch (error) { this.reportMutationError(error); return; } await this.resync(); return; } if ( resultRow && edited.result !== extractText(resultRow.content?.content) ) { try { const saved = await this.api.updateMessage( this.conversationId, assistant.id, { message: { role: 'tool', tool_call_id: callId, content: edited.result, }, // A tool result update checks the result row's // revision, with the assistant message as its address. updated_at: resultRow.updated_at, }, ); this.cacheRow(saved, resultRow.voice); } catch (error) { this.reportMutationError(error); return; } } this.ui.renderConversation(this.orderedRows()); } /** * deleteTarget deletes one bubble: a saved message, a tool call * with its result, or the reasoning of a saved message. An empty * message left by a reasoning delete is deleted with its row. * @param {{id: string, field: string | null, toolCallId: string | null}} target */ async deleteTarget({ id, field, toolCallId }) { const row = this.messages.get(id); if (!row) return; if (field === 'tool' && toolCallId) { // The server removes the call, its result, and its // execution record together, and rewrites the assistant row; // replace the cache from the saved order. try { await this.api.deleteToolCall( this.conversationId, id, toolCallId, row.updated_at, ); } catch (error) { this.reportMutationError(error); return; } await this.resync(); return; } if (field === 'reasoning') { const message = structuredClone(row.content ?? { role: row.role }); message.reasoning = ''; message.reasoning_content = ''; const hasText = Boolean(extractText(message.content) || message.refusal); const hasCalls = (message.tool_calls ?? []).length > 0; if (hasText || hasCalls) { await this.putMessage(row.id, { message, updated_at: row.updated_at }); } else { try { await this.api.deleteMessage(this.conversationId, id, row.updated_at); } catch (error) { this.reportMutationError(error); return; } await this.resync(); } return; } try { await this.api.deleteMessage(this.conversationId, id, row.updated_at); } catch (error) { this.reportMutationError(error); return; } await this.resync(); } /** * putMessage updates one saved message and re-renders the chat * from the cache. An update does not shift rows, so no resync is * needed. * @param {string} mid * @param {Object} body - message and updated_at. */ async putMessage(mid, body) { try { const saved = await this.api.updateMessage( this.conversationId, mid, body, ); const previous = this.messages.get(saved.id); this.cacheRow(saved, previous?.voice); this.ui.renderConversation(this.orderedRows()); } catch (error) { this.reportMutationError(error); } } /** * reportMutationError explains a failed edit and recovers what * the server knows: a conflict means the row changed elsewhere, * a missing row means the message is gone. * @param {Error} error */ async reportMutationError(error) { if (error instanceof ApiError && error.status === 409) { this.ui.renderError('The message changed elsewhere. Reloading it.'); await this.resync(); } else if (error instanceof ApiError && error.status === 404) { this.ui.renderError('The message is gone. Reloading the conversation.'); await this.resync(); } else { this.ui.renderError(`Edit failed: ${error.message}`); } } // ==================== // SPEECH // ==================== /** * play speaks one saved assistant message with the voice of its * run, or the selected voice when it has none. * @param {string} id - message row ID. */ play(id) { const row = this.messages.get(id); if (!row) return; const text = extractText(row.content?.content); if (this.tts.currentMessageId === id) { this.tts.stop(); return; } this.tts.stop(); this.tts.enqueue(text, row.voice || this.voice, id); } /** toggleMute silences or restores speech playback. */ toggleMute() { this.isMuted = !this.isMuted; this.storage.setMuted(this.isMuted); this.ui.setMuted(this.isMuted); this.tts.setMuted(this.isMuted); } /** * handlePushToTalkStart records audio while the button or the * space bar is held. */ async handlePushToTalkStart() { if (this.isRecording || this.isTranscribing || this.busy) return; this.tts.stop(); this.isRecording = true; this.ui.setRecording(true); // Keep the screen on while the user speaks. this.wakeLock.acquire(); try { await this.stt.acquire(); // The user may have released while acquire was pending, for // example during the permission dialog. if (!this.isRecording) return; this.stt.start(); } catch (error) { if (!this.isRecording) return; this.isRecording = false; this.ui.setRecording(false); this.wakeLock.release(); this.ui.renderError(`Microphone access denied: ${error.message}`); } } /** handlePushToTalkEnd sends the recorded audio as one turn. */ async handlePushToTalkEnd() { if (!this.isRecording) return; // Clear the flag first, so a pending acquire bails out. this.isRecording = false; this.ui.setRecording(false); this.wakeLock.release(); try { const { text, detectedLanguage } = await this.stt.stop(); if (!text) return; const existing = this.ui.takeComposeText().trim(); const combined = existing ? `${existing}\n${text}` : text; await this.send({ text: combined, voice: this.resolveVoice(detectedLanguage), }); } catch (error) { console.error('transcription failed:', error); this.ui.renderError(`Transcription failed: ${error.message}`); } } /** * handleTranscribeStart records audio that will be transcribed * into the compose field, not sent. */ async handleTranscribeStart() { if (this.isRecording || this.isTranscribing || this.busy) return; this.tts.stop(); this.isTranscribing = true; this.ui.setTranscribing(true); this.wakeLock.acquire(); try { await this.stt.acquire(); if (!this.isTranscribing) return; this.stt.start(); } catch (error) { if (!this.isTranscribing) return; this.isTranscribing = false; this.ui.setTranscribing(false); this.wakeLock.release(); this.ui.renderError(`Microphone access denied: ${error.message}`); } } /** handleTranscribeEnd puts the transcription into the field. */ async handleTranscribeEnd() { if (!this.isTranscribing) return; this.isTranscribing = false; this.ui.setTranscribing(false); this.wakeLock.release(); try { const { text } = await this.stt.stop(); if (text) this.ui.appendComposeText(text); this.ui.focusCompose(); this.updateSendEnabled(); } catch (error) { console.error('transcription failed:', error); this.ui.renderError(`Transcription failed: ${error.message}`); } } // ==================== // INPUT // ==================== /** * updateSendEnabled enables send when there is input to send or * saved history to continue. */ updateSendEnabled() { const hasInput = this.ui.getComposeText().trim().length > 0 || this.attachments.length > 0 || this.locationData !== null; this.ui.setSendEnabled(hasInput || this.messages.size > 0); } /** * handleAttachments adds files, skipping files already attached. * @param {FileList} files */ handleAttachments(files) { for (const file of files) { const duplicate = this.attachments.some( (attached) => attached.name === file.name && attached.size === file.size && attached.lastModified === file.lastModified, ); if (!duplicate) this.attachments.push(file); } this.renderAttachments(); this.updateSendEnabled(); } /** renderAttachments redraws the attachment chips. */ renderAttachments() { this.ui.renderAttachments(this.attachments, (index) => { this.attachments.splice(index, 1); this.renderAttachments(); this.updateSendEnabled(); }); } /** toggleLocation includes or drops the GPS location. */ toggleLocation() { if (this.locationData) { this.locationData = null; this.ui.setLocationActive(false); this.updateSendEnabled(); return; } if (!navigator.geolocation) return; navigator.geolocation.getCurrentPosition( (position) => { if (this.busy) return; this.locationData = position; this.ui.setLocationActive(true); this.updateSendEnabled(); }, () => {}, GEOLOCATION_OPTIONS, ); } // ==================== // MODELS AND VOICES // ==================== async loadModels() { try { const data = await this.api.getModels(); this.ui.populateModels(data, this.storage.getModelSelection()); this.ui.updateContext( this.totalTokens, this.ui.getSelectedModel().contextSize, ); } catch (error) { console.error('failed to load models:', error); this.ui.populateModels( { providers: {}, default_provider: '', default_model: '' }, {}, ); } } async loadVoices() { try { const data = await this.api.getVoices( document.querySelector('meta[name="tts-url"]')?.content || '', ); // Filter out legacy v0 voices. const voices = (data.voices ?? []) .map((voice) => (typeof voice === 'string' ? voice : voice.id)) .filter((id) => !id.includes('_v0')); this.ui.populateVoices(voices, this.storage.getVoice()); } catch (error) { console.error('failed to load voices:', error); this.ui.populateVoices([], ''); } } } new Odidere({ document });