Refactor UI
This commit is contained in:
685
internal/service/static/render.js
Normal file
685
internal/service/static/render.js
Normal file
@@ -0,0 +1,685 @@
|
||||
/**
|
||||
* 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;
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user