diff --git a/internal/service/static/css/main.css b/internal/service/static/css/main.css index c362e28..b3639cb 100644 --- a/internal/service/static/css/main.css +++ b/internal/service/static/css/main.css @@ -1218,6 +1218,54 @@ body { word-break: break-word; } +/* Approve and deny buttons, shown while a tool call waits for a + user decision. */ +.message__approval { + display: flex; + gap: var(--s-2); + padding: 0 var(--s-1) var(--s-1); +} + +.message__approval[hidden] { + display: none; +} + +.message__approval-btn { + flex: 1; + padding: 0.25rem var(--s-1); + font-size: var(--s-1); + font-weight: 500; + color: var(--color-text); + background: var(--color-surface); + border: var(--border-width) solid var(--color-border); + border-radius: var(--radius); + cursor: pointer; +} + +.message__approval-btn--approve { + color: white; + background: var(--color-green); + border-color: var(--color-green); +} + +.message__approval-btn--deny { + color: var(--color-red); + border-color: var(--color-red); +} + +@media (hover: hover) { + .message__approval-btn--approve:hover { + background: var(--color-green); + filter: brightness(1.1); + } + + .message__approval-btn--deny:hover { + color: white; + background: var(--color-red); + border-color: var(--color-red); + } +} + .message--system .message__icon { color: var(--color-text-muted); } diff --git a/internal/service/static/js/api.js b/internal/service/static/js/api.js new file mode 100644 index 0000000..428b6aa --- /dev/null +++ b/internal/service/static/js/api.js @@ -0,0 +1,365 @@ +/** + * API sends the odidere HTTP requests and decodes the server's + * Server-Sent Events (SSE) streams. It is the only module that opens + * network connections. It never reads the DOM and never touches + * localStorage: callers keep what it returns and render it elsewhere. + * + * The two streaming methods, inference and subscribe, are async + * generators. Each yielded value is one decoded SSE event: + * { type, data }. The server terminates a stream with a 'done' + * event. A generator that ends without 'done' reports that delivery + * stopped, not that the work stopped: the server keeps an accepted + * inference running after the client disconnects, and a new + * subscribe call re-attaches to it and replays what was saved. + * + * Every failed request throws ApiError, which carries the HTTP + * status. Status 409 reports either that the conversation is already + * running inference, or that a message changed since the caller last + * read it; the caller should re-sync before it retries. + */ + +export class ApiError extends Error { + /** + * @param {number} status - HTTP status code. + * @param {string} body - response body, often empty. + */ + constructor(status, body) { + super(`HTTP ${status}${body ? `: ${body}` : ''}`); + this.name = 'ApiError'; + this.status = status; + this.body = body; + } +} + +export class Api { + // --- Models and voices --- + + /** + * getModels lists the models of every configured provider, each + * with an availability status, and the server's default selection. + * @returns {Promise<{providers: Object, default_provider: string, default_model: string}>} + */ + async getModels() { + return this._json('/models'); + } + + /** + * getVoices lists the voices of the TTS server. The TTS server is + * a separate service, not part of the odidere API. + * @param {string} url - TTS server base URL. + * @returns {Promise} + */ + async getVoices(url) { + return this._json(`${url}/v1/audio/voices`); + } + + // --- Conversations --- + + /** + * createConversation saves a new conversation with its initial + * system message. An empty systemMessage makes the server use its + * configured default. + * @param {{title?: string, systemMessage?: string}} [body] + * @returns {Promise} the saved conversation row. + */ + async createConversation({ title, systemMessage } = {}) { + const body = {}; + if (title !== undefined) body.title = title; + if (systemMessage !== undefined) body.system_message = systemMessage; + return this._json('/conversations', { method: 'POST', body }); + } + + /** + * deleteConversation removes the conversation, all its messages, + * and its tool execution records. It also cancels an ongoing + * inference. This cannot be undone. + * @param {string} id + */ + async deleteConversation(id) { + await this._request(`/conversations/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + } + + /** + * stopConversation asks the server to cancel the conversation's + * ongoing inference. The response acknowledges the request, not + * completed cancellation: the events that follow report the + * outcome of the work that already finished. + * @param {string} id + */ + async stopConversation(id) { + await this._request(`/conversations/${encodeURIComponent(id)}/stop`, { + method: 'POST', + }); + } + + // --- Streaming --- + + /** + * inference starts one inference run and yields its events. The + * optional message is new user input; without it the run continues + * from saved history, which is how a run resumes after a tool + * approval or denial. + * + * The server rejects the request with status 409 while another run + * is reserved or ongoing. + * + * @param {string} id - conversation ID. + * @param {Object} body - request fields; message, provider, model, + * max_iterations, thinking_budget_tokens, stream. + * @param {AbortSignal} [signal] - ends delivery, not the run. + */ + async *inference(id, body, signal) { + yield* this._stream( + `/conversations/${encodeURIComponent(id)}/inference`, + body, + signal, + ); + } + + /** + * subscribe synchronizes the caller's message cache with the saved + * conversation and yields the events that follow. + * + * manifest lists the message IDs and timestamps the caller already + * holds. The first events are a diff: one message_saved per row + * that is missing or newer than the manifest, then one + * message_deleted per manifest ID that is no longer saved. An + * empty manifest requests every saved row. + * + * When an inference is reserved or ongoing, the stream then follows + * its events and ends with done. When none is, the stream ends + * after the diff without done. + * + * @param {string} id - conversation ID. + * @param {Array<{id: string, updated_at: string}>} manifest + * @param {AbortSignal} [signal] + */ + async *subscribe(id, manifest, signal) { + yield* this._stream( + `/conversations/${encodeURIComponent(id)}/subscribe`, + { manifest }, + signal, + ); + } + + // --- Message edits --- + + /** + * insertMessage saves a new message. For a user or assistant + * message, pass message only. For a tool result, pass the tool + * message and message_id and updated_at of its parent assistant + * message: message_id names the assistant, updated_at is the + * assistant's timestamp from the caller's last read. + * @param {string} id - conversation ID. + * @param {Object} body - message, and for tool results + * message_id and updated_at. + * @returns {Promise} the saved message row. + */ + async insertMessage(id, body) { + return this._json(`/conversations/${encodeURIComponent(id)}/messages`, { + method: 'POST', + body, + }); + } + + /** + * updateMessage replaces the model-visible content of one saved + * message. The role may not change. updated_at is the row's + * timestamp from the caller's last read; a mismatch returns 409. + * For a tool result, mid identifies the parent assistant message + * and updated_at is the tool result's timestamp. + * @param {string} id - conversation ID. + * @param {string} mid - message ID. + * @param {Object} body - message and updated_at. + * @returns {Promise} the saved message row. + */ + async updateMessage(id, mid, body) { + return this._json( + `/conversations/${encodeURIComponent(id)}` + + `/messages/${encodeURIComponent(mid)}`, + { method: 'PUT', body }, + ); + } + + /** + * deleteMessage removes one saved message. updated_at is the + * row's timestamp from the caller's last read; a mismatch returns + * 409. + * @param {string} id - conversation ID. + * @param {string} mid - message ID. + * @param {string} updatedAt + */ + async deleteMessage(id, mid, updatedAt) { + await this._request( + `/conversations/${encodeURIComponent(id)}` + + `/messages/${encodeURIComponent(mid)}`, + { method: 'DELETE', body: { updated_at: updatedAt } }, + ); + } + + /** + * updateToolCall replaces one call of a saved assistant message. + * The replacement must keep the call ID: the execution record and + * any saved result are keyed by it. updated_at is the assistant + * message's timestamp from the caller's last read. + * @param {string} id - conversation ID. + * @param {string} mid - assistant message ID. + * @param {string} cid - tool call ID. + * @param {Object} body - call and updated_at. + * @returns {Promise} the saved assistant message row. + */ + async updateToolCall(id, mid, cid, body) { + return this._json( + `/conversations/${encodeURIComponent(id)}` + + `/messages/${encodeURIComponent(mid)}` + + `/calls/${encodeURIComponent(cid)}`, + { method: 'PUT', body }, + ); + } + + /** + * deleteToolCall removes the call, its saved result, and its + * execution record together. updated_at is the assistant message's + * timestamp from the caller's last read. + * @param {string} id - conversation ID. + * @param {string} mid - assistant message ID. + * @param {string} cid - tool call ID. + * @param {string} updatedAt + */ + async deleteToolCall(id, mid, cid, updatedAt) { + await this._request( + `/conversations/${encodeURIComponent(id)}` + + `/messages/${encodeURIComponent(mid)}` + + `/calls/${encodeURIComponent(cid)}`, + { method: 'DELETE', body: { updated_at: updatedAt } }, + ); + } + + // --- Tool approvals --- + + /** + * listPendingExecutions lists every tool call that waits for a + * user decision, across all conversations. Filter the result to + * the conversation in view before showing it. + * @returns {Promise>} execution rows with status + * 'pending'. + */ + async listPendingExecutions() { + return this._json('/executions'); + } + + /** + * decideExecution records the user's decision for one tool call. + * An approved call runs on the conversation's next inference; a + * denied call does not. The status transition is refused once the + * row has left 'pending'. + * @param {string} executionId + * @param {'approved' | 'denied'} status + */ + async decideExecution(executionId, status) { + await this._request(`/executions/${encodeURIComponent(executionId)}`, { + method: 'PUT', + body: { status }, + }); + } + + // --- Private --- + + /** + * _request sends one request and checks the response. It returns + * the open Response and throws ApiError when the server rejects + * the request. Signals from the caller propagate as AbortError. + * @private + */ + async _request(path, { method = 'GET', body, signal } = {}) { + const res = await fetch(path, { + method, + signal, + headers: + body !== undefined ? { 'Content-Type': 'application/json' } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + throw new ApiError(res.status, (await res.text().catch(() => '')).trim()); + } + return res; + } + + /** + * _json sends one request and parses its JSON body. A response + * with no body (204 No Content) resolves to null. + * @private + */ + async _json(path, options) { + const res = await this._request(path, options); + return res.status === 204 ? null : res.json(); + } + + /** + * _stream sends one POST request and yields the response's decoded + * SSE events. See readEvents. + * @private + */ + async *_stream(path, body, signal) { + const res = await this._request(path, { + method: 'POST', + body, + signal, + }); + yield* readEvents(res); + } +} + +/** + * readEvents decodes one SSE response into { type, data } events. + * It buffers across network chunk boundaries, accepts LF and CRLF + * line endings, joins multi-line data fields, and skips comment + * lines. Events without an event field get type 'message'. A line + * with invalid JSON is logged and skipped; the stream continues. + * @param {Response} response + */ +export async function* readEvents(response) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let type = 'message'; + let data = []; + try { + while (true) { + const result = await reader.read(); + buffer += decoder.decode(result.value, { stream: !result.done }); + while (true) { + const newline = buffer.indexOf('\n'); + if (newline === -1) break; + const line = buffer.slice(0, newline).replace(/\r$/, ''); + buffer = buffer.slice(newline + 1); + if (line === '') { + if (data.length) { + const text = data.join('\n'); + try { + yield { type, data: JSON.parse(text) }; + } catch (error) { + console.error('failed to parse SSE data:', text, error); + } + } + type = 'message'; + data = []; + continue; + } + if (line.startsWith(':')) continue; + const colon = line.indexOf(':'); + const field = colon === -1 ? line : line.slice(0, colon); + const value = + colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, ''); + if (field === 'event') type = value; + if (field === 'data') data.push(value); + } + if (result.done) break; + } + } finally { + await reader.cancel().catch(() => { }); + reader.releaseLock(); + } +} diff --git a/internal/service/static/js/conversation.js b/internal/service/static/js/conversation.js deleted file mode 100644 index 6b44674..0000000 --- a/internal/service/static/js/conversation.js +++ /dev/null @@ -1,273 +0,0 @@ -/** - * 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/js/edit.js b/internal/service/static/js/edit.js deleted file mode 100644 index 3a8740a..0000000 --- a/internal/service/static/js/edit.js +++ /dev/null @@ -1,213 +0,0 @@ -/** - * 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/js/main.js b/internal/service/static/js/main.js index ec7ed17..437069c 100644 --- a/internal/service/static/js/main.js +++ b/internal/service/static/js/main.js @@ -1,35 +1,35 @@ /** - * 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). + * 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 { ConversationTree, extractText } from './conversation.js'; -import { EditModal } from './edit.js'; -import { Renderer } from './render.js'; -import { Settings } from './settings.js'; +import { Api, ApiError } from './api.js'; +import { Storage } from './storage.js'; import { STT, VOICE_MAP } from './stt.js'; import { TTSPlayer } from './tts.js'; +import { extractText, UI } from './ui.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 REASONING_BUDGET_KEY = 'odidere_reasoning_budget'; -const MUTE_KEY = 'odidere_mute'; -const ICONS_URL = '/static/icons.svg'; +// 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.'; -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 = { +// 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, @@ -37,10 +37,55 @@ const GeolocationOptions = { class Odidere { constructor({ document }) { - this.document = 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), + 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(), + }, + }); - // --- Create subsystems --- - this.conversation = new ConversationTree(); this.stt = new STT({ url: document.querySelector('meta[name="stt-url"]')?.content || '', }); @@ -49,923 +94,537 @@ class Odidere { 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), + // The playback buttons follow the TTS queue. + this.tts.addEventListener('playbackstart', (event) => + this.ui.setPlayButton(event.detail.messageId, true), ); - this.tts.addEventListener('playbackend', (e) => - this.renderer.setPlayButton(e.detail.messageId, false), + this.tts.addEventListener('playbackend', (event) => + this.ui.setPlayButton(event.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 + // --- In-memory state --- + this.conversationId = null; + 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(); - // --- 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.$reasoningBudgetInput = document.getElementById( - 'reasoning-budget-input', - ); - this.$compose = document.querySelector('.compose'); + // 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(); + this.init().catch((error) => + this.ui.renderError(`Startup failed: ${error.message}`), + ); } // ==================== // 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()); + 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()); } - 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(); + 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.currentController?.abort(); + 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 + // ==================== + /** - * 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(). + * 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} */ - 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(); + orderedRows() { + return [...this.messages.values()]; } - // ==================== - // EVENT BINDING - // ==================== + /** + * 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; + } - 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(); + /** + * 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 || '', }); - this.document.addEventListener('keydown', (e) => { - if ( - e.key === 'Escape' && - this.$settingsOverlay.classList.contains('open') - ) { - e.preventDefault(); - this.settings.close(); + 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; } - }); - this.$settingsTabs.forEach(($tab) => { - $tab.addEventListener('click', () => - this.settings.switchTab($tab.dataset.tab), - ); - }); - this.$saveSystemMessage.addEventListener('click', () => - this.settings.saveSystemMessage(), - ); + if (!after) continue; + if (candidate.role === 'assistant') break; + if ( + candidate.role === 'tool' && + callIds.has(candidate.content?.tool_call_id) + ) { + results.push(candidate); + } + } + return results; + } - // --- Compose settings --- - this.$composeSettingsClose.addEventListener('click', () => - this._closeComposeSettings(), - ); - this.$composeSettingsOverlay.addEventListener('click', (e) => { - if (e.target === this.$composeSettingsOverlay) - this._closeComposeSettings(); - }); + /** + * 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; + } - // --- 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, - ), - ); + /** + * 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; } // ==================== - // CLICK DELEGATION + // EVENT HANDLING // ==================== - _handleClick(event) { - const $target = event.target.closest('[data-action]'); - const action = $target?.dataset.action; + /** + * 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; + } + } - // Click outside any action button: cancel all pending states. - if (!action) { - this._cancelAllPending(); + /** + * 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; } - - // 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; + if (existed || wasDraft) { + if (wasDraft) { + this.ui.replaceDraftWithSaved(row.id, row, this.resultsFor(row)); } else { - event.preventDefault(); - event.stopPropagation(); + this.ui.renderConversation(this.orderedRows()); } - 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.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.$textInput.value = transcription; + this.ui.scrollToLastUserMessage(); } - 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() }); + 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); + } + } } /** - * 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] + * 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. */ - 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, + applyChunk(draftId, chunk) { + let draft = this.drafts.get(draftId); + if (!draft) { + draft = { + id: draftId, + reasoning: '', + text: '', + refusal: '', + toolCalls: [], }; - 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, - ); + 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; } - - 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; - } - - const reasoningBudget = this._getReasoningBudget(); - if (reasoningBudget !== null) { - payload.thinking_budget_tokens = reasoningBudget; - } - - // 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; + 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 { - await this._currentRun; + 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) { + 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 { - 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 }; + 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(); } } } - // ==================== - // BUILD USER MESSAGE - // ==================== - - async _buildUserMessage(text, attachments) { + /** + * 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 attachments) { + for (const file of this.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 }, - filename: file.name, + type: 'image_url', + image_url: { url: `data:${file.type};base64,${base64}` }, }); } else { const content = await file.text(); @@ -973,516 +632,774 @@ class Odidere { } } - if (text) { - textParts.push(text); - } - + if (text) textParts.push(text); if (this.locationData) { textParts.push(JSON.stringify(this.locationData, null, 2)); } - - const combinedText = textParts.join('\n\n'); + const combined = textParts.join('\n\n'); if (imageParts.length === 0) { - return { - id: crypto.randomUUID(), - role: 'user', - content: combinedText || '', - }; + return { role: 'user', content: combined }; } - const parts = []; - if (combinedText) { - parts.push({ type: ContentTypeText, text: combinedText }); - } + if (combined) parts.push({ type: 'text', text: combined }); parts.push(...imageParts); + return { role: 'user', content: parts }; + } - return { - id: crypto.randomUUID(), - 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] ?? '') : ''; } // ==================== - // VOICE RESOLUTION + // STOP AND RESET // ==================== - resolveVoice(detectedLanguage) { - let voice = this.settings.getVoice(); - if (!voice && detectedLanguage) { - voice = VOICE_MAP[detectedLanguage] || ''; + /** + * 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}`); } - return voice; + } + + /** + * 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; + // Clear the selection first, so the aborted run's cleanup does + // not open a new subscription to a conversation being deleted. + this.conversationId = null; + this.syncController?.abort(); + this.runController?.abort(); + if (this.currentRun) { + try { + await this.currentRun; + } catch { } + } + 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.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(); } // ==================== - // STATE + // SYNC // ==================== - 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', + /** + * 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 }); } - 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'); + // ==================== + // 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; } - // 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; + + 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 { - this.updateSendButtonState(); + await this.editText(row, 'text'); } - if (loading) { - this.wakeLock.acquire(); + } + + /** + * 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 { - this.wakeLock.release(); + 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); } } - setRecordingState(recording) { - this.$ptt.classList.toggle('recording', recording); - this.$ptt.setAttribute('aria-pressed', String(recording)); + /** + * 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; - 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.storage.setMuted(this.isMuted); + this.ui.setMuted(this.isMuted); 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; + /** + * 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}`); } - this.$context.hidden = false; + } - this.$context.classList.remove( - 'footer__toolbar-context--warning', - 'footer__toolbar-context--critical', - ); + /** 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}`); + } + } - const tokenLabel = - this.totalTokens >= 1000 - ? `${(this.totalTokens / 1000).toFixed(1)}k tokens` - : `${this.totalTokens} tokens`; + /** + * 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}`); + } + } - 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 = ''; + /** 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}`); } } // ==================== - // ATTACHMENTS & LOCATION + // 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 isDuplicate = this.attachments.some( - (a) => - a.name === file.name && - a.size === file.size && - a.lastModified === file.lastModified, + const duplicate = this.attachments.some( + (attached) => + attached.name === file.name && + attached.size === file.size && + attached.lastModified === file.lastModified, ); - if (isDuplicate) continue; - this.attachments.push(file); + if (!duplicate) this.attachments.push(file); } - this._renderAttachments(); - this.updateSendButtonState(); - this.$fileInput.value = ''; + this.renderAttachments(); + this.updateSendEnabled(); } - _renderAttachments() { - this.renderer.renderAttachments(this.attachments, (i) => - this._removeAttachment(i), - ); + /** renderAttachments redraws the attachment chips. */ + renderAttachments() { + this.ui.renderAttachments(this.attachments, (index) => { + this.attachments.splice(index, 1); + this.renderAttachments(); + this.updateSendEnabled(); + }); } - _removeAttachment(index) { - this.attachments.splice(index, 1); - this._renderAttachments(); - this.updateSendButtonState(); - } - - clearAttachments() { - this.attachments = []; - this._renderAttachments(); - } - - handleLocationToggle() { + /** toggleLocation includes or drops the GPS location. */ + toggleLocation() { if (this.locationData) { - this.clearLocation(); - this.updateSendButtonState(); + 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.$location.classList.add('compose__action-btn--location', 'active'); - this.updateSendButtonState(); + this.ui.setLocationActive(true); + this.updateSendEnabled(); }, - () => {}, - GeolocationOptions, + () => { }, + GEOLOCATION_OPTIONS, ); } - clearLocation() { - this.locationData = null; - this.$location.classList.remove('compose__action-btn--location', 'active'); - } - // ==================== - // COMPOSE SETTINGS + // MODELS AND VOICES // ==================== - _openComposeSettings() { - this.$maxTurnsInput.value = this._getMaxTurns(); - this.$reasoningBudgetInput.value = this._getReasoningBudget() ?? -1; - this.$composeSettingsOverlay.classList.add('open'); - this.$composeSettingsClose.focus(); - } - - _closeComposeSettings() { - this._setMaxTurns(this.$maxTurnsInput.value); - this._setReasoningBudget(this.$reasoningBudgetInput.value); - 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)); - } - - _getReasoningBudget() { - const stored = localStorage.getItem(REASONING_BUDGET_KEY); - if (stored === null) return null; - return Number(stored); - } - - _setReasoningBudget(value) { - localStorage.setItem(REASONING_BUDGET_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"]`, + 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, ); - } else if (field === 'tool' && toolCallId) { - $msg = this.renderer.$chat.querySelector( - `[data-tool-call-id="${toolCallId}"]`, + } catch (error) { + console.error('failed to load models:', error); + this.ui.populateModels( + { providers: {}, default_provider: '', default_model: '' }, + {}, ); - } else { - $msg = this.renderer.$chat.querySelector(`[data-id="${messageId}"]`); } + } - if ($msg) { - const $btn = $msg.querySelector('[data-action="delete"]'); - if ($btn) $btn.classList.remove('pending'); + 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([], ''); } - 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 }); diff --git a/internal/service/static/js/render.js b/internal/service/static/js/render.js deleted file mode 100644 index 07d9c1d..0000000 --- a/internal/service/static/js/render.js +++ /dev/null @@ -1,826 +0,0 @@ -/** - * 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.$scrollDown = document.getElementById('scroll-down'); - this.$voice = document.getElementById('voice'); - - // Auto-scroll follow state. Following is opt-in, so it starts off; the - // user engages it via the scroll button. - this.isAutoScrollEnabled = false; - - // Templates - this.$tplAssistantMessage = document.getElementById( - 'tpl-assistant-message', - ); - this.$tplAttachmentChip = document.getElementById('tpl-attachment-chip'); - this.$tplImagePill = document.getElementById('tpl-image-pill'); - 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'); - this.$tplSystemMessage = document.getElementById('tpl-system-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; - - this._renderImagePills($msg, message); - - 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.innerHTML = this._renderMarkdown(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, requestId = '') { - const $msg = - this.$tplErrorMessage.content.cloneNode(true).firstElementChild; - const $content = $msg.querySelector('.message__content'); - if (requestId) { - $content.textContent = `Request: ${requestId}\n${message}`; - $msg.dataset.copyText = `Request: ${requestId}\n${message}`; - } else { - $content.textContent = message; - $msg.dataset.copyText = message; - } - this.$chat.insertBefore($msg, this.$chatLoading); - this.scrollToBottom(); - } - - // --- Scroll --- - - scrollToBottom() { - if (this.isAutoScrollEnabled) { - this.$chat.scrollTop = this.$chat.scrollHeight; - } - } - - // Anchor the last user message to the top of the chat viewport. Uses direct - // scrollTop math (not scrollIntoView) to avoid moving ancestors/footer on - // iOS. No-op if the message is already fully visible. - scrollToLastUserMessage() { - const messages = this.$chat.querySelectorAll('.message--user'); - const $last = messages[messages.length - 1]; - if (!$last) return; - - const { scrollTop, clientHeight } = this.$chat; - const top = $last.offsetTop - this.$chat.offsetTop; - const bottom = top + $last.offsetHeight; - const fullyVisible = top >= scrollTop && bottom <= scrollTop + clientHeight; - if (fullyVisible) return; - - this.$chat.scrollTop = top; - } - - // Icon and label are driven by follow state, not scroll position, so they - // never drift from isAutoScrollEnabled. - updateFollowIndicator() { - this.$scrollDown.classList.toggle('following', this.isAutoScrollEnabled); - } - - // Position-based, disable-only. A user scroll-up past the threshold stops - // following. Never re-enables (a programmatic scroll-to-bottom lands at ~0px, - // so it cannot false-trigger; being near the bottom cannot re-enable). - handleChatScroll() { - const { scrollHeight, scrollTop, clientHeight } = this.$chat; - if (scrollHeight - scrollTop - clientHeight > 100) { - this.setAutoScroll(false); - } - } - - // Set the flag and sync the icon so the two never drift. - setAutoScroll(enabled) { - this.isAutoScrollEnabled = enabled; - this.updateFollowIndicator(); - } - - clearChat() { - const $loading = this.$chatLoading; - this.$chat.innerHTML = ''; - this.$chat.appendChild($loading); - } - - // --- Private --- - - /** - * _renderMarkdown converts Markdown text to safe HTML. - * Uses marked for parsing and DOMPurify for sanitization. - * Falls back to plain text if libraries are unavailable. - * @param {string} text - * @returns {string} - */ - _renderMarkdown(text) { - if (!text) return ''; - try { - if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { - const html = marked.parse(text); - // Wrap tables in a scrollable container for mobile. - const container = document.createElement('div'); - container.innerHTML = html; - for (const table of container.querySelectorAll('table')) { - const wrapper = document.createElement('div'); - wrapper.className = 'message__table-scroll'; - table.replaceWith(wrapper); - wrapper.appendChild(table); - } - return DOMPurify.sanitize(container.innerHTML); - } - } catch (e) { - console.error('failed to render markdown:', e); - } - // Fallback: escape and return plain text - const div = document.createElement('div'); - div.textContent = text; - div.style.whiteSpace = 'pre-wrap'; - return div.innerHTML; - } - - _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; - - this._renderImagePills($msg, message); - - 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.innerHTML = this._renderMarkdown(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(); - } - - /** - * renderSystemMessage renders the system message block. - * @param {string} content - */ - renderSystemMessage(content) { - this._renderSystemMessage(content); - } - - /** - * reRenderSystemMessage replaces the system message block in the DOM. - * @param {string} content - */ - reRenderSystemMessage(content) { - const $old = this.$chat.querySelector('[data-id="system"]'); - if (!$old) return; - - const $msg = - this.$tplSystemMessage.content.cloneNode(true).firstElementChild; - $msg.dataset.id = 'system'; - - const $content = $msg.querySelector('.message__content'); - if (content?.trim()) { - $content.textContent = content; - $msg.dataset.copyText = content; - } else { - $content.innerHTML = - 'System Default'; - $msg.dataset.copyText = ''; - } - - $old.replaceWith($msg); - } - - _renderSystemMessage(content, _meta = null) { - const $msg = - this.$tplSystemMessage.content.cloneNode(true).firstElementChild; - $msg.dataset.id = 'system'; - - const $content = $msg.querySelector('.message__content'); - if (content?.trim()) { - $content.textContent = content; - $msg.dataset.copyText = content; - } else { - $content.innerHTML = - 'System Default'; - $msg.dataset.copyText = ''; - } - - this.$chat.insertBefore($msg, this.$chatLoading); - } - - _renderImagePills($msg, message) { - if (!Array.isArray(message.content)) return; - const imageParts = message.content.filter((p) => p.type === 'image_url'); - if (imageParts.length === 0) return; - - const $wrapper = document.createElement('div'); - $wrapper.className = 'message__image-pills'; - for (const part of imageParts) { - const $pill = this.$tplImagePill.content.cloneNode(true); - $pill.querySelector('.message__image-pill-name').textContent = - part.filename || 'Image'; - $wrapper.appendChild($pill.firstElementChild); - } - $msg - .querySelector('.message__body') - .insertBefore($wrapper, $msg.querySelector('.message__actions')); - } - - _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), - ); - } -} - -// Configure marked to use highlight.js for syntax highlighting. -// marked v8+ removed the highlight option from setOptions; use the -// marked-highlight extension instead. -if ( - typeof marked !== 'undefined' && - typeof markedHighlight !== 'undefined' && - typeof hljs !== 'undefined' -) { - marked.use( - markedHighlight.markedHighlight({ - langPrefix: 'hljs language-', - highlight(code, lang) { - const language = hljs.getLanguage(lang) ? lang : 'plaintext'; - return hljs.highlight(code, { language }).value; - }, - }), - ); -} diff --git a/internal/service/static/js/settings.js b/internal/service/static/js/settings.js deleted file mode 100644 index 31e2eae..0000000 --- a/internal/service/static/js/settings.js +++ /dev/null @@ -1,350 +0,0 @@ -/** - * 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/js/storage.js b/internal/service/static/js/storage.js new file mode 100644 index 0000000..3ac9a9b --- /dev/null +++ b/internal/service/static/js/storage.js @@ -0,0 +1,184 @@ +/** + * Storage reads and writes the browser's local cache and the saved + * client settings. It is the only module that touches localStorage. + * It does no rendering and sends no requests: main.js decides what + * to cache and when, and Storage only stores and returns it. + * + * Keys and their contents: + * - odidere_conversation: the selected conversation ID and its saved + * message rows, for offline viewing and a fast start. The server + * owns the history; this entry is a copy, replaced by every sync. + * - odidere_system_message: the system message used when the next + * conversation is created. It does not change a conversation that + * already exists. + * - odidere_provider, odidere_model, odidere_voice: selections. + * - odidere_max_turns, odidere_reasoning_budget: compose settings. + * - odidere_mute: whether speech playback is muted. + * + * The browser-only history that older versions kept under + * odidere_history is not read and is left untouched. + */ + +const CONVERSATION_KEY = 'odidere_conversation'; +const SYSTEM_MESSAGE_KEY = 'odidere_system_message'; +const PROVIDER_KEY = 'odidere_provider'; +const MODEL_KEY = 'odidere_model'; +const VOICE_KEY = 'odidere_voice'; +const MAX_TURNS_KEY = 'odidere_max_turns'; +const REASONING_BUDGET_KEY = 'odidere_reasoning_budget'; +const MUTE_KEY = 'odidere_mute'; + +export class Storage { + /** + * loadConversation returns the cached selection: the conversation + * ID and its message rows in saved order. Rows are stored under + * their message IDs; the array order is the saved order. + * @returns {{id: string, rows: Array} | null} + */ + loadConversation() { + try { + const cached = JSON.parse(localStorage.getItem(CONVERSATION_KEY)); + if (!cached?.id || !Array.isArray(cached.rows)) return null; + return { id: cached.id, rows: cached.rows }; + } catch (error) { + console.error('failed to load conversation cache:', error); + return null; + } + } + + /** + * saveConversation caches the selected conversation and its rows. + * The server owns the history; this is a local copy for offline + * viewing. Streaming drafts are never passed to this method. + * @param {string} id + * @param {Array} rows + */ + saveConversation(id, rows) { + try { + localStorage.setItem(CONVERSATION_KEY, JSON.stringify({ id, rows })); + } catch (error) { + console.error('failed to cache conversation:', error); + } + } + + /** + * clearConversation drops the cached selection. It does not + * change or delete the conversation on the server. + */ + clearConversation() { + try { + localStorage.removeItem(CONVERSATION_KEY); + } catch (error) { + console.error('failed to clear conversation cache:', error); + } + } + + // --- System message --- + + /** @returns {string} the saved default system message, or '' */ + getSystemMessage() { + return localStorage.getItem(SYSTEM_MESSAGE_KEY) || ''; + } + + /** + * setSystemMessage stores the default system message for the next + * conversation. It does not change any existing conversation. + * @param {string} value + */ + setSystemMessage(value) { + localStorage.setItem(SYSTEM_MESSAGE_KEY, value); + } + + // --- Model and voice --- + + /** @returns {{provider: string | null, model: string | null}} */ + getModelSelection() { + return { + provider: localStorage.getItem(PROVIDER_KEY), + model: localStorage.getItem(MODEL_KEY), + }; + } + + /** + * setModelSelection stores the selected provider and model. + * @param {{provider: string, model: string}} selection + */ + setModelSelection({ provider, model }) { + localStorage.setItem(PROVIDER_KEY, provider); + localStorage.setItem(MODEL_KEY, model); + } + + /** @returns {string} the selected voice, or '' for automatic */ + getVoice() { + return localStorage.getItem(VOICE_KEY) || ''; + } + + /** + * setVoice stores the selected voice. '' selects the default. + * @param {string} voice + */ + setVoice(voice) { + localStorage.setItem(VOICE_KEY, voice); + } + + // --- Compose settings --- + + /** + * getMaxIterations returns the agent loop limit. Zero means no + * limit: the model replies without tool calls or the run fails. + * @returns {number} + */ + getMaxIterations() { + const stored = Number(localStorage.getItem(MAX_TURNS_KEY)); + return Number.isFinite(stored) && stored > 0 ? stored : 0; + } + + /** + * setMaxIterations stores the agent loop limit. Zero means no + * limit. + * @param {number} value + */ + setMaxIterations(value) { + localStorage.setItem(MAX_TURNS_KEY, String(value)); + } + + /** + * getReasoningBudget returns the llama.cpp reasoning token budget, + * or null when unset. -1 means unlimited and 0 means off. + * @returns {number | null} + */ + getReasoningBudget() { + const stored = localStorage.getItem(REASONING_BUDGET_KEY); + if (stored === null) return null; + const value = Number(stored); + return Number.isFinite(value) ? value : null; + } + + /** + * setReasoningBudget stores the reasoning token budget. null + * removes the setting and leaves the choice to the server. + * @param {number | null} value + */ + setReasoningBudget(value) { + if (value === null) { + localStorage.removeItem(REASONING_BUDGET_KEY); + } else { + localStorage.setItem(REASONING_BUDGET_KEY, String(value)); + } + } + + // --- Mute --- + + /** @returns {boolean} whether speech playback is muted */ + getMuted() { + return localStorage.getItem(MUTE_KEY) === 'true'; + } + + /** + * setMuted stores the speech mute state. + * @param {boolean} muted + */ + setMuted(muted) { + localStorage.setItem(MUTE_KEY, String(muted)); + } +} diff --git a/internal/service/static/js/ui.js b/internal/service/static/js/ui.js new file mode 100644 index 0000000..77f37f4 --- /dev/null +++ b/internal/service/static/js/ui.js @@ -0,0 +1,1773 @@ +/** + * UI renders the chat and owns every DOM interaction: messages, + * streaming drafts, the compose area, the toolbar, and the modals. + * It reports user actions to main.js through the handlers given at + * construction, and main.js calls back with data to render. + * + * Division of labor: + * - UI decides how things look and when a click counts. Destructive + * actions (send-as-stop, reset, delete) need a second press. + * - Main decides what the clicks mean: it sends the requests and + * changes the state, then calls UI with the result. + * - UI never sends requests and never reads or writes localStorage. + * It keeps presentation state only: scroll-follow, open and + * closed sections, recording indicators, and the set of tool + * calls that wait for an approval. + * + * Saved messages are rendered from row objects: { id, role, + * sequence, updated_at, content, metadata, voice }. content is the + * model-visible message JSON, the same object the server stores. + * Drafts are rendered from the unfinished output that main.js + * accumulates; drafts have no row and are never cached. + */ + +const ICONS_URL = '/static/icons.svg'; + +/** + * extractText concatenates the text of a message content field. + * The field is either a plain string or an array of content parts; + * only text and refusal parts carry text. + * @param {string | Array} content + * @returns {string} + */ +export function extractText(content) { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .filter((part) => part.type === 'text' || part.type === 'refusal') + .map((part) => part.text || part.refusal || '') + .join('\n'); +} + +export class UI { + /** + * @param {Object} options + * @param {Document} options.document + * @param {Object} options.handlers - callbacks main.js provides. + */ + constructor({ document, handlers }) { + this.document = document; + this.handlers = handlers; + + // Presentation state. + this.isAutoScrollEnabled = false; + this.modelContextSizes = new Map(); + this.pendingApprovals = []; + this.$pendingConfirm = null; + + // Chat and compose. + this.$chat = document.getElementById('chat'); + this.$chatLoading = document.getElementById('chat-loading'); + this.$attachments = document.getElementById('attachments'); + this.$compose = document.querySelector('.compose'); + this.$textInput = document.getElementById('text-input'); + this.$fileInput = document.getElementById('file-input'); + this.$attach = document.getElementById('attach'); + this.$location = document.getElementById('location'); + this.$ptt = document.getElementById('ptt'); + this.$transcribe = document.getElementById('transcribe'); + this.$send = document.getElementById('send'); + // Last enabled state of the send button. setBusy restores it + // when a run ends. + this.sendEnabled = false; + + // Toolbar. + this.$context = document.getElementById('context'); + this.$reset = document.getElementById('reset'); + this.$scrollDown = document.getElementById('scroll-down'); + this.$mute = document.getElementById('mute'); + this.$settings = document.getElementById('settings'); + + // Settings modal. + 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.$model = document.getElementById('model'); + this.$voice = document.getElementById('voice'); + this.$systemMessageInput = document.getElementById('system-message-input'); + this.$saveSystemMessage = document.getElementById('save-system-message'); + + // Compose settings modal. + this.$composeSettings = document.getElementById('compose-settings'); + this.$composeSettingsOverlay = document.getElementById( + 'compose-settings-overlay', + ); + this.$composeSettingsClose = document.getElementById( + 'compose-settings-close', + ); + this.$maxTurnsInput = document.getElementById('max-turns-input'); + this.$reasoningBudgetInput = document.getElementById( + 'reasoning-budget-input', + ); + + // Message templates. + this.$tplUserMessage = document.getElementById('tpl-user-message'); + this.$tplAssistantMessage = document.getElementById( + 'tpl-assistant-message', + ); + this.$tplReasoningMessage = document.getElementById( + 'tpl-reasoning-message', + ); + this.$tplToolMessage = document.getElementById('tpl-tool-message'); + this.$tplSystemMessage = document.getElementById('tpl-system-message'); + this.$tplErrorMessage = document.getElementById('tpl-error-message'); + this.$tplAttachmentChip = document.getElementById('tpl-attachment-chip'); + this.$tplImagePill = document.getElementById('tpl-image-pill'); + this.$tplDebugRow = document.getElementById('tpl-debug-row'); + this.$tplEditModal = document.getElementById('tpl-edit-modal'); + this.$tplEditToolModal = document.getElementById('tpl-edit-tool-modal'); + + this._bindEvents(); + this._initViewport(); + } + + // ==================== + // COMPOSE AREA + // ==================== + + /** + * getComposeText reads the compose text without changing it. + * @returns {string} + */ + getComposeText() { + return this.$textInput.value; + } + + /** + * takeComposeText reads the compose text and clears the field. + * Call it only when the text is accepted for sending; main.js + * restores it with appendComposeText when a request fails. + * @returns {string} + */ + takeComposeText() { + const text = this.$textInput.value; + this.$textInput.value = ''; + this._resizeTextarea(); + return text; + } + + /** + * appendComposeText puts text back into the compose field after a + * failed send, keeping any text typed in the meantime. + * @param {string} text + */ + appendComposeText(text) { + if (!text) return; + const existing = this.$textInput.value; + this.$textInput.value = existing ? `${existing}\n${text}` : text; + this._resizeTextarea(); + } + + /** focusCompose moves the caret into the compose field. */ + focusCompose() { + this.$textInput.focus(); + } + + /** + * setBusy reflects whether work is running. While busy, the send + * button acts as a stop control and the input controls are + * disabled; the compose text stays editable so nothing typed is + * lost. + * @param {boolean} busy + */ + setBusy(busy) { + this.$send.classList.toggle('loading', busy); + if (busy) { + this.$send.setAttribute('aria-label', 'Stop'); + // While busy the send button is the stop control, so it must + // stay clickable whatever the last enabled state was. + this.$send.disabled = false; + } else { + // Restore the enabled state recorded before the run. A caller + // cannot leave the button disabled by forgetting to call + // updateSendEnabled after this. + this.setSendEnabled(this.sendEnabled); + } + this.$chatLoading.hidden = !busy; + this.$ptt.disabled = busy; + this.$transcribe.disabled = busy; + this.$attach.disabled = busy; + this.$location.disabled = busy; + } + + /** + * setSendEnabled enables the send button. The button stays + * clickable while busy, because it is the stop control then. + * @param {boolean} enabled + */ + setSendEnabled(enabled) { + // Record the state even while busy, so that setBusy can + // restore it when the run ends. + this.sendEnabled = enabled; + if (this.$send.classList.contains('loading')) return; + this.$send.disabled = !enabled; + this.$send.setAttribute( + 'aria-label', + enabled ? 'Send message' : 'Start a conversation to enable send', + ); + } + + /** + * setRecording reflects the push-to-talk state. + * @param {boolean} recording + */ + setRecording(recording) { + this.$ptt.classList.toggle('recording', recording); + this.$ptt.setAttribute('aria-pressed', String(recording)); + } + + /** + * setTranscribing reflects the record-to-text state. + * @param {boolean} transcribing + */ + setTranscribing(transcribing) { + this.$transcribe.classList.toggle( + 'compose__action-btn--recording', + transcribing, + ); + } + + /** + * renderAttachments shows one chip per attached file. onRemove is + * called with the index of the chip the user removed. + * @param {File[]} files + * @param {(index: number) => void} onRemove + */ + renderAttachments(files, onRemove) { + this.$attachments.replaceChildren(); + for (const [index, file] of files.entries()) { + const $chip = + this.$tplAttachmentChip.content.cloneNode(true).firstElementChild; + const $name = $chip.querySelector('.compose__attachment-name'); + $name.textContent = file.name; + $name.title = file.name; + $chip + .querySelector('.compose__attachment-remove') + .addEventListener('click', () => onRemove(index)); + this.$attachments.appendChild($chip); + } + } + + /** + * setLocationActive reflects whether the next message will include + * the GPS location. + * @param {boolean} active + */ + setLocationActive(active) { + this.$location.classList.toggle('compose__action-btn--location', active); + this.$location.classList.toggle('active', active); + } + + // ==================== + // MESSAGES + // ==================== + + /** + * renderMessages renders saved message rows, in the order given, + * into the chat. Bubbles that already exist are skipped, so + * calling it again with the same rows is safe. + * + * A tool result fills the bubble of the call it answers, wherever + * that bubble is. A tool result whose call has no bubble renders + * as a standalone bubble with its output only. + * + * @param {Array} rows - saved message rows, in order. + */ + renderMessages(rows) { + // Map each tool call ID in rows to its result row. + const callIds = new Set(); + for (const row of rows) { + for (const call of row.content?.tool_calls ?? []) { + callIds.add(call.id); + } + } + const results = new Map(); + for (const row of rows) { + const callId = row.content?.tool_call_id; + if (row.role === 'tool' && callIds.has(callId)) { + results.set(callId, row); + } + } + + for (const row of rows) { + switch (row.role) { + case 'system': + if (!this._hasBubble(row.id)) this._renderSystemMessage(row); + break; + case 'user': + if (!this._hasBubble(row.id)) this._renderUserMessage(row); + break; + case 'assistant': + this._renderAssistantMessage(row, results); + break; + case 'tool': { + const $bubble = this.$chat.querySelector( + `[data-tool-call-id="${CSS.escape(row.content?.tool_call_id || '')}"]`, + ); + if ($bubble) { + this._fillToolOutput($bubble, row); + break; + } + if (this._hasBubble(row.id)) break; + this._renderOrphanToolResult(row); + break; + } + } + } + } + + /** + * _hasBubble reports whether a bubble with the given data-id is + * rendered. + * @param {string} id + */ + _hasBubble(id) { + return this.$chat.querySelector(`[data-id="${CSS.escape(id)}"]`) !== null; + } + + /** + * attachToolResult fills the output section of the tool bubble + * for one call. The bubble exists once its assistant message is + * rendered. When it does not, one is created: from the parent + * row's call when the call is found, or from the result row alone + * when the assistant message is unknown or no longer has the + * call. + * + * @param {Object} parentRow - the assistant message row that owns + * the call, or null. + * @param {string} callId + * @param {Object} resultRow - the saved tool result row. + */ + attachToolResult(parentRow, callId, resultRow) { + const $bubble = + this._findToolBubble(parentRow?.id, callId) ?? + this.$chat.querySelector(`[data-tool-call-id="${CSS.escape(callId)}"]`); + if ($bubble) { + this._fillToolOutput($bubble, resultRow); + return; + } + const call = + parentRow?.content?.tool_calls?.find((c) => c.id === callId) ?? null; + this._renderToolBubble(call ? parentRow : null, call, resultRow); + } + + /** + * removeMessageBubbles removes the bubbles of one saved message + * row: the text bubble, the reasoning bubble, and the tool + * bubbles. It does not remove the rows of other messages, such as + * tool results that belong to a deleted assistant message. + * @param {string} id - message row ID. + */ + removeMessageBubbles(id) { + this.$chat + .querySelectorAll( + `[data-id="${CSS.escape(id)}"],` + + ` [data-id="${CSS.escape(`${id}/reasoning`)}"],` + + ` [data-parent-id="${CSS.escape(id)}"]`, + ) + .forEach(($bubble) => { + $bubble.remove(); + }); + } + + /** + * clearToolOutput empties the output section of the bubble that + * shows one tool result, after the result row was deleted. The + * call itself stays: it can run again. + * @param {Object} resultRow - the deleted tool result row. + */ + clearToolOutput(resultRow) { + const id = CSS.escape(resultRow.id); + const $bubble = this.$chat.querySelector( + `[data-parent-id][data-id="${id}"]`, + ); + if (!$bubble) return; + $bubble.querySelector('[data-output]').textContent = ''; + $bubble + .querySelector('.message__tool-section--output') + .setAttribute('hidden', ''); + $bubble.dataset.id = `${$bubble.dataset.parentId}/${$bubble.dataset.toolCallId}`; + } + + /** + * replaceDraftWithSaved swaps the streaming bubbles of one draft + * for the bubbles of its saved message row, keeping each bubble's + * open or closed state. resultRows are the tool results saved for + * the same message, when known. + * @param {string} draftId - the draft ID, which equals the saved + * row ID: the server allocates it before the first fragment. + * @param {Object} row - the saved assistant message row. + * @param {Object[]} resultRows + */ + replaceDraftWithSaved(draftId, row, resultRows = []) { + const openOf = (selector) => { + const $bubble = this.$chat.querySelector(selector); + return $bubble?.querySelector('details')?.open ?? null; + }; + const textOpen = openOf(`[data-id="${CSS.escape(draftId)}"]`); + const reasoningOpen = openOf( + `[data-id="${CSS.escape(`${draftId}/reasoning`)}"]`, + ); + const toolOpen = new Map(); + for (const $bubble of this.$chat.querySelectorAll( + `[data-parent-id="${CSS.escape(draftId)}"][data-tool-call-id]`, + )) { + toolOpen.set( + $bubble.dataset.toolCallId, + $bubble.querySelector('details')?.open, + ); + } + + this.removeDraft(draftId); + this.renderMessages([row, ...resultRows]); + + const $text = this.$chat.querySelector(`[data-id="${CSS.escape(row.id)}"]`); + if ($text && textOpen !== null) { + $text.querySelector('details').open = textOpen; + } + const $reasoning = this.$chat.querySelector( + `[data-id="${CSS.escape(`${row.id}/reasoning`)}"]`, + ); + if ($reasoning && reasoningOpen !== null) { + $reasoning.querySelector('details').open = reasoningOpen; + } + for (const call of row.content?.tool_calls ?? []) { + const $bubble = this._findToolBubble(row.id, call.id); + if ($bubble && toolOpen.has(call.id)) { + $bubble.querySelector('details').open = toolOpen.get(call.id); + } + } + } + + /** + * renderDraft renders the unfinished output of one streaming + * assistant message. Repeated calls update the bubbles in place. + * Draft bubbles have no message actions: their content is not + * saved and may never be. + * @param {Object} draft - { id, reasoning, text, refusal, + * toolCalls: [{ id, function: { name, arguments } }] }. + */ + renderDraft(draft) { + if (draft.reasoning) { + let $bubble = this.$chat.querySelector( + `[data-id="${CSS.escape(`${draft.id}/reasoning`)}"]`, + ); + if (!$bubble) $bubble = this._createDraftReasoning(draft.id); + $bubble.querySelector('.message__content').textContent = draft.reasoning; + } + const text = draft.refusal || draft.text; + if (text) { + let $bubble = this.$chat.querySelector( + `[data-id="${CSS.escape(draft.id)}"]`, + ); + if (!$bubble) $bubble = this._createDraftText(draft.id); + $bubble.querySelector('.message__content').textContent = text; + } + for (const [index, call] of draft.toolCalls.entries()) { + if (!call) continue; + const callId = call.id || `#${index}`; + let $bubble = this._findToolBubble(draft.id, callId); + if (!$bubble) $bubble = this._createDraftToolBubble(draft.id, callId); + $bubble.querySelector('.message__collapsible-label').textContent = + call.function.name || 'Tool call'; + $bubble.querySelector('[data-args]').textContent = + call.function.arguments; + } + this.scrollToBottom(); + } + + /** + * removeDraft removes the streaming bubbles of one draft. Call it + * when a draft is replaced by its saved message, or when its + * stream is lost: an unfinished draft is not a saved message, and + * a re-sync either delivers the saved message or proves that none + * was saved. + * @param {string} draftId + */ + removeDraft(draftId) { + this.removeMessageBubbles(draftId); + } + + /** + * renderSystemDefault renders the system message bubble shown + * when no conversation is selected. It shows the default system + * message for the next conversation. + * @param {string} text + */ + renderSystemDefault(text) { + this.$chat.querySelector('[data-id="system"]')?.remove(); + const $bubble = + this.$tplSystemMessage.content.cloneNode(true).firstElementChild; + $bubble.dataset.id = 'system'; + const $content = $bubble.querySelector('.message__content'); + if (text?.trim()) { + $content.textContent = text; + $bubble.dataset.copyText = text; + } else { + $content.innerHTML = + 'System Default'; + } + this.$chat.insertBefore($bubble, this.$chatLoading); + } + + /** + * renderError renders one error message in the chat. Errors are + * display-only: the delete button removes the bubble without a + * request. + * @param {string} message + */ + renderError(message) { + const $bubble = + this.$tplErrorMessage.content.cloneNode(true).firstElementChild; + $bubble.querySelector('.message__content').textContent = message; + $bubble.dataset.copyText = message; + this.$chat.insertBefore($bubble, this.$chatLoading); + this.scrollToBottom(); + } + + /** + * clearChat removes every message bubble. The loading spinner + * stays. + */ + clearChat() { + for (const $bubble of [...this.$chat.children]) { + if ($bubble !== this.$chatLoading) $bubble.remove(); + } + } + + /** + * renderConversation replaces the whole chat with rows, keeping + * each bubble's open or closed state and the scroll position. + * @param {Array} rows + */ + renderConversation(rows) { + const open = new Map(); + for (const $bubble of this.$chat.querySelectorAll('.message')) { + const details = $bubble.querySelector('details'); + if (details) open.set($bubble.dataset.id, details.open); + } + const { scrollTop } = this.$chat; + this.clearChat(); + this.renderMessages(rows); + for (const [id, isOpen] of open) { + const $bubble = this.$chat.querySelector(`[data-id="${CSS.escape(id)}"]`); + if ($bubble) $bubble.querySelector('details').open = isOpen; + } + this.$chat.scrollTop = scrollTop; + this.scrollToBottom(); + } + + // ==================== + // TOOL APPROVALS + // ==================== + + /** + * setPendingApprovals shows the approve and deny buttons on the + * tool bubbles named by the given executions, and hides them on + * every other bubble. The set is remembered, so bubbles rendered + * later show their buttons too. + * @param {Array<{executionId: string, messageId: string, toolCallId: string}>} pending + */ + setPendingApprovals(pending) { + this.pendingApprovals = pending; + for (const $bubble of this.$chat.querySelectorAll( + '[data-parent-id][data-tool-call-id]', + )) { + const $approval = $bubble.querySelector('.message__approval'); + if (!$approval) continue; + const isPending = pending.some( + ({ messageId, toolCallId }) => + messageId === $bubble.dataset.parentId && + toolCallId === $bubble.dataset.toolCallId, + ); + $approval.hidden = !isPending; + } + } + + // ==================== + // MESSAGE DETAILS + // ==================== + + /** + * setPlayButton switches the play button of one message between + * its play and stop icon, for speech that starts or ends. + * @param {string} messageId + * @param {boolean} playing + */ + setPlayButton(messageId, playing) { + const $bubble = this.$chat.querySelector( + `[data-id="${CSS.escape(messageId)}"]`, + ); + const $btn = $bubble?.querySelector('[data-action="play"]'); + if (!$btn) return; + $btn.replaceChildren(this._icon(playing ? 'stop' : 'volume')); + $btn.classList.toggle('playing', playing); + $btn.setAttribute('aria-label', playing ? 'Stop' : 'Play'); + } + + /** + * updateContext shows the token counter in the toolbar. When the + * selected model reports its context size, the counter is the + * used percentage; otherwise it is the token count. + * @param {number} tokens - total tokens of the last inference. + * @param {number | null} contextSize + */ + updateContext(tokens, contextSize) { + if (tokens <= 0) { + this.$context.hidden = true; + return; + } + this.$context.hidden = false; + this.$context.classList.remove( + 'footer__toolbar-context--warning', + 'footer__toolbar-context--critical', + ); + const tokenLabel = + tokens >= 1000 + ? `${(tokens / 1000).toFixed(1)}k tokens` + : `${tokens} tokens`; + if (contextSize > 0) { + const pct = Math.round((tokens / 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 = ''; + } + } + + // ==================== + // SETTINGS MODAL + // ==================== + + /** + * populateModels fills the model selection. stored selects the + * model chosen earlier; without it the server default is used, + * then the first available model. + * @param {Object} data - /models response. + * @param {{provider: string | null, model: string | null}} stored + */ + populateModels(data, stored) { + this.$model.replaceChildren(); + this.modelContextSizes.clear(); + + for (const provider of Object.keys(data.providers).sort()) { + const $group = this.document.createElement('optgroup'); + $group.label = provider; + for (const model of [...data.providers[provider]].sort((a, b) => + a.model.localeCompare(b.model), + )) { + const $option = this.document.createElement('option'); + $option.value = model.model; + $option.dataset.provider = provider; + $option.dataset.model = model.model; + if (model.context_size) { + this.modelContextSizes.set(model.model, model.context_size); + } + switch (model.status) { + case 'available': + $option.textContent = model.model; + break; + case 'not_found': + $option.textContent = `~~${model.model}~~ (not found)`; + $option.disabled = true; + $option.classList.add('model-unavailable-not-found'); + break; + default: + $option.textContent = `${model.model} (provider error)`; + $option.disabled = true; + $option.classList.add('model-unavailable-error'); + } + $group.appendChild($option); + } + this.$model.appendChild($group); + } + + const find = (provider, model) => + this.$model.querySelector( + `option[data-provider="${CSS.escape(provider ?? '')}"]` + + `[data-model="${CSS.escape(model ?? '')}"]`, + ); + let $option = + (stored.provider && + stored.model && + find(stored.provider, stored.model)) || + find(data.default_provider, data.default_model); + if (!$option) { + $option = [...this.$model.options].find((option) => !option.disabled); + } + if ($option) { + this.$model.value = $option.value; + } else { + // No model answered: name the failure in the selection. + const $none = this.document.createElement('option'); + $none.value = ''; + $none.disabled = true; + $none.textContent = 'Failed to load'; + this.$model.replaceChildren($none); + } + } + + /** + * getSelectedModel reports the selected provider and model, with + * the model's context size when the provider reported one. + * @returns {{provider: string | null, model: string | null, contextSize: number | null}} + */ + getSelectedModel() { + const $option = this.$model.options[this.$model.selectedIndex]; + const model = $option?.dataset.model ?? null; + return { + provider: $option?.dataset.provider ?? null, + model, + contextSize: model ? (this.modelContextSizes.get(model) ?? null) : null, + }; + } + + /** + * populateVoices fills the voice selection from the TTS server. + * @param {string[]} voices + * @param {string} selected + */ + populateVoices(voices, selected) { + const labels = { + af: '🇺🇸 American English (F)', + am: '🇺🇸 American English (M)', + bf: '🇬🇧 British English (F)', + bm: '🇬🇧 British English (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)', + jf: '🇯🇵 Japanese (F)', + jm: '🇯🇵 Japanese (M)', + kf: '🇰🇷 Korean (F)', + km: '🇰🇷 Korean (M)', + pf: '🇧🇷 Brazilian Portuguese (F)', + pm: '🇧🇷 Brazilian Portuguese (M)', + zf: '🇨🇳 Mandarin Chinese (F)', + zm: '🇨🇳 Mandarin Chinese (M)', + }; + + this.$voice.replaceChildren(); + const $auto = this.document.createElement('option'); + $auto.value = ''; + $auto.textContent = '🌐 Auto'; + this.$voice.appendChild($auto); + + const groups = new Map(); + for (const voice of voices) { + const prefix = voice.substring(0, 2); + if (!groups.has(prefix)) groups.set(prefix, []); + groups.get(prefix).push(voice); + } + for (const prefix of [...groups.keys()].sort()) { + const $group = this.document.createElement('optgroup'); + $group.label = labels[prefix] ?? prefix.toUpperCase(); + for (const voice of groups.get(prefix).sort()) { + const name = voice.substring(3).replace(/_/g, ' '); + const $option = this.document.createElement('option'); + $option.value = voice; + $option.textContent = name.charAt(0).toUpperCase() + name.slice(1); + $group.appendChild($option); + } + this.$voice.appendChild($group); + } + if (voices.length === 0) { + // No voice answered: name the failure in the selection. + const $none = this.document.createElement('option'); + $none.value = ''; + $none.disabled = true; + $none.textContent = 'Failed to load'; + this.$voice.appendChild($none); + return; + } + this.$voice.value = this.$voice.querySelector( + `option[value="${CSS.escape(selected)}"]`, + ) + ? selected + : ''; + } + + /** + * setSystemMessageDraft fills the system message textarea of the + * settings modal. + * @param {string} text + */ + setSystemMessageDraft(text) { + this.$systemMessageInput.value = text; + } + + // ==================== + // COMPOSE SETTINGS MODAL + // ==================== + + /** + * openComposeSettings shows the compose settings with the given + * values. Closing it reports the values back, even unchanged. + * @param {{maxIterations: number, reasoningBudget: number | null}} values + */ + openComposeSettings({ maxIterations, reasoningBudget }) { + this.$maxTurnsInput.value = String(maxIterations); + this.$reasoningBudgetInput.value = + reasoningBudget == null ? '' : String(reasoningBudget); + this.$composeSettingsOverlay.classList.add('open'); + this.$composeSettingsClose.focus(); + } + + // ==================== + // MUTE + // ==================== + + /** + * setMuted switches the mute icon. + * @param {boolean} muted + */ + setMuted(muted) { + this.$mute.replaceChildren(this._icon(muted ? 'volume-off' : 'volume')); + this.$mute.classList.toggle('footer__toolbar-btn--muted', muted); + this.$mute.setAttribute('aria-label', muted ? 'Unmute' : 'Mute'); + } + + // ==================== + // EDIT MODALS + // ==================== + + /** + * editText opens the text edit modal. It resolves with the edited + * text when saved, and with null when cancelled. The modal shows + * the raw text; markdown renders after saving. + * @param {{title: string, value: string}} options + * @returns {Promise} + */ + editText({ title, value }) { + const $modal = this.$tplEditModal.content.cloneNode(true).firstElementChild; + $modal.querySelector('h2').textContent = title; + $modal.querySelector('#edit-textarea').value = value; + return this._openModal( + $modal, + ($modal) => $modal.querySelector('#edit-textarea').value, + ); + } + + /** + * editToolCall opens the tool edit modal, which edits the call's + * name and arguments and, when a result exists, its text. It + * resolves with { name, arguments, result } when saved, and with + * null when cancelled. The call is edited as JSON; saving stays in + * the modal until the JSON parses. + * @param {{name: string, arguments: string, result: string}} options + * @returns {Promise<{name: string, arguments: string, result: string} | null>} + */ + editToolCall({ name, arguments: args, result }) { + const $modal = + this.$tplEditToolModal.content.cloneNode(true).firstElementChild; + const $call = $modal.querySelector('#edit-tool-call'); + try { + $call.value = JSON.stringify( + { name, arguments: JSON.parse(args || '{}') }, + null, + 2, + ); + } catch { + $call.value = JSON.stringify({ name, arguments: args }, null, 2); + } + $modal.querySelector('#edit-tool-result').value = result; + return this._openModal($modal, ($modal) => { + const parsed = JSON.parse($modal.querySelector('#edit-tool-call').value); + return { + name: String(parsed.name ?? ''), + arguments: + typeof parsed.arguments === 'string' + ? parsed.arguments + : JSON.stringify(parsed.arguments ?? {}), + result: $modal.querySelector('#edit-tool-result').value, + }; + }); + } + + // ==================== + // SCROLL + // ==================== + + /** + * setAutoScroll turns following the newest output on or off, and + * syncs the toolbar indicator. Following is off until the user + * turns it on; scrolling up turns it off. + * @param {boolean} enabled + */ + setAutoScroll(enabled) { + this.isAutoScrollEnabled = enabled; + this.$scrollDown.classList.toggle('following', enabled); + } + + /** scrollToBottom follows the output only while enabled. */ + scrollToBottom() { + if (this.isAutoScrollEnabled) { + this.$chat.scrollTop = this.$chat.scrollHeight; + } + } + + /** + * scrollToLastUserMessage anchors the last user message to the + * top of the viewport, so the reply grows below it. No effect + * while following, or when the message is already visible. + */ + scrollToLastUserMessage() { + const $last = this.$chat.querySelectorAll('.message--user'); + const $message = $last[$last.length - 1]; + if (!$message || this.isAutoScrollEnabled) return; + const { scrollTop, clientHeight } = this.$chat; + const top = $message.offsetTop - this.$chat.offsetTop; + const bottom = top + $message.offsetHeight; + if (top >= scrollTop && bottom <= scrollTop + clientHeight) return; + this.$chat.scrollTop = top; + } + + // ==================== + // PRIVATE: EVENT BINDING + // ==================== + + _bindEvents() { + // One delegated click listener routes every data-action. + this.document.addEventListener('click', (event) => + this._handleClick(event), + ); + + // Press-and-hold for push-to-talk and transcription. + this.$compose.addEventListener('mousedown', (event) => + this._handlePressStart(event), + ); + this.$compose.addEventListener('touchstart', (event) => + this._handlePressStart(event), + ); + this.document.addEventListener('mouseup', (event) => + this._handlePressEnd(event), + ); + this.document.addEventListener('touchend', (event) => + this._handlePressEnd(event), + ); + this.document.addEventListener('touchcancel', (event) => + this._handlePressEnd(event), + ); + this.$ptt.addEventListener('contextmenu', (event) => + event.preventDefault(), + ); + this.$transcribe.addEventListener('contextmenu', (event) => + event.preventDefault(), + ); + this.document.addEventListener('keydown', (event) => + this._handleKeyDown(event), + ); + this.document.addEventListener('keyup', (event) => + this._handleKeyUp(event), + ); + + // Compose field. + this.$textInput.addEventListener('keydown', (event) => { + if (event.code === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.handlers.onComposeSubmit(); + } + }); + this.$textInput.addEventListener('input', () => { + this._resizeTextarea(); + this.handlers.onComposeInput(); + }); + + // Attachments. + this.$attach.addEventListener('click', () => this.$fileInput.click()); + this.$fileInput.addEventListener('change', (event) => { + this.handlers.onAttachFiles(event.target.files); + this.$fileInput.value = ''; + }); + + // Chat scroll. + this.$chat.addEventListener('scroll', () => this._handleChatScroll(), { + passive: true, + }); + + // Settings modal. + this.$settings.addEventListener('click', () => this._openSettings()); + this.$settingsClose.addEventListener('click', () => this._closeSettings()); + this.$settingsOverlay.addEventListener('click', (event) => { + if (event.target === this.$settingsOverlay) this._closeSettings(); + }); + this.document.addEventListener('keydown', (event) => { + if ( + event.key === 'Escape' && + this.$settingsOverlay.classList.contains('open') + ) { + event.preventDefault(); + this._closeSettings(); + } + }); + this.$settingsTabs.forEach(($tab) => { + $tab.addEventListener('click', () => this._switchTab($tab.dataset.tab)); + }); + this.$saveSystemMessage.addEventListener('click', () => + this._saveSystemMessage(), + ); + this.$model.addEventListener('change', () => { + const { provider, model } = this.getSelectedModel(); + this.handlers.onModelChange({ provider, model }); + }); + this.$voice.addEventListener('change', () => + this.handlers.onVoiceChange(this.$voice.value), + ); + + // Compose settings modal. + this.$composeSettings.addEventListener('click', () => + this.handlers.onComposeSettings(), + ); + this.$composeSettingsClose.addEventListener('click', () => + this._closeComposeSettings(), + ); + this.$composeSettingsOverlay.addEventListener('click', (event) => { + if (event.target === this.$composeSettingsOverlay) { + this._closeComposeSettings(); + } + }); + } + + _handleClick(event) { + const $target = event.target.closest('[data-action]'); + const action = $target?.dataset.action; + + // A click outside any action button cancels every pending + // confirmation. + if (!action) { + this._clearConfirm(); + return; + } + + const $message = $target.closest('.message'); + const messageId = $message?.dataset.messageId || $message?.dataset.id; + + switch (action) { + case 'send': + if (this.$send.classList.contains('loading')) { + this._confirm('stop', $target, 'pending', () => + this.handlers.onStop(), + ); + } else { + this._clearConfirm(); + this.handlers.onComposeSubmit(); + } + break; + case 'reset': + this._confirm('reset', $target, 'footer__toolbar-btn--pending', () => + this.handlers.onReset(), + ); + break; + case 'delete': + // Error bubbles are display-only: remove without a request. + if ( + !$message || + (!$message.dataset.id && !$message.dataset.messageId) + ) { + this._clearConfirm(); + $message?.remove(); + break; + } + this._confirm( + `delete:${messageId}:${$message.dataset.field ?? ''}` + + `:${$message.dataset.toolCallId ?? ''}`, + $target, + 'pending', + () => + this.handlers.onDelete({ + id: messageId, + field: $message.dataset.field || null, + toolCallId: $message.dataset.toolCallId || null, + }), + ); + break; + case 'approve': + case 'deny': { + this._clearConfirm(); + const execution = this.pendingApprovals.find( + ({ messageId: mid, toolCallId }) => + mid === $message?.dataset.parentId && + toolCallId === $message?.dataset.toolCallId, + ); + if (!execution) break; + if (action === 'approve') { + this.handlers.onApprove(execution); + } else { + this.handlers.onDeny(execution); + } + break; + } + case 'edit': + this._clearConfirm(); + this.handlers.onEdit({ + id: messageId, + field: $message?.dataset.field || null, + toolCallId: $message?.dataset.toolCallId || null, + }); + break; + case 'play': + this._clearConfirm(); + if (messageId) this.handlers.onPlay(messageId); + break; + case 'copy': { + this._clearConfirm(); + const text = $message?.dataset.copyText || ''; + this._copyToClipboard($target, text); + break; + } + case 'inspect': { + this._clearConfirm(); + const isOpen = $message.classList.toggle('message--debug-open'); + $target.setAttribute('aria-expanded', String(isOpen)); + break; + } + case 'scroll-up': + this._clearConfirm(); + this.setAutoScroll(false); + this.$chat.scrollTop = 0; + break; + case 'scroll-down': + // Set scrollTop first, so the scroll listener sees the + // bottom position and does not turn following off again. + this._clearConfirm(); + this.$chat.scrollTop = this.$chat.scrollHeight; + this.setAutoScroll(true); + break; + case 'mute': + this._clearConfirm(); + this.handlers.onToggleMute(); + break; + case 'location': + this._clearConfirm(); + this.handlers.onLocationToggle(); + break; + case 'settings': + this._clearConfirm(); + this._openSettings(); + break; + case 'compose-settings': + this._clearConfirm(); + this.handlers.onComposeSettings(); + break; + case 'attach': + // Handled by the listener on the button itself. + break; + case 'ptt': + case 'transcribe': + // Handled by press-and-hold. + break; + default: + this._clearConfirm(); + } + } + + /** + * _confirm runs action on the second press of the same control. + * The first press marks the control; any other click clears the + * mark. key tells the presses apart. + */ + _confirm(key, $target, className, action) { + if ( + this.$pendingConfirm?.key === key && + this.$pendingConfirm?.$target === $target + ) { + this._clearConfirm(); + action(); + return; + } + this._clearConfirm(); + this.$pendingConfirm = { key, $target }; + $target.classList.add(className); + } + + _clearConfirm() { + const pending = this.$pendingConfirm; + if (!pending) return; + pending.$target.classList.remove('pending', 'footer__toolbar-btn--pending'); + this.$pendingConfirm = null; + } + + _handlePressStart(event) { + const $target = event.target.closest('[data-action]'); + const action = $target?.dataset.action; + if (action !== 'ptt' && action !== 'transcribe') return; + + // Let the document-level click handler ignore these; touch and + // mouse both arrive as presses here. + if (event.type === 'touchstart') { + event.preventDefault(); + event.stopPropagation(); + } else if (event.sourceCapabilities?.firesTouchEvents) { + return; + } else { + event.preventDefault(); + event.stopPropagation(); + } + + if (action === 'ptt') this.handlers.onPushToTalkStart(); + else this.handlers.onTranscribeStart(); + } + + _handlePressEnd() { + // The handlers ignore the end of a press that never started, + // so both controls can share the release listeners. + this.handlers.onPushToTalkEnd(); + this.handlers.onTranscribeEnd(); + } + + _handleKeyDown(event) { + if (event.code !== 'Space' || event.repeat) return; + if (this._isFormField(event.target)) return; + event.preventDefault(); + this.handlers.onPushToTalkStart(); + } + + _handleKeyUp(event) { + if (event.code !== 'Space') return; + if (this._isFormField(event.target)) return; + event.preventDefault(); + this.handlers.onPushToTalkEnd(); + } + + _isFormField(target) { + return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName); + } + + _resizeTextarea() { + const textarea = this.$textInput; + textarea.style.height = 'auto'; + const maxHeight = parseFloat(getComputedStyle(textarea).maxHeight); + textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`; + } + + _handleChatScroll() { + // Disable-only: scrolling away from the bottom turns following + // off. Being at the bottom does not turn it back on; only the + // scroll-down button does. + const { scrollHeight, scrollTop, clientHeight } = this.$chat; + if (scrollHeight - scrollTop - clientHeight > 100) { + this.setAutoScroll(false); + } + } + + // ==================== + // PRIVATE: MODALS + // ==================== + + _openSettings() { + this.$settingsOverlay.classList.add('open'); + this.handlers.onSettingsOpen(); + this._switchTab('model-voice'); + this.$settingsClose.focus(); + } + + _closeSettings() { + 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; + }); + } + + _saveSystemMessage() { + this.handlers.onSaveSystemMessage(this.$systemMessageInput.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); + } + + _closeComposeSettings() { + this.$composeSettingsOverlay.classList.remove('open'); + this.$composeSettings.focus(); + const budget = this.$reasoningBudgetInput.value.trim(); + this.handlers.onComposeSettingsChange({ + maxIterations: Number(this.$maxTurnsInput.value), + reasoningBudget: budget === '' ? null : Number(budget), + }); + } + + /** + * _openModal shows one edit modal and resolves with the value + * read by readValue when saved. Cancel, Escape, and a click + * outside resolve with null. A readValue that throws keeps the + * modal open and marks the error. + */ + _openModal($modal, readValue) { + return new Promise((resolve) => { + const $overlay = this.document.createElement('div'); + $overlay.className = 'modal-overlay'; + $overlay.appendChild($modal); + this.document.body.appendChild($overlay); + + const $save = $modal.querySelector('.modal__save'); + const close = (value) => { + $overlay.remove(); + this.document.removeEventListener('keydown', onKey); + resolve(value); + }; + const onKey = (event) => { + if (event.key === 'Escape') close(null); + }; + + $modal + .querySelector('.modal__close') + .addEventListener('click', () => close(null)); + $overlay.addEventListener('click', (event) => { + if (event.target === $overlay) close(null); + }); + this.document.addEventListener('keydown', onKey); + + $save.addEventListener('click', () => { + let value; + try { + value = readValue($modal); + } catch (error) { + console.error('invalid input:', error); + for (const $textarea of $modal.querySelectorAll('.modal__textarea')) { + $textarea.classList.add('modal__textarea--error'); + } + $save.classList.add('modal__save--error'); + setTimeout(() => { + for (const $textarea of $modal.querySelectorAll( + '.modal__textarea', + )) { + $textarea.classList.remove('modal__textarea--error'); + } + $save.classList.remove('modal__save--error'); + }, 500); + return; + } + close(value); + }); + + $modal.querySelector('textarea')?.focus(); + }); + } + + // ==================== + // PRIVATE: RENDERING + // ==================== + + _renderUserMessage(row) { + const $bubble = + this.$tplUserMessage.content.cloneNode(true).firstElementChild; + $bubble.dataset.id = row.id; + const text = extractText(row.content?.content); + $bubble.querySelector('.message__content').textContent = text; + $bubble.dataset.copyText = text; + this._renderImagePills($bubble, row); + this._populateDebug($bubble.querySelector('.message__debug-list'), row); + this.$chat.insertBefore($bubble, this.$chatLoading); + this.scrollToBottom(); + } + + _renderAssistantMessage(row, results) { + const content = row.content; + if ( + (content?.reasoning_content || content?.reasoning) && + !this._hasBubble(`${row.id}/reasoning`) + ) { + this._renderReasoningMessage(row); + } + const text = content?.refusal || extractText(content?.content); + if (text && !this._hasBubble(row.id)) { + this._renderTextMessage(row, text); + } + for (const call of content?.tool_calls ?? []) { + if (this._findToolBubble(row.id, call.id)) continue; + this._renderToolBubble(row, call, results.get(call.id) ?? null); + } + } + + _renderTextMessage(row, text) { + const $bubble = + this.$tplAssistantMessage.content.cloneNode(true).firstElementChild; + $bubble.dataset.id = row.id; + $bubble.querySelector('.message__content').innerHTML = + this._renderMarkdown(text); + $bubble.dataset.voice = row.voice || ''; + $bubble.dataset.copyText = text; + this._populateDebug($bubble.querySelector('.message__debug-list'), row); + this.$chat.insertBefore($bubble, this.$chatLoading); + this.scrollToBottom(); + } + + _renderReasoningMessage(row) { + const $bubble = + this.$tplReasoningMessage.content.cloneNode(true).firstElementChild; + $bubble.dataset.id = `${row.id}/reasoning`; + $bubble.dataset.parentId = row.id; + $bubble.dataset.field = 'reasoning'; + $bubble.dataset.messageId = row.id; + const reasoning = row.content?.reasoning_content || ''; + $bubble.querySelector('.message__content').textContent = reasoning; + $bubble.dataset.copyText = reasoning; + this._populateDebug($bubble.querySelector('.message__debug-list'), row); + this.$chat.insertBefore($bubble, this.$chatLoading); + this.scrollToBottom(); + } + + /** + * _renderToolBubble renders the bubble for one tool call of an + * assistant message. result is the saved tool result row, when + * one exists. parentRow links the bubble to the assistant + * message; without it the bubble belongs to a result whose + * assistant message is unknown, shows output only, and reports + * no actions that need the parent. + */ + _renderToolBubble(parentRow, call, result) { + const $bubble = + this.$tplToolMessage.content.cloneNode(true).firstElementChild; + const callId = call?.id ?? result?.content?.tool_call_id ?? ''; + $bubble.dataset.id = result?.id ?? `${parentRow?.id ?? ''}/${callId}`; + if (parentRow) { + $bubble.dataset.parentId = parentRow.id; + $bubble.dataset.messageId = parentRow.id; + $bubble.dataset.field = 'tool'; + } + if (callId) $bubble.dataset.toolCallId = callId; + $bubble.querySelector('.message__collapsible-label').textContent = + call?.function?.name || result?.content?.name || 'Tool call'; + + const $args = $bubble.querySelector('[data-args]'); + if (call) { + $args.textContent = this._prettyJSON(call.function?.arguments); + } else { + // A result without its assistant message shows only output. + $args.closest('.message__tool-section').remove(); + } + + const copyParts = []; + if (call) { + copyParts.push( + `${call.function?.name || 'unknown'}(${call.function?.arguments || '{}'})`, + ); + } + copyParts.push(extractText(result?.content?.content) || ''); + $bubble.dataset.copyText = copyParts.filter(Boolean).join('\n\n'); + + if (result) this._fillToolOutput($bubble, result); + this._populateDebug($bubble.querySelector('.message__debug-list'), { + ...result, + voice: parentRow?.voice, + }); + this.$chat.insertBefore($bubble, this.$chatLoading); + this._applyApproval($bubble); + this.scrollToBottom(); + } + + /** _renderOrphanToolResult renders a result row without parent. */ + _renderOrphanToolResult(row) { + this._renderToolBubble(null, null, row); + } + + /** + * _fillToolOutput fills the output section of a tool bubble with + * the text of its saved result row. + */ + _fillToolOutput($bubble, result) { + const output = this._prettyJSON(extractText(result.content?.content)); + $bubble.dataset.id = result.id; + $bubble.querySelector('.message__tool-section--output').hidden = false; + $bubble.querySelector('[data-output]').textContent = output; + // Keep the copy text in step with the output now shown. + const label = $bubble.querySelector( + '.message__collapsible-label', + ).textContent; + const args = $bubble.querySelector('[data-args]')?.textContent.trim(); + $bubble.dataset.copyText = [`${label}(${args || '{}'})`, output] + .filter(Boolean) + .join('\n\n'); + } + + _renderSystemMessage(row) { + // A saved system row replaces the no-conversation placeholder. + this.$chat.querySelector('[data-id="system"]')?.remove(); + const $bubble = + this.$tplSystemMessage.content.cloneNode(true).firstElementChild; + $bubble.dataset.id = row.id; + const text = extractText(row.content?.content); + $bubble.querySelector('.message__content').textContent = text; + $bubble.dataset.copyText = text; + this.$chat.insertBefore($bubble, this.$chatLoading); + } + + _createDraftReasoning(draftId) { + const $bubble = + this.$tplReasoningMessage.content.cloneNode(true).firstElementChild; + $bubble.dataset.id = `${draftId}/reasoning`; + $bubble.dataset.parentId = draftId; + $bubble.classList.add('message--streaming'); + $bubble.querySelector('.message__collapsible').open = true; + for (const $button of $bubble.querySelectorAll('.message__action-btn')) { + $button.remove(); + } + this.$chat.insertBefore($bubble, this.$chatLoading); + return $bubble; + } + + _createDraftText(draftId) { + const $bubble = + this.$tplAssistantMessage.content.cloneNode(true).firstElementChild; + $bubble.dataset.id = draftId; + $bubble.classList.add('message--streaming'); + for (const $button of $bubble.querySelectorAll('.message__action-btn')) { + $button.remove(); + } + this.$chat.insertBefore($bubble, this.$chatLoading); + return $bubble; + } + + _createDraftToolBubble(draftId, callId) { + const $bubble = + this.$tplToolMessage.content.cloneNode(true).firstElementChild; + $bubble.dataset.id = `${draftId}/${callId}`; + $bubble.dataset.parentId = draftId; + $bubble.dataset.toolCallId = callId; + $bubble.classList.add('message--streaming'); + $bubble.querySelector('.message__collapsible').open = true; + $bubble.querySelector('.message__actions').remove(); + this.$chat.insertBefore($bubble, this.$chatLoading); + return $bubble; + } + + /** + * _findToolBubble finds the bubble of one call. callId may be a + * '#index' key while a draft's call has no ID yet. + */ + _findToolBubble(parentId, callId) { + if (!parentId || !callId) return null; + return this.$chat.querySelector( + `[data-parent-id="${CSS.escape(parentId)}"]` + + `[data-tool-call-id="${CSS.escape(callId)}"]`, + ); + } + + _renderImagePills($bubble, row) { + const parts = Array.isArray(row.content?.content) + ? row.content.content.filter((part) => part.type === 'image_url') + : []; + if (parts.length === 0) return; + const $wrapper = this.document.createElement('div'); + $wrapper.className = 'message__image-pills'; + for (const part of parts) { + const $pill = + this.$tplImagePill.content.cloneNode(true).firstElementChild; + // The server does not store the original file name with the + // image; name it by its data format instead. + const format = part.image_url?.url?.match(/^data:image\/(\w+)/)?.[1]; + $pill.querySelector('.message__image-pill-name').textContent = format + ? `Image (${format})` + : 'Image'; + $wrapper.appendChild($pill); + } + $bubble + .querySelector('.message__body') + .insertBefore($wrapper, $bubble.querySelector('.message__actions')); + } + + /** + * _populateDebug fills the inspect panel of a bubble from the + * message's inference metadata and the voice used for playback. + * Missing metadata shows as a single 'No data' row. + */ + _populateDebug($list, row) { + const meta = { ...row?.metadata, voice: row?.voice }; + if (meta.voice) this._appendDebugRow($list, 'Voice', meta.voice); + const usage = meta.usage; + if (usage) { + this._appendDebugRow($list, 'Prompt tokens', String(usage.prompt_tokens)); + this._appendDebugRow( + $list, + 'Completion tokens', + String(usage.completion_tokens), + ); + this._appendDebugRow($list, 'Total tokens', String(usage.total_tokens)); + if (usage.completion_tokens_details?.reasoning_tokens > 0) { + this._appendDebugRow( + $list, + 'Reasoning tokens', + String(usage.completion_tokens_details.reasoning_tokens), + ); + } + if (usage.prompt_tokens_details?.cached_tokens > 0) { + this._appendDebugRow( + $list, + 'Cached tokens', + String(usage.prompt_tokens_details.cached_tokens), + ); + } + } + const timings = meta.timings; + if (timings) { + if (timings.prompt_ms > 0) { + this._appendDebugRow( + $list, + 'Prompt eval', + `${timings.prompt_ms.toFixed(1)} ms ` + + `(${timings.prompt_per_second.toFixed(0)} tok/s)`, + ); + } + if (timings.predicted_ms > 0) { + this._appendDebugRow( + $list, + 'Predicted eval', + `${timings.predicted_ms.toFixed(1)} ms ` + + `(${timings.predicted_per_second.toFixed(0)} tok/s)`, + ); + } + } + if ($list.children.length === 0) { + this._appendDebugRow($list, 'No data', ''); + } + } + + _appendDebugRow($list, label, value) { + const $row = this.$tplDebugRow.content.cloneNode(true); + $row.querySelector('dt').textContent = label; + $row.querySelector('dd').textContent = value; + $list.appendChild($row); + } + + /** + * _applyApproval shows or hides the approve and deny buttons of + * one bubble, according to the pending approval set. + */ + _applyApproval($bubble) { + const $approval = $bubble.querySelector('.message__approval'); + if (!$approval) return; + $approval.hidden = !this.pendingApprovals.some( + ({ messageId, toolCallId }) => + messageId === $bubble.dataset.parentId && + toolCallId === $bubble.dataset.toolCallId, + ); + } + + /** _prettyJSON renders JSON text indented, or raw on failure. */ + _prettyJSON(text) { + try { + return JSON.stringify(JSON.parse(text || 'null'), null, 2); + } catch { + return text || ''; + } + } + + /** + * _renderMarkdown converts Markdown text to sanitized HTML. It + * wraps tables in a scrollable container for small screens, and + * falls back to escaped plain text when the libraries are + * unavailable. + */ + _renderMarkdown(text) { + if (!text) return ''; + try { + if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { + const container = this.document.createElement('div'); + container.innerHTML = marked.parse(text); + for (const table of container.querySelectorAll('table')) { + const wrapper = this.document.createElement('div'); + wrapper.className = 'message__table-scroll'; + table.replaceWith(wrapper); + wrapper.appendChild(table); + } + return DOMPurify.sanitize(container.innerHTML); + } + } catch (error) { + console.error('failed to render markdown:', error); + } + const div = this.document.createElement('div'); + div.textContent = text; + div.style.whiteSpace = 'pre-wrap'; + return div.innerHTML; + } + + _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); + }, + (error) => console.error('failed to copy:', error), + ); + } + + /** + * _initViewport keeps the page inside the visual viewport on + * mobile, where the on-screen keyboard resizes the window. + */ + _initViewport() { + if (!window.visualViewport) return; + const container = this.document.querySelector('.container'); + if (!container) return; + const handler = () => { + const { height } = window.visualViewport; + if (height > 0) container.style.height = `${height}px`; + }; + window.visualViewport.addEventListener('resize', handler); + handler(); + } + + _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; + } +} + +// Configure marked to highlight code blocks with highlight.js. +// marked v8+ removed the highlight option from setOptions; use the +// marked-highlight extension instead. +if ( + typeof marked !== 'undefined' && + typeof markedHighlight !== 'undefined' && + typeof hljs !== 'undefined' +) { + marked.use( + markedHighlight.markedHighlight({ + langPrefix: 'hljs language-', + highlight(code, lang) { + const language = hljs.getLanguage(lang) ? lang : 'plaintext'; + return hljs.highlight(code, { language }).value; + }, + }), + ); +}