Files
odidere/internal/service/static/render.js
2026-07-05 18:03:46 +00:00

687 lines
21 KiB
JavaScript

/**
* 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 = `<svg class="icon"><use href="${ICONS_URL}#stop"></use></svg>`;
$btn.setAttribute('aria-label', 'Stop');
} else {
$btn.classList.remove('playing');
$btn.innerHTML = `<svg class="icon"><use href="${ICONS_URL}#volume"></use></svg>`;
$btn.setAttribute('aria-label', 'Play');
}
}
// --- Error ---
/**
* renderError renders an error message in the chat.
* @param {string} message
*/
renderError(message) {
const $msg =
this.$tplErrorMessage.content.cloneNode(true).firstElementChild;
$msg.querySelector('.message__content').textContent = message;
$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),
);
}
}