2026-07-04 12:44:59 +00:00
|
|
|
/**
|
|
|
|
|
* 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');
|
2026-07-06 17:22:32 +00:00
|
|
|
this.$scrollDown = document.getElementById('scroll-down');
|
2026-07-04 12:44:59 +00:00
|
|
|
this.$voice = document.getElementById('voice');
|
|
|
|
|
|
2026-07-06 17:22:32 +00:00
|
|
|
// Auto-scroll follow state. Following is opt-in, so it starts off; the
|
|
|
|
|
// user engages it via the scroll button.
|
|
|
|
|
this.isAutoScrollEnabled = false;
|
2026-07-04 12:44:59 +00:00
|
|
|
|
|
|
|
|
// Templates
|
|
|
|
|
this.$tplAssistantMessage = document.getElementById(
|
|
|
|
|
'tpl-assistant-message',
|
|
|
|
|
);
|
|
|
|
|
this.$tplAttachmentChip = document.getElementById('tpl-attachment-chip');
|
2026-07-07 11:40:52 +00:00
|
|
|
this.$tplImagePill = document.getElementById('tpl-image-pill');
|
2026-07-04 12:44:59 +00:00
|
|
|
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');
|
2026-07-05 20:53:30 +00:00
|
|
|
this.$tplSystemMessage = document.getElementById('tpl-system-message');
|
2026-07-04 12:44:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 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;
|
|
|
|
|
|
2026-07-07 11:40:52 +00:00
|
|
|
this._renderImagePills($msg, message);
|
|
|
|
|
|
2026-07-04 12:44:59 +00:00
|
|
|
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) {
|
2026-07-09 14:47:43 +00:00
|
|
|
$content.innerHTML = this._renderMarkdown(text);
|
2026-07-04 12:44:59 +00:00
|
|
|
} 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
|
|
|
|
|
*/
|
2026-07-05 18:14:40 +00:00
|
|
|
renderError(message, requestId = '') {
|
2026-07-04 12:44:59 +00:00
|
|
|
const $msg =
|
|
|
|
|
this.$tplErrorMessage.content.cloneNode(true).firstElementChild;
|
2026-07-05 18:14:40 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2026-07-04 12:44:59 +00:00
|
|
|
this.$chat.insertBefore($msg, this.$chatLoading);
|
|
|
|
|
this.scrollToBottom();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Scroll ---
|
|
|
|
|
|
|
|
|
|
scrollToBottom() {
|
|
|
|
|
if (this.isAutoScrollEnabled) {
|
|
|
|
|
this.$chat.scrollTop = this.$chat.scrollHeight;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 17:22:32 +00:00
|
|
|
// 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;
|
2026-07-04 12:44:59 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 17:22:32 +00:00
|
|
|
// 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);
|
2026-07-04 12:44:59 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 17:22:32 +00:00
|
|
|
// 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).
|
2026-07-04 12:44:59 +00:00
|
|
|
handleChatScroll() {
|
2026-07-06 17:22:32 +00:00
|
|
|
const { scrollHeight, scrollTop, clientHeight } = this.$chat;
|
|
|
|
|
if (scrollHeight - scrollTop - clientHeight > 100) {
|
|
|
|
|
this.setAutoScroll(false);
|
2026-07-04 12:44:59 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 17:22:32 +00:00
|
|
|
// Set the flag and sync the icon so the two never drift.
|
2026-07-04 12:44:59 +00:00
|
|
|
setAutoScroll(enabled) {
|
|
|
|
|
this.isAutoScrollEnabled = enabled;
|
2026-07-06 17:22:32 +00:00
|
|
|
this.updateFollowIndicator();
|
2026-07-04 12:44:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearChat() {
|
|
|
|
|
const $loading = this.$chatLoading;
|
|
|
|
|
this.$chat.innerHTML = '';
|
|
|
|
|
this.$chat.appendChild($loading);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Private ---
|
|
|
|
|
|
2026-07-09 14:47:43 +00:00
|
|
|
/**
|
|
|
|
|
* _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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 12:44:59 +00:00
|
|
|
_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;
|
|
|
|
|
|
2026-07-07 11:40:52 +00:00
|
|
|
this._renderImagePills($msg, message);
|
|
|
|
|
|
2026-07-04 12:44:59 +00:00
|
|
|
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) {
|
2026-07-09 14:47:43 +00:00
|
|
|
$content.innerHTML = this._renderMarkdown(text);
|
2026-07-04 12:44:59 +00:00
|
|
|
} 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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 20:53:30 +00:00
|
|
|
/**
|
|
|
|
|
* 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 =
|
|
|
|
|
'<em class="message__placeholder">System Default</em>';
|
|
|
|
|
$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 =
|
|
|
|
|
'<em class="message__placeholder">System Default</em>';
|
|
|
|
|
$msg.dataset.copyText = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.$chat.insertBefore($msg, this.$chatLoading);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 11:40:52 +00:00
|
|
|
_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'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 12:44:59 +00:00
|
|
|
_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),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-10 13:07:05 +00:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|