/** * Renderer creates and updates message DOM elements from templates. * Handles streaming bubbles, attachments, and play-button state. * Takes the conversation tree as a dependency. */ import { extractText } from './conversation.js'; const ICONS_URL = '/static/icons.svg'; export class Renderer { /** * @param {Object} options * @param {Document} options.document * @param {import('./conversation.js').ConversationTree} options.conversation */ constructor({ document, conversation }) { this.document = document; this.conversation = conversation; // DOM Elements this.$chat = document.getElementById('chat'); this.$chatLoading = document.getElementById('chat-loading'); this.$attachments = document.getElementById('attachments'); this.$scroll = document.getElementById('scroll'); this.$voice = document.getElementById('voice'); // Auto-scroll: enabled by default, disabled when user scrolls up. this.isAutoScrollEnabled = true; this._lastScrollTop = 0; // Templates this.$tplAssistantMessage = document.getElementById( 'tpl-assistant-message', ); this.$tplAttachmentChip = document.getElementById('tpl-attachment-chip'); this.$tplDebugRow = document.getElementById('tpl-debug-row'); this.$tplErrorMessage = document.getElementById('tpl-error-message'); this.$tplUserMessage = document.getElementById('tpl-user-message'); this.$tplReasoningMessage = document.getElementById( 'tpl-reasoning-message', ); this.$tplToolMessage = document.getElementById('tpl-tool-message'); } // --- Full / incremental render --- /** * renderMessages renders messages to the chat container. * Messages already in the DOM (matched by data-id) are skipped. * @param {Object[]} messages * @param {Object} [meta] */ renderMessages(messages, meta = {}) { const toolResultMap = new Map(); for (const msg of messages) { if (msg.role === 'tool' && msg.tool_call_id) { toolResultMap.set(msg.tool_call_id, msg); } } for (const msg of messages) { if (msg.role === 'user') { if (msg.id && this.$chat.querySelector(`[data-id="${msg.id}"]`)) { continue; } this._renderUserMessage(msg, msg.meta ?? meta); continue; } if (msg.role !== 'assistant') continue; const hasContent = msg.content || msg.reasoning_content || msg.tool_calls?.length; if (!hasContent) continue; const assistantId = msg.id; const bubbleMeta = msg.meta ?? meta; if (msg.reasoning_content) { const reasoningId = `${assistantId}/reasoning`; if (!this.$chat.querySelector(`[data-id="${reasoningId}"]`)) { this._renderReasoningMessage(msg, bubbleMeta); } } const textContent = extractText(msg.content); if (textContent) { if ( !assistantId || !this.$chat.querySelector(`[data-id="${assistantId}"]`) ) { this._renderAssistantMessage(msg, bubbleMeta); } } if (msg.tool_calls?.length > 0) { for (const toolCall of msg.tool_calls) { const result = toolResultMap.get(toolCall.id); const toolBubbleId = result?.id || toolCall.id; if (!this.$chat.querySelector(`[data-id="${toolBubbleId}"]`)) { this._renderToolMessage(msg, toolCall, result, bubbleMeta); } } } } } /** * reRenderActivePath re-renders the entire active conversation path. */ reRenderActivePath() { const $loading = this.$chatLoading; this.$chat.innerHTML = ''; this.$chat.appendChild($loading); this._renderTree(this.conversation.rootId); this.scrollToBottom(); } /** * reRenderMessage replaces the DOM element(s) for a message. * @param {Object} message */ reRenderMessage(message) { if (message.role === 'user') { const $old = this.$chat.querySelector(`[data-id="${message.id}"]`); if (!$old) return; const $msg = this.$tplUserMessage.content.cloneNode(true).firstElementChild; if (message.id) $msg.dataset.id = message.id; const content = extractText(message.content); $msg.querySelector('.message__content').textContent = content; const $dl = $msg.querySelector('.message__debug-list'); if (message.meta?.language) this._appendDebugRow($dl, 'Language', message.meta.language); if (message.meta?.voice) this._appendDebugRow($dl, 'Voice', message.meta.voice); if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', ''); $old.replaceWith($msg); } else if (message.role === 'assistant') { const $textBubble = this.$chat.querySelector(`[data-id="${message.id}"]`); const $reasoningBubble = this.$chat.querySelector( `[data-id="${message.id}/reasoning"]`, ); const $toolBubbles = this.$chat.querySelectorAll( `[data-parent-id="${message.id}"]`, ); let $reference = $reasoningBubble || $textBubble; if (!$reference && $toolBubbles.length > 0) { $reference = $toolBubbles[0]; } if (!$reference) return; const $toRemove = []; if ($reasoningBubble) $toRemove.push($reasoningBubble); if ($textBubble) $toRemove.push($textBubble); for (const $tool of $toolBubbles) { $toRemove.push($tool); } const toolResultMap = new Map(); if (message.tool_calls?.length > 0) { const toolCallIds = new Set(message.tool_calls.map((tc) => tc.id)); for (const result of this.conversation.messagesMap.values()) { if (result.role === 'tool' && toolCallIds.has(result.tool_call_id)) { toolResultMap.set(result.tool_call_id, result); } } } const bubbleMeta = message.meta ?? {}; for (const $el of $toRemove) { $el.remove(); } if (message.reasoning_content) { this._renderReasoningMessage(message, bubbleMeta); } const textContent = extractText(message.content); if (textContent) { this._renderAssistantMessage(message, bubbleMeta); } if (message.tool_calls?.length > 0) { for (const toolCall of message.tool_calls) { const result = toolResultMap.get(toolCall.id); this._renderToolMessage(message, toolCall, result, bubbleMeta); } } } } // --- Streaming bubbles --- /** * createStreamingReasoning creates a streaming reasoning bubble. * @param {string} assistantId * @param {Object} [meta] * @returns {HTMLElement} */ createStreamingReasoning(assistantId, meta = null) { const $msg = this.$tplReasoningMessage.content.cloneNode(true).firstElementChild; $msg.dataset.id = `${assistantId}/reasoning`; $msg.dataset.parentId = assistantId; $msg.dataset.field = 'reasoning'; $msg.classList.add('message--streaming'); $msg.querySelector('.message__collapsible').open = true; const $dl = $msg.querySelector('.message__debug-list'); if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice); if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', ''); // Remove action buttons during streaming. $msg.querySelector('[data-action="delete"]').remove(); $msg.querySelector('[data-action="edit"]').remove(); $msg.querySelector('[data-action="copy"]').remove(); this.$chat.insertBefore($msg, this.$chatLoading); this.scrollToBottom(); return $msg; } /** * createStreamingText creates a streaming text content bubble. * @param {Object} message * @param {Object} [meta] * @returns {HTMLElement} */ createStreamingText(message, meta = null) { const $msg = this.$tplAssistantMessage.content.cloneNode(true).firstElementChild; if (message.id) $msg.dataset.id = message.id; $msg.classList.add('message--streaming'); const $dl = $msg.querySelector('.message__debug-list'); if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice); if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', ''); // Remove action buttons during streaming. $msg.querySelector('[data-action="play"]').remove(); $msg.querySelector('[data-action="copy"]').remove(); $msg.querySelector('[data-action="delete"]').remove(); $msg.querySelector('[data-action="edit"]').remove(); this.$chat.insertBefore($msg, this.$chatLoading); this.scrollToBottom(); return $msg; } /** * appendDelta appends text to a streaming assistant message. * @param {HTMLElement} $msg * @param {string} delta */ appendDelta($msg, delta) { const $content = $msg.querySelector('.message__content'); $content.textContent += delta; this.scrollToBottom(); } /** * appendReasoningDelta appends reasoning text to a streaming bubble. * @param {HTMLElement} $msg * @param {string} delta */ appendReasoningDelta($msg, delta) { const $content = $msg.querySelector('.message__content'); $content.textContent += delta; this.scrollToBottom(); } /** * finalizeStreamingReasoning replaces a streaming reasoning bubble * with a fully bound one. * @param {HTMLElement} $streaming * @param {Object} message * @param {Object} [meta] * @returns {HTMLElement|null} */ finalizeStreamingReasoning($streaming, message, meta = null) { if (!$streaming) return null; const $msg = this.$tplReasoningMessage.content.cloneNode(true).firstElementChild; $msg.dataset.id = `${message.id}/reasoning`; $msg.dataset.parentId = message.id; $msg.dataset.field = 'reasoning'; $msg.dataset.messageId = message.id; $msg.querySelector('.message__content').textContent = message.reasoning_content || ''; $msg.querySelector('.message__collapsible').open = false; const $dl = $msg.querySelector('.message__debug-list'); if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice); if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', ''); $msg.dataset.copyText = message.reasoning_content || ''; $streaming.replaceWith($msg); this.scrollToBottom(); return $msg; } /** * finalizeStreamingText replaces a streaming text bubble with a * fully bound one. * @param {HTMLElement} $streaming * @param {Object} message * @param {Object} [meta] * @returns {HTMLElement|null} */ finalizeStreamingText($streaming, message, meta = null) { if (!$streaming) return null; const $msg = this.$tplAssistantMessage.content.cloneNode(true).firstElementChild; if (message.id) $msg.dataset.id = message.id; const $content = $msg.querySelector('.message__content'); const text = extractText(message.content); if (text) { $content.textContent = text; } else { $content.remove(); } const $dl = $msg.querySelector('.message__debug-list'); this._populateDebugPanel($dl, meta); if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', ''); const voice = meta?.voice || this.$voice.value || ''; $msg.dataset.voice = voice; $msg.dataset.copyText = text || ''; $streaming.replaceWith($msg); this.scrollToBottom(); return $msg; } // --- Attachments --- /** * renderAttachments renders the attachment chips in the footer. * @param {File[]} attachments * @param {number} attachments.length * @param {Function} onRemove - Called with the index to remove. */ renderAttachments(attachments, onRemove) { this.$attachments.innerHTML = ''; for (let i = 0; i < attachments.length; i++) { const file = attachments[i]; const $chip = this.$tplAttachmentChip.content.cloneNode(true).firstElementChild; const $name = $chip.querySelector('.compose__attachment-name'); $name.textContent = file.name; $name.title = file.name; const $remove = $chip.querySelector('.compose__attachment-remove'); $remove.setAttribute('aria-label', `Remove ${file.name}`); $remove.addEventListener('click', () => onRemove(i)); this.$attachments.appendChild($chip); } } // --- Play button --- /** * setPlayButton updates the play/stop button icon for a message. * @param {string} messageId * @param {boolean} playing */ setPlayButton(messageId, playing) { const $msg = this.$chat.querySelector(`[data-id="${messageId}"]`); if (!$msg) return; const $btn = $msg.querySelector('[data-action="play"]'); if (!$btn) return; if (playing) { $btn.classList.add('playing'); $btn.innerHTML = ``; $btn.setAttribute('aria-label', 'Stop'); } else { $btn.classList.remove('playing'); $btn.innerHTML = ``; $btn.setAttribute('aria-label', 'Play'); } } // --- Error --- /** * renderError renders an error message in the chat. * @param {string} message */ renderError(message, 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; } } isAtBottom() { const { scrollTop, scrollHeight, clientHeight } = this.$chat; return scrollHeight - clientHeight - scrollTop < 10; } updateScrollIcon() { const atBottom = this.isAtBottom(); this.$scroll.classList.toggle('scrolled-up', !atBottom); this.$scroll.setAttribute( 'aria-label', atBottom ? 'Scroll to top' : 'Scroll to bottom', ); } handleChatScroll() { const { scrollTop, scrollHeight, clientHeight } = this.$chat; const distanceFromBottom = scrollHeight - clientHeight - scrollTop; const isAtBottom = distanceFromBottom < 10; if (scrollTop < this._lastScrollTop && !isAtBottom) { this.isAutoScrollEnabled = false; } else if (isAtBottom) { this.isAutoScrollEnabled = true; } this._lastScrollTop = scrollTop; } setAutoScroll(enabled) { this.isAutoScrollEnabled = enabled; } clearChat() { const $loading = this.$chatLoading; this.$chat.innerHTML = ''; this.$chat.appendChild($loading); } // --- Private --- _renderTree(messageId) { const messages = []; this._collectMessages(messageId, messages); this.renderMessages(messages); } _collectMessages(messageId, messages) { if (!messageId) return; const msg = this.conversation.get(messageId); if (!msg) return; messages.push(msg); if (msg.children) { for (const childId of msg.children) { this._collectMessages(childId, messages); } } } _renderUserMessage(message, meta = null) { const content = (() => { if (typeof message.content === 'string') { return message.content; } if (Array.isArray(message.content)) { return message.content .filter((p) => p.type === 'text') .map((p) => p.text) .join('\n'); } return ''; })(); const $msg = this.$tplUserMessage.content.cloneNode(true).firstElementChild; if (message.id) $msg.dataset.id = message.id; $msg.querySelector('.message__content').textContent = content; $msg.dataset.copyText = content; const $dl = $msg.querySelector('.message__debug-list'); if (meta?.language) this._appendDebugRow($dl, 'Language', meta.language); if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice); if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', ''); this.$chat.insertBefore($msg, this.$chatLoading); this.scrollToBottom(); } _renderAssistantMessage(message, meta = null) { const $msg = this.$tplAssistantMessage.content.cloneNode(true).firstElementChild; if (message.id) $msg.dataset.id = message.id; const $content = $msg.querySelector('.message__content'); const text = extractText(message.content); if (text) { $content.textContent = text; } else { $content.remove(); } const $dl = $msg.querySelector('.message__debug-list'); this._populateDebugPanel($dl, meta); if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', ''); const voice = meta?.voice || this.$voice.value || ''; $msg.dataset.voice = voice; $msg.dataset.copyText = text || ''; this.$chat.insertBefore($msg, this.$chatLoading); this.scrollToBottom(); } _renderReasoningMessage(message, meta = null) { const $msg = this.$tplReasoningMessage.content.cloneNode(true).firstElementChild; $msg.dataset.id = `${message.id}/reasoning`; $msg.dataset.parentId = message.id; $msg.dataset.field = 'reasoning'; $msg.dataset.messageId = message.id; $msg.querySelector('.message__content').textContent = message.reasoning_content || ''; $msg.dataset.copyText = message.reasoning_content || ''; const $dl = $msg.querySelector('.message__debug-list'); if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice); if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', ''); this.$chat.insertBefore($msg, this.$chatLoading); this.scrollToBottom(); } _renderToolMessage(assistantMessage, toolCall, result = null, meta = null) { const $msg = this.$tplToolMessage.content.cloneNode(true).firstElementChild; const toolBubbleId = result?.id || toolCall.id; $msg.dataset.id = toolBubbleId; $msg.dataset.parentId = assistantMessage.id; $msg.dataset.field = 'tool'; $msg.dataset.messageId = assistantMessage.id; $msg.dataset.toolCallId = toolCall.id; $msg.querySelector('.message__collapsible-label').textContent = toolCall.function?.name || 'Tool Call'; const $args = $msg.querySelector('[data-args]'); try { const args = JSON.parse(toolCall.function?.arguments || '{}'); $args.textContent = JSON.stringify(args, null, 2); } catch { $args.textContent = toolCall.function?.arguments || ''; } if (result) { const $outputSection = $msg.querySelector( '.message__tool-section--output', ); $outputSection.hidden = false; const $output = $msg.querySelector('[data-output]'); try { const output = JSON.parse(extractText(result.content) || '{}'); $output.textContent = JSON.stringify(output, null, 2); } catch { $output.textContent = extractText(result.content) || ''; } } // Set copy text. const copyParts = []; const name = toolCall.function?.name || 'unknown'; const args = toolCall.function?.arguments || '{}'; copyParts.push(`${name}(${args})`); if (result) { copyParts.push(extractText(result.content) || ''); } $msg.dataset.copyText = copyParts.join('\n\n'); const $dl = $msg.querySelector('.message__debug-list'); if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice); if ($dl.children.length === 0) this._appendDebugRow($dl, 'No data', ''); this.$chat.insertBefore($msg, this.$chatLoading); this.scrollToBottom(); } _populateDebugPanel($dl, meta) { if (meta?.voice) this._appendDebugRow($dl, 'Voice', meta.voice); if (meta?.usage) { const u = meta.usage; this._appendDebugRow($dl, 'Prompt tokens', String(u.prompt_tokens)); this._appendDebugRow( $dl, 'Completion tokens', String(u.completion_tokens), ); this._appendDebugRow($dl, 'Total tokens', String(u.total_tokens)); if (u.completion_tokens_details?.reasoning_tokens > 0) this._appendDebugRow( $dl, 'Reasoning tokens', String(u.completion_tokens_details.reasoning_tokens), ); if (u.prompt_tokens_details?.cached_tokens > 0) this._appendDebugRow( $dl, 'Cached tokens', String(u.prompt_tokens_details.cached_tokens), ); } if (meta?.timings) { const t = meta.timings; if (t.prompt_ms > 0) this._appendDebugRow( $dl, 'Prompt eval', `${t.prompt_ms.toFixed(1)} ms (${t.prompt_per_second.toFixed(0)} tok/s)`, ); if (t.predicted_ms > 0) this._appendDebugRow( $dl, 'Predicted eval', `${t.predicted_ms.toFixed(1)} ms (${t.predicted_per_second.toFixed(0)} tok/s)`, ); } } _appendDebugRow($dl, label, value) { const $row = this.$tplDebugRow.content.cloneNode(true); $row.querySelector('dt').textContent = label; $row.querySelector('dd').textContent = value; $dl.appendChild($row); } _icon(name) { const svg = this.document.createElementNS( 'http://www.w3.org/2000/svg', 'svg', ); svg.setAttribute('class', 'icon'); const use = this.document.createElementNS( 'http://www.w3.org/2000/svg', 'use', ); use.setAttribute('href', `${ICONS_URL}#${name}`); svg.appendChild(use); return svg; } copyToClipboard($button, text) { navigator.clipboard.writeText(text).then( () => { $button.replaceChildren(this._icon('check')); $button.classList.add('message__action-btn--success'); setTimeout(() => { $button.replaceChildren(this._icon('copy')); $button.classList.remove('message__action-btn--success'); }, 1500); }, (e) => console.error('failed to copy:', e), ); } }